Build agents with ADK
ADK — Google's Agent Development Kit — is an open-source Python framework for building agents. The Agents service runs unmodified ADK agents. You write standard ADK code, and the harness handles serving, sessions, and memory. This page is the complete guide: the author contract, both ways to start (from source or from a Console template), configuration, the deploy, invoking and streaming, sessions, memory, tools from MCP Servers, SDK snippets, common patterns, the debugging checklist, and limits.
Before you begin
- You can deploy: account, CLI, and sign-in per deploy an agent.
- Python knowledge. You do not need ADK installed locally to deploy — the build happens on the platform.
- Your project has a model API key saved (console Project Settings, or
PUT /v1/projects/{id}/inference). Deploying an agent that calls a model is refused without one.
What your agent.py must define
Your directory must contain an agent.py that defines a variable named root_agent at module level — at the top level of the file, not inside a function or a class. That is the standard ADK convention, and it is the one thing the harness looks for when your agent starts.
If it is missing, your agent crash-loops: it starts, fails, gets restarted, and fails again, over and over. This exact error lands in the agent's message field:
could not import `root_agent` from /app/agent/agent.py - the agent image must define a module-level `root_agent` in agent.py
You can add other .py files and import them from agent.py. If you include a requirements.txt, pip installs it at build time. A dependency that cannot be installed fails the build, not the running agent, and the reason is readable in the agent's message field.
What the base image already provides
The ADK base image ships with pinned versions of everything the harness needs, so most agents need an empty requirements.txt or none at all:
| Package | Pinned version |
|---|---|
google-adk | 2.5.0 |
litellm | 1.93.0 |
fastapi | 0.139.2 |
| Python | 3.12 |
The first-party crusoe_adk package described below is in there too. Pinned versions mean your agent will not break quietly when one of these libraries releases a new version. These versions move only when the platform ships a new base image.
Build from source, or start from a template
There are two starting points, and they produce the same deployable directory.
Build from source. Write agent.py (and any modules it imports) in a folder on your machine. The complete example below is deployable as-is; so are the adk-minimal and adk-mcp examples that ship in the platform's examples folder.
Start from a Console template. In the Console, go to Compute → Agents → Deploy agent, pick ADK, and choose the write mode. The dialog opens with a working ADK starter — the same shape as the example below — which you edit in the browser and deploy without ever touching a local folder. Choose this when you want to try the framework with zero local setup.
Either way you end up with the same thing: a directory whose agent.py defines root_agent, deployed by the same platformctl deploy (or its API/Console equivalent).
Configure the framework: env, secrets, MCP attach
Everything below is set per agent and takes effect on the next revision — changing any of it rolls a new revision, exactly like a deploy. Full reference: secrets and environment variables.
Environment variables (readable)
Plain configuration you can read back at any time. The most common one for ADK agents is CHAT_MODEL, which swaps the model with no code change:
platformctl agents env set my-agent CHAT_MODEL=nvidia/NVIDIA-Nemotron-3-Super-120B-A12B
Other env vars worth knowing:
| Variable | Effect |
|---|---|
CHAT_MODEL | The model foundry_model() picks when called with no argument |
CAI_EXPOSE_EXTERNAL=apikey|jwt|none | Publishes the agent's public URL, and names the protection it gets. A bare true is refused — see invoke |
CAI_EXPOSE_RATE_LIMIT=100/minute | Required whenever exposure is on: N/second|minute|hour|day |
MEMORY_SCOPE=shared | One common memory bank for all callers instead of one per caller — see memory |
TOOL_SANDBOX=false — the opt-out from running your own tool code in sandboxes — is not settable here. PATCH /v1/agents/{name}/env refuses it with a 400 naming the rule, because it is in the platform's reserved set. Store it as a per-agent secret instead: platformctl secrets set my-agent TOOL_SANDBOX=false. See tools.
Secrets (write-only)
Credentials you can never read back. The one every agent has an opinion about is MODEL_API_KEY, which overrides the project's model key for this one agent:
platformctl secrets set my-agent MODEL_API_KEY=«redacted:sk-…»
Put anything else your code needs the same way — a downstream API token, for instance — and read it in Python with os.environ["YOUR_KEY"]. That read works in the agent process; it does not work inside a sandboxed tool, whose environment is empty.
If the credential is shared with several agents, or you want versions and read auditing, keep it in the project Secrets store instead and attach it — see use secrets in workloads. Your code then reads it at call time with the helper, never from the environment:
from crusoe_adk import secret
api_key = secret("weather-api-key") # latest version
pinned = secret("weather-api-key", 3) # a pinned version
secret() reads a project Secret at call time from your agent process. It does not work inside a sandboxed tool: the sandbox has no environment variables and no route to the secrets API, so the call raises SecretError: crusoe.secret is not configured. A tool that must hold a credential or reach an internal service belongs in an MCP server, or the agent must run with TOOL_SANDBOX=false. See tools.
MCP servers attach automatically
There is nothing to attach — no flag, no field, no console control. At deploy time the platform writes MCP_SERVERS into the agent's environment with an entry for every MCP server in the project whose state is ready, each carrying that server's name, its internal address and its own bearer token. The helper reads that variable and builds one ADK toolset per server:
from crusoe_adk import foundry_model, mcp_toolsets
from crusoe_adk.tools import run_python
from google.adk.agents import Agent
root_agent = Agent(
name="adk_mcp",
model=foundry_model(),
instruction="Prefer an attached tool when one fits; use run_python for computation.",
tools=[run_python, *mcp_toolsets()],
)
With no servers attached, mcp_toolsets() returns an empty list and the agent still runs. With a server attached, every tool it publishes is available to the model — publishing a new tool on the server needs no agent change, only redeploying the server.
MCP_SERVERS is captured at deployMCP_SERVERS is baked into the agent's revision at deploy time. A server created — or first reaching ready — after an agent was deployed does not reach that agent until you redeploy it. Build the server first, wait for ready, then deploy the agent:
platformctl mcp get doc-search-mcp # wait until state is ready
platformctl deploy ./my-agent --name my-agent
An agent deployed too early starts cleanly with no tools and says nothing about it. You cannot read MCP_SERVERS back to check — platformctl agents env get my-agent returns only the variables you set yourself, and /debug/config does not carry it — so the check is to invoke the agent and look for the server's tools in tool_calls, or simply redeploy once the server is ready.
Attach only the servers you name
mcp_toolsets() takes a names= argument — a single name, or a list of them:
tools=[run_python, *mcp_toolsets()] # every attached server
tools=[run_python, *mcp_toolsets(names="doc-search-mcp")] # just that one
tools=[run_python, *mcp_toolsets(names=["doc-search-mcp", "weather-tools"])] # those two
The default (no names=) is right for most agents: publishing a new tool to an attached server then needs no agent change. Name them when an agent should not see every tool in the project, or must not have its toolset widened the day somebody else deploys a server. A bare string counts as one name, not an iterable of characters; an explicit empty list attaches nothing.
A name that is not attached raises crusoe_core.UnknownMCPServer rather than quietly attaching nothing, and the message names what is attached — the mistake is almost always a spelling:
MCP server(s) not attached to this project: doc-serch-mcp. Attached: doc-search-mcp, weather-tools
crusoe_core.mcp_server_names() returns that same attachable list if you want to inspect it in code. Several servers at once is ordinary — each becomes its own toolset and one turn can call a tool from each — but tool names are not namespaced by server, so two servers publishing the same tool name collide.
ADK connects lazily, so an unreachable server is a call-time failure
mcp_toolsets() builds toolset objects; it makes no network call. An attached but unreachable server therefore does not fail the agent's startup — the failure surfaces on the first tool call instead. What does fail loudly at startup is a base image missing ADK's mcp extra while servers are configured; the platform's ADK image ships it, so that fires only on a hand-rolled image that dropped it. CrewAI and LangGraph connect eagerly and do fail closed at startup; ADK is the exception.
For the end-to-end walk — publish a server, attach it, call its tools — see publish tools and connect agents and clients. The adk-mcp example in the platform's examples folder is this section, runnable.
foundry_model(): the platform model helper
foundry_model() returns an ADK model object already pointed at the platform's managed inference endpoint. That endpoint is OpenAI-compatible — it speaks the same HTTP protocol as OpenAI's API — and the platform injects its address into every agent:
from crusoe_adk.foundry import foundry_model
model = foundry_model() # use the platform's configured model
model = foundry_model("nvidia/NVIDIA-Nemotron-3-Super-120B-A12B") # or pin one in code
- With no arguments, it uses the
CHAT_MODELenvironment variable. That means you can swap models from the console (or with the env API) without touching code — see secrets and environment variables. - When nothing is configured anywhere, the platform default is
zai/GLM-5.2, served from the managed inference endpoint. - The model API key is yours — the platform supplies none. It comes from your project's managed inference credential (the console's Project Settings page, or
PUT /v1/projects/{id}/inference), injected asMODEL_API_KEY. SettingMODEL_API_KEYas a per-agent secret overrides the project's key for that one agent. - You can also bring your own model — declare a native ADK model such as
Agent(model="gemini-2.5-flash")with your own credentials in place — and the harness runs it as-is.
Built-in tools
Two ready-made tools ship in crusoe_adk.tools:
from crusoe_adk.tools import run_python, search_memory
run_python(code)— runs Python in the platform's code sandbox. That is an isolated environment used once and thrown away, never your agent's own container. Execution is capped at 20 seconds.search_memory(query)— searches the agent's long-term memory bank and returns the top 5 matching snippets. Things get into that bank only through the explicit memorize step.
You can also write your own tools as plain Python functions — see tools. By default the platform runs your tool code in sandboxes too. That is the TOOL_SANDBOX setting, and it is on unless you turn it off with platformctl secrets set my-agent TOOL_SANDBOX=false — a per-agent secret, because the env route refuses that name.
Note on ADK tool execution: unlike LangGraph and CrewAI, ADK does not refuse to start when one of your tools cannot be sandboxed — a class-based tool stays in the agent process, next to your credentials, with no error. The startup log line tool sandbox ON: ... naming the tools that were relocated is the confirmation, so check it after every deploy that adds a tool. To ensure sandboxing, write the tool as a plain module-level function. Details and the exact log lines: tools.
Complete example: research-buddy
This is the platform's canonical example agent, complete and runnable as-is. Two files.
my-agent/agent.py:
from google.adk.agents import Agent
from crusoe_adk.foundry import foundry_model
from crusoe_adk.tools import run_python, search_memory
root_agent = Agent(
name="research_buddy",
# foundry_model() with no args uses the CHAT_MODEL env var, so you can
# swap the model from the console without touching code. To pin a model
# in code instead, pass it:
# foundry_model("nvidia/NVIDIA-Nemotron-3-Super-120B-A12B").
model=foundry_model(),
instruction=(
"You are Research Buddy, a research assistant. Use the run_python tool "
"for calculations and the search_memory tool to recall things you have "
"been told to remember."
),
tools=[run_python, search_memory],
)
my-agent/requirements.txt:
# No extra dependencies - google-adk, litellm, and crusoe_adk are already
# provided by the harness base image.
Deploy it
- platformctl
- curl
- Console
The CLI detects ADK automatically - the folder has no crew.py or graph.py.
platformctl deploy ./my-agent --name my-agent
You should see:
packaging ./my-agent...
uploading my-agent (1.2 KiB, framework=adk)...
build 2f6f2f6e-8a1e-4c3b-9d2a-1b2c3d4e5f6a accepted
state: -> building
state: building -> deploying
state: deploying -> ready
my-agent is ready at https://my-agent-x7k2q.apps.codyhill.dev
Package the directory and post it, naming the framework explicitly — there is no file-name detection on the API:
tar -czf my-agent.tar.gz -C my-agent .
curl -s -X POST "$CAI_API/v1/agents" \
-H "Authorization: Bearer ***" \
-F "name=my-agent" \
-F "framework=adk" \
-F "code=@my-agent.tar.gz"
You should see (HTTP 202 — the build runs in the background):
{"agent": "my-agent", "build_id": "2f6f2f6e-8a1e-4c3b-9d2a-1b2c3d4e5f6a"}
Poll GET /v1/agents/my-agent until state reads ready or failed.
Go to Compute → Agents → Deploy agent. Name the agent my-agent, pick the framework, and provide agent.py — write it in the browser, upload the folder, or upload a .tar.gz. The build panel streams building, deploying, ready.
Invoke it: the sync response
Once deployed, an ADK agent answers the exact same HTTP API as every other agent — callers can't tell the frameworks apart. This message exercises the model and the sandbox tool in one turn.
- platformctl
- curl
- Console
platformctl invoke my-agent "Compute 2**32 in python."
You should see:
2**32 is 4294967296.
(session: 3f2c8a1e-7b4d-4e2f-9a1c-5d6e7f8a9b0c)
tool_call: run_python called with args={'code': 'print(2**32)'}
curl -s -X POST "$CAI_API/v1/agents/my-agent/invoke" \
-H 'content-type: application/json' \
-d '{"message":"Compute 2**32 in python."}'
You should see:
{
"session_id": "3f2c8a1e-7b4d-4e2f-9a1c-5d6e7f8a9b0c",
"user_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"output": "2**32 is 4294967296.",
"reasoning": "",
"tool_calls": [
{"name": "run_python", "summary": "called with args={'code': 'print(2**32)'}"}
],
"events": ["..."]
}
Open the agent's Test tab and send Compute 2**32 in python. You should see the answer, plus a tool-call entry showing run_python ran.
Full request/response field reference, status codes, and the public-URL variant: invoke an agent.
Invoke it streaming: NDJSON
POST /v1/agents/{name}/invoke/stream takes the same request body and answers with application/x-ndjson — one complete JSON object per line, in the order things happened:
curl -sN -X POST "$CAI_API/v1/agents/my-agent/invoke/stream" \
-H 'content-type: application/json' \
-d '{"message":"Compute 2**32 in python."}'
You should see (one object per line — the -N flag makes curl print them as they arrive):
{"type":"thinking", "seq":1, "text":"I need to compute 2**32..."}
{"type":"block_end", "seq":2, "kind":"thinking"}
{"type":"tool_call", "name":"run_python", "args":{"code":"print(2**32)"}}
{"type":"tool_result", "name":"run_python", "result":"4294967296\n"}
{"type":"output", "seq":3, "text":"2**32 is "}
{"type":"output", "seq":4, "text":"4294967296."}
{"type":"block_end", "seq":5, "kind":"output"}
{"type":"done", "session_id":"3f2c8a1e-7b4d-4e2f-9a1c-5d6e7f8a9b0c", "user_id":"7c9e6679-7425-40de-944b-e07fc1f90ae7"}
Six of the seven line types appear in that one turn. The seventh, error, arrives instead of done when the turn fails. Line-by-line semantics: invoke → streaming.
Sessions: turn 2 remembers turn 1
Reuse the session_id from any response and the conversation continues — the platform replays the stored history to your ADK Runner on every turn:
platformctl invoke my-agent "My boat is a Mastercraft Maristar 245."
# -> (session: 3f2c8a1e-7b4d-4e2f-9a1c-5d6e7f8a9b0c)
platformctl invoke my-agent "What boat do I have?" \
--session 3f2c8a1e-7b4d-4e2f-9a1c-5d6e7f8a9b0c
# -> You have a Mastercraft Maristar 245.
Nothing in the ADK code reads or writes sessions — the session service the harness wires in for you does it, backed by the platform's managed store. Turn 2 sees turn 1 by construction. Browse the stored transcript afterwards with platformctl agents sessions get my-agent <session-id> -o json, or in the Console's Users & Sessions tab. Full mechanics and id rules: sessions.
Memory: memorize a session, recall it in another
Sessions are short-term. For facts that must survive across sessions, memorize a session explicitly, then let the search_memory tool find it from any later conversation:
# 1) Have a conversation, note the session id
platformctl invoke my-agent "My boat is a Mastercraft Maristar 245."
# 2) Commit it to the memory bank (needs sign-in)
platformctl memorize my-agent --session 3f2c8a1e-7b4d-4e2f-9a1c-5d6e7f8a9b0c
# 3) A brand-new session — no --session flag
platformctl invoke my-agent "What do you know about my boat?"
You should see:
You have a Mastercraft Maristar 245.
(session: 91b0f4d7-2a6c-4e8f-b3d1-5c7e9a0f2b4d)
tool_call: search_memory called with args={'query': 'boat'}
The new session's history was empty — the fact came back from the memory bank. ADK also exposes this bank to ADK's own built-in memory tooling (the harness's memory service satisfies ADK's BaseMemoryService), but the search_memory tool above is the supported, documented path. Scope, privacy, MEMORY_SCOPE, and the read/write routes: long-term memory.
SDK snippets
There is no generated Agents SDK to install; the invoke endpoint is plain HTTP, so any HTTP client works.
- Python
- TypeScript
import httpx
API = "https://api.codyhill.dev"
AGENT = "my-agent"
def chat(message: str, session_id: str | None = None) -> dict:
body = {"message": message}
if session_id:
body["session_id"] = session_id
resp = httpx.post(f"{API}/v1/agents/{AGENT}/invoke", json=body, timeout=120)
resp.raise_for_status()
return resp.json()
turn1 = chat("My boat is a Mastercraft Maristar 245.")
turn2 = chat("What boat do I have?", session_id=turn1["session_id"])
print(turn2["output"])
Add headers={"Authorization": f"Bearer {token}"} if the platform requires invoke authentication (INVOKE_AUTH_REQUIRED=true), or when using "memorize": true.
const API = "https://api.codyhill.dev";
const AGENT = "my-agent";
async function chat(message: string, sessionId?: string): Promise<any> {
const res = await fetch(`${API}/v1/agents/${AGENT}/invoke`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ message, ...(sessionId ? { session_id: sessionId } : {}) }),
});
if (!res.ok) throw new Error(`invoke failed: ${res.status} ${await res.text()}`);
return res.json();
}
const turn1 = await chat("My boat is a Mastercraft Maristar 245.");
const turn2 = await chat("What boat do I have?", turn1.session_id);
console.log(turn2.output);
For the streaming variant, POST to /invoke/stream and read the body line by line — each line is one complete JSON event of the shapes shown in streaming above.
Common patterns
Tool-calling with verification. Give the agent run_python and instruct it to compute rather than guess. Verify the tool actually fired three ways: the tool_calls array in the sync response, the tool_call/tool_result stream lines, or the function_call parts in the session transcript. The example turn above does all three.
Multi-turn chat. Reuse session_id, always. The Console's Test tab does it for you; your own client stores the id from turn 1's response and sends it back on turn 2. Skipping it starts a fresh, empty conversation every time.
RAG over long-term memory. Teach facts with platformctl memorize, then deploy an agent whose instruction tells it to consult search_memory first. Every caller's memories stay private to that caller by default — set MEMORY_SCOPE=shared explicitly if the bank should be a shared knowledge base instead. For retrieval over your own documents rather than memorized chat, use Vectors directly — see integration examples.
Agent + MCP Servers. One import (mcp_toolsets()) attaches every tool your project's ready MCP Servers publish, or mcp_toolsets(names="doc-search-mcp") for one of them; see MCP servers attach automatically.
Debugging checklist
Work top to bottom; each step names the exact string it should confirm or produce.
- Did the deploy reach
ready?platformctl status my-agent. Onfailed, themessagefield holds the tail of the real build output — read that first. - Is
root_agentat module level? A missing one crash-loops withcould not import root_agent from /app/agent/agent.pyin themessagefield. - Is a model key in force? Publish the agent, then
GET <public_url>/debug/config— it reportsmodel_key_presentand the resolved model, never the key. A deploy with no project model key is refused up front. - Did your tool actually get sandboxed?
platformctl logs my-agent --history | grep "tool sandbox"— every startup logs one of three lines; the first names the relocated tools by name. ADK never refuses to start over a non-sandboxable tool, so this line is your only check. - Is the turn failing, or the tool inside it? A tool that raises in the sandbox does not fail the invoke — the model gets a tool result starting with
ERROR:and answers from it. Look for that prefix in the stream'stool_resultline or the transcript'sfunction_responsepart. - Does turn 2 forget turn 1? You changed
session_idbetween calls (or passed two differentuser_idvalues). Reuse both; omituser_idand the platform keeps it stable for you. - Did invoke hang ~60s then 502? That was a cold start outrunning the invoke timeout on the first call — retry; the second call is warm. Slow imports at module level are the usual cause.
- Is
search_memoryreturning nothing? Nobody has memorized a session for that caller yet, or memories predate per-caller scoping (log line:holds ~N memories written before per-user scoping). SetMEMORY_SCOPE=sharedto read legacy memories back.
Everything here, with the verbatim error strings, is in troubleshooting.
Limits and costs
| Limit | Value |
|---|---|
| Deploy upload | 100 MiB tarball; one build at a time per agent |
| Invoke | 1 MiB request, 32 MiB buffered response, 60 s default timeout |
run_python | 20 s per execution, DNS-only network |
| Your own tools (sandboxed) | 30 s default / 120 s max per call; public internet reachable |
| Conversation state | Sessions kept until deleted (admin-settable expiry); the memory bank has no expiry |
Cost works like every serverless workload here: an idle agent scales to zero and bills nothing; per-turn cost is model tokens (on your own inference key), plus any sandboxed tool executions. The project quota on total services can refuse a deploy (409) — that's a quota, not a fee. Full list: limits reference.
Clean up
- platformctl
- curl
- Console
platformctl delete my-agent
curl -s -X DELETE -H "Authorization: Bearer ***" \
"$CAI_API/v1/agents/my-agent"
On the agent's page, click Delete and confirm.
Deleting the agent removes its runtime, not its data: sessions and the memory bank survive. The deletion walk is in deploy → clean up.
Next steps
- Integration examples — ADK agent plus Vectors, Memory Store, Secrets, and MCP Servers, end to end.
- Invoke — sessions, streaming, and the full request/response shapes.
- Memory — make
search_memoryactually find things. - Tools — write your own tool functions.
- Same agent, other frameworks: LangGraph, CrewAI.