How to give a Mastra agent a code execution tool
Mastra's tool API is small on purpose: a description, a Zod input schema, an execute function. Ten lines and your agent has a new capability. That simplicity is why the code-execution tool tends to get written badly — the interesting decisions are all outside the tool definition, so they never get made.
This walks through a run-code tool that survives contact with a real agent: where the code runs, how the session behaves across turns, what happens when something hangs, and how to return output the model can actually use.
The one rule: not in your process
Start here because everything else is detail. The obvious implementation runs the code with child_process or Node's vm module. Both give the model your environment variables, your database credentials, your filesystem, and your network position.
The vm module is worth calling out specifically because its name misleads people. It provides a separate V8 context, not a security boundary, and the documentation says so. Escaping it is a known exercise, not a research problem. Neither is child_process with a shell.
The tool, in full
A fresh microVM per call, destroyed when the call returns, with a server-side lifetime as a backstop in case your process dies mid-call.
import { createTool } from "@mastra/core/tools";
import { z } from "zod";
import { Sandbox } from "@pandastack/sdk";
export const runPython = createTool({
id: "run_python",
description:
"Execute Python in an isolated sandbox and return stdout and stderr. " +
"Use for calculations, data transforms, and checking that code works. " +
"The environment is fresh each call: nothing persists between calls, " +
"and pip packages must be installed in the same snippet that uses them.",
inputSchema: z.object({
code: z.string().max(50_000).describe("Python source to execute"),
}),
outputSchema: z.object({
stdout: z.string(),
stderr: z.string(),
exitCode: z.number(),
}),
execute: async ({ context }) => {
await using sb = await Sandbox.create({
template: "code-interpreter",
ttlSeconds: 120, // the platform reaps it even if we crash
});
const out = await sb.runCode(context.code, "python");
return {
stdout: truncate(out.stdout),
stderr: truncate(out.stderr),
exitCode: out.exitCode ?? 0,
};
},
});
function truncate(s: string, limit = 8_000): string {
if (!s || s.length <= limit) return s ?? "";
return s.slice(0, limit) + "\n...[truncated, " + s.length + " chars total]";
}Three details in there do real work, and each one corresponds to a failure you would otherwise hit.
- The description tells the model that state does not persist. Without that sentence, models write a snippet that imports pandas in one call and uses it in the next, then report a confusing error. Descriptions are prompt engineering, not documentation.
- ttlSeconds is a backstop, not a timeout. Your cleanup code will fail eventually — an exception, a process restart, a network blip between creating the sandbox and recording it. A server-side lifetime means the leak cleans itself up.
- truncate exists because a model that accidentally prints a large dataframe will otherwise put all of it into your context window. Truncating at a few thousand characters costs almost nothing and prevents a class of expensive turns.
Attaching it to an agent
import { Agent } from "@mastra/core/agent";
import { anthropic } from "@ai-sdk/anthropic";
export const analyst = new Agent({
name: "analyst",
instructions: [
"You are a data analyst. When a question needs computation, write",
"Python and run it with run_python rather than doing arithmetic",
"in your head. Each call starts from a clean environment, so include",
"any imports and installs in the same snippet.",
"If a call fails, read the stderr and fix the code before retrying.",
].join(" "),
model: anthropic("claude-sonnet-4-5"),
tools: { runPython },
});When one sandbox per call is wrong
The fresh-per-call design is right for most agents. It is simple, it leaks nothing between users, and cleanup is not a system you have to build. It is wrong in one specific case: when getting to the starting line is expensive.
If every call installs the same three packages, or downloads the same dataset, or clones the same repository, you are paying that cost on every turn, and a five-turn conversation pays it five times. At that point you want one sandbox per conversation.
// One sandbox per thread. Two rules make this safe.
const perThread = new Map<string, string>(); // threadId -> sandbox id
async function sandboxFor(threadId: string) {
const existing = perThread.get(threadId);
if (existing) {
try { return await Sandbox.get(existing); } catch { /* gone; recreate */ }
}
const sb = await Sandbox.create({
template: "code-interpreter",
ttlSeconds: 1800, // rule 1: it dies on its own
metadata: { threadId }, // rule 2: orphans are findable
});
perThread.set(threadId, sb.id);
return sb;
}The two rules in that comment are the whole safety story for session reuse. A time-to-live means a forgotten sandbox disappears rather than billing forever. Metadata means a periodic sweep can list sandboxes, compare against your active threads, and reap anything your bookkeeping lost. Every long-lived-session implementation that skips those two eventually grows a quiet population of abandoned machines.
The failures worth handling explicitly
- Infinite loops. A model writes a while loop with a bad condition roughly as often as a human does. Set an execution timeout, return a clear message saying the code exceeded it, and let the agent try again — that failure is recoverable if you describe it plainly.
- Missing packages. An ImportError in stderr is exactly the feedback the model needs. Return stderr verbatim rather than replacing it with a generic error, and mention in the tool description that installs must happen in the same snippet.
- Enormous output. Truncate, and say so in the truncation marker. A model that knows output was cut will write code that prints a summary next time.
- Sandbox creation failures. Transient, and worth one retry with a short backoff. Two retries is usually the point at which the honest answer is to tell the user rather than keep trying.
Decide what the code may reach
Isolation from your process is the first boundary. The second is what the sandbox can reach on the network, and it is easy to forget because the default is usually everything.
If the tool exists to do arithmetic and transform data, it does not need the internet at all, and a sandbox with no egress removes both exfiltration and a whole category of surprising behaviour. If it needs pip, it needs the package index and probably nothing else. Be deliberate about it, because the difference between an agent that can only compute and one that can post your data anywhere is a configuration setting nobody read.
Wrapping up
The tool definition is genuinely ten lines, and Mastra deserves credit for that. The engineering is in the four decisions around it: an isolated environment per call, a time-to-live so nothing leaks, truncated output so nothing floods your context, and a description honest enough that the model stops assuming state persists.
Get those right and code execution becomes the most useful tool your agent has, because it converts confident guesses into verified answers.
Frequently asked questions
Can I run agent-generated code with Node's vm module?
No, and the name is the problem — vm provides a separate V8 context, not a security boundary, and Node's own documentation is explicit that it should not be used to run untrusted code. Escapes are well documented and typically involve reaching a constructor from an object that crossed the context boundary, then getting back to the host realm from there. The same applies to child_process with a shell, which gives the code your environment variables, your credentials, and your network position outright. Neither of these is a matter of hardening — they are the wrong shape for the job. The workable answers are a separate machine with its own kernel, or a WebAssembly runtime if your language and workload fit inside one, which for arbitrary Python with native dependencies they usually do not.
Should each tool call get its own sandbox, or should I reuse one?
Default to one per call. It is simpler, nothing leaks between users or conversations, and you never have to build a cleanup system because there is nothing to clean up. Reuse becomes worth the complexity when setup dominates: if every call installs the same packages, downloads the same dataset, or clones the same repository, then a five-turn conversation pays that cost five times and the arithmetic changes quickly. When you do reuse, key the sandbox by conversation or thread rather than by user, always set a server-side time-to-live so a forgotten one dies on its own, and tag it with the thread id so a periodic sweep can find orphans your bookkeeping lost. The failure mode of session reuse is never dramatic — it is a slowly growing population of abandoned machines nobody noticed.
How do I stop a model's code from running forever?
Two limits, at different layers, because each covers a case the other misses. An execution timeout on the call itself bounds a single snippet: when it trips, return a clear message saying the code exceeded the limit rather than a generic error, and the agent will usually rewrite the loop correctly on the next attempt. A time-to-live on the environment bounds everything else — a process that detaches and keeps running, a sandbox created just before your service restarted, anything your cleanup path missed. Set both. The execution timeout should be generous enough for real work, since a model that gets timeouts on legitimate computation starts avoiding the tool, while the environment lifetime can be much longer because it exists to catch leaks rather than to shape behaviour.
How much output should a code execution tool return to the model?
Less than you think, with a marker saying you cut it. A few thousand characters covers almost every genuinely useful result — printed values, a small table, an error and its traceback — while an untruncated result can be a printed dataframe with fifty thousand rows that consumes your context window and costs real money for no information gain. Truncate at a fixed limit and append a note that says how much was dropped, because a model that knows output was cut will write code that summarises next time, whereas one that silently receives partial output may reason from an incomplete picture. Keep stderr verbatim within the same limit: a stack trace is the single most useful thing you can hand back, since it lets the agent fix its own mistake instead of reporting failure.
Does the sandbox need internet access?
Only if the tool's job requires it, and most of the time it does not. A tool that exists to do calculations and transform data the agent already has needs no network at all, and running with egress disabled removes exfiltration as a possibility along with a category of confusing behaviour where code silently reaches out to something. If the tool needs to install packages, it needs the package index and usually nothing more, which an allowlist expresses precisely. Open access is worth granting deliberately when the agent's job genuinely involves fetching things, and worth pairing with logging when you do. The reason to be explicit is that the default is almost always full access, so this ends up decided by inattention rather than by judgement.
Keep reading
49ms p50 cold start. Fork, snapshot, and scale to zero.