Skip to main content

TanStack AI Coding Agents

6 min read

TanStack AI ships a native Upstash Box sandbox provider. Install @tanstack/ai-sandbox-upstash-box, point it at your API key, and a coding agent driven by chat() runs inside a Box with a real filesystem, shell, background processes, preview URLs, and snapshots. No glue code.

A sandboxed run has three independent parts:

  • Provider: where the agent runs. That is Upstash Box here.
  • Workspace: what the agent sees. The repo, the package manager, setup commands, secrets.
  • Harness: which agent runs. Claude Code, Codex, Grok Build, OpenCode, or any ACP agent.

Only the provider changes when you move a run from a local container to Upstash Box. The workspace and the harness stay the same.


1. Install#

  • @tanstack/ai: the chat() pipeline.
  • @tanstack/ai-sandbox: defineSandbox, defineWorkspace, withSandbox.
  • @tanstack/ai-sandbox-upstash-box: the Upstash Box provider.
  • @tanstack/ai-claude-code: the harness adapter. Swap it for @tanstack/ai-codex or @tanstack/ai-grok-build if you prefer another agent.

2. Set your environment variables#

Get a Box API key from the Upstash Console.

.env

upstashBoxSandbox() reads UPSTASH_BOX_API_KEY when you omit apiKey. The harness key is different: it belongs to the agent running inside the box, so it travels as a workspace secret.


3. Define the sandbox#

defineSandbox() binds a provider, a workspace, and a lifecycle into one reusable object.

sandbox.ts
Warning

Clone the repo in a setup step rather than with source: githubRepo(...). A box's home directory is created with agent dotfiles already in it (.claude, .codex, .cursor, .local), and @tanstack/ai-sandbox clones the source straight into that directory. git clone refuses a non-empty target and exits 128, the bootstrap does not surface the failure, and your setup steps then run against an empty workspace. Cloning into /tmp and copying the contents across sidesteps it.

The harness spawns its CLI inside the box, so the CLI has to be on the box's PATH. The node runtime already ships claude, codex, and opencode in /usr/local/bin, so those three need no setup step at all.

For any other CLI, install it with sudo -n. A box runs as the non-root boxuser, and /usr/local/lib/node_modules is root-owned, so a plain npm install -g fails with EACCES and takes the whole bootstrap down with it:

Agent CLIs ship their binary as an optional npm dependency, so npm install -g can exit 0 with a broken install. Running the CLI in the same step turns that into a loud bootstrap failure instead of a broken run.

Secrets are injected into the box environment on create and resume. They are never written to snapshots, the sandbox store, or the event log.

Note

TanStack AI's conventional /workspace virtual root maps to the box home directory, /workspace/home. That is the handle's workspaceRoot, and it is where the cloned repo lands.


4. Run the agent#

withSandbox() is chat() middleware. It resumes or creates the box, bootstraps the workspace, runs the harness inside it, and tears everything down according to the lifecycle.

run.ts

The threadId keys the box. Two runs on the same thread reuse the same box instead of cloning and installing again.

Harness runs emit standard AG-UI chunks (text, tool calls, reasoning) plus a namespaced CUSTOM event. When the run finishes, the adapter emits file.changed with the working tree git diff, which is the change the agent made.

To serve the same run from a server route, return the stream as Server-Sent Events:

app/api/chat/route.ts

5. Preview the app the agent builds#

If the agent starts a dev server inside the box, ports.connect() mints a public preview URL for it. It is a method on the SandboxHandle, which you get from the provider directly (step 7) or from the sandbox instance inside a middleware hook:

Preview URLs are public by default. Pass publicUrlAuth on the provider to gate them:

{ bearerToken: true } returns a token plus an Authorization: Bearer header. { basicAuth: true } returns Basic credentials instead.


6. Skip the cold start with snapshots#

Cloning the repo and installing dependencies is the expensive part of a run. Upstash Box supports native snapshots, so snapshot: "after-setup" is the default: bootstrap runs once, the result is snapshotted, and later runs resume from it.

Note

There are two keepAlive settings and they are unrelated. The one in lifecycle is TanStack's duration hint ("30m") for how long a sandbox should stay warm between runs. The one on upstashBoxSandbox({ keepAlive }) is Box's boolean: false (the default) lets the box auto-pause when idle, true keeps it running and billing.

A snapshot outlives the box that produced it. restoreSnapshot() rebuilds a fresh box from it with Box.fromSnapshot(), and resume by id probes the box status first, so a deleted box resumes as null rather than a dead handle.

fork() works the same way, snapshot plus create from that snapshot. It costs a full snapshot round trip, roughly 25 seconds.


7. Use the box without a harness#

The provider also gives you the uniform SandboxHandle directly, with no chat() or agent involved. This is the quickest way to check that your key and runtime are wired correctly.

smoke-test.ts

Provider options#

OptionDefaultNotes
apiKeyUPSTASH_BOX_API_KEYYour Box API key.
baseUrlSDK defaultOverrides the Box API base URL.
runtimenodeBox runtime image: node, python, golang, ruby, rust.
sizesmallsmall (2 CPU / 4 GB), medium (4 / 8), large (8 / 16).
keepAlivefalsefalse avoids billing a perpetually running box and keeps pause() available. true prevents auto-pause mid-run, bills continuously, and disables pausing.
snapshotnoneBase snapshot id to create the box from.
namenoneHuman readable box name. A deterministic sandbox id from ensure() takes precedence.
publicUrlAuthnone{ bearerToken?, basicAuth? } for ports.connect().

What the provider supports#

CapabilitySupportedNotes
fsYesNative Box file API.
execYesSeparate stdout and stderr.
envYesShell export prefixes for exec, passed natively to spawn.
portsYesPublic preview URLs.
snapshotsYesNative box.snapshot() and Box.fromSnapshot().
durableFilesystemYesPersists across pause and resume until the box is deleted.
backgroundProcessesYesspawn() opens a live exec.session with a real in-box pid.
writableStdinYesstdin.write() and stdin.end().
killableProcessesYeskill() signals the process tree server-side.
networkPolicyYesnetwork: "deny" maps to the deny-all egress mode.
forkYesSnapshot round trip, about 25 seconds.

Things to know#

Spawned processes are tied to the handle. spawn() runs the command as a live exec.session over a WebSocket, which is what gives it a real pid, a writable stdin, and server-side signals. The session owns its process, so dropping the connection kills the command and sessions cannot be reattached. A spawned process lives as long as the handle, not as long as the box.

network: "deny" is strict. It maps to Box's deny-all egress mode, which blocks every outbound connection. Providers that model deny as an allowlist are more permissive. An agent that works under one of those will not reach package registries or model provider hosts here. Leave the capability unset if the agent needs either, and use network policies on the box for domain and CIDR rules.

Bridged tools need a tunnel in local development. Tools you pass to chat() execute on your host and are bridged into the box over an authenticated proxy the box dials back to. A box is a remote machine, so it cannot reach localhost on your laptop. Deployed orchestrators work as they are. For local development, tunnel the bridge with withNgrokBridge. See the TanStack AI tools guide.


Next steps#