Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/upstash-box-sandbox-provider.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@tanstack/ai-sandbox-upstash-box': minor
---

Add `@tanstack/ai-sandbox-upstash-box`, an Upstash Box sandbox provider. Runs
harness adapters inside isolated Upstash Box cloud sandboxes through the uniform
`SandboxHandle` — a native filesystem (including `stat`-backed `exists`), shell
`exec` with separate stdout and stderr, background processes over Box's live
`exec.session` (real in-box pid, writable stdin, and `kill()` that signals the
process tree server-side), public preview URLs via `getPublicURL`, and native
snapshots (`box.snapshot()` / `Box.fromSnapshot()`), `fork()` built on the same
snapshot pair, and a `deny` network policy mapped onto Box's `deny-all` egress
mode.

Requires `@upstash/box` 0.7.1 or newer for `exec.session` and the filesystem
metadata operations.
2 changes: 1 addition & 1 deletion docs/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -578,7 +578,7 @@
"label": "Providers",
"to": "sandbox/providers",
"addedAt": "2026-06-29",
"updatedAt": "2026-08-18"
"updatedAt": "2026-08-24"
},
{
"label": "Harnesses",
Expand Down
54 changes: 51 additions & 3 deletions docs/sandbox/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
title: Providers
id: providers
order: 3
description: "Pick and configure where a TanStack AI sandbox runs (local process, Docker container, Docker Sandboxes microVM, Daytona, or Vercel) and what each one can do."
description: "Pick and configure where a TanStack AI sandbox runs (local process, Docker container, Docker Sandboxes microVM, Daytona, Vercel, or Upstash Box) and what each one can do."
---

A provider owns the isolation primitive: where the harness actually runs. Every
Expand Down Expand Up @@ -30,6 +30,7 @@ completed workspace data in your application persistence for reconstruction.
| Daytona | `@tanstack/ai-sandbox-daytona` | cloud sandbox | Managed [Daytona](https://www.daytona.io/) sandboxes; snapshots after setup, port preview links, resume-by-id. Needs `DAYTONA_API_KEY`. |
| Vercel | `@tanstack/ai-sandbox-vercel` | microVM | Managed [Vercel Sandbox](https://vercel.com/docs/sandbox) microVMs; exposed-port domains, resume-by-id (persistent). Needs `VERCEL_TOKEN` + team/project. |
| Sprites | `@tanstack/ai-sandbox-sprites` | stateful sandbox | Managed [Sprites](https://sprites.dev) (Fly.io) sandboxes; durable filesystem, in-place checkpoints, single proxied public-URL port, resume-by-id. Needs `SPRITES_API_KEY`. |
| Upstash Box | `@tanstack/ai-sandbox-upstash-box` | cloud sandbox | Managed [Upstash Box](https://github.com/upstash/box) sandboxes; interactive processes over a WebSocket session (real pid, stdin, signals), native snapshots, preview URLs, resume-by-id. Needs `UPSTASH_BOX_API_KEY`. |

Most providers are their own package. `dockerSandbox()` and `sbxSandbox()` both
come from `@tanstack/ai-sandbox-docker`. The constructor is the only thing that
Expand All @@ -40,15 +41,17 @@ import { localProcessSandbox } from '@tanstack/ai-sandbox-local-process'
import { dockerSandbox, sbxSandbox } from '@tanstack/ai-sandbox-docker'
import { daytonaSandbox } from '@tanstack/ai-sandbox-daytona'
import { vercelSandbox } from '@tanstack/ai-sandbox-vercel'
import { upstashBoxSandbox } from '@tanstack/ai-sandbox-upstash-box'

const dev = localProcessSandbox() // runs on your host
const isolated = dockerSandbox({ image: 'node:22' }) // container
const microvm = sbxSandbox() // Docker Sandboxes microVM
const daytona = daytonaSandbox({ apiKey: process.env.DAYTONA_API_KEY }) // managed cloud sandbox
const vercel = vercelSandbox({ runtime: 'node24' }) // managed Vercel microVM
const box = upstashBoxSandbox({ apiKey: process.env.UPSTASH_BOX_API_KEY }) // managed Upstash Box
```

> Cloud providers (Daytona, Vercel) run as remote VMs. When you drive them from
> Cloud providers (Daytona, Vercel, Upstash Box) run as remote VMs. When you drive them from
> your laptop, [tools](./tools) bridged from `chat()` can't dial your machine's
> `localhost`, you need the bridge tunnel. See the [tools guide](./tools) for the
> ngrok subpath, and the [Cloudflare guide](./cloudflare) for the edge-native
Expand Down Expand Up @@ -300,6 +303,50 @@ const sprites = spritesSandbox({ apiKey: process.env.SPRITES_API_KEY })
- **Bridge:** like Daytona and Vercel, it is a remote VM, so bridged tools need the tunnel in
local dev (see [tools](./tools)).

## Upstash Box

```ts
import { upstashBoxSandbox } from '@tanstack/ai-sandbox-upstash-box'

const box = upstashBoxSandbox({ apiKey: process.env.UPSTASH_BOX_API_KEY })
```

- **Isolation:** a managed [Upstash Box](https://github.com/upstash/box) cloud
sandbox, a remote container you do not run yourself.
- **Auth / env:** needs `UPSTASH_BOX_API_KEY` (or `apiKey`); override the API
base with `baseUrl` / `UPSTASH_BOX_BASE_URL`. Pick the image and size with
`runtime` (default `node`) and `size`.
- **Paths:** the conventional `/workspace` virtual root maps to the box home,
`/workspace/home`, which is the handle's `workspaceRoot`.
- **Processes:** `spawn()` opens a live `exec.session` over a WebSocket, so a
background process has a real in-box pid, a writable stdin, separate stdout and
stderr, and server-side signals. A session owns its process: dropping the
connection kills the command and sessions cannot be reattached, so `spawn()` is
scoped to the lifetime of the handle rather than the box. Blocking `exec()`
stays on the HTTP path and is shell-wrapped for `cwd`/env, which the session
takes natively.
- **Snapshot / resume:** `snapshot()` calls `box.snapshot()` and
`restoreSnapshot()` reconstructs a new box from it via `Box.fromSnapshot()`, so
a snapshot survives deletion of the box that made it. Resume-by-id uses
`Box.get` (id or name) and probes `getStatus`, so a deleted record resumes as
`null` rather than a tombstone handle.
- **Ports:** `ports.connect(port)` mints a preview URL via `getPublicURL`. Pass
`publicUrlAuth` to gate it, `{ bearerToken: true }` returns a token plus an
`Authorization: Bearer` header and `{ basicAuth: true }` returns Basic
credentials; without it the preview URL is unauthenticated.
- **Network:** a `policy.capabilities.network` of `'deny'` maps to Box's
`deny-all` egress mode. The contract's gate is coarse, so Box's domain and CIDR
allowlists are not reachable through it. This is stricter than providers that
model deny as an allowlist: `deny-all` blocks every outbound connection, so an
agent that works under an allowlist-style deny will not reach package
registries or model provider hosts here. Leave the capability unset if the
agent needs either.
- **Fork:** `fork()` snapshots the box and creates a new one from that snapshot,
the same shape as Docker's commit plus create. It costs a full snapshot round
trip (about 25 seconds), unlike Docker's local commit.
- **Bridge:** like Daytona and Vercel, it is a remote VM, so bridged tools need
the tunnel in local dev (see [tools](./tools)).

## Capabilities

Providers declare what they support via `capabilities()`. The flags are:
Expand All @@ -311,7 +358,7 @@ Providers declare what they support via `capabilities()`. The flags are:
| `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, Docker container, and Daytona. `false` for Docker Sandboxes (`sbx`), Vercel, and Cloudflare. When `false`, stdin-fed harnesses write the prompt to a file and redirect it in the shell. |
| `writableStdin` | A spawned process exposes a writable host→process stdin. `true` for local-process, Docker container, Daytona, and Upstash Box. `false` for Docker Sandboxes (`sbx`), Vercel, Sprites, and Cloudflare. When `false`, stdin-fed harnesses write the prompt to a file and redirect it in the shell. |
| `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. |
Expand Down Expand Up @@ -360,6 +407,7 @@ merely slower while a wrong `follow` is a leak.
| 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`. |
| Upstash Box | `true` | **Measured.** `kill()` sends an allowlisted signal (`TERM`/`KILL`/`INT`/`HUP`) that the box agent delivers to the process TREE server-side, so a forked child is signalled too. Verified against production: a spawned `sleep 5 && touch <marker>` was killed and the marker never appeared. Needs `UPSTASH_BOX_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
Expand Down
100 changes: 100 additions & 0 deletions packages/ai-sandbox-upstash-box/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# @tanstack/ai-sandbox-upstash-box

Upstash Box sandbox provider for [TanStack AI](https://tanstack.com/ai). Runs
harness adapters inside isolated [Upstash Box](https://github.com/upstash/box)
cloud sandboxes through the uniform `SandboxHandle` — real filesystem, shell,
interactive processes, public preview URLs, and native snapshots.

## Install

```bash
npm install @tanstack/ai @tanstack/ai-sandbox @tanstack/ai-sandbox-upstash-box
```

## Usage

```ts
import {
defineSandbox,
defineWorkspace,
withSandbox,
} from '@tanstack/ai-sandbox'
import { upstashBoxSandbox } from '@tanstack/ai-sandbox-upstash-box'

const sandbox = defineSandbox({
id: 'agent',
provider: upstashBoxSandbox({
apiKey: process.env.UPSTASH_BOX_API_KEY, // or set the env var and omit
runtime: 'node',
}),
workspace: defineWorkspace({/* … */}),
})

// Then pass `withSandbox(sandbox)` as chat() middleware.
```

The API key falls back to the `UPSTASH_BOX_API_KEY` environment variable when
`apiKey` is omitted.

### End-to-end example

Using the provider directly through the uniform `SandboxHandle` (no harness /
`chat()` involved):

```ts
import { upstashBoxSandbox } from '@tanstack/ai-sandbox-upstash-box'

const provider = upstashBoxSandbox({ runtime: 'node' })
const box = await provider.create({})
try {
await box.fs.write('/workspace/hello.txt', 'hello from upstash box')
console.log(await box.fs.read('/workspace/hello.txt'))

const run = await box.process.exec('node --version')
console.log('node', run.stdout.trim(), '(exit', run.exitCode, ')')

const channel = await box.ports.connect(3000)
console.log('preview url:', channel.url)
} finally {
await box.destroy()
}
```

## Configuration

| Option | Default | Notes |
| --------------- | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `apiKey` | `UPSTASH_BOX_API_KEY` | Upstash Box API key. |
| `baseUrl` | SDK default | Overrides the Box API base URL. |
| `runtime` | `node` | Box runtime image. |
| `size` | `small` | Box resource size. |
| `keepAlive` | `false` | `false` avoids billing a perpetually-running box and keeps `pause()` available. `true` prevents auto-pause mid-run but bills continuously and disables pausing. |
| `snapshot` | — | Base snapshot id to create the box from (routed through `Box.fromSnapshot`). |
| `name` | — | Human-readable box name. The caller's deterministic sandbox id (from `ensure()`) takes precedence when present. |
| `publicUrlAuth` | none | `{ bearerToken?, basicAuth? }` — auth to request when minting public URLs via `ports.connect`. |

## Capabilities

| Capability | Supported | Notes |
| --------------------- | --------- | ------------------------------------------------------------------------------------------- |
| `fs` | ✅ | Native Box file API throughout; `exists` is a `stat` probe. |
| `exec` | ✅ | Separate `stdout` and `stderr`. |
| `env` | ✅ | Shell `export` prefixes for `exec`; passed natively to `spawn`. |
| `ports` | ✅ | Public preview URLs via `getPublicURL`. |
| `snapshots` | ✅ | Native `box.snapshot()` / `Box.fromSnapshot()`. |
| `durableFilesystem` | ✅ | Persists across pause/resume until deleted. |
| `backgroundProcesses` | ✅ | `spawn()` runs the command as a live `exec.session` with a real in-box pid. |
| `writableStdin` | ✅ | `stdin.write()` / `stdin.end()` map to the session's `write` / `endStdin`. |
| `killableProcesses` | ✅ | `kill()` signals the process tree server-side; `TERM`/`KILL`/`INT`/`HUP`, others send TERM. |
| `networkPolicy` | ✅ | `policy.capabilities.network: 'deny'` maps to Box's `deny-all` egress mode. |
| `fork` | ✅ | `snapshot()` + `Box.fromSnapshot()`. Costs a full snapshot round trip (~25s). |

A spawned process is tied to its session: dropping the connection kills the
command, and sessions cannot be reattached. `spawn()` is therefore scoped to the
lifetime of the handle, not the box.

`network: 'deny'` is stricter here than under providers that model deny as an
allowlist. Box's `deny-all` blocks every outbound connection, so an agent that
runs fine under an allowlist-style deny will not reach package registries or
model provider hosts on this one. Leave the capability unset if the agent needs
either.
55 changes: 55 additions & 0 deletions packages/ai-sandbox-upstash-box/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
{
"name": "@tanstack/ai-sandbox-upstash-box",
"version": "0.1.0",
"description": "Upstash Box sandbox provider for TanStack AI \u2014 run harness adapters inside isolated Upstash Box cloud sandboxes through the uniform SandboxHandle.",
"author": "",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/TanStack/ai.git",
"directory": "packages/ai-sandbox-upstash-box"
},
"keywords": [
"ai",
"tanstack",
"sandbox",
"upstash",
"box",
"harness",
"agent",
"isolation"
],
"type": "module",
"module": "./dist/esm/index.js",
"types": "./dist/esm/index.d.ts",
"exports": {
".": {
"types": "./dist/esm/index.d.ts",
"import": "./dist/esm/index.js"
}
},
"files": [
"dist",
"src"
],
"scripts": {
"build": "vite build",
"clean": "premove ./build ./dist",
"lint:fix": "oxlint src --type-aware --fix",
"test:build": "publint --strict",
"test:oxlint": "oxlint src --type-aware",
"test:lib": "vitest",
"test:lib:dev": "pnpm test:lib --watch",
"test:types": "tsc"
},
"dependencies": {
"@upstash/box": "^0.7.1"
},
"peerDependencies": {
"@tanstack/ai-sandbox": "workspace:^"
},
"devDependencies": {
"@tanstack/ai-sandbox": "workspace:*",
"@vitest/coverage-v8": "4.1.10"
}
}
Loading
Loading