Mastra agent code execution (TypeScript)
Give a Mastra AI agent a code execution sandbox tool in TypeScript — a createTool() backed by a PandaStack Firecracker microVM that hands the model charts as objects, not stdout.
A Mastra agent that needs to compute — sum a column, fit a regression, plot a trend — has to run code somewhere. Running it in your Node process is a liability: a hallucinated child_process.exec is now your problem. The fix is a tool that runs the code in an isolated Firecracker microVM, and hands the model back a chart object instead of stdout it has to parse.
This recipe wires a Mastra createTool() to a persistent PandaStack code context — a long-lived Jupyter-style kernel inside a microVM. State survives across tool calls, so the agent can build a DataFrame in one step and plot it in the next, exactly like a notebook. It's the TypeScript sibling of the Vercel AI SDK recipe; the framework wiring differs, the PandaStack half is identical.
Setup
export PANDASTACK_API_KEY=pds_... # your PandaStack key
export ANTHROPIC_API_KEY=sk-ant-... # for the model-router string below
npm install @mastra/core @pandastack/sdk zodThe SDK reads PANDASTACK_API_KEY from the environment — never hardcode it. @mastra/core ships both createTool and Agent. The model is set with a router magic string ("anthropic/claude-..."), so you don't import an @ai-sdk/* provider — just set the matching provider key (ANTHROPIC_API_KEY here). zod v3 or v4 both work.
The kernel: one persistent context, 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 tool invocation to the next.
import { Sandbox, type CodeContext } from "@pandastack/sdk";
let _ctx: CodeContext | null = null;
// Lazily create one sandbox + one persistent kernel for the whole agent session.
async function kernel(): Promise<CodeContext> {
if (!_ctx) {
const sandbox = await Sandbox.create({
template: "code-interpreter",
ttlSeconds: 3600,
metadata: { tool: "mastra-code-exec" },
});
_ctx = await sandbox.createCodeContext(); // Jupyter-style kernel; state persists
}
return _ctx;
}The kernel is created on first use, so merely importing this module doesn't spin up a live VM.
The tool: createTool backed by runCode
createTool takes an id, a description (both required — the model reads the description to decide when to call the tool), a zod inputSchema, and an execute callback. The callback's first argument is the validated input — destructure your schema fields straight off it.
import { createTool } from "@mastra/core/tools";
import { z } from "zod";
export const runPythonTool = createTool({
id: "run-python",
description:
"Execute Python in a secure, isolated sandbox kernel and return its output. " +
"State (variables, imports, DataFrames) persists across calls within this " +
"session, so build up your analysis incrementally. Use matplotlib/plotly " +
"normally — charts are captured automatically; do not savefig or write files.",
inputSchema: z.object({
code: z.string().describe("Python source to run in the sandbox kernel."),
}),
outputSchema: z.object({
output: z.string(),
chart: z.string().nullable().describe("Base64 PNG if the cell rendered one."),
}),
// First arg = the validated input. (Second arg is the execution context.)
execute: async ({ code }) => {
const ctx = await kernel();
const ex = await ctx.runCode(code);
if (ex.error) {
// Hand the traceback straight back so the model can self-correct.
return { output: `Error:\n${ex.error}`, chart: null };
}
const output = ex.stdout || ex.text || "(no output)";
return { output, chart: ex.png ?? null };
},
});Signature gotcha. In current Mastra the first
executeparameter is the input data — useasync ({ code }) => .... The oldasync ({ context }) => ...destructure was the pre-1.x shape; don't use it.
The fields you wire on createTool:
| Field | Required | What it is |
|---|---|---|
id | yes | Stable tool identifier the runtime uses |
description | yes | What the model reads to decide when to call the tool |
inputSchema | no (but use it) | zod schema; validated input is passed to execute |
outputSchema | no | zod schema for the return value |
execute | yes | async (inputData, context) => result; first arg is the validated input |
Register it on an Agent
Agent needs a name, instructions, and a model. Tools are passed as a plain object map keyed by name — { runPythonTool }, not an array. The key becomes the tool's reference name to the agent.
import { Agent } from "@mastra/core/agent";
export const analystAgent = new Agent({
name: "Data Analyst",
instructions:
"You are a rigorous data analyst. Never guess at numbers — compute them by " +
"calling run-python. The kernel keeps state between calls, so build up your " +
"analysis step by step. Read any error traceback and fix your own code before " +
"answering. Prefer matplotlib for charts.",
model: "anthropic/claude-3-opus", // model-router string; swap for any current model
tools: { runPythonTool }, // OBJECT MAP, not an array
});The model value is a model-router magic string — "anthropic/claude-3-opus", "openai/gpt-4", etc. Pick any current model your provider key covers; this is the one knob you swap.
Generate
One generate call runs the full loop: the model emits a tool call, Mastra validates the input against inputSchema, runs execute (which runs the Python in the microVM), feeds the result back, and the model reasons over it — looping until it has an answer.
const res = await analystAgent.generate(
"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 " +
"with matplotlib (call plt.show()).",
);
console.log(res.text);Because the kernel is persistent, a follow-up generate in the same session — "now overlay a kernel-density estimate" — reuses the already-sampled array. No recomputation, no re-upload.
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 automatically — no savefig, no temp file, no second download. The tool above already pulls ex.png (a base64 PNG) into its structured output, so the agent receives a chart object, not pixels flattened into text.
Each Execution carries more than png:
Execution field | What the tool gets |
|---|---|
stdout / stderr | captured streams |
error | formatted traceback when a cell raises (feed it back so the model self-corrects) |
text | convenience: the last text repr (a bare expression result) |
png | convenience: the first chart as a base64 PNG object |
results | full list of rich outputs — each has .html (DataFrames → tables), .json, .svg, .markdown |
To let the agent reason over a table rather than a flattened repr, return ex.results[0].html from the tool. A Mastra agent on a multimodal model can be handed the captured PNG back as an image part on the next turn and asked to describe the trend.
Cleanup
The kernel and its microVM are reaped when the ttlSeconds budget expires, but close them explicitly when a session ends:
await _ctx?.close(); // close the kernel
// the underlying sandbox is reaped on TTL; or hold a handle and call sandbox.kill()To make agent sessions resumable across process restarts, snapshot the sandbox instead of letting it expire and restore on the next turn — the kernel state comes back with it. To fan out (give each parallel agent branch its own warm kernel), fork() the sandbox so every branch starts from the same loaded state.
Notes
- Isolation. Each sandbox runs in its own Firecracker microVM with netns-isolated egress — a model that writes
rm -rf /or tries to read host files is contained to the VM, not your Node process. - One kernel per session. The
kernel()helper shares a singleCodeContextacross everyexecutecall so state persists within a run. For concurrent agent sessions, create one kernel per session to avoid cross-talk. createToolis also exposed via MCP. If you'd rather give the agent a managed sandbox over the wire, point it at the PandaStack MCP server instead of wiring the tool by hand.
Next steps
- Code interpreter — the pillar guide: code contexts, rich results, one-shot exec, and file uploads.
- Vercel AI SDK code execution — the same wedge for the AI SDK's
tool()helper (TS sibling). - Run code from a LangGraph agent — the same pattern in a Python graph runtime.
- Build a data-analyst agent — upload a CSV, explore it across many tool calls, and stream charts to a UI.
Vercel AI SDK code execution tool (TypeScript)
Build a Vercel AI SDK code execution sandbox in TypeScript — a tool() that runs model-written code inside a self-hosted Firecracker microVM and hands back charts as objects.
Build an MCP code execution server on Firecracker
Build your own MCP code execution server in Python whose run_python tool runs every snippet in a Firecracker-isolated PandaStack sandbox.