Agents Workshop

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@beta

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

lib/sandbox.ts
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:

  1. callOptionsSchema - defines what runtime data the agent accepts (in this case, a sandbox instance)
  2. prepareCall - a hook that runs before each LLM call, letting you transform the options into context that tools can access via experimental_context

Define a context schema and update the agent:

lib/agent.ts
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).

lib/tools.ts
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.

lib/agent.ts
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.

app/api/chat/route.ts
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.

app/page.tsx
"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.