# How to Add a Code Execution Tool to the Vercel AI SDK

> 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.

Source: https://sandboxer.com/guides/add-code-execution-to-the-vercel-ai-sdk. Updated 2026-10-27.

**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

```bash
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-keys
```

`new Sandboxer()` reads `SANDBOXER_API_KEY` from the environment, so the key never sits in your code. A [scoped key](/docs/api-keys) is enough.

## 2. Define the tool

```typescript title="tool.ts"
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.write` puts the program at `/home/user/main.py`, and `exec` gets 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 `stderr` and 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_NAME`** lets you label each run's sandbox so it's easy to find with `sbx list`.
- **`ttlSeconds: 900`** is a safety net: if your process crashes before `cleanup()`, 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](/docs/sdk-typescript).

## 3. Call the tool directly

This tests the sandbox side without any model:

```typescript title="direct.ts"
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();
}
```

```bash
node direct.ts
```

```text
{ exitCode: 0, stdout: '3.12.3 x86_64\n338350\n', stderr: '' }
{ exitCode: 3, stdout: '', stderr: 'to-stderr\n' }
sandbox: sb_rg21kkaldu
```

The 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.

```typescript title="agent.ts"
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();
}
```

```text
tool result: {"exitCode":0,"stdout":"15511210043330985984000000\n","stderr":""}
steps: 2
text: 25! is 15511210043330985984000000.
sandbox: sb_ibz1illj7x
```

The 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](https://ai-sdk.dev/docs/ai-sdk-core/tools-and-tool-calling). The mock model is documented under [Testing](https://ai-sdk.dev/docs/ai-sdk-core/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](https://ai-sdk.dev/docs/ai-sdk-core/mcp-tools) to `https://sandboxer.com/mcp`:

```bash
npm i ai @ai-sdk/mcp
```

```typescript title="mcp.ts"
import { 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();
}
```

```text
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_snapshot
```

We 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](/docs/mcp).

## 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

- [TypeScript SDK](/docs/sdk-typescript)
- [MCP server](/docs/mcp)
- [Local or hosted MCP for Cursor, Claude Code and Codex](/guides/local-or-hosted-mcp-for-cursor-claude-code-codex)
- [Pause and resume a sandbox with its memory](/guides/pause-and-resume-a-sandbox-with-memory)
