Sandbox
Our agent can chat and search the web, but it can't actually do anything. Let's give it the ability to run commands in a cloud sandbox using Vercel Sandbox. This provides the agent with a secure, isolated environment to execute code without touching your local machine.
Install the Vercel Sandbox package.
pnpm add @vercel/sandbox@betaCreate a sandbox utility that either retrieves an existing named sandbox or creates a new one. Using a named sandbox means the agent's environment persists across requests.
import { Sandbox } from "@vercel/sandbox";
export const createOrGetSandbox = async (name: string) => {
try {
const sandbox = await Sandbox.get({ name });
return sandbox;
} catch {
const sandbox = await Sandbox.create({
snapshotExpiration: 7 * 24 * 60 * 60 * 1000,
timeout: 2700000,
name,
});
return sandbox;
}
};Now we need a way to pass the sandbox instance into our agent's tools at runtime. The AI SDK provides two mechanisms for this:
callOptionsSchema- defines what runtime data the agent accepts (in this case, a sandbox instance)prepareCall- a hook that runs before each LLM call, letting you transform the options into context that tools can access viaexperimental_context
Define a context schema and update the agent:
import { openai } from "@ai-sdk/openai";
import { type InferAgentUIMessage, ToolLoopAgent } from "ai";
import { Sandbox } from "@vercel/sandbox";
import { z } from "zod";
const callOptionsSchema = z.object({
sandbox: z.instanceof(Sandbox),
});
export const agentContextSchema = z.object({
sandbox: z.instanceof(Sandbox),
});
export type AgentContext = z.infer<typeof agentContextSchema>;
export const myAgent = new ToolLoopAgent({
model: "openai/gpt-5.4-mini",
tools: {
webSearch: openai.tools.webSearch(),
},
callOptionsSchema,
prepareCall: ({ options, ...rest }) => {
return {
...rest,
experimental_context: agentContextSchema.parse({
sandbox: options.sandbox,
}),
};
},
});
export type MyAgentUIMessage = InferAgentUIMessage<typeof myAgent>;Create a bash tool that executes commands inside the sandbox. The tool reads the sandbox from experimental_context (which was set up by prepareCall in the previous step).
import { tool } from "ai";
import { z } from "zod";
import { agentContextSchema } from "./agent";
export const bash = tool({
description: "Run a bash command in the sandbox environment",
inputSchema: z.object({
command: z.string().describe("The bash command to run"),
}),
execute: async ({ command }, { experimental_context }) => {
const context = agentContextSchema.parse(experimental_context);
const result = await context.sandbox.runCommand("bash", ["-lc", command]);
return {
stdout: await result.stdout(),
stderr: await result.stderr(),
exitCode: result.exitCode,
};
},
});Register the bash tool on the agent.
import { openai } from "@ai-sdk/openai";
import { type InferAgentUIMessage, ToolLoopAgent } from "ai";
import { bash } from "./tools";
import { Sandbox } from "@vercel/sandbox";
import { z } from "zod";
const callOptionsSchema = z.object({
sandbox: z.instanceof(Sandbox),
});
export const agentContextSchema = z.object({
sandbox: z.instanceof(Sandbox),
});
export type AgentContext = z.infer<typeof agentContextSchema>;
export const myAgent = new ToolLoopAgent({
model: "openai/gpt-5.4-mini",
tools: {
webSearch: openai.tools.webSearch(),
bash,
},
callOptionsSchema,
prepareCall: ({ options, ...rest }) => {
return {
...rest,
experimental_context: agentContextSchema.parse({
sandbox: options.sandbox,
}),
};
},
});
export type MyAgentUIMessage = InferAgentUIMessage<typeof myAgent>;Update the route handler to create a sandbox and pass it to the agent via the options property.
import { createAgentUIStreamResponse } from "ai";
import { type MyAgentUIMessage, myAgent } from "@/lib/agent";
import { createOrGetSandbox } from "@/lib/sandbox";
const sandboxName = "my-agent-sandbox";
export const POST = async (req: Request) => {
const { messages }: { messages: MyAgentUIMessage[] } = await req.json();
const sandbox = await createOrGetSandbox(sandboxName);
return createAgentUIStreamResponse({
agent: myAgent,
uiMessages: messages,
options: { sandbox },
});
};Add a terminal renderer to the chat UI so we can see what commands the agent is running and their output.
"use client";
import { useChat } from "@ai-sdk/react";
import { useState } from "react";
import { MyAgentUIMessage } from "@/lib/agent";
export default function Chat() {
const [input, setInput] = useState("");
const { messages, error, sendMessage } = useChat<MyAgentUIMessage>();
if (error) return <div>{error.message}</div>;
return (
<div className="flex flex-col w-full max-w-md py-24 mx-auto stretch">
<div className="space-y-4">
{messages.map((m) =>
m.parts.map((p, i) => {
switch (p.type) {
case "text":
return (
<div key={i} className="whitespace-pre-wrap">
<div>
<div className="font-bold">{m.role}</div>
<p>{p.text}</p>
</div>
</div>
);
case "tool-webSearch":
return (
<div
key={i}
className="flex items-center gap-2 text-sm text-gray-500 py-1"
>
{p.state === "output-available" ? (
<>
<svg
className="size-4"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={2}
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z"
/>
</svg>
Searched the web
</>
) : (
<>
<svg
className="size-4 animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"
/>
</svg>
Searching the web...
</>
)}
</div>
);
case "tool-bash":
return (
<div
key={i}
className="my-2 rounded-lg bg-neutral-900 font-mono text-sm overflow-hidden border border-neutral-800"
>
<div className="flex items-center gap-2 px-3 py-2 bg-neutral-800/50 border-b border-neutral-800 text-neutral-400">
<svg
className="size-4"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={2}
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="m6.75 7.5 3 2.25-3 2.25m4.5 0h3m-9 8.25h13.5A2.25 2.25 0 0 0 21 18V6a2.25 2.25 0 0 0-2.25-2.25H5.25A2.25 2.25 0 0 0 3 6v12a2.25 2.25 0 0 0 2.25 2.25Z"
/>
</svg>
<span className="text-xs">
{p.state === "output-available"
? "Terminal"
: "Running..."}
</span>
</div>
<div className="px-3 py-2">
<div className="text-neutral-100 font-semibold">
$ {p.input?.command}
</div>
{p.state === "output-available" && p.output && (
<div className="mt-1 text-neutral-300">
{p.output.stdout && (
<pre className="whitespace-pre-wrap">
{p.output.stdout}
</pre>
)}
{p.output.stderr && (
<pre className="whitespace-pre-wrap text-red-400">
{p.output.stderr}
</pre>
)}
</div>
)}
{p.state !== "output-available" && (
<div className="mt-1 flex items-center gap-1 text-neutral-500">
<span className="animate-pulse">▊</span>
</div>
)}
</div>
</div>
);
default:
return null;
}
}),
)}
</div>
<form
onSubmit={(e) => {
e.preventDefault();
sendMessage({ text: input });
setInput("");
}}
>
<input
className="fixed bottom-0 w-full max-w-md p-2 mb-8 border border-gray-300 rounded shadow-xl"
value={input}
placeholder="Say something..."
onChange={(e) => setInput(e.currentTarget.value)}
/>
</form>
</div>
);
}Try asking the agent to run a command like "list the files in the current directory". You should see a styled terminal block appear showing the command, a blinking cursor while it runs, and the output when it completes.