A provider owns the isolation primitive: where the harness actually runs. Every provider implements the same SandboxProvider / SandboxHandle contract, so the workspace you hand the agent and the policy that guards it are provider-agnostic. Pick a provider for the isolation, auth, and snapshot/resume behaviour you need; the rest of your sandbox definition stays the same.
The provider is where the agent runs. For which agent runs (Grok Build, Claude Code, Codex, OpenCode, or any ACP agent via acpCompatible) see Harnesses.
| Provider | Package | Isolation | Notes |
|---|---|---|---|
| Local process | @tanstack/ai-sandbox-local-process | none (host) | The fast, no-Docker dev loop. Trusted/dev use only. |
| Docker | @tanstack/ai-sandbox-docker | container | Real isolation; commit-based snapshots, fork, resume-by-id. |
| Daytona | @tanstack/ai-sandbox-daytona | cloud sandbox | Managed Daytona sandboxes; port preview links, resume-by-id. Needs DAYTONA_API_KEY. |
| Vercel | @tanstack/ai-sandbox-vercel | microVM | Managed Vercel Sandbox microVMs; exposed-port domains, resume-by-id (persistent). Needs VERCEL_TOKEN + team/project. |
| Sprites | @tanstack/ai-sandbox-sprites | stateful sandbox | Managed Sprites (Fly.io) sandboxes; durable filesystem, in-place checkpoints, single proxied public-URL port, resume-by-id. Needs SPRITES_API_KEY. |
Each provider is its own package, and the constructor is the only thing that differs between them:
import { localProcessSandbox } from '@tanstack/ai-sandbox-local-process'
import { dockerSandbox } from '@tanstack/ai-sandbox-docker'
import { daytonaSandbox } from '@tanstack/ai-sandbox-daytona'
import { vercelSandbox } from '@tanstack/ai-sandbox-vercel'
const dev = localProcessSandbox() // runs on your host
const isolated = dockerSandbox({ image: 'node:22' }) // runs in a container
const daytona = daytonaSandbox({ apiKey: process.env.DAYTONA_API_KEY }) // managed cloud sandbox
const vercel = vercelSandbox({ runtime: 'node24' }) // managed Vercel microVMCloud providers (Daytona, Vercel) run as remote VMs. When you drive them from your laptop, tools bridged from chat() can't dial your machine's localhost, you need the bridge tunnel. See the tools guide for the ngrok subpath, and the Cloudflare guide for the edge-native co-located model.
import { localProcessSandbox } from '@tanstack/ai-sandbox-local-process'
const dev = localProcessSandbox()Because localProcessSandbox runs the harness on your host, it inherits your host environment, including any API keys exported there. Use scrubEnv to remove variables before spawning, so the host CLI falls back to its own logged-in session instead of billing the API. For example, drop XAI_API_KEY so Grok Build uses your grok.com login (the same trick works for Claude Code with ANTHROPIC_API_KEY → claude login):
import { localProcessSandbox } from '@tanstack/ai-sandbox-local-process'
const hostLogin = localProcessSandbox({ scrubEnv: ['XAI_API_KEY'] })Only local-process can do this. It is the only provider that runs your host CLI. Isolated and cloud providers have no host login, so they always use an injected API key (supplied as a workspace secret).
Killing a spawned process means killing the whole tree, and on Windows that takes more than taskkill /T. Commands run through a git-bash sh, and MSYS's fork emulation runs the final command of a statement list, such as the tail -f behind a journal follow read, under an intermediate shell that immediately exits. Windows never reparents, so the surviving process points at a dead parent and taskkill /T, which walks only live parent links, cannot reach it while still exiting 0. Left alone, every follow read leaks a process for the life of the machine.
localProcessSandbox therefore consults MSYS's own process table, which does keep the logical parentage before killing, then kills any descendant /T missed. Teardown is total by construction: it never throws, because a throwing kill would strand a run mid-flight. That means a kill it genuinely cannot complete (a protected process, access denied) is otherwise invisible, so pass a logger to see it:
import { localProcessSandbox } from '@tanstack/ai-sandbox-local-process'
const dev = localProcessSandbox({
logger: {
warn: (message, meta) => console.warn(message, meta),
},
})Any object with a warn(message, meta?) method works, so the InternalLogger your adapter already receives can be handed straight in. A process that had already exited on its own is not a failure and is never reported.
Nothing here changes on POSIX, where sh really is the command's parent and signalling the wrapper is enough.
import { dockerSandbox } from '@tanstack/ai-sandbox-docker'
const isolated = dockerSandbox({ image: 'node:22' })import { daytonaSandbox } from '@tanstack/ai-sandbox-daytona'
const daytona = daytonaSandbox({ apiKey: process.env.DAYTONA_API_KEY })import { vercelSandbox } from '@tanstack/ai-sandbox-vercel'
const vercel = vercelSandbox({ runtime: 'node24' })import { spritesSandbox } from '@tanstack/ai-sandbox-sprites'
const sprites = spritesSandbox({ apiKey: process.env.SPRITES_API_KEY })Providers declare what they support via capabilities(). The flags are:
| Capability | Meaning |
|---|---|
| fs | Read/write the sandbox filesystem. |
| exec | Run commands. |
| env | Inject environment variables. |
| ports | Expose/forward ports (preview URLs). |
| backgroundProcesses | Keep long-running processes alive between calls. |
| writableStdin | A spawned process exposes a writable host→process stdin. true for local-process and Docker; false on remote/edge providers (Daytona, Vercel, Cloudflare), where stdin-fed harnesses deliver the prompt via a file + shell redirection instead. |
| killableProcesses | A spawned process can be forcibly stopped via SpawnHandle.kill() and aborted mid-flight via the signal passed to spawn. |
| snapshots | Capture and restore point-in-time snapshots. |
| networkPolicy | Enforce network allow/deny rules. |
| durableFilesystem | Disk that survives across resumes. |
| fork | Branch a sandbox from an existing one. |
Code that uses an optional capability checks the flag first and degrades gracefully. For example, bootstrap only snapshots when snapshots is supported, so localProcessSandbox simply skips the step. Calling an unsupported optional method directly (instead of checking the flag) throws an UnsupportedCapabilityError:
import { localProcessSandbox } from '@tanstack/ai-sandbox-local-process'
const provider = localProcessSandbox()
const caps = provider.capabilities()
if (caps.snapshots) {
// safe to take a snapshot
} else {
// degrade gracefully, local-process has no snapshots
}Use the flags to write provider-agnostic code: branch on the capability rather than the concrete provider, and your sandbox definition keeps working when you swap one provider for another.
This flag is measured, not asserted. A wrong true hands the journal reader an unstoppable tail -f and leaks a process per run, so a provider only declares it once killing has been observed to work against a real sandbox. Two of these declarations were once true on reasoning alone and both turned out to be false when probed (Docker's stream destroy left the container-side process in ps; local-process's sh -c did not exec, so killing the shell left the command alive). Anything that cannot be measured yet stays false, because poll is merely slower while a wrong follow is a leak.
| Provider | killableProcesses | Why |
|---|---|---|
| Local process | true | Measured. Kills the process GROUP, not the wrapper: detached spawn plus process.kill(-pid, signal) on POSIX (killing only the sh left the command running, dash does not reliably exec); on Windows taskkill /T plus a verified sweep, see Windows teardown. |
| Docker | true | Measured. Signals the process INSIDE the container by the pid the wrapper recorded for itself, process group first and escalating to KILL. Destroying the hijacked exec stream is not sufficient: it only detaches the client. |
| Daytona | false | kill() only aborts the client-side poll loop and does not await any termination; the deleteSession that might terminate the command runs later from the pump's teardown, is failure-swallowed, and is documented as cleanup for a completed session. Unmeasured, needs DAYTONA_API_KEY. |
| Vercel | false | The abort signal reaches only the HTTP request that STARTS a detached command, so the old kill() was a no-op. It now issues the SDK's server-side Command.kill, but whether that reaches a forked child (the follow command is a multi-statement shell, so tail -f is always a child) is unmeasured, needs Vercel credentials. |
| Sprites | true (unverified) | Not a client-side detach: kill() issues a real server-side POST /exec/<sessionId>/kill before closing the socket. What that endpoint signals (process group or pid) is undocumented and unmeasured; needs SPRITES_API_KEY. |
| Cloudflare | false | kill() is a no-op, and the caller's AbortSignal reaches neither exec nor spawn, because Workers RPC cannot serialize one. |
Each of the remote providers registers the shared journal conformance suite, so the claim is falsifiable rather than asserted: with credentials present the suite runs against a real sandbox, and without them it reports a named skip carrying the reason instead of a silent pass. Cloudflare's gate is the runtime rather than credentials, its provider can only create a sandbox through a Sandbox Durable Object binding, which no Node test process has, so its registration is a named skip saying exactly that, until a Workers-runtime harness can measure it.
This flag is required on every provider, including a bring-your-own one. A provider that omitted it would be treated as killable, which is the dangerous default: a follower process started there could never be reclaimed, and it would keep running inside the sandbox for as long as the sandbox lives.
It is the flag the run journal reads to decide how to tail a run's output: a killable provider gets a streaming tail -f, and a provider like Cloudflare gets a loop of bounded reads, each of which terminates on its own.
The flag also bounds what cancel can mean. On a false provider there is no signal path to the agent process, so the only cancel that actually stops the agent means destroying the sandbox, which is what the cancel path does. See what cancel means on a provider that cannot kill.