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.
A Vercel AI SDK agent that needs to compute — crunch numbers, parse a CSV, plot a trend — has to run the model's code somewhere. Running it in your Node process is a liability: a hallucinated child_process.exec is now your server's problem. Vercel's own answer is ai-sdk-tool-code-execution backed by Vercel Sandbox. This recipe is the same tool shape, but the execution backend is a self-hosted Firecracker microVM you own — portable, no per-vendor lock-in — and the tool can hand the model back a chart object instead of stdout it has to parse. (For the head-to-head, see PandaStack vs Vercel Sandbox.)
The wiring is a single tool() with a Zod inputSchema and an execute that runs code in a persistent PandaStack code context, plus a generateText loop bounded by stepCountIs. This audience is TypeScript-native, so TypeScript is primary throughout.
Setup
export PANDASTACK_API_KEY=pds_...
npm i ai @ai-sdk/openai @pandastack/sdk zodThe SDK reads PANDASTACK_API_KEY from the environment — never hardcode it. @ai-sdk/openai is one model provider; swap in @ai-sdk/anthropic or any AI SDK provider and the rest of the recipe is unchanged.
The tool: one persistent kernel, reused across steps
The win here is persistence. Instead of spinning up a fresh sandbox per tool call (and losing every variable between them), create one CodeContext up front and reuse it across every step of the agent loop. The model's imports, DataFrames, and intermediate results carry over from one execute call to the next, exactly like a notebook.
import { Sandbox } from "@pandastack/sdk";
// One sandbox + one persistent kernel for the whole agent session.
const sandbox = await Sandbox.create({
template: "code-interpreter",
ttlSeconds: 3600,
});
const ctx = await sandbox.createCodeContext(); // Jupyter-style kernel; state persists
// Carries the chart (if any) the most recent step produced, for surfacing later.
let lastPng: string | undefined;Now the tool itself. The schema field is inputSchema (a Zod schema — not parameters), and execute receives the validated, typed input. The body calls ctx.runCode(...) and returns the Execution's output or formatted traceback as a string the model reads on the next step.
import { tool } from "ai";
import { z } from "zod";
const runPython = tool({
description:
"Execute Python in a persistent 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 execute in the sandbox."),
}),
execute: async ({ code }) => {
const ex = await ctx.runCode(code);
lastPng = ex.png; // remember the chart object for the post-run hop
if (ex.error) return `Error:\n${ex.error}`;
const parts: string[] = [];
if (ex.stdout) parts.push(ex.stdout.trimEnd());
if (ex.stderr) parts.push(`[stderr]\n${ex.stderr.trimEnd()}`);
if (ex.text && ex.text !== ex.stdout) parts.push(`[result] ${ex.text}`);
if (ex.png) parts.push("[chart] PNG produced (available as an object, not text)");
return parts.join("\n\n") || "(no output)";
},
});Returning stderr and the error traceback alongside stdout is deliberate: it feeds the model its own failure so it can self-correct on the next step instead of guessing. The description is what the model reads to decide when to call the tool — keep it specific about persistence and chart capture.
Execution field | What the model gets |
|---|---|
stdout / stderr | captured streams (feed both back so it can self-correct) |
error | formatted traceback when the cell raises |
text | convenience: the last text repr (a bare expression result) |
png | convenience: first chart as a base64 PNG object |
results | full list of rich outputs (.html, .json, .svg, .markdown, …) |
Why a code context, not a one-shot exec
createCodeContext() is a long-lived kernel. Imports and variables survive across runCode calls, so the agent can define a DataFrame in one step and plot it in the next without re-importing pandas every turn. The one-shot sandbox.runCode(code) spawns fresh each call and keeps no state — fine for an isolated snippet, wrong for a multi-step agent. See the code interpreter guide for both paths.
The agent loop
generateText with a tools record runs the multi-step loop: the model calls runPython, the SDK runs execute, feeds the string result back, and the model continues until it answers or hits the stop condition. tools is a record keyed by tool name (not an array), and the loop is bounded by stopWhen: stepCountIs(n) — the v5+ replacement for the old maxSteps.
import { generateText, stepCountIs } from "ai";
import { openai } from "@ai-sdk/openai";
const result = await generateText({
model: openai("gpt-5.1"),
tools: { runPython }, // keyed by the name the model invokes
stopWhen: stepCountIs(8), // bound the tool loop explicitly
system:
"You are a data analyst. Use the runPython tool to compute answers and to " +
"plot results. The kernel keeps state between calls, so build up your " +
"analysis incrementally. Prefer matplotlib for charts.",
prompt:
"Compute the first 15 Fibonacci numbers, then plot them on a log-scale " +
"line chart. Tell me the 15th value.",
});
console.log(result.text);Behind the scenes the model emits a tool call, the SDK runs runPython (which executes the code in the microVM), appends the string result, and the model reasons over it — looping until it produces a final answer or stepCountIs(8) trips. Because the kernel is persistent, a follow-up like "now zoom into the last 5 points" reuses the already-computed fib list — no recomputation.
| Field | What it carries |
|---|---|
model | a provider model instance (here openai("gpt-5.1")) or a gateway string |
tools | record of { name: tool({...}) } the model may call |
stopWhen | loop bound — stepCountIs(n), hasToolCall(name), or an array of conditions |
result.text | the model's final natural-language answer |
result.steps | full step transcript, including every tool call and its result |
Version note. Always set
stopWhenexplicitly. The pre-v5 API usedmaxSteps, which is removed; and the multi-step default changed across majors (effectively single-step in v5,stepCountIs(20)in v6). Pinning the bound in the call is version-robust and makes the loop ceiling obvious.stepCountIsis a named export of"ai"— don't import it from anywhere else.
Surfacing the chart back to the model
This is the wedge. 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 model's code produces a chart object (ex.png), not pixels-as-text.
A multimodal model can see that chart. After the run, hand the captured PNG back as an image content part and ask a follow-up:
import { generateText } from "ai";
import { openai } from "@ai-sdk/openai";
if (lastPng) {
const followup = await generateText({
model: openai("gpt-5.1"),
messages: [
{
role: "user",
content: [
{ type: "text", text: "Describe the trend in this chart in one sentence." },
{ type: "image", image: `data:image/png;base64,${lastPng}` },
],
},
],
});
console.log(followup.text);
}For richer outputs, every Execution exposes .results, a list of typed Result objects with .png / .jpeg / .svg, .html (DataFrames render as HTML tables), .json, .markdown, and .text. Return ex.results[0].html from execute when you want the model to reason over a table rather than a flattened repr — or render any of these straight in your UI.
Cleanup
The kernel and its microVM are reaped when the ttlSeconds budget expires, but close them explicitly when a session ends:
await ctx.close();
await 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. Forking a warm kernel also lets each branch of an exploration start from the same loaded state.
Notes
- Isolation. Each sandbox runs in its own Firecracker microVM with netns-isolated egress — model-written code that tries
rm -rf /or to read host files is contained to the VM, not your server. - One kernel per session. The tool above closes over a single
CodeContext, so state persists across every step of onegenerateTextrun. For concurrent requests, create one sandbox + context per request to avoid sharing a kernel across users. inputSchema, notparameters. The Zod schema field isinputSchemain currentaiversions. Zod is the idiomatic default, but any Standard-Schema validator (Valibot) orjsonSchema()also works.- Charts as objects. When the model's code produces a plot,
ex.pngis a ready-to-render base64 PNG — surface it in your UI or feed it back to a multimodal model rather than asking for a text description.
Next steps
- Code interpreter — the pillar guide: code contexts, rich results, one-shot exec, and file uploads.
- Mastra code execution — the same tool wedge wired into Mastra, the other TS-native agent framework.
- Run code from a LangGraph agent — the same persistent-kernel pattern in a graph runtime.
- Build a data-analyst agent — upload a CSV, explore it across many tool calls, and stream charts to a UI.
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.
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.