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.
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:
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:
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:
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.
Related reading
The 2026-07-28 MCP revision removes sessions, the initialize handshake, and server-initiated requests. Here's what actually breaks in your server, the new wire format, the requestState and MRTR patterns that replace sessions, and the SDK v2 migration path.
agent-for-insurance is an open-source drafting aid that will not state a coverage conclusion without citing your policy's own text. Here's how it works, and the two parsing bugs that taught us why that rule has to be enforced in code, not prose.
agent-for-field-service is a free, self-hosted AI copilot for HVAC, plumbing, electrical, and roofing contractors that prices jobs to a real margin instead of a markup that only looks like one.
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.