How to give a Google ADK agent a code execution tool
Google's Agent Development Kit makes tools pleasantly boring: a tool is a Python function. Its signature and its docstring are what the model sees, the runner handles the calling loop, and you can serve the whole thing locally or push it at Vertex AI Agent Engine or Cloud Run when you're done. That simplicity is why a code execution tool is about fifteen lines of glue and about a day of decisions. The fifteen lines are the boring part, so this post is mostly about the decisions: how long the sandbox lives, what happens when the model writes a loop that never ends, and what you hand back when the code prints two hundred kilobytes. I build PandaStack, a Firecracker microVM platform, so that's the sandbox in the examples — the shape is the same with any provider that gives you an isolated machine and an exec API.
Why the local execution options are a demo feature
Every agent framework, ADK included, offers some form of built-in or local code execution. They are genuinely useful for a demo, a notebook, or an internal tool where the only person typing is you. They are the wrong answer for anything a stranger can type into, and the reason is one sentence long.
A tool that calls exec() or spawns a subprocess runs model-generated code inside your agent's process, with your agent's credentials, on your agent's filesystem. The model's output is your server's shell.
Note what that sentence does not say. It doesn't say anyone has to attack you. The code being executed was written by a language model from a prompt that may include a user's message, a scraped web page, or a CSV somebody uploaded — and the model does not need to be jailbroken to delete a directory. It only needs to be wrong about which directory it's in, which is a thing models are wrong about routinely. The second problem is that a subprocess plus a timeout feels like a boundary and isn't one: a timeout bounds how long generated code runs, not what it can read while it runs. Your environment variables, your service account credentials, the metadata endpoint, your database connection, the rest of the filesystem — all in scope. On Cloud Run in particular, the process serving your agent usually holds a service account that can do interesting things to your project, and that identity is inherited by anything it spawns. A microVM sandbox moves execution to a different kernel on a different machine, with its own filesystem and its own network namespace, so nothing your agent process holds is reachable from inside it. There is no path from there to here except the one you built.
In ADK, the docstring is the prompt
This is the one ADK-specific thing worth internalising. Because a tool is a plain function, the declaration the model receives is derived from your signature and your docstring — which means the docstring is not documentation for a future colleague. It is the instruction the model reads at the moment it decides whether to call your tool and what to put in the arguments. So the usual complaint, "my agent won't use the code tool", is almost always a docstring that says what the tool is rather than when to use it and what the rules are. Write it like a prompt, because it is one.
# pip install pandastack
from pandastack import Sandbox
def run_python(code: str, session_id: str) -> dict:
"""Run Python code in a secure, isolated sandbox and return its output.
Use this for ANY calculation, data analysis, file processing, or code the
user's request implies. Never compute a number in your head - compute it
here and report what was printed.
State persists for the whole conversation. Variables, imports and files
created in one call are still available in the next one, so load data
once and query it in later steps.
Anything you want to see must be printed with print(); return values are
not captured. Write plots and generated files under /workspace/out and
tell the user the path - do not print file contents as bytes or base64.
Args:
code: The Python source to execute. pandas, numpy and matplotlib
are already installed.
session_id: The current conversation id, used to select the sandbox.
Returns:
A dict with "status" ("ok" or "error"), "stdout", "stderr" and
"duration_ms". On error, "error_type" and "traceback" explain what
went wrong so the code can be corrected and retried.
"""
sbx = sandbox_for(session_id)
# Write the source to a file rather than shelling in a -c string: no
# quoting bugs, and you get a replayable transcript of the session.
n = _next_cell(session_id)
path = f"/workspace/.cells/{n:03d}.py"
sbx.filesystem.write(path, code)
result = sbx.exec(f"python3 {path}", timeout_seconds=60)
return shape_result(result)Four things in that docstring are doing real work: naming the situations that should trigger a call, stating that state persists, stating that only printed output is visible, and stating where files go. Every one of those lines exists because I watched a model get it wrong without it. The `Returns:` block matters too — telling the model that errors come back as data with a traceback is what makes it try again instead of apologising to the user. One caution on the plumbing: how the session id reaches your function is exactly the part that has moved between ADK versions, whether that's a tool context object, an injected argument, or session state, so check the current docs. Whatever the mechanism, do not let the model supply the session id as a free-text argument. A model that can name any session can attach to somebody else's sandbox.
How long should the sandbox live?
This is the decision that shapes everything else, and it's the one people make by accident. There are three defensible answers and the right one depends entirely on what your agent is for.
- Lifetime — Per call: a fresh sandbox for every tool invocation, killed when it returns. Maximum isolation, zero state. Right for untrusted one-shots — a user pastes code, you run it, you throw the machine away. Wrong for analysis, because `df` is gone by the next turn and the model has to reload a CSV it already parsed.
- Lifetime — Per session: one sandbox per conversation, created on first use, killed when the session ends. The model's mental model matches reality: it wrote `df = pd.read_csv(...)` two turns ago and it expects `df` to still exist. This is the right default for anything conversational, and it's what the rest of this post assumes.
- Lifetime — Per user: one long-lived sandbox across sessions, with a home directory that persists. Nice for a personal analysis assistant where installed packages and past outputs should survive. Expensive, and it turns every mistake into a permanent one — a user who fills their disk in March is still full in April.
Per call gets picked more often than it should because it sounds safest. It is safest, and it also makes the agent noticeably worse: models reason about a Python session the way a person does at a REPL, and an environment that silently forgets everything between calls produces exactly the repetitive, frustrated behaviour you'd expect. If you do need per-call isolation, say so in the docstring — "state does not persist, re-load any data you need" — so the model plans around it rather than being surprised by it. The cost argument for per-call is weaker than it used to be, incidentally: on a snapshot-restore platform a fresh microVM comes back in roughly 179ms at p50, so a new machine per call is a rounding error next to the model's own response time. Prefer per session because it makes the agent better, not because it's faster.
Mapping the ADK session to a sandbox
Per-session means you need a mapping from ADK's session identifier to a sandbox. The simplest correct version is a dictionary holding the sandbox object, keyed by session id, created lazily. The important detail is that you hold the object — reattaching by id later is possible with most SDKs, but it's an extra failure mode and the method name for it is exactly the kind of thing that gets renamed between versions.
import threading
from pandastack import Sandbox
_lock = threading.Lock()
_sandboxes: dict[str, Sandbox] = {}
_cells: dict[str, int] = {}
SESSION_TTL_SECONDS = 3600
def sandbox_for(session_id: str) -> Sandbox:
"""Return this session's sandbox, creating it on first use."""
with _lock:
sbx = _sandboxes.get(session_id)
if sbx is not None:
return sbx
sbx = Sandbox.create(
template="code-interpreter",
ttl_seconds=SESSION_TTL_SECONDS,
metadata={
"app": "adk-analyst",
"session": session_id,
},
)
sbx.filesystem.write("/workspace/.cells/.keep", "")
_sandboxes[session_id] = sbx
_cells[session_id] = 0
return sbx
def _next_cell(session_id: str) -> int:
with _lock:
_cells[session_id] = _cells.get(session_id, 0) + 1
return _cells[session_id]
def close_session(session_id: str) -> None:
"""Call this when the ADK session ends, and from your idle sweeper."""
with _lock:
sbx = _sandboxes.pop(session_id, None)
_cells.pop(session_id, None)
if sbx is not None:
sbx.kill()The `metadata` on create is worth the two lines. When you're staring at a bill, or at a host doing something strange, the difference between "forty sandboxes" and "forty sandboxes, and here is the session id of each" is the difference between a query and an investigation. Tag them with whatever you'd want to filter on later: session, user, tenant, deployment. In production the dictionary becomes something that survives a restart — Redis, or your session store, holding sandbox ids rather than objects. Note that you then have two things that can disagree, your record and the platform's; make the platform the source of truth about liveness and treat a missing sandbox as "create a new one" rather than an error the user has to read.
Timeouts and cancellation
Every execution gets a timeout. Not most of them — every one, including the ones you're sure are fast. The model will eventually write `while True:` with no exit condition, or a nested loop over a dataframe that would finish in about a week, and without a timeout your agent turn simply never returns. Sixty seconds is a reasonable default for an analysis agent, but choose it against the layer above rather than against the code: if the ADK runner is behind an HTTP request with its own deadline, the tool timeout has to sit comfortably inside it, or the user gets a gateway error while your sandbox happily keeps computing. Budget downwards from the outermost deadline, not upwards from a guess.
When a timeout fires, treat it as information rather than a crash. Return it as a normal tool result saying the code exceeded the limit, and the model will usually do the sensible thing — sample the data, add a `LIMIT`, narrow the query. Raise an exception instead and you've thrown away a turn the model could have used to fix itself. The subtler failure is a cancelled request that leaves work running: if the user closes the tab and your framework abandons the turn, the sandbox has no idea. This is where per-session sandboxes are quietly helpful, because the abandoned process is inside a machine you're already tracking and closing the session kills both. If you build something more elaborate, make sure the cancellation path calls `kill()` and not just `return`.
Returning output the model can actually use
Here is the mistake I see most often, and it's expensive in the literal sense: the tool runs code, the code prints a dataframe, and two hundred kilobytes of output goes straight into the model's context. You pay for those tokens on this turn and on every subsequent turn of the conversation, and the model's attention is now spread across a wall of numbers it can't use. So truncate — but truncate correctly, which means keeping both ends. The head of the output has the column names, the shape, the first rows: the structural information. The tail has the actual answer, because whatever the model printed last is usually what it was computing. Keep only the head and you lose the result; keep only the tail and you lose the schema. Keep both, with an explicit marker in between so the model knows something was removed and doesn't reason as though it saw everything.
MAX_OUTPUT_CHARS = 6000
HEAD_CHARS = 2000
TAIL_CHARS = 3000
def _clamp(text: str, label: str) -> str:
"""Keep the head and the tail; the middle is almost never the answer."""
text = (text or "").strip()
if len(text) <= MAX_OUTPUT_CHARS:
return text
omitted = len(text) - HEAD_CHARS - TAIL_CHARS
marker = (
f"\n\n... [{omitted} characters of {label} omitted - re-run with a "
f"filter, a head(), or a summary if you need the middle] ...\n\n"
)
return text[:HEAD_CHARS] + marker + text[-TAIL_CHARS:]
def shape_result(result) -> dict:
"""Turn an exec result into something a model can act on."""
if result.exit_code == 0:
stdout = _clamp(result.stdout, "stdout")
return {
"status": "ok",
"stdout": stdout or "(ran successfully but printed nothing - use print())",
"stderr": _clamp(result.stderr, "stderr"),
"duration_ms": result.duration_ms,
}
trace = _clamp(result.stderr, "traceback")
return {
"status": "error",
"error_type": _last_exception_name(trace),
"traceback": trace,
"stdout": _clamp(result.stdout, "stdout"),
"duration_ms": result.duration_ms,
"hint": (
"Fix the code and call run_python again. If a module is missing, "
"install it first with: import subprocess; "
"subprocess.run(['pip', 'install', '<pkg>'])"
),
}
def _last_exception_name(traceback_text: str) -> str:
for line in reversed(traceback_text.splitlines()):
if line and not line.startswith(" ") and ":" in line:
return line.split(":", 1)[0].strip()
return "Error"Notice that the error path returns a dict rather than raising. This is the single highest-leverage decision in the whole tool. A traceback is the most actionable feedback a language model ever receives — models fix their own `NameError` and `KeyError` on the next attempt at a rate that would embarrass most humans — and raising converts that feedback into a failed turn and an apology to the user. Two caveats. Put a ceiling on it: track attempts per turn and stop after three or four, or a genuinely impossible task becomes an expensive argument between a model and an `ImportError`. And shape the error before returning it, because the tool boundary is the right place to strip anything from your infrastructure that you'd rather not see quoted back in a chat transcript.
Files the code produces
Sooner or later the agent makes a chart. The wrong instinct — and I have watched several teams have it — is to base64 the PNG into the tool result. Forty megabytes of base64 in a transcript is a bad afternoon: it blows the context window, it costs a fortune, and the model can't see the image anyway unless you've separately wired up multimodal handling. Files belong on the sandbox filesystem. The tool returns a path, your application reads the bytes back out and serves them the way it serves anything else, and the context window stays a context window. The same rule runs in the input direction: if the user uploaded a CSV, write it into the sandbox and tell the agent the path in the prompt rather than pasting rows into the context, where you'd pay for every row twice and the model would transcribe at least one number wrong. Bytes through the filesystem, paths through the model.
def collect_artifacts(session_id: str) -> list[dict]:
"""Pull anything the agent wrote to /workspace/out back out of the VM."""
sbx = sandbox_for(session_id)
listing = sbx.exec("ls -1 /workspace/out 2>/dev/null", timeout_seconds=10)
if listing.exit_code != 0 or not listing.stdout.strip():
return []
artifacts = []
for name in listing.stdout.split():
data = sbx.filesystem.read(f"/workspace/out/{name}")
artifacts.append({
"name": name,
"bytes": len(data),
"url": upload_to_object_store(session_id, name, data),
})
return artifacts
# And in the input direction: put the data there before the agent asks for it.
def seed_input(session_id: str, csv_bytes: bytes) -> str:
sbx = sandbox_for(session_id)
sbx.filesystem.write("/workspace/data.csv", csv_bytes)
return "/workspace/data.csv"What the model will do to you
It will pip install the internet
Faced with a missing module, the model installs it. Faced with a failing install, it installs three more things that might help. This is fine and you should mostly let it — a code agent that can't install a package is a code agent that gives up — but it means outbound network from the sandbox is a decision you make deliberately rather than discover in a bill. Each sandbox getting its own network namespace is what makes that decision enforceable: allow the package index and nothing else, or nothing at all for hostile workloads, without touching the tool. Just remember that a locked-down sandbox needs its docstring to say so, or the model will spend three turns retrying an install against a wall.
It will write an infinite loop
Usually not on purpose. It's a `while` waiting on a condition that a bug prevents, or a retry loop around a network call that will never succeed because there is no network. This is the timeout's entire reason for existing, and it's why the timeout belongs per execution rather than per turn: the model should get its "that timed out" result and a chance to fix it, not a hung request and a user watching a spinner.
It will read your environment variables
`os.environ` is a completely reasonable thing for generated code to touch — it's looking for a database URL, an API key, a region. It isn't being malicious; it's doing what the tutorials do. The only question is what it finds. In a subprocess it finds your service account credentials and your production connection string. In a microVM on another host it finds whatever you deliberately put there, which should be nothing you'd mind seeing quoted back in a chat transcript. Assume everything inside the sandbox is one clever prompt away from being printed.
One day it will emit rm -rf /
Not because it hates you. Because a Stack Overflow answer from 2013 said that's how you clear a build directory, and the model has read that answer, and today it's being asked to tidy up a workspace. When it happens, the only question that matters is what "/" refers to. If the answer is "a disposable microVM filesystem that a fresh restore recreates in a couple of hundred milliseconds", you have an amusing log line. If the answer is "the container your agent is serving from", you have an incident, a postmortem, and a very quiet standup. The pattern across all four is the same: none of them require an attacker. They are ordinary model behaviour meeting an environment that assumed better, and if you design for the ordinary behaviour the adversarial case is mostly covered as a side effect.
Cleanup, and the TTL nobody remembers
Three layers, and you want all three because each covers a different way the previous one fails. An explicit `kill()` when the session ends handles the happy path. An idle sweeper that reaps sandboxes whose sessions haven't been touched in a while handles users who close the tab without saying goodbye, which is the overwhelming majority of them. And a TTL set at creation handles the case where your process died and neither of the first two ran. If you only implement one, implement the TTL — it's an argument to `create()`, and it's the only layer that survives your own code being dead. Everything above it is an optimisation that saves money by releasing resources sooner.
A note on deployment
ADK agents are served through its runner and typically deployed to Vertex AI Agent Engine or Cloud Run. Both are managed environments where you don't fully control the process, which is another way of saying that any local executor needing a Docker daemon or a writable host filesystem is going to be awkward at best. A sandbox reached over HTTP has no such problem: the deployment needs network access and an API key, which every managed runtime has. The other deployment-shaped trap is that managed runtimes scale past one instance and your `_sandboxes` dictionary is per-instance. Two requests from the same conversation landing on different instances will each create a sandbox and neither will see the other's variables, which presents to the user as an agent with amnesia every other turn. Move the mapping into shared storage before you scale, not after somebody files that bug.
The short version
- Write the docstring as a prompt: when to call it, that state persists, that only printed output is visible, where files go.
- Default to one sandbox per session; per call for untrusted one-shots, per user only if you mean it.
- Never let the model choose the session id — derive it from context you control.
- Timeout every execution, budgeted inside the deadline of whatever is calling you.
- Truncate output keeping head and tail with a marker between; never dump a dataframe into the context.
- Return errors as structured data with the traceback, bounded by a retry ceiling, so the model can fix itself.
- Bytes through the filesystem, paths through the model. Never base64 a PNG into a transcript.
- Kill on session end, sweep on idle, and set a TTL for the day neither of those runs.
Almost none of that is about ADK. Swap the function wrapper and the same tool body works in LangChain, CrewAI or Pydantic AI — which is convenient, because ADK's surface will have moved by the time you read this and the decisions won't have.
Frequently asked questions
Why not just use ADK's built-in code execution?
For a demo, a notebook, or an internal tool where you're the only user, it's the right call — less code and nothing to operate. The problem starts when a stranger can influence the prompt. Built-in and local executors generally run generated code close to your agent process, which puts your environment variables, your service account and your filesystem in scope for whatever the model writes. On Cloud Run that service account often has real permissions in your project. A sandbox reached over HTTP removes the shared blast radius entirely, and it deploys anywhere your agent can make an outbound request, which managed runtimes make easy and Docker-based executors make hard.
Should each ADK session get its own sandbox, or each tool call?
Per session, for anything conversational. Models reason about a Python environment the way a person does at a REPL: they load a CSV in one turn and expect the dataframe to still exist in the next. A fresh sandbox per call breaks that assumption silently, and you get an agent that reloads and re-parses the same file every turn, burning tokens and occasionally producing inconsistent results. Per call is right when you're running genuinely untrusted one-shot code and want maximum isolation between submissions. If you pick per call, say so in the docstring so the model plans for a cold environment rather than being surprised by one.
How much output should the tool return to the model?
A few thousand characters, keeping both ends. The head carries the structural information — column names, shapes, the first rows — and the tail carries the answer, because whatever the model printed last is usually what it was computing. Dropping the middle and inserting an explicit marker means the model knows something was removed and won't reason as if it saw everything. Never return a full dataframe dump: you pay for those tokens on this turn and every turn afterwards, and the model can't use most of them. If it genuinely needs the middle, it can re-run with a filter, which is far cheaper than shipping the whole thing.
Should the tool raise an exception when the code fails?
No. Return a structured result with a status field, the exception type and the traceback. Tracebacks are the most useful feedback a model gets — a NameError or a missing import gets corrected on the next attempt with high reliability — and raising throws that opportunity away, turning a recoverable mistake into a failed turn. Add a retry ceiling of three or four attempts per turn so an impossible task can't loop forever, and shape the error before returning it so nothing from your infrastructure ends up quoted in a chat transcript. Errors as data, bounded by a counter, is the pattern.
How do I handle charts and files the agent generates?
Have the code write them to a known directory in the sandbox — /workspace/out works fine — and say so in the docstring. The tool returns the path; your application reads the bytes back over the filesystem API afterwards and serves them like any other file, or uploads them to object storage and hands back a URL. What you must not do is base64 the file into the tool result: a forty megabyte PNG as base64 will blow your context window, cost real money, and still not be visible to the model unless you've separately wired up multimodal input. Bytes through the filesystem, paths through the model.
Keep reading
- How to give a Pydantic AI agent a code execution tool — The same tool, with typed deps and a retry path
- How to give a CrewAI agent a code execution tool — What changes when several agents share one kernel
- Agent tool timeouts and cancellation — Budgeting deadlines from the outside in
- Sandbox lifetime, TTL and idle timeouts — The three cleanup layers in detail
49ms p50 cold start. Fork, snapshot, and scale to zero.