How to Add a Code Execution Tool to the Vercel AI SDK
Updated October 27, 2026 .md
Short answer: Define an AI SDK tool() with a zod inputSchema for language and code. In its execute function, create a Sandboxer sandbox with sandboxer-sdk, write the code to a file, run it with exec and return stdout, stderr and the exit code. Pass the tool to generateText, then delete the sandbox when the run ends.
At a glance#
Your own tool() with sandboxer-sdk |
Hosted MCP (https://sandboxer.com/mcp) |
|
|---|---|---|
| What the model sees | One tool you design (runCode below) |
18 Sandboxer tools (create_sandbox, exec, write_file, …) |
| Who decides the sandbox lifecycle | Your code: create, reuse, delete | The model, through the tools |
| Packages | ai, zod, sandboxer-sdk |
ai, @ai-sdk/mcp |
| Best for | Production apps that want a narrow, predictable tool | Agents that need files, ports and snapshots too |
| Where the code runs | A Sandboxer microVM, never your server | Same |
What we ran. On 2026-10-27 we installed ai 7.0.154, zod 4.6.7 and sandboxer-sdk 0.1.1 in Node 24 and ran everything below against production. We called the tool's execute directly, and we ran the full generateText → tool → sandbox loop with the AI SDK's MockLanguageModelV4 from ai/test, scripted to emit one tool call. The mock stands in for the LLM, because we had no model key. The tool call, the sandbox and its output were real; the model's final sentence was scripted. With a real provider, you only change model.
1. Install#
npm i ai zod sandboxer-sdk
npm pkg set type=module # the examples use ES modules and top-level await
export SANDBOXER_API_KEY=... # create one at sandboxer.com/app/api-keysnew Sandboxer() reads SANDBOXER_API_KEY from the environment, so the key never sits in your code. A scoped key is enough.
2. Define the tool#
import { tool } from "ai";
import { z } from "zod";
import { Sandboxer, type Sandbox } from "sandboxer-sdk";
const sandboxer = new Sandboxer(); // reads SANDBOXER_API_KEY
let sandbox: Sandbox | undefined;
// One sandbox per agent run, created on the first tool call.
async function getSandbox(): Promise<Sandbox> {
sandbox ??= await sandboxer.create({ type: "small", ttlSeconds: 900, name: process.env.SANDBOX_NAME ?? "ai-sdk-run" });
return sandbox;
}
export const runCode = tool({
description: "Run Python or a bash command in an isolated Linux sandbox. Returns stdout, stderr and the exit code.",
inputSchema: z.object({
language: z.enum(["python", "bash"]),
code: z.string().describe("The full program or command to run"),
}),
execute: async ({ language, code }) => {
const box = await getSandbox();
const file = language === "python" ? "main.py" : "main.sh";
await box.files.write(file, code);
const run = await box.exec(language === "python" ? ["python3", file] : ["bash", file], { timeoutSeconds: 120 });
return { exitCode: run.exitCode, stdout: run.stdout.slice(-4000), stderr: run.stderr.slice(-4000) };
},
});
export async function cleanup() {
if (sandbox) {
console.log("sandbox:", sandbox.id);
await sandbox.delete();
}
}Why it's built this way:
- The code goes to a file, not a shell string.
files.writeputs the program at/home/user/main.py, andexecgets an argv array, so quotes and newlines in model-written code can't break the command. - One sandbox per run. Later tool calls in the same run reuse it, so a variable the model wrote to disk in step 1 is still there in step 3. Create the sandbox per request (or per user) in a server.
- A non-zero exit is returned, not thrown. The model sees the traceback in
stderrand can fix its code on the next step. - Output is trimmed to the last 4,000 characters so a noisy program doesn't fill the context window.
SANDBOX_NAMElets you label each run's sandbox so it's easy to find withsbx list.ttlSeconds: 900is a safety net: if your process crashes beforecleanup(), the sandbox stops itself after 15 minutes. A stopped sandbox bills no running time.
exec waits up to timeoutSeconds (1–600). For longer jobs, use sandbox.start() and poll; see the TypeScript SDK.
3. Call the tool directly#
This tests the sandbox side without any model:
import { runCode, cleanup } from "./tool.ts";
try {
const out = await runCode.execute!(
{ language: "python", code: "import platform, sys\nprint(sys.version.split()[0], platform.machine())\nprint(sum(i*i for i in range(1, 101)))" },
{ toolCallId: "direct-1", messages: [], context: {} },
);
console.log(out);
const bad = await runCode.execute!({ language: "bash", code: "echo to-stderr >&2; exit 3" }, { toolCallId: "direct-2", messages: [], context: {} });
console.log(bad);
} finally {
await cleanup();
}node direct.ts{ exitCode: 0, stdout: '3.12.3 x86_64\n338350\n', stderr: '' }
{ exitCode: 3, stdout: '', stderr: 'to-stderr\n' }
sandbox: sb_rg21kkalduThe second call shows the error path: exit code 3 and the stderr text come back as data. We ran the .ts files directly with Node 24, which strips the types itself.
4. Run it through generateText#
Give the tool to generateText and allow more than one step, so the model can read the tool result and answer. Here a mock model plays the LLM: its first response is a tool call, its second is text.
import { generateText, isStepCount } from "ai";
import { MockLanguageModelV4 } from "ai/test";
import { runCode, cleanup } from "./tool.ts";
const usage = {
inputTokens: { total: 10, noCache: 10, cacheRead: undefined, cacheWrite: undefined },
outputTokens: { total: 10, text: 10, reasoning: undefined },
};
// A scripted model: step 1 asks for the tool, step 2 answers in text.
const model = new MockLanguageModelV4({
doGenerate: [
{
content: [{
type: "tool-call",
toolCallId: "call_1",
toolName: "runCode",
input: JSON.stringify({ language: "python", code: "import math\nprint(math.factorial(25))" }),
}],
finishReason: { unified: "tool-calls", raw: undefined },
usage,
warnings: [],
},
{
content: [{ type: "text", text: "25! is 15511210043330985984000000." }],
finishReason: { unified: "stop", raw: undefined },
usage,
warnings: [],
},
],
});
try {
const result = await generateText({
model,
tools: { runCode },
stopWhen: isStepCount(5),
prompt: "What is 25 factorial? Compute it, don't guess.",
});
for (const step of result.steps) {
for (const r of step.toolResults) console.log("tool result:", JSON.stringify(r.output));
}
console.log("steps:", result.steps.length);
console.log("text:", result.text);
} finally {
await cleanup();
}tool result: {"exitCode":0,"stdout":"15511210043330985984000000\n","stderr":""}
steps: 2
text: 25! is 15511210043330985984000000.
sandbox: sb_ibz1illj7xThe AI SDK parsed the tool call, validated it against the zod schema, ran execute, and the factorial was computed by Python inside the sandbox. The whole run, including creating and deleting the sandbox, took 3.2 seconds of wall time in our single run on October 27, 2026.
To use a real model, install its provider (for example @ai-sdk/anthropic or @ai-sdk/openai) and replace model. Nothing else changes. stopWhen: isStepCount(5) lets the model call the tool, read an error and try again up to five steps; see the AI SDK's tool calling docs. The mock model is documented under Testing.
The alternative: the hosted MCP server#
If you'd rather give the model Sandboxer's full toolset than write your own tool, connect the AI SDK's MCP client to https://sandboxer.com/mcp:
npm i ai @ai-sdk/mcpimport { createMCPClient } from "@ai-sdk/mcp";
const mcp = await createMCPClient({
transport: {
type: "http",
url: "https://sandboxer.com/mcp",
headers: { Authorization: `Bearer ${process.env.SANDBOXER_API_KEY}` },
},
});
try {
const tools = await mcp.tools(); // pass these to generateText({ tools })
console.log(Object.keys(tools).join(", "));
} finally {
await mcp.close();
}create_sandbox, list_sandboxes, get_sandbox, exec, command_status, kill_command, read_file, write_file, host_port, stop_sandbox, resume_sandbox, extend_sandbox, rename_sandbox, label_sandbox, delete_sandbox, save_snapshot, list_snapshots, delete_snapshotWe ran this against production with an API key and listed the tools; we didn't run a model over them. With MCP, the model creates and deletes sandboxes itself, so tell it in the system prompt to call delete_sandbox or stop_sandbox when it's done. Sandboxes created through MCP stop on their own after an hour unless ttlSeconds says otherwise. Every tool is described in the MCP server docs.
What it costs#
Sandbox time is billed per second while the sandbox runs. A small sandbox (2 vCPU, 4 GB) is half the default rate of $0.036/hour. Deleting the sandbox at the end of each run, as cleanup() does, keeps the bill to the seconds the run used.
FAQ#
Does the model's code run on my server?#
No. execute runs on your server, but it only sends the code to Sandboxer. The program runs inside a Sandboxer microVM with its own kernel, so a bad rm -rf or an infinite loop stays in the sandbox.
Can I install packages in the sandbox?#
Yes. The sandbox is an Ubuntu VM with passwordless sudo, Python and Node, so the model can run pip install or npm install through the bash language before running its code. Packages stay for the rest of that sandbox's life.
Does this work in a Next.js route handler?#
sandboxer-sdk has no runtime dependencies and supports Node 20+, Bun, Deno and edge runtimes. We tested in Node 24 only. Keep SANDBOXER_API_KEY in a server-side environment variable, never in client code.
How do I keep state between requests?#
Store the sandbox id with the conversation and call sandboxer.get(id) on the next request instead of creating a new one. Call sandbox.stop() between turns to stop billing and keep its files and memory, and sandbox.resume() to continue.
Should I use my own tool or the MCP server?#
Use your own tool() when you want one narrow capability and your code to control the sandbox. Use MCP when the agent also needs files, public ports or snapshots and you're fine letting the model manage the sandbox.
Related#
More guides
For running AI-written code we rank Sandboxer, Freestyle and E2B first: each gives code its own VM with an API or SDK and can pause with memory. Pick Daytona for Windows or GPUs, Modal or Northflank for GPUs at scale, E2B or Northflank to run in your own cloud, and Vercel Sandbox if your app lives on Vercel.
For coding agents we rank Sandboxer first: a full Ubuntu VM with Claude Code and Codex preinstalled, Docker, and stop-and-resume that keeps the agent's session in memory. Freestyle and Amp Orbs also keep memory across pauses. Pick E2B for prebuilt agent templates on open source, Docker Sandboxes for free local runs, and Codespaces if you live in GitHub.
The best Daytona alternatives depend on why you're switching. For a full Linux microVM on every sandbox with memory-saving stop and Docker, we rank Sandboxer first. E2B suits open source and self-hosting, Modal massive short-job fleets, Northflank bring-your-own-cloud with GPUs, and Vercel Sandbox apps on Vercel.
If you're leaving E2B for longer sessions or a fuller machine, we rank Sandboxer first, then Freestyle: both give each sandbox a full Linux VM that keeps memory across pauses, with Docker and no short session cap. Pick Daytona for Windows or GPUs, Northflank for bring-your-own-cloud, and stay on E2B if you need open source.