Skip to content
Open
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
1 change: 1 addition & 0 deletions .agent/domain-map.json
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@
"packages/ai-sandbox-local-process",
"packages/ai-sandbox-railway",
"packages/ai-sandbox-sprites",
"packages/ai-sandbox-tangle",
"packages/ai-sandbox-upstash-box",
"packages/ai-sandbox-vercel"
],
Expand Down
16 changes: 16 additions & 0 deletions .changeset/add-ai-sandbox-tangle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@tanstack/ai-sandbox-tangle': minor
---

Add `@tanstack/ai-sandbox-tangle`, a sandbox provider backed by Tangle cloud
sandboxes through `@tangle-network/sandbox`. It implements the
`SandboxProvider` / `SandboxHandle` contract: file reads and writes through the
sandbox file API, commands with separate stdout and stderr and real exit codes,
background processes with streamed output, public preview URLs through
`ports.connect`, `env` injection, and resume by id across stop.

`snapshot()` captures the workspace and `restoreSnapshot()` creates a new
sandbox from it. `fork()` branches the running sandbox into a new one.
`kill()` signals the process tree inside the sandbox, and an aborted `exec`
kills its process; both are measured against production. A
`network: 'deny'` policy creates the sandbox with egress blocked.
4 changes: 2 additions & 2 deletions docs/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -1371,7 +1371,7 @@
"label": "Providers",
"to": "sandbox/providers",
"addedAt": "2026-06-29",
"updatedAt": "2026-11-02"
"updatedAt": "2026-11-09"
},
{
"label": "Harnesses",
Expand Down Expand Up @@ -1487,7 +1487,7 @@
"label": "Providers (Advanced)",
"to": "sandbox/providers-advanced",
"addedAt": "2026-10-21",
"updatedAt": "2026-10-29"
"updatedAt": "2026-11-09"
},
{
"label": "The Run Journal",
Expand Down
26 changes: 25 additions & 1 deletion docs/sandbox/providers-advanced.md
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,29 @@ Processes on Railway:
- Blocking `exec` runs without a durable session. If the connection drops, the command dies.
- `kill()` and an aborted `exec` send `TERM` to the process group, then `KILL` after 2 s. An aborted `spawn` sends `KILL` at once. All of them finish only on the confirmed remote exit. An aborted `exec` resolves with the exit code of the signal.

## Tangle

```ts
import { tangleSandbox } from '@tanstack/ai-sandbox-tangle'

const tangle = tangleSandbox({
apiKey: process.env.TANGLE_API_KEY,
environment: 'node:22',
resources: { cpuCores: 2, memoryMB: 4096 },
})
```

- **Isolation:** a managed [Tangle](https://tangle.tools) sandbox that you do not run. Pick the image with `environment`: a catalog environment or a container image reference. The default is the platform's default environment. A container image runs with a read-only root filesystem as the image's user, and `fork()` on it takes minutes (measured: 551 s for `node:22`, against 3 to 8 s on the default environment).
- **Auth and env:** needs `TANGLE_API_KEY` (or `apiKey`). Set `baseUrl` for another endpoint; it must use HTTPS. Sandboxes are created without platform model credentials, startup scripts, or the account's stored secrets. A restore inherits the account's secrets, because the API refuses an isolated restore. Harness credentials are [workspace secrets](./provisioning) and travel as env for each command. They are not sent at create.
- **Lifetime:** the platform suspends a sandbox after `maxLifetimeSeconds` of run time or `idleTimeoutSeconds` without activity, and keeps the workspace. If you omit them, the platform defaults apply. Resume by id starts a stopped or expired sandbox again.
- **Processes:** every command, blocking or not, runs through the runtime process API. It has no run limit (measured: a 10.5-minute command with an 8-minute silent stretch), and stdout and stderr stay separate. A dropped status poll or log stream is read again. Output that the sandbox dropped before it was read fails the command, so a result is never cut short without an error. A spawned process's `stdout` or `stderr` past 8 MiB is cut with a marker, and the process is killed. A relative `cwd` resolves against the workdir, and a missing `cwd` fails the command. `kill()` signals the process tree inside the sandbox, and an aborted `exec` does the same. The requested signal is used when the runtime supports it (`TERM`, `KILL`, `INT`, `HUP`, `QUIT`, `USR1`, `USR2`), and anything else is `KILL`. The process API takes no stdin stream, so `writableStdin` is `false`, and a command that reads stdin gets end of file.
- **Snapshot and resume:** `snapshot()` captures the workspace. The returned id names the snapshot and its source sandbox, and `restoreSnapshot()` creates a new sandbox from it. A restore works after the source sandbox is destroyed (measured a few seconds after the delete). A missing, `deleted`, or `failed` sandbox resumes as `null`.
- **Fork:** `fork()` branches the running sandbox into a new one with the same files. The fork gets a copy of the env overlay of the parent, and keeps its egress policy. A parent cannot be destroyed while a fork of it exists: the API stops the parent and refuses the delete. `destroy()` on a parent tries again for 30 to 40 seconds (a refusal takes about 8 seconds), which covers a parent and its forks destroyed at once. Then it rejects with an error that says to destroy the forks first, and the parent stays stopped. Destroy the forks first; destroying the parent again then works.
- **Ports:** `ports.connect(port)` creates a preview link for the port, or reuses the existing one, and returns its HTTPS URL. The URL is public: anyone who has it can reach the port.
- **Network:** egress is `{ mode: 'open' }` unless you set `egressPolicy`. The platform's own default, `strict`, allows only package registries and code hosts, so a harness that calls a model API needs `open` or `allowDomains`. The provider always sends an egress policy, so a team default policy does not apply. `policy.capabilities.network: 'deny'` maps to egress mode `blocked`, which blocks all outbound traffic.
- **Paths:** `/workspace` maps to `/home/agent/workspace` by default. Set `workdir` to change it, and keep it under `/home/agent` or `/tmp`. Commands run as the `agent` user on the default environment. The native file calls (`read`, `readBytes`, and `write`) reach `/home/agent` and `/tmp`. Read other paths with `process.exec`.
- **Bridge:** a remote sandbox, so bridged tools need the tunnel in local dev. See [Tools](./tools).

## Cloudflare

```ts
Expand All @@ -383,7 +406,7 @@ The agent coordinator, durable runs, live previews through a quick tunnel, callb
## `writableStdin` per provider

- `true`: local process, Docker container, Upstash Box, E2B, boxd, and Railway.
- `false`: Docker Sandboxes (`sbx`), Daytona, Vercel, Sprites, Blaxel, and Cloudflare. Daytona can write to stdin, but it cannot close stdin, so a process that reads to EOF never starts.
- `false`: Docker Sandboxes (`sbx`), Daytona, Vercel, Sprites, Blaxel, Tangle, and Cloudflare. Daytona can write to stdin, but it cannot close stdin, so a process that reads to EOF never starts.

When the value is `false`, a harness that reads its prompt from stdin gets the prompt from a file. The shell redirects that file into the process.

Expand Down Expand Up @@ -412,6 +435,7 @@ If a provider cannot be measured yet, it stays `false`. A `false` value only mak
| Cloudflare | `false` | `kill()` does nothing. The `AbortSignal` of the caller reaches neither `exec` nor `spawn`, because Workers RPC cannot serialize one. |
| boxd | `true` | **Measured.** The spawn wrapper runs under `setsid`, so the recorded pid leads its own process group. `kill()` runs a shell inside the machine that signals that group, then sends `KILL`, then checks with `kill -0`. Closing the stream alone is not a kill: the process survived it. Verified against production: a spawned `sleep 5 && touch <marker>` was killed, and the marker never appeared. Needs `BOXD_API_KEY`. |
| Railway | `true` | **Measured.** `kill()` and an aborted `exec` send `TERM` to the process group, then `KILL` after 2 s. An aborted `spawn` sends `KILL` at once. Each one finishes only when the remote exit is confirmed. Verified against production: the shared journal conformance kill case passes. Spawned `sleep 5 && touch <marker>` commands (one with a background child) were killed before the marker appeared, for `kill()`, an aborted `exec`, and an aborted `spawn`. Needs `RAILWAY_API_TOKEN` or `RAILWAY_TOKEN` and `RAILWAY_ENVIRONMENT_ID`. |
| Tangle | `true` | **Measured.** `kill()` asks the runtime to signal the process tree. Verified against production: a spawned `(sleep 5 && touch <marker>) & wait` was killed and the marker never appeared, an aborted `exec` of `sleep 5 && touch <marker>` left no marker, and the shared journal conformance kill case passes. Needs `TANGLE_API_KEY`. |

Each remote provider registers the shared journal conformance suite, so you can test each claim:

Expand Down
8 changes: 6 additions & 2 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 where a TanStack AI sandbox runs (local process, Docker container, Docker Sandboxes microVM, Daytona, Vercel, Sprites, Upstash Box, Blaxel, E2B, boxd, Railway, or Cloudflare), install it, and configure it."
description: "Pick where a TanStack AI sandbox runs (local process, Docker container, Docker Sandboxes microVM, Daytona, Vercel, Sprites, Upstash Box, Blaxel, E2B, boxd, Railway, Tangle, or Cloudflare), install it, and configure it."
---

A provider is where the sandbox runs: a local process for a fast loop, or an isolated VM in production.
Expand All @@ -26,6 +26,7 @@ A provider is that place. Every provider has the same contract, so your [workspa
| [E2B](https://e2b.dev) | `@tanstack/ai-sandbox-e2b` | microVM | `E2B_API_KEY` |
| [boxd](https://boxd.sh) | `@tanstack/ai-sandbox-boxd` | microVM | `BOXD_API_KEY` and `BOXD_ORG`. |
| [Railway](https://railway.com) | `@tanstack/ai-sandbox-railway` | microVM | `RAILWAY_API_TOKEN` or `RAILWAY_TOKEN`, and an environment. |
| [Tangle](https://tangle.tools) | `@tanstack/ai-sandbox-tangle` | Cloud sandbox | `TANGLE_API_KEY` |
| [Cloudflare](./cloudflare) | `@tanstack/ai-sandbox-cloudflare` | Container | A `Sandbox` Durable Object binding in a Worker. |

2. Install its package. This example installs the Docker provider. Use the package from your row.
Expand Down Expand Up @@ -82,6 +83,7 @@ import { blaxelSandbox } from '@tanstack/ai-sandbox-blaxel'
import { e2bSandbox } from '@tanstack/ai-sandbox-e2b'
import { boxdSandbox } from '@tanstack/ai-sandbox-boxd'
import { railwaySandbox } from '@tanstack/ai-sandbox-railway'
import { tangleSandbox } from '@tanstack/ai-sandbox-tangle'

const dev = localProcessSandbox() // runs on your host
const isolated = dockerSandbox({ image: 'node:22' }) // container
Expand All @@ -94,6 +96,7 @@ const blaxel = blaxelSandbox() // uses the API key or the CLI login
const e2b = e2bSandbox() // reads E2B_API_KEY
const boxd = boxdSandbox({ org: 'acme' }) // reads BOXD_API_KEY
const railway = railwaySandbox() // reads RAILWAY_TOKEN and RAILWAY_ENVIRONMENT_ID
const tangle = tangleSandbox() // reads TANGLE_API_KEY
```

`dockerSandbox()` and `sbxSandbox()` come from the same package. Every other provider has its own package.
Expand All @@ -108,7 +111,7 @@ const railway = railwaySandbox() // reads RAILWAY_TOKEN and RAILWAY_ENVIRONMENT_
- Provider-native snapshots and resume keep or recreate the provider state. They make bootstrap faster. [Portable Snapshots](./portable-snapshots) store finished files in your own persistence.

> [!NOTE]
> Cloud providers (Daytona, Vercel, Sprites, Upstash Box, Blaxel, E2B, boxd, and Railway) run remotely. When you drive them from your laptop, [bridged tools](./tools) cannot reach your `localhost`. Use the ngrok bridge tunnel from the [tools guide](./tools). Cloudflare is different: the Worker that owns the sandbox also runs the bridge, so there is no laptop in the path. See [Cloudflare](./cloudflare).
> Cloud providers (Daytona, Vercel, Sprites, Upstash Box, Blaxel, E2B, boxd, Railway, and Tangle) run remotely. When you drive them from your laptop, [bridged tools](./tools) cannot reach your `localhost`. Use the ngrok bridge tunnel from the [tools guide](./tools). Cloudflare is different: the Worker that owns the sandbox also runs the bridge, so there is no laptop in the path. See [Cloudflare](./cloudflare).

## Capabilities

Expand Down Expand Up @@ -162,6 +165,7 @@ The [run journal](./journal) reads this flag to decide how to read a run's outpu
| Cloudflare | `false` |
| boxd | `true` |
| Railway | `true` |
| Tangle | `true` |

A bring-your-own provider must set this flag too. For the reason behind each value, see [Providers (Advanced)](./providers-advanced#killableprocesses-per-provider).

Expand Down
123 changes: 123 additions & 0 deletions packages/ai-sandbox-tangle/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
<div align="center">
<picture>
<source
media="(prefers-color-scheme: dark)"
srcset="https://tanstack.com/api/readme/ai.png?theme=dark"
/>
<source
media="(prefers-color-scheme: light)"
srcset="https://tanstack.com/api/readme/ai.png"
/>
<img
src="https://tanstack.com/api/readme/ai.png"
alt="TanStack AI"
width="900"
/>
</picture>
</div>

<br />

# @tanstack/ai-sandbox-tangle

Tangle sandbox provider for [TanStack AI](https://tanstack.com/ai). Runs harness
adapters inside isolated [Tangle](https://tangle.tools) cloud sandboxes through
the uniform `SandboxHandle`: real filesystem, shell, background processes,
preview URLs, snapshots, and fork.

## Install

```bash
npm install @tanstack/ai @tanstack/ai-sandbox @tanstack/ai-sandbox-tangle
```

## Usage

```ts
import {
defineSandbox,
defineWorkspace,
withSandbox,
} from '@tanstack/ai-sandbox'
import { tangleSandbox } from '@tanstack/ai-sandbox-tangle'

const sandbox = defineSandbox({
id: 'agent',
provider: tangleSandbox({
apiKey: process.env.TANGLE_API_KEY, // or set the env var and omit
}),
workspace: defineWorkspace({ source: { type: 'none' } }),
})

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

The API key falls back to the `TANGLE_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 { tangleSandbox } from '@tanstack/ai-sandbox-tangle'

const provider = tangleSandbox()
const sbx = await provider.create({})
try {
await sbx.fs.write('/workspace/hello.txt', 'hello from tangle')
console.log(await sbx.fs.read('/workspace/hello.txt'))

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

const server = await sbx.process.spawn('python3 -m http.server 3000')
const channel = await sbx.ports.connect(3000)
console.log('preview url:', channel.url)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
await server.kill()
} finally {
await sbx.destroy()
}
```

## Configuration

| Option | Default | Notes |
| -------------------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------ |
| `apiKey` | `TANGLE_API_KEY` | Tangle API key. |
| `baseUrl` | `https://sandbox.tangle.tools` | Sandbox API URL. Must use HTTPS. |
| `environment` | platform default | A catalog environment or a container image, such as `node:22`. An image has a read-only root, and its `fork()` is slow. |
| `resources` | platform default | `{ cpuCores, memoryMB, diskGB }`. |
| `maxLifetimeSeconds` | platform default | Run time before the platform suspends the sandbox. The workspace is kept, and `resume` starts it again. |
| `idleTimeoutSeconds` | platform default | Idle time before the platform suspends the sandbox. |
| `egressPolicy` | `{ mode: 'open' }` | `open`, `strict` with `allowDomains`, or `blocked`. The platform default is `strict`. A `network: 'deny'` policy wins. |
| `workdir` | `/home/agent/workspace` | Where the `/workspace` virtual root maps. Keep it under `/home/agent` or `/tmp`. |
| `metadata` | none | Extra metadata on every created sandbox. The deterministic sandbox id from `ensure()` is stored under `tanstack-ai-key`. |

## Capabilities

| Capability | Supported | Notes |
| --------------------- | --------- | ---------------------------------------------------------------------------------------------------------- |
| `fs` | βœ… | Native file API for `read`, `readBytes`, and `write`; the rest runs as commands. `lstat` reports symlinks. |
| `exec` | βœ… | Separate `stdout` and `stderr`, no run limit. Lost output is an error. An abort kills the process. |
| `env` | βœ… | Env for each command, plus the `env.set` overlay. Nothing is sent at create. |
| `ports` | βœ… | `ports.connect(port)` creates a preview link and returns its public HTTPS URL. |
| `snapshots` | βœ… | `snapshot()` captures the workspace. `restoreSnapshot()` creates a new sandbox from it. |
| `durableFilesystem` | βœ… | The workspace persists across stop and resume. |
| `backgroundProcesses` | βœ… | `spawn()` returns a real pid and separate streams. Past 8 MiB a stream is cut and the process killed. |
| `writableStdin` | ❌ | The process API takes no stdin stream; harnesses get their prompt from a file. |
| `killableProcesses` | βœ… | `kill()` signals the process tree inside the sandbox, measured against a live sandbox. |
| `networkPolicy` | βœ… | `policy.capabilities.network: 'deny'` maps to egress mode `blocked`. |
| `fork` | βœ… | `fork()` branches the running sandbox into a new one. |

The native file calls reach the sandbox user's home (`/home/agent`) and
`/tmp`. Read other paths with `process.exec`.

A new sandbox is created without the account's stored secrets. A restore from
a snapshot inherits them, because the API refuses an isolated restore.

A parent cannot be destroyed while a fork of it exists. The API stops the
parent and refuses the delete. `destroy()` tries again for 30 to 40 seconds
(a refusal takes about 8 seconds), then rejects, and the parent stays stopped.
Destroy the forks first, or destroy them together with the parent.
Loading