Pydantic AI code execution with PandaStack
Give a Pydantic AI agent a run_python tool backed by a PandaStack code execution sandbox in Python — the model writes code, the microVM runs it, and charts come back as objects.
A Pydantic AI agent that needs to compute — crunch numbers, parse a file, plot a trend — has to run the code it writes somewhere. Running it in your process is a liability: a hallucinated os.system call is now your problem. This recipe gives a Pydantic AI Agent a run_python tool backed by a PandaStack code interpreter microVM — a pydantic ai code execution sandbox in Python where the model's code runs in Firecracker isolation and hands back a chart object, not stdout it has to parse.
The tool is a long-lived code context — a Jupyter-style kernel inside the VM. State survives across tool calls, so the agent can define a DataFrame in one step and plot it in the next, exactly like a notebook.
Install
The PandaStack SDK reads PANDASTACK_API_KEY from the environment — never hardcode it.
export PANDASTACK_API_KEY=pds_... # your API key
pip install pandastack pydantic-aipydantic-ai pulls in the agent framework; install the provider extra for whichever model you use (e.g. the OpenAI or Anthropic client). The model string below is illustrative — swap in any provider:model-name you have credentials for.
The tool: one persistent kernel, reused across calls
The win is persistence. Instead of a fresh sandbox per tool call (and losing every variable between them), create one CodeContext up front and reuse it. The agent's imports, DataFrames, and intermediate results carry over from one run_python call to the next.
from pandastack import Sandbox
# One sandbox + one persistent kernel for the whole agent session.
sandbox = Sandbox.create(template="code-interpreter", ttl_seconds=3600)
ctx = sandbox.create_code_context(language="python") # state persists across calls
# Carries any chart the last run_python call produced, for surfacing later.
last_png: str | None = NonePydantic AI generates the tool's JSON schema from the function signature and docstring automatically. Use @agent.tool_plain for a tool that does not need the run context — that's exactly our case, since the kernel handle is a module-level object the function closes over.
from pydantic_ai import Agent
agent = Agent(
"anthropic:claude-sonnet-4-6", # any provider:model-name you have configured
system_prompt=(
"You are a data analyst. Use the run_python tool to compute answers "
"and to plot results. The kernel keeps state between calls, so build "
"up your analysis incrementally. Prefer matplotlib for charts — they "
"are captured automatically, so never savefig or write image files."
),
)
@agent.tool_plain
def run_python(code: str) -> str:
"""Execute Python in a persistent sandbox kernel and return its output.
State (variables, imports, DataFrames) persists across calls within this
session. Use matplotlib/plotly normally — charts are captured as objects,
so you do not need to savefig or write files. On error, the traceback is
returned so you can read your own failure and fix it.
"""
global last_png
ex = ctx.run_code(code)
last_png = ex.png # remember the chart (if any) for surfacing later
if ex.error:
return f"ERROR:\n{ex.error}\n\nstdout:\n{ex.stdout}".strip()
return ex.stdout or ex.text or "(no output)"The docstring becomes the tool description the model reads, and the type-annotated code: str parameter becomes the args schema — so the docstring is doing real work, not decoration. Returning stderr and the traceback alongside stdout is the move that lets the model self-correct: it reads its own error on the next turn and retries.
Pydantic AI is Python-only, so there's no TypeScript agent wiring. The underlying PandaStack call maps one-to-one if you build the same tool in a TS framework:
ex = ctx.run_code(code) # Execution: .stdout .stderr .error .text .pngconst ex = await ctx.runCode(code); // Execution: { stdout, stderr, error?, text?, png? }@agent.tool_plain vs @agent.tool
Two decorators, one underscore apart:
| Decorator | First param | Reads dependencies | Use when |
|---|---|---|---|
@agent.tool_plain | your tool args only | no | the tool is self-contained (this recipe) |
@agent.tool | ctx: RunContext[DepsType] | ctx.deps | the tool needs per-run dependencies |
@agent.tool_plain is the right call here: the kernel is a session-level resource shared by every invocation, not a per-run dependency. If you'd rather inject the CodeContext through Pydantic AI's typed dependencies instead of a module global, declare Agent(..., deps_type=CodeContext), switch to @agent.tool with a ctx: RunContext[CodeContext] first parameter, read the kernel via ctx.deps, and pass deps=ctx to the run call below.
Run it
agent.run_sync(prompt) is the synchronous entry point — it wraps the async agent.run(prompt) coroutine, so you don't need an event loop. The final answer is on result.output.
result = agent.run_sync(
"Generate 1,000 samples from a normal distribution (mean 50, std 8) with "
"numpy. Report the sample mean and the 95th percentile, then plot a "
"histogram of the samples with matplotlib (call plt.show())."
)
print(result.output)Behind the scenes the agent emits a tool call, Pydantic AI runs run_python (which executes the code in the microVM), feeds the kernel's output back into the conversation, and the model reasons over it. Because the kernel is persistent, a follow-up turn — "now overlay the mean as a vertical line" — reuses the already-generated samples instead of regenerating them.
| Symbol | What it carries |
|---|---|
result.output | the agent's final natural-language answer (use this, not the removed result.data) |
ex.stdout / ex.text | what the tool hands back to the model |
ex.error | formatted traceback when a cell raises — returned so the model can self-correct |
ex.png | base64 PNG of any chart the cell rendered — captured automatically |
ex.results | full list of rich Result objects (.html, .json, .svg, .markdown, …) |
The wedge: charts come back as objects
The code-interpreter template bakes IPython, matplotlib, plotly, kaleido, and pandas, so a plt.show() or a bare DataFrame as the last expression is captured as a rich result — no savefig, no temp file, no second download. The agent's tool gets a chart object (ex.png), not pixels flattened into text.
A multimodal model can see that chart. After a run that produced one, hand the captured PNG back as an image and ask a follow-up — message_history keeps the prior turn in context:
from pydantic_ai import BinaryContent
import base64
if last_png:
followup = agent.run_sync(
[
"Describe the trend in this histogram in one sentence.",
BinaryContent(data=base64.b64decode(last_png), media_type="image/png"),
],
message_history=result.all_messages(),
)
print(followup.output)For tables, return ex.results[0].html from the tool instead of a flattened repr — a DataFrame renders as an HTML table the model can reason over directly. See the code interpreter guide for the full result model.
Cleanup
The kernel and its microVM are reaped when the ttl_seconds budget expires, but close them explicitly when a session ends:
ctx.close() # or use: with sandbox.create_code_context() as ctx: ...
sandbox.kill()To make agent sessions resumable across process restarts, snapshot the sandbox instead of killing it and restore on the next turn — the kernel state comes back with it.
MCP alternative
Pydantic AI also ships mcp-run-python, an MCP server that exposes a code-execution tool over the Model Context Protocol. If you'd rather wire code execution as an MCP server — usable by any MCP client, not just Pydantic AI — run the equivalent PandaStack-backed server from the MCP code execution server recipe and point your agent's toolset at it. Same isolation, different integration surface: the @agent.tool_plain path above is in-process; the MCP path is a separate service.
Next steps
- Code interpreter — the pillar guide: code contexts, rich results, one-shot exec, and file uploads.
- MCP code execution server — expose this same wedge over MCP (Pydantic AI's
mcp-run-python, self-hosted). - Run code from a LangGraph agent — the same tool wired into a graph runtime.
- Build a data-analyst agent — upload a CSV and stream charts back to the model.
LlamaIndex code execution tool with PandaStack
Give a LlamaIndex agent a Python sandbox by wiring a code execution tool to a PandaStack CodeContext, so the model can run code instead of guessing.
smolagents secure code execution with PandaStack
Give a Hugging Face smolagents agent a run_python tool for secure code execution in an isolated PandaStack sandbox microVM.