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.
On this page4
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 with fetch, so there is no Prized package to install.
1. Install the AI SDK and export your keys
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 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).
2. Save the agent
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
node agent.mtsThe 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
toolboxon every turn, and delete it when the session ends; files and installs carry over between commands.maxRuntimeMin: 60deletes it 60 minutes after the create, even mid-chat, so download what you need before then; 60 is the free tier's limit, and a paid workspace can set up to 1,440. - What the model sees.
exitCode,stdoutandstderrcut to their last 10,000 characters, andtimedOut, 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). A failed request reaches the model as a tool error. - Keys.
callsends your Prized key only toprized.devhosts. Keep keys out of commands andenvVars: code in the sandbox, the model's included, can read them.
Try 3 approaches at once (optional)
Do the setup the attempts share in the parent, such as a clone and an install, then fork it with await call(`/: 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, so delete any other sandbox, and let it finish deleting, before you fork.