AI Engineering2026-07-138 min read

Letting an Agent Run Shell Commands Safely with Vercel AI SDK v7 Sandboxes

I built a dependency-audit agent that runs npm commands on real projects. The new experimental sandbox support in Vercel AI SDK v7 is what made me comfortable shipping it.

Varun Raj Manoharan
Varun Raj Manoharan
VercelAISDK v7SandboxesSecurityTypeScript

The most useful agents I have built all share one trait: they run commands. Checking whether a dependency upgrade breaks the build, reproducing a bug report, running a linter over a branch. The moment an agent can execute shell commands, it becomes genuinely useful. It also becomes genuinely dangerous, because the model decides what those commands are.

Until now I solved this with duct tape. Every project had its own bespoke wrapper around E2B or a Docker container, and every tool I wrote was welded to that specific wrapper. Vercel AI SDK v7 ships a first-class answer: an Experimental_SandboxSession abstraction that you pass into generateText, streamText, or a ToolLoopAgent call. Tools are written against the interface, not the provider, so the same tool runs against a local session in dev and a real isolation provider in production.

I tested it by building a dependency-audit agent. You point it at a repository, it runs npm audit and the test suite, tries the upgrade, and reports what actually broke.

Writing a sandbox-aware tool

The key change in v7 is that the sandbox arrives inside the tool's execution options, right next to abortSignal. The tool never imports a sandbox provider. It just delegates the risky part:

TypeScript
import { generateText, tool } from 'ai';
import { z } from 'zod';

const runCommand = tool({
  description: 'Run a shell command in the project workspace',
  inputSchema: z.object({
    command: z.string(),
    workingDirectory: z.string().optional(),
  }),
  execute: async (
    { command, workingDirectory },
    { abortSignal, experimental_sandbox },
  ) => {
    if (!experimental_sandbox) {
      throw new Error('Sandbox is not available');
    }
    return experimental_sandbox.run({
      command,
      workingDirectory,
      env: { CI: 'true' },
      abortSignal,
    });
  },
});

Two details here matter more than they look. Forwarding abortSignal means that when the overall generation is aborted or times out, the sandbox can actually kill the running process instead of leaving a zombie npm install behind. And workingDirectory lets one sandbox session serve multiple repos without the tool caring where the default working directory is.

Then you attach a sandbox at the call site:

TypeScript
const result = await generateText({
  model: 'anthropic/claude-sonnet-4.5',
  prompt: 'Upgrade next to the latest minor version and tell me if the tests still pass.',
  tools: { runCommand },
  experimental_sandbox: sandbox, // your Experimental_SandboxSession
});

The part everyone will get wrong

The docs are unusually blunt about this, and they are right to be: the sandbox interface is a contract, not a security boundary. Your tool code still runs wherever your app runs. Only what you explicitly delegate through experimental_sandbox.run(...) executes in the sandbox environment.

That means a local implementation backed by child_process.exec with a working directory is fine for development and worthless for isolation. A model-generated rm -rf ~ does not respect your working directory. For anything where the model composes the command from user input, you want a real provider (Vercel Sandbox, a Firecracker microVM, a container) behind the interface.

Layering timeouts and approvals on top

What sold me on doing this in v7 rather than keeping my own wrapper is how the sandbox composes with the rest of the release. Commands hang. Test suites deadlock. v7's granular timeout config catches all of it without custom plumbing:

TypeScript
timeout: {
  totalMs: 300_000,
  toolMs: 60_000,
  tools: { runCommand: 180_000 }, // npm install is slow, give it more
},

When a timeout fires, the abort signal we forwarded earlier propagates into the sandbox and the command dies with the step. And for the commands that scare me, I flagged the tool to require approval, so the agent pauses and shows me the exact command string before anything executes. Sandbox for containment, timeouts for runaways, approvals for judgment calls. Three separate guardrails, all native.

Why this matters

My audit agent found a transitive vulnerability, ran the upgrade, watched two snapshot tests fail, and reported the diff, all inside an isolated session I could throw away afterwards. The interesting part is not that this is possible. It was always possible with enough glue code. The interesting part is that my tools no longer know which sandbox they run in, which means I can finally reuse them across projects and swap providers when pricing or cold-start times change.

It is experimental, and the API surface can shift in patch releases, so pin your version. But the shape of it is correct, and I have already deleted my wrapper.

Available for new projects

Let's build something great.

Have a project in mind? We are an elite software and AI development studio ready to bring your ideas to production. Let's talk about your roadmap.