Vercel AI SDK
Give a model called through the AI SDK a Linux machine of its own. Three tools run commands and read and write files in a froog sandbox, a full Ubuntu VM with sudo, Docker and internet, and generateText loops until the job is done.
Install
Node.js 22 or newer (AI SDK 7 needs it). The froog SDK is one TypeScript file that uses fetch: download it next to your code, as the SDK docs say.
$ npm init -y && npm pkg set type=module
$ npm install ai @ai-sdk/openai zod
$ npm install --save-dev tsx
$ curl -fsSLO https://froog.tech/sdk/froog.ts # the froog client: one file, uses fetch
$ export OPENAI_API_KEY=... # your model provider's key
$ export FROOG_API_KEY=froog_... # dashboard, API keys; or: froog api-key create agents --preset ci
A complete example
Save as agent.ts next to froog.ts and run npx tsx agent.ts. Any provider works: swap openai(...) for another provider's model.
import { generateText, isStepCount, tool } from "ai";
import { openai } from "@ai-sdk/openai";
import { z } from "zod";
import { Froog, FroogError, type ExecResult } from "./froog";
const froog = new Froog({ apiKey: process.env.FROOG_API_KEY });
const LIMIT = 20_000; // characters of output handed back to the model
// Make sure the sandbox `name` is running: create it, or start it if it was stopped.
async function openSandbox(name: string): Promise<string> {
let state: string;
try {
state = (await froog.getSandbox(name)).state;
} catch (e) {
if (!(e instanceof FroogError) || e.status !== 404) throw e;
await froog.createSandboxes({
name,
size: "small", // 2 vCPU, 4 GB
ttl_secs: 1800, // auto-stop 30 minutes after it starts; the disk is kept
environment: false, // none of your account's variables or files
labels: { app: "ai-sdk-demo" },
});
state = "creating";
}
if (state === "stopping") state = (await froog.waitUntil(name, "stopped")).state;
if (state === "stopped") await froog.startSandbox(name, {});
await froog.waitUntil(name, "running");
return name;
}
const clip = (s: string) => (s.length <= LIMIT ? s : "[...cut...]\n" + s.slice(-LIMIT));
function formatExec(r: ExecResult): string {
let out = `exit code ${r.exit_code}${r.timed_out ? " (timed out)" : ""}`;
if (r.stdout) out += "\nstdout:\n" + clip(r.stdout);
if (r.stderr) out += "\nstderr:\n" + clip(r.stderr);
return out;
}
// Tools bound to one froog sandbox.
function sandboxTools(sandbox: string) {
return {
runCommand: tool({
description:
"Run a bash command in your Linux sandbox (Ubuntu 24.04, passwordless sudo, Docker, internet). " +
"The working directory is /home/user. Returns the exit code, stdout and stderr.",
inputSchema: z.object({
command: z.string().describe('The bash command to run, e.g. "python3 app.py"'),
timeoutSecs: z.number().int().min(1).max(3600).optional().describe("Seconds before it is killed (default 300)"),
}),
execute: async ({ command, timeoutSecs }) =>
formatExec(await froog.exec(sandbox, { command, timeout_secs: timeoutSecs ?? 300 })),
}),
writeFile: tool({
description: "Create or overwrite a text file in the sandbox. Parent folders are created.",
inputSchema: z.object({
path: z.string().describe("Absolute, or relative to /home/user"),
content: z.string().describe("The whole file"),
}),
execute: async ({ path, content }) => {
await froog.writeFile(sandbox, content, { path });
return `wrote ${content.length} characters to ${path}`;
},
}),
readFile: tool({
description: "Read a text file from the sandbox.",
inputSchema: z.object({ path: z.string().describe("Absolute, or relative to /home/user") }),
execute: async ({ path }) => clip(new TextDecoder().decode(await froog.readFile(sandbox, { path }))),
}),
};
}
const sandbox = await openSandbox("chat-demo"); // one sandbox per conversation
try {
const { text } = await generateText({
model: openai("gpt-6.1-sol"),
system: "You have a Linux sandbox. Use your tools to write and run code, and check results by running them.",
prompt: "Write primes.py that prints the first 20 primes, run it, and tell me the output.",
tools: sandboxTools(sandbox),
stopWhen: isStepCount(20),
});
console.log(text);
} finally {
await froog.deleteSandbox(sandbox); // or froog.stopSandbox(sandbox) to keep the disk
}
How it works
openSandboxgets the conversation's sandbox running: it creates it (asmallUbuntu VM, ready in under a minute), or starts it if it was stopped, and waits forrunning.- Three tools bound to that sandbox: run a command (
POST /v1/sandboxes/{name}/exec, up to an hour, output and exit code back), write a file and read a file (PUTandGET /v1/sandboxes/{name}/files/content). Output is cut to the last 20,000 characters so a noisy build doesn't fill the context window. - Cleanup in
finally: the example deletes the sandbox. In a real app, stop it or let its auto-stop timer stop it (see the notes).
Notes
- One sandbox per conversation or user. Name it after the conversation, thread or user id (
chat-42,user-7: 1–40 lowercase letters, digits and dashes) so every turn lands in the same machine with the same files.openSandboxabove creates it the first time and starts it again if it was stopped. Labels ("labels": {"user": "7"}) let you find a user's sandboxes later withGET /v1/sandboxes?label=user=7. - Stop instead of delete when the user may come back. The example's
ttl_secs: 1800stops the sandbox 30 minutes after it starts, busy or not (PATCHa newttl_secsto push it back while the conversation goes on). Its disk is kept for 14 days, a start takes about 15–30 seconds (about a second within 10 minutes of the stop), and installed packages and files are still there. Delete it when the conversation is over for good. - Cost. Running time is billed per second, only while the sandbox runs:
small(2 vCPU, 4 GB) is $0.0137 an hour,default(4 vCPU, 8 GB) $0.0274. A 10-minute conversation onsmallcosts about a quarter of a cent; the idle time before the TTL stops it counts as running time, so pick a TTL that matches how long users pause. A stopped sandbox costs no running time. See pricing. - Templates. Install what your agent needs once (Python packages, a repo, a database), then save it:
froog snapshot my-box --name agent-base. Create each conversation's sandbox with"from": "agent-base"and it starts with all of it, instead of the agent spending its first turns onpip install. See snapshots. - Keep your secrets out.
"environment": falsestarts the sandbox without your account's environment variables, repos and secret files, since the model runs whatever it likes in there. Pass only what that sandbox needs inenv. Use a scoped API key for your backend (froog api-key create agents --preset cican only touch the sandboxes it created) and never send it to a browser. See API keys. - More tools. The same pattern covers the rest of the API:
POST /sandboxes/{name}/urls(hostPort) gives a running web server a public URL the agent can return to the user,startJobruns servers and long builds in the background,execStreamstreams output as it arrives, andforkSandboxcopies a sandbox to try several approaches at once. Every call is in the HTTP API. - In a route handler. The same
sandboxTools(name)work withstreamTextin a Next.js route: look up or create the conversation's sandbox at the start of the request and pass its tools. Keep the froog key on the server. - Versions.
isStepCountis AI SDK 7's name. AI SDK 5 and 6 call itstepCountIs, and AI SDK 4 usedmaxStepsandparametersin place ofstopWhenandinputSchema.
Questions
Does this work on Vercel or other serverless hosts?
Yes. The froog SDK is a single file that only uses fetch, and the sandbox lives at froog, not in your function, so it outlives the request. Long commands are bounded by your function's own time limit; run them as background jobs if they take longer.
Can the agent show the user a running app?
Yes. Start the server in the sandbox as a background job (startJob), then POST /v1/sandboxes/{name}/urls with its port gives an HTTPS URL to return to the user.
7 days free: 25 hours of running time, 2 sandboxes at once. Then from $0.0137 an hour, billed per second.