# Run Vercel AI SDK tool calls in a sandbox

> Give a Vercel AI SDK agent a runCommand tool: each session gets its own sandbox, the model's commands run there through the Sandbox API, and the sandbox is deleted at the end.

URL: https://prized.dev/docs/sandboxes-vercel-ai-sdk

The agent below runs every command the model asks for in a sandbox, a microVM with its own kernel, instead of on the machine that runs the agent. The tool calls the [Sandbox API](https://prized.dev/docs/sandboxes-api) with `fetch`, so there is no Prized package to install.

## 1. Install the AI SDK and export your keys [#1-install-the-ai-sdk-and-export-your-keys]

```bash
npm install ai zod
export PRIZED_API_KEY=dcp_…
export AI_GATEWAY_API_KEY=…
```

Make the Prized key under **Dashboard → Settings → API keys** with **Full access**, or **Custom** with **Sandboxes: Write**. The example uses AI SDK 7's [`ToolLoopAgent` and `tool`](https://ai-sdk.dev/docs/agents/building-agents) and Node.js 22.18 or later. Its model goes through the Vercel AI Gateway, which reads `AI_GATEWAY_API_KEY`; for another provider, install its package and change `model` ([Providers and models](https://ai-sdk.dev/docs/foundations/providers-and-models)).

## 2. Save the agent [#2-save-the-agent]

```ts title="agent.mts"
import { ToolLoopAgent, tool } from "ai";
import { z } from "zod";

const API = "https://api.prized.dev/api/v1";
const headers = { Authorization: `Bearer ${process.env.PRIZED_API_KEY}`, "Content-Type": "application/json" };
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

// A path on the API, or a full URL on a sandbox's toolboxBase. The key only
// goes to Prized's own hosts. Each request gives up after 3 minutes, past a
// command's 120-second timeout.
async function call(url: string, method = "GET", body?: object, idempotencyKey?: string): Promise<any> {
  const to = new URL(url.startsWith("https://") ? url : API + url);
  if (to.protocol !== "https:" || !to.hostname.endsWith(".prized.dev")) throw new Error(`not a Prized address: ${to.origin}`);
  const res = await fetch(to, { method, body: body && JSON.stringify(body), signal: AbortSignal.timeout(180_000),
    headers: idempotencyKey ? { ...headers, "Idempotency-Key": idempotencyKey } : headers });
  const json = res.ok ? await res.json() : await res.json().catch(() => ({})); // a cut-off answer throws
  if (!res.ok) throw Object.assign(new Error(`${res.status} ${JSON.stringify(json)}`), { status: res.status });
  return json;
}

// Wait until the sandbox is running and takes commands; return its toolboxBase.
async function ready(id: string): Promise<string> {
  let s: any;
  for (let i = 0; (s = (await call(`/sandboxes/${id}`)).sandbox).state === "starting"; i++) {
    if (i === 120) throw new Error(`${id} is still starting after 2 minutes`);
    await sleep(1000);
  }
  if (s.state !== "running") throw new Error(`${id} is ${s.state}: ${s.errorReason}`);
  for (let i = 0; ; i++) {
    const res = await call(`${s.toolboxBase}/process/execute`, "POST", { command: "true" }).catch((e) => e);
    if (!(res instanceof Error)) return s.toolboxBase;
    if (i === 30 || ![409, 429, 502, 503].includes(res.status)) throw res; // those mean: not taking commands yet
    await sleep(1000);
  }
}

const runCommand = (toolbox: string) => tool({
  description: "Run a bash command in a Linux sandbox (Ubuntu 24.04, Python 3, Node.js 22, gcc; no sudo). Files persist between calls. Send long output to a file and read its tail.",
  inputSchema: z.object({ command: z.string().describe("The bash command to run") }),
  execute: async ({ command }) => {
    const { exitCode, stdout, stderr, timedOut } = await call(`${toolbox}/process/execute`, "POST", { command, timeout: 120 });
    return { exitCode, stdout: stdout.slice(-10_000), stderr: stderr.slice(-10_000), timedOut: !!timedOut };
  },
});

const agent = (toolbox: string) => new ToolLoopAgent({
  model: "anthropic/claude-sonnet-5.5",
  instructions: "Use runCommand to run code and check your work before you answer.",
  tools: { runCommand: runCommand(toolbox) },
});

const task = "Count the primes below 10 million with a program, and say how long it took.";
// A create whose answer is lost is sent again with the same Idempotency-Key,
// which returns the sandbox it made instead of a second one.
const key = crypto.randomUUID();
const create = () => call("/sandboxes", "POST", { template: "prized-base-v1", resources: { vcpu: 2, memoryMb: 4096 }, maxRuntimeMin: 60 }, key);
const { sandbox } = await create().catch(create);
try {
  const toolbox = await ready(sandbox.id);
  console.log((await agent(toolbox).generate({ prompt: task })).text);
} finally {
  await call(`/sandboxes/${sandbox.id}`, "DELETE");
}
```

## 3. Run it [#3-run-it]

```bash
node agent.mts
```

The agent creates a 2 vCPU, 4 GB sandbox, writes and runs a program in it through `runCommand` as often as it needs, prints its answer, and deletes the sandbox.

* **One sandbox per session.** In a chat, create the sandbox when the session starts, build the agent with its `toolbox` on every turn, and delete it when the session ends; files and installs carry over between commands. `maxRuntimeMin: 60` deletes it 60 minutes after the create, even mid-chat, so [download](https://prized.dev/docs/sandboxes-api#files) what you need before then; 60 is the [free tier](https://prized.dev/docs/sandboxes#free-tier)'s limit, and a paid workspace can set up to 1,440.
* **What the model sees.** `exitCode`, `stdout` and `stderr` cut to their last 10,000 characters, and `timedOut`, true when a command passed its 120 seconds and was killed. The edge keeps the first 4 MiB of each stream, so the tool's description asks the model to send long output to a file and read its tail ([Run a command](https://prized.dev/docs/sandboxes-api#run-a-command)). A failed request reaches the model as a tool error.
* **Keys.** `call` sends your Prized key only to `prized.dev` hosts. Keep keys out of commands and `envVars`: code in the sandbox, the model's included, can read them.

## Try 3 approaches at once (optional) [#try-3-approaches-at-once-optional]

Do the setup the attempts share in the parent, such as a clone and an install, then [fork](https://prized.dev/docs/sandboxes#fork) it with ``await call(`/sandboxes/${sandbox.id}/fork`, "POST", { count: 3 })``: its `sandboxes` are 3 live copies, each with the parent's files and running processes. Run `agent(await ready(id))` on each copy's `id` under `Promise.allSettled`, with a different approach in each prompt, so one failed copy doesn't cut the others short, and delete the copies in a `finally`. The parent and 3 copies are 4 sandboxes of 2 vCPU and 4 GB, the whole [free tier](https://prized.dev/docs/sandboxes#free-tier), so delete any other sandbox, and let it finish deleting, before you fork.
