How to add a code interpreter to the Vercel AI SDK
The AI SDK's tool() helper is about as clean as tool definitions get: a description, a Zod schema, an execute function. Adding code execution to a chat app is therefore a small amount of code — and most of the small amount of code you find online calls eval() or spawns a child process, which is fine in a demo and a genuine incident in production.
Here's the version that survives contact with real users. I build PandaStack, so the sandbox calls are ours; the structure holds for any sandbox provider with a TypeScript client.
The tool
// npm i @pandastack/sdk ai zod
import { tool } from "ai";
import { z } from "zod";
import { Sandbox } from "@pandastack/sdk";
export function codeTool(sessionId: string) {
return tool({
description:
"Execute Python in a secure sandbox and return the result. Variables " +
"and imports persist across calls, so you can build up work step by step.",
inputSchema: z.object({
code: z.string().describe("Python source to execute."),
}),
execute: async ({ code }) => {
const ctx = await kernelFor(sessionId);
const ex = await ctx.runCode(code, { timeoutSeconds: 60 });
return {
stdout: ex.stdout,
stderr: ex.stderr,
error: ex.error ?? null,
result: ex.text ?? null,
chart: ex.png ? "a chart was rendered" : null,
};
},
});
}Note what execute returns: an object, not a string. The SDK serialises it for you, and giving the model named fields rather than a blob of text measurably improves how well it reads the result — particularly the difference between empty stdout and an actual error.
The serverless problem, and the fix
This is where most implementations quietly go wrong. A Next.js route handler is not a long-lived process. Your module-level Map of sandboxes may or may not exist on the next request, because the next request may land on a different instance — or on the same one after the module state was discarded.
The fix is to stop caching the sandbox object and start caching its id. Sandbox ids are durable; the client object isn't.
import { Sandbox } from "@pandastack/sdk";
// Your own store: Redis, Postgres, wherever the conversation already lives.
async function kernelFor(sessionId: string) {
const saved = await store.get(`sandbox:${sessionId}`);
if (saved) {
const sandbox = await Sandbox.get(saved.sandboxId);
return sandbox.createCodeContext();
}
const sandbox = await Sandbox.create({
template: "code-interpreter",
ttlSeconds: 1800,
metadata: { session: sessionId },
});
await store.set(`sandbox:${sessionId}`, { sandboxId: sandbox.id });
return sandbox.createCodeContext();
}Two details make this work. The TTL means an abandoned conversation cleans itself up without you writing a reaper, and the metadata means you can find a session's sandbox later from a dashboard or a script when someone asks what a particular conversation actually ran.
Streaming, and what the user sees
Code execution takes seconds, which is long enough for a chat UI to look broken. The AI SDK streams tool calls as parts of the message, so the fix is on the client — render the pending state instead of waiting for the result.
// app/api/chat/route.ts
import { streamText, convertToModelMessages } from "ai";
export async function POST(req: Request) {
const { messages, sessionId } = await req.json();
const result = streamText({
model: anthropic("claude-sonnet-4-5"),
messages: convertToModelMessages(messages),
tools: { runPython: codeTool(sessionId) },
stopWhen: stepCountIs(8), // let it iterate on its own errors
});
return result.toUIMessageStreamResponse();
}The step limit is worth thinking about rather than copying. A code tool invites iteration — the model runs something, reads the traceback, fixes it, runs again — and that loop is the whole value. Cap it too low and the model gives up mid-repair with a half-finished answer; leave it uncapped and a stubborn bug becomes twenty tool calls charged to you.
Charts: show them, don't send them
A matplotlib cell returns a base64 PNG. Putting it in the tool result sends the whole thing into the model's context on every plotting call — tens of thousands of characters the model cannot use for anything.
execute: async ({ code }) => {
const ex = await ctx.runCode(code, { timeoutSeconds: 60 });
let chartId: string | null = null;
if (ex.png) {
chartId = await store.putImage(ex.png); // out of band, for the UI
}
return {
stdout: ex.stdout,
error: ex.error ?? null,
result: ex.text ?? null,
chartId, // a short id, not 40kb of base64
};
}The client then renders the image from chartId while the model only ever sees that a chart exists. Same for DataFrames: ex.results[0].html gives you a real HTML table to render, and the model gets the text repr, which is all it needs to reason about the numbers.
User uploads, and getting files in
The moment a chat app has a code tool, users start dragging CSVs into it. The wrong instinct is to pass the file contents through the model — a 50,000-row CSV does not fit in a prompt, and the parts that do fit are the parts the model least needs.
Write the file into the sandbox and tell the model the path. It then reads the file the same way a person would, and the size of the data becomes irrelevant to your token bill.
// In your upload handler, not in the tool
const sandbox = await Sandbox.get(saved.sandboxId);
await sandbox.filesystem.write(
`/workspace/${file.name}`,
new Uint8Array(await file.arrayBuffer()),
);
// Then add one system message, once:
// "The user uploaded /workspace/sales.csv (2.1 MB). Read it with pandas."The same trick works in reverse for output. Have the model write a report or a cleaned dataset to a known path, then read the file back and offer it as a download — rather than trying to squeeze a large result out through the chat stream.
Running JavaScript instead of Python
Worth saying because it's a natural question in a TypeScript codebase, and the answer is usually still Python. The persistent-kernel model — variables surviving between cells, rich results for tables and charts — comes from the Jupyter ecosystem, and the data libraries the model reaches for by default are Python ones.
If you do need Node, run it as a shell command rather than through the kernel, and accept that state doesn't persist between calls in the same way:
const res = await sandbox.exec(`node -e ${JSON.stringify(code)}`);
// res.stdout, res.stderr, res.exitCodeIn practice the split that works is Python in the kernel for analysis, and shell exec for everything else — installing a package, running a build, calling a CLI. Two tools rather than one, with clearly different descriptions, produces better tool selection than one overloaded tool with a language parameter.
Cleanup, three ways
There's no finally block that reliably fires in a serverless function, so cleanup has to be arranged rather than assumed. Three mechanisms, in order of reliability:
- A TTL set at creation. This is the one that always works, because the platform enforces it rather than your code. Everything else is an optimisation on top.
- An explicit kill when the user closes or resets the conversation — a small endpoint that looks up the sandbox id and deletes it.
- A periodic sweep over your own store for sessions with no activity, which catches the ones the other two missed.
// app/api/chat/reset/route.ts
export async function POST(req: Request) {
const { sessionId } = await req.json();
const saved = await store.get(`sandbox:${sessionId}`);
if (saved) {
const sandbox = await Sandbox.get(saved.sandboxId);
await sandbox.kill();
await store.del(`sandbox:${sessionId}`);
}
return Response.json({ ok: true });
}The short version
- One tool() with a Zod schema whose execute talks to a sandbox — never eval, never a child process on your server.
- Cache the sandbox id in a durable store, not the client object in module scope.
- A TTL at creation as the cleanup you can rely on, plus an explicit reset endpoint for the normal path.
- Return named fields, not a string, so the model can tell empty output from an error.
- Keep images and large HTML out of the tool result; return an id and render it client-side.
- Set a step limit that allows the model a few self-repair rounds without letting it loop forever.
The awkward part of this is never the tool definition — it's that a chat app has conversation state and a serverless function does not. Solve that once, with an id in a durable store and a TTL as the backstop, and the rest is about twenty lines.
Frequently asked questions
Can I run model-generated code with eval in a Next.js route?
You can, and it means any user who can influence the model's output can run code inside your server with your environment variables and your database credentials. That includes indirect paths: a document the agent reads, a web page it fetches, a filename in an upload. eval and a spawned child process on the same host are the same risk with different syntax, because both run with your application's privileges and network position. Send the code to an isolated sandbox instead — the tool interface is unchanged, and the blast radius of a bad generation becomes a VM you were going to delete anyway.
How do I keep sandbox state across requests in a serverless function?
Store the sandbox id, not the sandbox object. A route handler's module scope is not a reliable cache: the next request may hit a different instance, or the same instance after module state was discarded, so a Map keyed by session works in development and drops state unpredictably in production. Persist the id in whatever store already holds your conversation — Redis, Postgres, the same row as the chat history — and reconnect to the sandbox by id at the start of each request. Set a TTL when creating it so an abandoned conversation is reaped without any cleanup code of yours running.
Should the tool result include the chart image?
No. A rendered chart comes back as base64 PNG data, typically tens of thousands of characters, and anything in the tool result goes into the model's context on every call. The model cannot interpret the image bytes, so you are paying for tokens that do nothing. Store the image out of band, return a short identifier, and render it in the UI from that identifier. Return the text representation of the data alongside it so the model can still reason about the numbers it just plotted.
How many steps should I allow when a code tool is available?
Enough for the model to fix its own mistakes, which in practice means somewhere around five to ten. The productive pattern with a code tool is iterative: run something, read the traceback, correct it, run again — and cutting that off after two steps leaves the user with a half-finished answer and a visible error. The opposite failure is a model that cannot solve the problem and keeps trying, so an upper bound is necessary. Set the limit explicitly rather than relying on a default, and log the runs that hit it, because a rising rate of maxed-out runs usually points at a prompt or data problem rather than a model one.
Do I need a separate sandbox per user in a chat app?
Yes. A shared sandbox means one user's uploaded files, variables, and installed packages are visible to the next user, which is a data-exposure problem before it is a correctness problem. Key the sandbox by conversation or session id, create it lazily on the first code call so conversations that never run code cost nothing, and rely on a TTL to reclaim it. Whether this is affordable depends on the billing model: under per-second metering on active CPU and resident memory, a sandbox idling while the model thinks costs almost nothing, so per-conversation isolation is both the safe option and the cheap one.
Keep reading
- Sandboxes on PandaStack — microVMs with a persistent kernel and a TypeScript SDK
- The best sandbox APIs for TypeScript agents
- The same tool, wired into LangChain
- How to stream command output from a sandbox
- Running untrusted code safely
49ms p50 cold start. Fork, snapshot, and scale to zero.