Best Sandbox APIs for TypeScript AI Agents in 2026
I'm Ajay; I built PandaStack, which is one of the entries below, so treat this as a vendor's roundup and discount accordingly. What I can offer in exchange is a post written for the person it's actually for: someone with a `tools.ts` open, a Vercel AI SDK route handler half-finished, and a model that keeps deciding it needs to run `npx tsx` on something it just invented. The isolation-boundary argument matters and I'll get to it. But you will not spend your week on isolation boundaries. You'll spend it on whether the SDK is ESM or CJS, whether `exec` streams into something you can pipe to a `ReadableStream`, whether an `AbortSignal` actually stops the guest or merely rejects your promise, and whether the result type is a tidy union or `any` with extra steps.
So: a TypeScript-first pass. First the criteria a generic roundup skips because they're language-specific, then the runtime-shape criteria that apply to everyone, then real code, then an honest walk over the field, then a decision tree. The Python counterpart of this argument lives at /blog/best-sandbox-apis-for-coding-agents-python-2026, and the framework-agnostic version at /blog/best-ai-agent-sandboxes-2026.
The TypeScript criteria a generic roundup misses
Every sandbox on this list will run a command and hand you stdout. That's the floor and it tells you nothing. The differences that bite show up in week two, when the agent is real and the integration is load-bearing. Seven of them are specifically about the client library, not the machine it talks to.
A real first-party SDK vs. a generated client vs. "just call our REST API"
These three are sold with the same words and are not the same product. A **first-party TypeScript SDK** is designed by someone who has written an agent: it has a `Sandbox` object with a lifecycle, an ergonomic `exec`, a filesystem namespace, and error classes you can `instanceof`. A **generated OpenAPI client** is a faithful transcription of the HTTP surface — every field optional, every response a loose interface, `sandbox_id` threaded through as a bare string, and no session object to stash in your framework state. It works; it just makes you write the SDK yourself. **"Call our REST API"** is honest and occasionally correct — it's the right answer when the platform is a general primitive rather than an agent product — but budget for the exec transport, the session lifecycle, the cleanup path, and the retry logic, because all four are now yours. The tell is the docs: if the TypeScript examples are `fetch` calls with hand-built headers, you are getting option three regardless of what the sidebar says.
Typed results, not `any` with extra steps
The single highest-value type in a sandbox SDK is the result of running a command, and the difference between a good one and a bad one is whether the compiler makes you handle failure. A result typed as `{ stdout: string; stderr: string; exitCode: number }` is fine and already better than most. A discriminated union — success carries stdout, failure carries an exit code plus stderr — is better, because your call site cannot silently drop the failure branch, and the failure branch is the one your agent hits most. If the SDK hands you `Promise<any>` or a `Record<string, unknown>` you have to cast, you've inherited a runtime error that TypeScript was supposed to catch. Check the `.d.ts`, not the marketing page. While you're there, check the error types: `catch (e)` in TypeScript is `unknown`, so an SDK without exported error classes means every error path in your agent turns into string matching on `e.message`, which breaks the first time someone rewords an error.
Streaming that plays nicely with async iterators and ReadableStream
Agent tasks are long — an install, a build, a test suite — and a blocking `exec` that returns nothing for four minutes gives you no progress UI, no early hang detection, and no way to truncate intelligently. That much is universal. What's TypeScript-specific is the *shape* of the stream. Modern TS has three idioms and they are not interchangeable: callbacks (`onStdout`), an `AsyncIterable<string>` you can `for await` over, and a web `ReadableStream` you can return straight from a Next.js route handler or pipe into the AI SDK's streaming helpers. Callbacks are the most primitive and the easiest to adapt into the other two, so an SDK that gives you callbacks is not a dead end — but if you're building a UI that shows build output live, check that the adapter is ten lines and not a buffering rewrite. Also check whether the stream carries the exit code out-of-band or expects you to parse it out of the text, because the latter breaks the moment a build tool prints something that looks like your sentinel.
AbortSignal, because agents change their minds constantly
This is the criterion I'd add to every TypeScript roundup and see in almost none. Agents abandon tool calls: the user hits stop, a race between parallel tool calls resolves, a framework timeout fires, a Next.js request is cancelled when the tab closes. In TypeScript that all arrives as an `AbortSignal`, and the question is what the SDK does with it. There are three levels. **None**: no signal support at all, so cancellation means abandoning a promise and hoping. **Client-side**: the signal aborts the HTTP request, so your code proceeds — while the guest keeps executing the model's infinite loop until something else notices. **Server-enforced**: aborting actually stops execution inside the guest. Only the third is a cancellation; the second is a leak with good manners. If the SDK gives you the second, you can build the third yourself by wiring the signal to a kill call, which is a few lines and shown below — just do it deliberately rather than assuming the framework handled it.
ESM vs. CJS, and whether it survives an edge runtime
The boring plumbing that eats an afternoon. A modern SDK ships dual ESM/CJS with correct `exports` conditions and its own `.d.ts`; a less-maintained one ships CJS only and blows up on `import` in a `"type": "module"` project, or ships ESM only and breaks a Jest setup somebody wrote in 2022. Worse, it may ship types that only resolve under `moduleResolution: "node"` and go silent under `"bundler"`. Then the bigger question: does it work where you actually deploy? If your agent runs in an edge runtime — Vercel Edge, Cloudflare Workers, a Deno deploy target — you have no filesystem, no `child_process`, no long-lived process, and often no Node built-ins beyond a polyfill set. An SDK built on `fetch` and web streams runs there; one that reaches for `fs`, `net`, `ws`, or a native addon does not. Note that this is a property of the *client*, not the sandbox: plenty of platforms whose control plane is perfectly reachable from an edge function ship a client that isn't.
Bundle size, if the client ships inside a serverless function
Nobody thinks about this until a cold start regresses. If the sandbox client ships in a serverless function alongside a model SDK, a framework, and a validation library, its dependency tree is now part of your cold-start budget and, on edge runtimes, part of a hard size limit. A client that is a thin wrapper over native `fetch` with zero or near-zero runtime dependencies costs you almost nothing. One that pulls in a WebSocket library, a gRPC stack, a protobuf runtime, or a heavyweight HTTP client costs you real milliseconds on every cold invocation. It's worth thirty seconds with a bundle analyzer during evaluation — and worth checking whether the heavy dependency is only needed for a feature you don't use (interactive PTY, say), in which case a subpath import may let you avoid it entirely.
How cleanly it becomes a tool
Whichever framework you're in — the Vercel AI SDK's `tool()` with a Zod schema, LangChain.js's `DynamicStructuredTool`, Mastra's tool primitives, the OpenAI Agents SDK, or a hand-rolled Anthropic tool-use loop — the sandbox eventually has to become a JSON Schema, a handler, and a string that goes back to the model. The friction points are consistent. Can you produce a deterministic, truncated string result in about five lines, or do you need forty lines of glue to reassemble a stream? Is there a session handle you can stash between calls, or must you thread a raw ID through your entire state machine? Does the SDK's own type for the result serialize cleanly, or does it carry class instances that break the moment your framework tries to persist state? The best answer is a small plain object with `stdout`, `stderr`, and an exit code, which is three lines from being a tool result.
The runtime-shape criteria
SDK ergonomics decide your week. These decide your year, and they're language-agnostic — run every candidate through the same eight.
- Isolation boundary — does the model's code get its own guest kernel (a hardware-virtualized microVM), a user-space kernel in front of the host (gVisor-style), or namespaces on the shared host kernel (a container)? This is the one you cannot retrofit. Ask the vendor directly; 'sandbox' is not a regulated term. More on the ladder at /blog/code-isolation-hierarchy.
- Cold start — an agent loop is a latency amplifier: twenty to forty sandbox calls in a trajectory means any per-call stall multiplies. Watch which number is being quoted, too: warm pool, snapshot resume, and true cold boot differ by an order of magnitude and all get printed as 'startup time'.
- Stateful sessions vs. one-shot exec — does the machine survive between tool calls, so a dependency installed on turn three is still there on turn twelve? That's the difference between an agent that feels fast and one that reinstalls node_modules every step.
- Filesystem and transfer ergonomics — first-class read/write/upload/download beats base64-through-shell, which breaks the first time the model emits a quote character it shouldn't have. Check that binary artifacts (a PNG, a built bundle) come back as bytes rather than as something you parse out of stdout.
- Network egress control — a perfectly isolated microVM with unrestricted internet can still exfiltrate whatever you put in it. Look for per-sandbox network policy, not an account-level firewall. /blog/why-ai-agents-need-a-sandbox covers the half of the boundary everyone forgets.
- Snapshot and fork — can you warm an environment once and branch it cheaply? This is the primitive that makes best-of-N repair and tree-search affordable. Note that a snapshot you restore later is a backup and a fork of a live machine is a branch; plenty of platforms have one without the other.
- Timeouts and TTLs — you want a server-enforced per-exec timeout and a TTL on the sandbox itself, so a crashed orchestrator reaps rather than bills. See /blog/agent-tool-timeouts-and-cancellation.
- Pricing shape, not rate — I'm printing no prices for anyone, including myself, but shape outlives rate. Per-second, per-invocation, and per-seat behave completely differently for an agent loop, which spends most of its wall-clock time idle waiting on a model call. Ask what you pay while nothing is running.
What the TypeScript actually looks like
Concretely, here's the shape those criteria are arguing for, in PandaStack's TypeScript SDK. Create per task, run with an explicit timeout, branch on the exit code, and let the machine clean itself up:
import { Sandbox } from "@pandastack/sdk";
// Reads PANDASTACK_API_KEY from the environment; PANDASTACK_API points the
// same code at the hosted service or your own self-hosted control plane.
//
// `await using` (TS 5.2+, Node 20+ with a Symbol.asyncDispose polyfill on
// older runtimes) kills the microVM on scope exit even if the block throws.
// ttlSeconds is the backstop for the case where your process never gets to
// run cleanup at all -- a SIGKILL, an OOM, a container eviction.
await using sb = await Sandbox.create({
template: "code-interpreter",
ttlSeconds: 900,
});
const r = await sb.exec("node -e 'console.log(2 + 2)'", { timeoutSeconds: 30 });
// Branch on exitCode, never on "is stderr empty". Plenty of well-behaved
// tools write to stderr and exit 0; plenty of broken ones exit 1 in silence.
if (r.exitCode !== 0) {
console.error(r.stderr.slice(-4000));
}
console.log(r.stdout.trim()); // -> 4On the numbers behind that, and only for my own system: create is snapshot-restore on every call with no warm pool — the restore step lands around 49ms, end-to-end create is 179ms p50 and roughly 203ms p99, and only the first-ever spawn of a brand-new template cold-boots at about 3 seconds to bake the snapshot. Forking a warm sandbox runs 400–750ms same-host and 1.2–3.5s cross-host, which is what makes best-of-N repair affordable rather than aspirational. Managed Postgres on the same substrate takes 30–90s to create, because Postgres bootstrap is Postgres bootstrap. Per-sandbox networking comes from 16,384 pre-allocated /30 subnets per agent, which is where egress policy hangs.
Files in, artifacts out
Coding agents are file-shaped: write a module, run it, read the traceback, patch, repeat — and somewhere in there a CSV or a chart needs to come back to your process.
// The model wrote a file; put it in, run it, take the artifact out. This
// beats shelling out to `cat` with a heredoc, which breaks the first time the
// model emits a quote character it shouldn't have -- and it will.
await sb.filesystem.write("/work/report.ts", modelWrittenCode);
const r = await sb.exec("cd /work && npx tsx report.ts", { timeoutSeconds: 180 });
if (r.exitCode !== 0) {
// stderr is the highest-value thing a sandbox gives an agent: it's the
// correction signal. Truncate before it reaches the context window -- a
// script that prints 100MB will otherwise eat your whole token budget.
throw new Error(r.stderr.slice(-4000));
}
const csv = await sb.filesystem.read("/work/out.csv"); // -> string
await sb.filesystem.download("/work/chart.png", "./chart.png"); // binary, as bytesStreaming a long command into a ReadableStream
A test suite that produces nothing for four minutes is indistinguishable from a hang. Streaming exec is callback-shaped here, which adapts into a web `ReadableStream` in about ten lines — enough to return straight out of a route handler so the browser sees output as it happens:
import type { Sandbox } from "@pandastack/sdk";
// The primitive: chunks arrive via callbacks, the promise resolves with the
// exit code. The exit code is out-of-band, which matters -- you never have to
// sniff it out of the text, so a build tool printing something that looks
// like your sentinel can't corrupt the result.
const exitCode = await sb.execStream("npm test", {
timeoutSeconds: 600,
onStdout: (chunk) => process.stdout.write(chunk),
onStderr: (chunk) => process.stderr.write(chunk),
});
// The adapter: callbacks -> ReadableStream, so a Next.js route handler can
// `return new Response(streamExec(sb, "npm run build"))` and the user watches
// the build instead of watching a spinner.
export function streamExec(sb: Sandbox, cmd: string): ReadableStream<Uint8Array> {
const encoder = new TextEncoder();
return new ReadableStream({
async start(controller) {
try {
const code = await sb.execStream(cmd, {
timeoutSeconds: 600,
onStdout: (c) => controller.enqueue(encoder.encode(c)),
onStderr: (c) => controller.enqueue(encoder.encode(c)),
});
controller.enqueue(encoder.encode(`\n[exit ${code}]\n`));
controller.close();
} catch (err) {
controller.error(err);
}
},
});
}Wiring it into a tool-calling loop
The tool wrapper is where the SDK's shape either helps or fights you. Here it is end to end: a discriminated union for the result, a JSON Schema for the model, an `AbortSignal` threaded through both the model call and the guest, and a single user message carrying every tool result.
import Anthropic from "@anthropic-ai/sdk";
import { Sandbox } from "@pandastack/sdk";
// 1. The typed result. A discriminated union means the call site cannot
// silently drop the failure branch -- and failure is the branch your agent
// hits most. `any` here is a runtime error you've agreed to find in prod.
type RunResult =
| { ok: true; exitCode: 0; stdout: string }
| { ok: false; exitCode: number; stdout: string; stderr: string };
// 2. The tool definition: one JSON Schema, one boring string back. Models fix
// code far better when the tool result has a stable header than when it's
// prose that varies run to run.
const runCodeTool: Anthropic.Tool = {
name: "run_code",
description:
"Execute a shell command inside an isolated microVM sandbox. Returns the " +
"exit code, stdout, and stderr. Nothing persists between calls.",
input_schema: {
type: "object",
properties: {
command: { type: "string", description: "Shell command to run." },
},
required: ["command"],
},
};
// 3. The handler. The AbortSignal is the load-bearing part: an abort that
// only rejects your promise while the guest keeps burning CPU on the model's
// infinite loop is not a cancellation, it's a leak with good manners.
async function runCode(command: string, signal: AbortSignal): Promise<RunResult> {
const sb = await Sandbox.create({ template: "code-interpreter", ttlSeconds: 300 });
const onAbort = () => void sb.kill();
signal.addEventListener("abort", onAbort, { once: true });
try {
const r = await sb.exec(command, { timeoutSeconds: 60 });
return r.exitCode === 0
? { ok: true, exitCode: 0, stdout: r.stdout.slice(-6000) }
: {
ok: false,
exitCode: r.exitCode,
stdout: r.stdout.slice(-2000),
stderr: r.stderr.slice(-4000),
};
} finally {
signal.removeEventListener("abort", onAbort);
await sb.kill().catch(() => {}); // idempotent; the TTL is the last resort
}
}
function render(res: RunResult): string {
return res.ok
? `exit_code=0\n--- stdout ---\n${res.stdout}`
: `exit_code=${res.exitCode}\n--- stdout ---\n${res.stdout}\n--- stderr ---\n${res.stderr}`;
}
// 4. The loop. One AbortController threaded through the model call and every
// tool call, so a single controller.abort() stops the turn end to end.
const anthropic = new Anthropic();
const controller = new AbortController();
const messages: Anthropic.MessageParam[] = [{ role: "user", content: task }];
for (let turn = 0; turn < 12; turn++) {
const res = await anthropic.messages.create(
{ model: "claude-opus-5", max_tokens: 8192, tools: [runCodeTool], messages },
{ signal: controller.signal },
);
messages.push({ role: "assistant", content: res.content });
if (res.stop_reason !== "tool_use") break;
const results: Anthropic.ToolResultBlockParam[] = [];
for (const block of res.content) {
if (block.type !== "tool_use") continue;
const { command } = block.input as { command: string };
const out = await runCode(command, controller.signal);
results.push({
type: "tool_result",
tool_use_id: block.id,
content: render(out),
// Surface failure as is_error so the model treats it as a signal to fix
// rather than as data to summarise back at you.
is_error: !out.ok,
});
}
// Every tool_result goes back in a SINGLE user message. Splitting them
// across messages quietly trains the model out of parallel tool calls.
messages.push({ role: "user", content: results });
}Two notes that generalize past this SDK. One sandbox per tool call is the safe default but not the fast one — if your agent is iterating on a repo, hold a session across calls and stash the handle in framework state, or you'll `npm install` on every turn. And keep the returned string boring and stable: a predictable header beats a prose summary that varies run to run, because the model is pattern-matching on it.
The anti-pattern: sandboxing JavaScript in JavaScript
Before the roundup, the option people try first, because it's already in the runtime. Node ships `eval()` and a `vm` module, so the shortest path from "the model wrote code" to "the code ran" is zero dependencies. Calling `eval()` on model output inside your API process is a fine choice if you enjoy incident retrospectives — the code runs with your process's credentials, your environment variables, your database connection, and your filesystem. Node's built-in `vm` module is worse than it looks, because it is honestly documented as *not* a security mechanism and is still routinely used as one; the context boundary is a JavaScript object graph, and object graphs are reachable. `vm2` was the serious attempt at doing this properly, maintained by people who knew what they were doing, and it taught a generation that "sandboxing JS in JS" is a load-bearing hope rather than a boundary — it was ultimately deprecated with its maintainer stating the approach could not be made secure. `isolated-vm` is a genuinely different and more defensible thing (real V8 isolates, separate heaps), and it's a reasonable in-process limit for semi-trusted code — but it's a JavaScript isolate, not a machine, so the moment your agent wants to run `tsc`, install a package, or shell out, you're back to needing an operating system. Model output is adversarial by construction rather than by intent; give it a real boundary. /blog/why-docker-is-not-a-sandbox makes the same argument one layer up.
The field, through a TypeScript lens
Grouped by the job each one is genuinely best at, not ranked, because ranking these against each other requires pretending they're the same product. Same caveat on every entry: verify against their current docs before you build on a method name.
PandaStack (mine — read accordingly)
Open-source (Apache-2.0) Firecracker microVMs, self-hostable end to end on any Linux box with `/dev/kvm`, with a hosted service on the same binaries so one base-URL change moves between them. The TypeScript SDK is `npm i @pandastack/sdk`: dual ESM/CJS, built on native `fetch`, with `Sandbox.create` / `exec` / `execStream` / `filesystem` / `snapshot` / `fork` / `kill`, `Symbol.asyncDispose` support for `await using`, and exported error classes. Snapshot-restore on every create makes per-turn creation cheap, and copy-on-write forking is first-class, which is the primitive best-of-N repair actually wants. Where it isn't the right fit: vCPU and RAM are baked into the snapshot and can't be changed at restore, so per-run memory sizing means re-baking a template rather than passing a number — that's the price of the millisecond restore. Self-hosting is real operational weight, and if you have no infra appetite a hosted-only API is genuinely less work. The client also carries a WebSocket dependency for interactive PTY, so if you're bundling into an edge function, check what your bundler actually pulls in.
E2B
The most focused entry, and focus is a feature. E2B does sandboxes for AI agents and doesn't try to be a cloud platform, so the TypeScript surface is small and the docs are about the thing you're doing rather than the platform around it. Firecracker-backed per its own infrastructure docs, hosted-first with an Apache-2.0 open-source core, with first-party TypeScript and Python SDKs and a code-interpreter heritage that shows in the ergonomics. Where it isn't the right fit: anything else your product needs — a database, app hosting, a build pipeline — is a separate vendor, and if you want to run the substrate yourself you should check the current self-host path against the repo rather than assuming it from the license. Verify current exec, streaming, filesystem, and persistence semantics in its docs. See /blog/pandastack-vs-e2b.
Vercel Sandbox
The TypeScript-native entry, and for a lot of readers of this post that's decisive. It's built for the Vercel AI SDK and the Vercel platform, so if your agent already lives in a Next.js app the integration tax is close to zero — the sandbox speaks the same idioms as everything else in that stack, which is the entire selling point. Vercel states plainly that sandboxes run as Firecracker microVMs; the client SDK is open source, the runtime is not. Where it isn't the right fit: there's no self-host path, so residency or air-gapped requirements rule it out, and the tighter the coupling to one ecosystem the more it costs you if you later leave that ecosystem. Verify current limits, session semantics, and regional availability in Vercel's docs. See /blog/pandastack-vs-vercel-sandbox.
Modal
Modal's centre of gravity is serverless AI/ML compute — GPU jobs, batch inference, training-adjacent work — with a Sandbox primitive alongside. It's genuinely excellent at that job, and if your agent's real workload is a GPU task with a sandbox attached rather than the other way round, it fits. Where it isn't the right fit for a TypeScript team specifically: Modal is a Python-first platform and its programming model is the product, so you're adopting decorators and functions, not just a client. Check the current state of its TypeScript surface against its docs rather than inferring it. Separately, Modal's own security docs describe gVisor as the isolation mechanism — a user-space kernel rather than a hardware-virtualized VM. That's a considered choice and a real step up from a plain container; evaluate it as the different bet it is. Hosted-only. See /blog/pandastack-vs-modal.
Daytona
Daytona comes at this from the development-environment direction rather than the ephemeral-invocation one: sandboxes feel like machines you work in, which maps well onto agents that operate inside a long-lived workspace rather than firing a thousand disposable creates an hour. Its docs describe a dedicated-kernel, complete-isolation model without naming a hypervisor, so I won't name one either. Open-source (AGPL-3.0) with managed, self-hosted, and hybrid deployment. Where it isn't the right fit: environment semantics don't map cleanly onto very high-churn disposable workloads, and the license is worth reading against your distribution plans before you build a product on it. See /blog/pandastack-vs-daytona.
Fly.io Machines
A lower-level, more general primitive than the agent-shaped sandbox APIs: fast-starting VMs you drive through an API, deployable near users, with persistent volumes and a scale-to-zero story. The bet differs in kind — real machines that stick around and cost little when idle, rather than cheap disposable creates. That's the right bet when each of your agents needs a durable environment that survives across sessions. Where it isn't the right fit for a TypeScript agent team: you're typically talking to the Machines HTTP API or a community client rather than a first-party agent SDK, which means the session lifecycle, the exec transport, cleanup, and the safety layer are yours to build. Fair trade for the flexibility; budget for it honestly. See /blog/pandastack-vs-flyio-machines.
Cloudflare (Workers, Containers, Sandbox SDK)
Cloudflare is the strongest answer to a question the others aren't asking: what if the agent itself lives at the edge? Workers is a V8-isolate runtime with startup characteristics and global distribution that nothing on this list matches, it's TypeScript-native, and the surrounding platform — Durable Objects for agent state, R2, KV, queues — is unusually well-suited to an agent's shape. Cloudflare has also been building out Containers and a Sandbox SDK for running actual processes rather than just isolates. Where the nuance lies: a V8 isolate is a JavaScript boundary, not a machine, so it's a poor fit for "run whatever the model wrote" in the general case — you can't `npm install` or run a Python script in an isolate — and the container-based path is a different product with different properties from the isolate path. Verify which product you're actually buying, what its isolation model is, and its current maturity against Cloudflare's docs, because this area is moving fast. See /blog/pandastack-vs-cloudflare-workers.
Northflank
Northflank is an application platform — build pipelines, services, jobs, managed databases, bring-your-own-cloud — that has extended toward the agent-workload case. Its genuine strength is breadth under one roof and BYOC: if you want your agent's sandboxes, your API, your queues, and your Postgres on one control plane and inside your own cloud account, that consolidation is real and underrated. Where it isn't the right fit: if all you want is a narrow disposable-sandbox API with three lines to first exec, a full platform is more surface than the job needs. As with everything here, verify the isolation model, the TypeScript client surface, and the current sandbox-oriented feature set against Northflank's own docs. See /blog/pandastack-vs-northflank.
Self-host: raw Firecracker, gVisor, Docker-in-a-VM
Three different things that get lumped together. **Raw Firecracker** gives you the smallest, best-audited VMM and total control — and the VMM is the easy part. The work is per-tenant networking that doesn't leak addresses, snapshot storage, a template pipeline, cross-host scheduling, and reaping orphaned VMs before they quietly bankrupt you. I've built exactly that; my estimate was wrong by a large multiple. **gVisor** (runsc) is a real step up you can self-operate today: a user-space kernel intercepts most syscalls before they reach the host, with workload-dependent compatibility and performance trade-offs, and it drops in as an OCI runtime so your existing container tooling mostly still works. **Docker-in-a-VM** is the pragmatic middle: containers for the ergonomics, one hardware-virtualized boundary around the whole fleet so a container escape lands in a VM rather than on your host. It's genuinely better than bare Docker and genuinely weaker than per-sandbox VMs, because tenants share a kernel with each other. All three are the right call when the substrate is strategic at your scale and you can staff a team; reread that clause honestly before committing. More at /blog/build-vs-buy-firecracker-sandbox and /blog/best-open-source-code-sandboxes.
The "just use a container" anti-pattern
Worth naming separately because it's the most common shipped answer, not a strawman. Spawning a Docker container per tool call from your Node process is fast, familiar, and about twenty lines with `dockerode`. It's also namespaces and cgroups around a process on the host's one kernel, which means the entire Linux syscall interface is your attack surface for code nobody reviewed. Add seccomp, drop capabilities, run rootless, set a read-only rootfs — all worth doing, none of it changes the shared-kernel fact. It's a perfectly good choice for code *you* wrote and reviewed, and a bet on kernel bug-freeness for code a model wrote. If a container is what you can ship this quarter, ship it and put a VM boundary around the fleet; just don't tell your security reviewer it's a sandbox.
At a glance
- PandaStack — Strength: open-source Firecracker microVMs you can self-host end to end, with a dual-ESM/CJS fetch-based TS SDK, snapshot-restore on every create (179ms p50, ~203ms p99), and first-class CoW forking (400–750ms same-host) for best-of-N agent patterns. Watch out for: vCPU/RAM are fixed at snapshot-bake time, self-hosting is real operational weight, and the client carries a WebSocket dependency for PTY.
- E2B — Strength: the most focused agent-sandbox API in the field, with a mature first-party TypeScript SDK and docs that stay on topic. Watch out for: it's deliberately not a platform, so databases and hosting are somebody else's vendor; verify the current self-host path and method names against its own docs.
- Vercel Sandbox — Strength: TypeScript-native and built around the Vercel AI SDK, so integration tax inside a Next.js app is close to zero; Firecracker microVMs per Vercel's own statement. Watch out for: hosted-only with no self-host path, and the coupling that makes it cheap inside the ecosystem makes it costly to leave.
- Modal — Strength: outstanding for serverless AI/ML compute — GPU jobs, batch inference — with a Sandbox primitive alongside, when the sandbox is incidental to the real workload. Watch out for: Python-first programming model you adopt wholesale, gVisor rather than hardware virtualization per its own security docs, and hosted-only; verify the TypeScript surface.
- Daytona — Strength: development-environment semantics that map well onto agents working inside long-lived workspaces, with managed, self-hosted, and hybrid deployment. Watch out for: a poor fit for very high-churn disposable creates, and an AGPL-3.0 license worth reading against your distribution plans.
- Fly.io Machines — Strength: durable, fast-starting VMs near your users, with persistent volumes and scale-to-zero — the right bet when per-agent state must survive sessions. Watch out for: it's a general primitive, not an agent SDK, so the session lifecycle, exec transport, and safety layer are yours to build.
- Cloudflare (Workers / Containers) — Strength: unmatched edge distribution and startup for TypeScript-native agents, with Durable Objects and the rest of the platform fitting agent state unusually well. Watch out for: a V8 isolate is a JS boundary, not a machine — arbitrary model-written code needs the container-based path, which is a different product; verify its current shape and isolation model.
- Northflank — Strength: one control plane for sandboxes, services, jobs, and databases, with bring-your-own-cloud, if you want the whole agent stack in your own account. Watch out for: more platform than a narrow sandbox job needs; verify the isolation model and TypeScript client surface against its docs.
- Raw Firecracker (self-host) — Strength: the smallest, best-audited VMM and total control over the substrate. Watch out for: the VMM is the easy 10% — networking, snapshot storage, scheduling, and orphan reaping are the other 90%, and they are a team, not a sprint.
- gVisor (self-host) — Strength: a real boundary improvement over a plain container that drops in as an OCI runtime, so existing tooling mostly survives. Watch out for: workload-dependent syscall compatibility and performance, and you now operate a kernel implementation.
- Docker-in-a-VM (self-host) — Strength: container ergonomics with one hardware-virtualized boundary around the fleet, which is a pragmatic and honest middle ground. Watch out for: tenants still share a kernel with each other, so it contains blast radius to the VM rather than to the sandbox.
- Plain containers, unwrapped — Strength: instant, familiar, twenty lines with `dockerode`, and entirely appropriate for first-party code you reviewed. Watch out for: it's the shared host kernel and the full Linux syscall interface as your attack surface — not a boundary for code a model wrote.
- In-process JS isolation (`eval`, `vm`, `vm2`, `isolated-vm`) — Strength: `isolated-vm` is a legitimate in-process limit for semi-trusted JavaScript with real V8 isolate separation. Watch out for: `eval` and Node's `vm` are not security boundaries and never claimed to be, `vm2` was deprecated as unfixable, and none of them give you a machine — no installs, no shell, no other languages.
The decision tree
- Pick Vercel Sandbox if your agent already lives in a Next.js app on the Vercel AI SDK — the integration is near-free, and near-free integration beats a marginally better sandbox you have to plumb yourself.
- Pick E2B if you want a mature, focused, well-documented TypeScript sandbox API with zero infrastructure to operate — the right default for most teams shipping their first coding agent.
- Pick PandaStack if you want microVM isolation with cheap per-turn create and first-class copy-on-write forking for branching agent state, and you want the option to own the substrate — accepting fixed-at-bake-time vCPU/RAM and real self-hosting weight.
- Pick Modal if the real workload is GPU or batch compute with a sandbox attached, you're comfortable adopting its programming model, and gVisor's boundary satisfies your threat model after you've actually read about it.
- Pick Daytona if your agents work inside longer-lived, dev-environment-shaped workspaces rather than firing a thousand disposable creates per hour.
- Pick Fly.io Machines if durable per-agent state is the hard requirement and you're happy to build the session lifecycle and exec transport yourself.
- Pick Cloudflare if the agent itself belongs at the edge and the platform around it — Durable Objects, R2, queues — is doing as much work as the sandbox; then check carefully which of its execution products actually runs your untrusted code.
- Pick Northflank if you want sandboxes, services, jobs, and databases on one control plane inside your own cloud account, and the breadth is the point rather than overhead.
- Pick self-hosted gVisor, Docker-in-a-VM, or raw Firecracker if the substrate is strategic at your scale and you can staff a team for it — and reread that sentence honestly before committing.
- Pick a plain `child_process` if the code is first-party code you wrote and reviewed. Wrapping a trusted script in a microVM buys latency and an on-call surface in exchange for protection against a threat that isn't in your model.
- Pick nothing from the in-process family — `eval`, Node's `vm`, `vm2` — as your only boundary for model output. `isolated-vm` is defensible as a limit on semi-trusted JavaScript; none of them are a machine.
The bottom line
There's no best sandbox API for TypeScript agents — there's a best fit for the shape of your loop, your deploy target, and the honesty of your threat model. The serious options broadly agree on the thing that matters most: code a model wrote needs a real boundary, and a shared kernel isn't one. Where they diverge is the part you'll live in daily — whether the SDK is first-party or generated, whether the result type forces you to handle failure, whether exec streams into something your framework can consume, whether an abort stops the guest or just your promise, whether the client survives an edge runtime, and whether you can own the substrate.
So work backwards from the capability your agent leans on hardest. If it's per-turn create cost, measure create latency on your own template in your own region. If it's branching — try five fixes, keep the one that passes — check fork semantics explicitly, because a snapshot you restore later is a backup and a fork of a live machine is a branch, and plenty of platforms have one without the other. Shortlist two, wire both behind the same tool signature so swapping is a one-line change, and let a week of real agent traffic decide. PandaStack's bet, for the record, is an Apache-2.0 Firecracker core with a small, boring-in-the-good-way TypeScript API — 179ms p50 create, 400–750ms same-host forks, streaming exec, first-class filesystem, disposable-friendly TTLs — that you can run end to end on your own hardware. If that matches your loop, benchmark it against the field and keep us honest. If it doesn't, one of the others above genuinely fits you better, and I'd rather you use that than churn off mine in six months.
Frequently asked questions
Can I run a sandbox SDK from an edge runtime like Vercel Edge or Cloudflare Workers?
Sometimes, and it depends entirely on the client library rather than on the sandbox platform. Edge runtimes give you `fetch`, web streams, `AbortSignal`, and `crypto`, but no filesystem, no `child_process`, no raw TCP sockets, no native addons, and no long-lived process between requests. An SDK built purely on native `fetch` and web streams will run there unchanged; one that imports `fs`, `net`, a WebSocket library, or a native binding will fail — sometimes at build time, sometimes only on the first request in production, which is a considerably worse way to find out. Check three things before you commit: does the package declare an `edge-light` or `worker` export condition, is the dependency tree effectively zero-runtime-deps, and does any feature you need (interactive PTY, file upload from disk) reach for a Node built-in? A useful pattern when the client isn't edge-safe is to keep the agent loop at the edge and put the sandbox calls behind a small Node route or a queue consumer, which also gives you somewhere to enforce timeouts and clean up sandboxes that outlive a cancelled request. Also remember that edge functions are short-lived by design, so if your sandbox call can take four minutes, an edge runtime is the wrong place for it regardless of whether the SDK imports cleanly.
Do I need a microVM, or is a container enough for running model-generated code?
It depends on who wrote the code and what else lives on that host, and the honest test is whether you'd be comfortable telling a security reviewer exactly what you're doing. A container is namespaces and cgroups around a process on the host's one shared kernel, which means the entire Linux syscall interface is exposed to whatever runs inside it. Hardening helps genuinely and materially — seccomp profiles, dropped capabilities, rootless, read-only rootfs, no host mounts — but none of it changes the fundamental fact that a kernel bug is a host compromise. For code you wrote and reviewed, that's a perfectly reasonable risk posture. For arbitrary code a language model generated, possibly influenced by a prompt injection in a document your agent just read, a hardware-virtualized microVM (Firecracker, Kata, Cloud Hypervisor) is the right default: each sandbox gets its own guest kernel and guest code never touches the host kernel directly, so your exposed surface becomes a small, heavily-audited VMM instead. gVisor is a meaningful middle rung — a user-space kernel intercepting most syscalls before they reach the host, with workload-dependent compatibility and performance costs. And don't read 'microVM' as 'unbreakable': VMMs and KVM have both had bugs, so layer per-sandbox egress control and a privilege-dropping jailer on top regardless. A pragmatic middle path if you're already container-shaped is to keep containers but run the whole fleet inside one VM, so an escape lands in a VM rather than on your host — better than nothing, weaker than per-sandbox isolation, and worth being precise about which one you have.
What should I look for in a TypeScript sandbox SDK specifically?
Seven things, roughly in the order they'll cost you time. First, whether it's a genuine first-party SDK or a generated OpenAPI client — the latter works but leaves you writing the session lifecycle, the ergonomics, and the error handling yourself. Second, the result type: a discriminated union that forces you to handle the failure branch beats a loose interface, and both beat `any`. Third, streaming exec, and specifically what shape it takes — callbacks, an async iterable, or a `ReadableStream` you can return from a route handler; callbacks adapt into the others easily, so they're not a dead end. Fourth, `AbortSignal` support, and whether aborting actually stops execution inside the guest or merely abandons your HTTP request; if it's the latter, wire the signal to a kill call yourself. Fifth, packaging: dual ESM/CJS with correct export conditions and its own type declarations, and whether it imports Node built-ins that break in edge runtimes. Sixth, bundle size, if the client ships inside a serverless function where it's part of your cold-start budget. Seventh, exported error classes, because `catch (e)` is `unknown` in TypeScript and the alternative is string-matching on error messages. Read the `.d.ts` and the `package.json` before you read the marketing page — both are more honest.
How do I cancel a sandbox tool call when my agent changes its mind mid-turn?
You want three layers, and most naive integrations only have one. First, a per-exec timeout that the server enforces inside the guest, because a client-side timeout that raises in your Node process while the sandbox keeps burning CPU on an infinite loop is not a timeout — it's a leak with good manners, and model-generated code contains infinite loops at a rate that will genuinely surprise you. Second, an `AbortSignal` threaded from wherever cancellation originates (the user's stop button, a framework timeout, a cancelled HTTP request) through both the model call and every tool handler, with an abort listener that calls the sandbox's kill method so the guest actually stops rather than just your promise settling. Third, a TTL on the sandbox itself, so that if your process is SIGKILLed, evicted, or crashes between create and cleanup, the sandbox reaps itself instead of billing quietly until someone spots it. In practice that's one `AbortController` per agent turn, passed to your model SDK's request options and to each tool handler, plus an explicit kill in a `finally` block so the normal path cleans up promptly rather than waiting for the TTL. Worth testing deliberately: abort a turn mid-`npm install` and then go look at whether the sandbox is actually gone, because the failure mode here is silent and shows up on an invoice.
Should I create a fresh sandbox per tool call, or hold one session across the whole agent run?
Fresh-per-call is the safer default and the right choice for stateless one-shot tools — 'evaluate this expression', 'run this snippet' — because there's no state bleed between steps and no cleanup ambiguity. But for an iterative coding agent it's usually the wrong economics: if every turn re-clones the repo and re-runs `npm install`, you've made dependency installation the dominant cost of your entire agent loop, and you'll feel it in both latency and spend. The better pattern for agents that iterate on a codebase is to create one sandbox per task, stash the handle in your framework's state (a LangGraph state field, a Mastra workflow context, a Durable Object, whatever you're using), reuse it across tool calls, and destroy it when the task ends. Always set a TTL alongside it so a crashed run reaps itself, and be deliberate about what 'stateful' means for your platform: filesystem persistence, where the machine survives between calls, is a different feature from process persistence, where a REPL keeps variables alive between calls Jupyter-style. Most coding agents want the first plus explicit file passing, which is more debuggable anyway; data-analysis agents often want the second. Ask vendors which one they're offering, because the words are used interchangeably and the capabilities are not.
49ms p50 cold start. Fork, snapshot, and scale to zero.