How to give an Agno agent a code execution tool
Agno's whole pitch is that an agent should be cheap. Instantiate one in the time it takes to allocate a dict, keep the memory footprint small enough that you can hold thousands of them, hand it a list of ordinary Python functions as tools, and go. Adding a capability feels like adding a helper function, because that is literally what it is.
I'm Ajay; I build PandaStack, a Firecracker microVM platform, and the tool people show me most often is a fifteen-line `run_python` that shells out to the interpreter. It works on the first try, it demos beautifully, and it is a remote code execution endpoint wearing a docstring. This post is about the version that does not do that: where the code actually runs, what the docstring has to say to the model, how much output comes back, and who is responsible for killing the sandbox when the run ends.
Why Agno agents end up executing code
The first tools you give an agent are wrappers. Search the docs. Query Postgres. Call an internal HTTP endpoint. Those are bounded capabilities — the tool does one thing, and the worst input produces a bad result rather than a bad outcome. You can reason about the blast radius by reading the function body, which is the nicest property a tool can have.
Then a user asks the agent to reconcile two exports, or reshape a nested JSON payload into a table, or work out a weighted growth rate the way an analyst would, and there is no fixed tool for it because there is no fixed shape to it. The general answer — the one that makes the agent genuinely useful instead of a menu — is to let the model write the transform and run it. That is a real capability jump, and it is also the moment the action space stops being the five things you wrapped and becomes any Python the model can produce.
Everything interesting about this feature is therefore in the execution environment, not the tool list. Agno makes the tool list easy. It does not make that decision for you.
The tool everyone writes first
Here is the implementation that appears in every prototype, with the security model spelled out underneath it in the comments where it belongs.
import subprocess
from agno.agent import Agent
def run_python(code: str) -> str:
"""Runs a short Python snippet and returns its output.
Args:
code: The Python source to run.
"""
proc = subprocess.run(
["python", "-c", code],
capture_output=True,
text=True,
timeout=30,
)
return proc.stdout + proc.stderr
agent = Agent(model=..., tools=[run_python], instructions="...")
# Now read the docstring again, then read what the tool actually grants:
#
# os.environ every secret your deploy injects -- DATABASE_URL, the
# model provider key, cloud credentials, the Stripe key
# open("/etc/...") your filesystem, as whatever user the API runs as,
# including the private key you mounted for something else
# requests.get(...) your network position: private subnets, internal
# services that trust anything from this box, and the
# cloud metadata endpoint at 169.254.169.254
# os.fork() timeout=30 kills the child it can see. It does not kill
# the grandchild that detached from it and kept going.
#
# The docstring says "runs a short Python snippet". The model reads that as a
# capability grant, and it is not wrong.Swapping `subprocess` for `exec()` is worse, not better, which surprises people who reach for it to avoid the process overhead. `exec()` has no separate process at all: the code runs inside your interpreter, sharing your module globals, your open database session, your connection pool, and your signal handlers. There is no clean way to time it out and no way at all to take back memory it decided to allocate. It is the same permission grant with the isolation removed.
None of this requires a malicious user. The snippet the model produces is downstream of everything in its context, and if any of that came from a web page, a PDF, an uploaded spreadsheet, or a support ticket, then a stranger has partial write access to the string you are about to execute. In this setting prompt injection is not a jailbreak curiosity; it is a code path with a name and a line number.
The same tool, running somewhere else
The good version barely changes shape. The tool function stops being the thing that runs the code and becomes a courier: it holds a handle to a sandbox, forwards the source, and returns text. Everything the model sees is identical. Everything about the failure mode is different, because the process that executes the snippet has its own kernel and knows nothing about your application.
from pandastack import Sandbox
from agno.agent import Agent
MAX_CHARS = 6_000
def _clip(text: str) -> str:
if len(text) <= MAX_CHARS:
return text
dropped = len(text) - MAX_CHARS
return text[:MAX_CHARS] + (
f"\n\n[TRUNCATED: {dropped} more characters were dropped. "
"Print a summary, a shape, or a slice instead of the whole object.]"
)
def make_run_python(sbx: Sandbox):
"""Build a run_python tool bound to one sandbox."""
def run_python(code: str) -> str:
"""Execute Python in an isolated Linux sandbox and return its output.
Use this whenever a question needs real computation, file parsing, or a
library -- write the code and run it rather than answering from memory.
The sandbox is a separate machine: it cannot see this application.
Files you write under /work persist for the whole conversation, but
variables do NOT survive between calls, so print anything you need.
pandas and numpy are installed; there is no network access.
If the code raises, you get the traceback back as plain text. Read it,
fix the code, and call this tool again.
Args:
code: Python source to execute. Include every import you use.
Returns:
Combined stdout and stderr, truncated if very long.
"""
sbx.filesystem.write("/work/snippet.py", code)
r = sbx.exec("cd /work && python snippet.py", timeout_seconds=120)
out = (r.stdout or "") + (r.stderr or "")
if r.exit_code != 0:
out += f"\n[process exited with code {r.exit_code}]"
return _clip(out) or "(the snippet ran and produced no output)"
return run_python
sbx = Sandbox.create(template="code-interpreter", ttl_seconds=1800)
try:
agent = Agent(
model=..., # whichever model you already use
tools=[make_run_python(sbx)],
instructions=[
"You are a data analyst. Compute answers with run_python instead",
"of estimating them. If a call returns a traceback, read it and",
"fix the code before retrying.",
],
markdown=True,
)
agent.print_response("How many rows in /work/orders.csv have a null total?")
finally:
sbx.kill()Four details in there are load-bearing, and each corresponds to something that bites later.
- The closure. The sandbox is a dependency passed in, not a module-level global. Globals are how a per-run sandbox quietly becomes a shared one the first time your service handles two requests at once.
- `ttl_seconds` is a backstop, not a timeout. Your cleanup will fail eventually — an unhandled exception, a pod restart, a network blip between create and the line that recorded the id. A server-side lifetime means the leak reaps itself instead of billing forever.
- Write the file, then run it. `python -c` reports errors against a synthetic `<string>` with no line context; a real file gives the model a traceback with line numbers that match the code it wrote, which is the difference between a self-correcting agent and a confused one.
- `finally`. Not a context manager you might forget, not a cleanup at the end of the happy path — the one place that runs when the agent throws, when the user disconnects, and when the model decides the task is impossible.
The docstring is the schema, so write it like a prompt
In Agno you do not hand-write a JSON schema. The signature supplies the parameter names and types, and the docstring supplies the description that ends up in front of the model. That is a genuinely lovely ergonomic and it hides a trap: a vague docstring is a correctness bug wearing a style-guide costume. Nobody reviews it, because it looks like documentation.
It is not documentation. It is the only thing telling the model how this particular execution environment behaves, and the model will invent confident defaults for anything you leave out. Every gap has a predictable failure:
- State — Vague: the model assumes a REPL, references a DataFrame it built three calls ago, and gets a NameError it cannot explain. Specific: it either rebuilds state in each snippet or uses the persistent kernel deliberately.
- Packages — Vague: it writes an import for something that is not installed, gets an ImportError, and tries `pip install` in a sandbox with no network. Specific: it works with what is there, or asks you for the package.
- Filesystem — Vague: it writes output to the current directory, which it guesses is the home directory, and the next call cannot find the file. Specific: everything lands in /work and the paths line up.
- Errors — Vague: it treats a returned traceback as a final answer and reports failure to your user. Specific: it reads stderr, fixes the bug, and calls again — which models are genuinely good at.
- Output — Vague: it prints an entire DataFrame and then reasons over a silently truncated tail. Specific: it prints `df.head()` and `df.shape` because you told it that output is capped.
Five extra sentences in a docstring buy you all of that. It is the highest-leverage prose in the codebase and it is usually the least edited.
Truncate the output, and tell the model that you did
The default implementation returns whatever the process printed. Somewhere around the third useful analysis, the model writes `print(df.to_string())` on a two hundred thousand row frame because that is what a person does at a terminal. The result is a single tool response that fills the context window, evicts the actual task, and costs more than the rest of the conversation combined. The agent then produces a worse answer than it would have without the data.
Cap the return value at a few thousand characters. That covers essentially every genuinely useful result: printed values, a small table, an error and its traceback. Then do the part people skip — say in the returned text that you truncated it, how much was dropped, and what to do instead. A model that is told output was cut will print a summary next time. A model handed a silently amputated string will reason over it as if it were complete, which is a subtler and worse bug than the cost.
Keep stderr verbatim inside that budget rather than replacing it with a generic message. A stack trace is the highest-value payload the tool can return, because it converts a failed turn into a fixable one.
When one snippet at a time is not enough
The exec-per-call design above is stateless by construction: each snippet is a fresh interpreter, and only files persist. That is the right default and it is fine for self-contained work. It is painful for real analysis, where the agent wants to load a file once, look at it, then ask three follow-up questions of the object already in memory. Forcing it to re-read and re-parse on every turn burns tokens and produces worse plans, because the model spends its reasoning budget on bookkeeping.
The fix is a persistent code context — a long-lived kernel inside the sandbox, the same idea as a notebook. Wrap it in a toolkit class so setup and teardown have somewhere to live.
from agno.tools import Toolkit
from pandastack import Sandbox
class CodeSandboxToolkit(Toolkit):
"""A persistent Python kernel inside a microVM, exposed as agent tools."""
def __init__(self, packages: list[str] | None = None, **kwargs):
super().__init__(name="code_sandbox", **kwargs)
self.sbx = Sandbox.create(template="code-interpreter", ttl_seconds=1800)
if packages:
# Pre-install once, at setup -- not on the model's whim mid-run.
self.sbx.exec(
"pip install " + " ".join(packages), timeout_seconds=300
)
# The kernel: variables and imports survive across tool calls.
self.ctx = self.sbx.create_code_context(language="python")
self.register(self.run_python)
self.register(self.put_file)
def run_python(self, code: str) -> str:
"""Run Python in a persistent kernel and return stdout, stderr, errors.
The kernel KEEPS STATE between calls: variables, imports and DataFrames
from earlier calls are still in memory. Build an analysis up step by
step rather than resending the whole script each time.
The working directory is /work. pandas and matplotlib are installed.
Output is truncated if long, so print summaries, not whole objects.
Args:
code: Python source to execute in the kernel.
"""
ex = self.ctx.run_code(code, timeout_seconds=120)
logs = ex.logs
parts = [logs.get("stdout", ""), logs.get("stderr", "")]
err = getattr(ex, "error", None)
if err:
parts.append(str(err))
return _clip("\n".join(p for p in parts if p)) or "(no output)"
def put_file(self, name: str, contents: str) -> str:
"""Write a text file into the sandbox at /work/<name> before running code.
Args:
name: File name relative to /work, for example "data.csv".
contents: The text to write.
"""
self.sbx.filesystem.write(f"/work/{name}", contents)
return f"wrote /work/{name} ({len(contents)} bytes)"
def close(self) -> None:
self.ctx.close()
self.sbx.kill()
tools = CodeSandboxToolkit(packages=["pandas", "matplotlib"])
try:
agent = Agent(model=..., tools=[tools], instructions="...")
agent.print_response("Plot the weekly revenue trend from /work/orders.csv")
finally:
tools.close() # this line is the entire cleanup strategy; do not lose itTwo things to notice. Pre-installing packages in the constructor is not just a speed optimisation — it makes runs reproducible, because the environment stops depending on what PyPI served that afternoon and on whether the model felt like installing something. And if the `Toolkit` base class import has moved by the time you read this, the pattern degrades gracefully: a plain class whose bound methods you pass into `tools=[self.run_python, self.put_file]` behaves identically, because Agno is reading callables with docstrings either way.
Who owns the sandbox
One sandbox per agent run is the right default. State should persist across the steps of a single task — that is the point of the kernel — and it should not persist into the next task, because a file left over from someone else's question turning up in yours is a data-leak bug dressed as a caching win.
Per user, held open across a session, is defensible when continuity is the product: an analyst assistant where the user expects yesterday's loaded dataset to still be there. If you do that, be deliberate about it. The sandbox now contains everything that user has ever uploaded or computed, and its lifetime is part of your data-retention policy whether or not anyone wrote that down.
Never share one across users. This sounds too obvious to state, and it happens constantly, always as a module-level singleton that was correct in development and became multi-tenant on deploy. If a sandbox handle is reachable from an import statement, it is shared.
Return the traceback; do not raise it
When the snippet fails, the instinct of a Python engineer is to raise. Resist it. An exception propagating out of a tool function ends the agent run, and what the user gets is a failure — for a missing import, a typo, or an off-by-one in a slice.
Return the traceback as a string instead. Models are unreasonably good at reading their own tracebacks: a `KeyError: 'total_amount'` is enough for the model to go and print the column list, notice the field is called `total`, and fix it in the next call. That loop only exists if the error reaches the model as text. Reserve real exceptions for infrastructure problems the agent cannot do anything about — the sandbox failed to create, the platform is unreachable — and even then, one retry with a short backoff before you give up.
The same argument covers timeouts. When a snippet exceeds its limit, return a clear sentence saying it exceeded the limit and was killed. The model will usually spot the runaway loop and rewrite it. A generic "tool error" produces a blind retry of the identical code.
What isolation actually costs
The objection to a VM per run is always cost and latency, and it is worth answering with numbers rather than vibes. On PandaStack there is no warm pool of idle VMs — every create restores a baked Firecracker snapshot, which lands around 179ms at p50 and 203ms at p99. That is well under the first token of the model's first response, which means the sandbox is ready before the agent has decided to use it.
Billing is per second at $0.054 per active vCPU-hour and $0.0162 per GiB-hour, so a sandbox sitting idle while the model thinks bills close to nothing for that waiting — and an agent run is mostly waiting. Set against the tokens a single multi-step run burns, the compute is a rounding error.
Which reframes the trade honestly. Running generated code in your API process does not save money in any amount worth discussing. It saves about twenty minutes of setup, once, and charges you the difference in a currency you would rather not be billed in.
Before you ship it
- The code runs in something with its own kernel, not in your API process, for any agent whose context includes text you did not write.
- The sandbox holds no credentials it does not need. Pass the one token the task requires, if any, rather than forwarding your environment.
- Network is off unless the task needs it, and allow-listed when it does.
- Every call has an execution timeout and the sandbox has a TTL that outlives no conversation.
- Output is truncated before it reaches the model, with a marker saying so and telling it what to print instead.
- The docstring states what persists, what is installed, where files go, and that errors come back as text.
- Teardown lives in a `finally`, and there is a periodic sweep for the sandboxes your bookkeeping lost anyway.
Get those right and code execution becomes the most valuable tool in the agent's list, because it is the one that turns a confident guess into a checked answer. Get them wrong and it is the most valuable tool in someone else's list.
Frequently asked questions
Can I just use subprocess for an Agno code execution tool?
Only if the code is yours and the model cannot influence it, which defeats the point of the tool. `subprocess.run` starts a child that inherits your environment variables, your filesystem access, and your network position, so a snippet the model wrote can read your database URL, reach internal services, and query the cloud metadata endpoint. The timeout argument is also weaker than it looks: it kills the child process it started, not anything that child forked and detached. If you need generated Python to run at all, it needs to run somewhere that has no interesting access to lose.
Does the docstring really matter, or is it just documentation?
It is the schema. Agno derives the tool description the model sees from your docstring, and the parameter names and types from the signature, so a vague docstring is a functional defect rather than a cosmetic one. State explicitly whether variables persist between calls, which packages are installed, what the working directory is, whether there is network access, and that errors come back as text to be fixed. Every fact you omit gets replaced by a confident model assumption, and the failure that follows looks like a model problem when it is a prose problem.
Should state persist between tool calls in an Agno agent?
Within a single run, usually yes. A persistent code context — a long-lived kernel inside the sandbox — lets the agent load a dataset once and ask several questions of the object already in memory, instead of re-parsing on every turn and spending its reasoning budget on bookkeeping. Across runs, usually no: one sandbox per run means nothing leaks from one task into the next, and the cleanup story is a single `finally`. Per-user sandboxes held across a session are defensible for interactive assistants where continuity is the feature, but that sandbox then accumulates everything the user has ever done and becomes part of your retention policy.
How do I stop a code tool from flooding the model's context window?
Truncate the return value at a fixed budget — a few thousand characters is plenty for printed values, a small table, or a traceback — and append a marker that says how much was dropped and what to do instead. The marker matters as much as the cap: a model told its output was cut will print a summary or a slice next time, whereas one handed a silently shortened string will reason over partial data as though it were complete. Keep stderr verbatim within that budget, because a stack trace is the single most useful thing the tool can hand back.
Is a microVM per agent run too slow or too expensive?
Neither, on both axes, once you measure instead of guessing. On PandaStack a create is a snapshot restore rather than a boot — roughly 179ms at p50 and 203ms at p99 — so the sandbox is ready before the model has finished deciding to use it, and there is no warm pool of idle VMs paying rent in the background. Billing is per second at $0.054 per active vCPU-hour and $0.0162 per GiB-hour, so the sandbox waiting on the model costs close to nothing. Against the token bill for a multi-step run, the compute is a rounding error, which makes weaker isolation hard to justify on cost grounds.
Keep reading
- The same idea, in Pydantic AI
- Give a CrewAI agent a code execution tool
- From prompt injection to RCE — why the tool argument is untrusted even when your user is not
- Jailing LLM-generated code
- Sandboxes for AI agents — a microVM per run, restored in ~179ms
49ms p50 cold start. Fork, snapshot, and scale to zero.