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
11 changes: 11 additions & 0 deletions .changeset/sandbox-durability.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@tanstack/ai-sandbox': minor
---

Durable **sandbox instance** resume for multi-process / multi-replica deploy.

- **`SandboxInstanceStore` / `SandboxInstanceRecord` / `InMemorySandboxInstanceStore` / `SandboxInstanceStoreCapability`** in `@tanstack/ai-sandbox`
- **`withSandboxInstanceStore(store)`** provides the capability; **`withSandbox`** consumes it in `ensure` (in-memory fallback when absent)
- Pair multi-instance with **`withLocks`** from `@tanstack/ai` (distributed lock)
- Independent of chat persistence — compose both when the app needs transcript durability _and_ instance reuse
- Conformance: `runSandboxInstanceStoreConformance` from `@tanstack/ai-sandbox/testkit`
8 changes: 7 additions & 1 deletion docs/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -445,7 +445,7 @@
"label": "Overview",
"to": "sandbox/overview",
"addedAt": "2026-06-16",
"updatedAt": "2026-07-03"
"updatedAt": "2026-07-27"
},
{
"label": "Quick Start",
Expand Down Expand Up @@ -490,6 +490,12 @@
"addedAt": "2026-06-29",
"updatedAt": "2026-07-09"
},
{
"label": "Instance Durability",
"to": "sandbox/durability",
"addedAt": "2026-07-23",
"updatedAt": "2026-07-27"
},
{
"label": "Events",
"to": "sandbox/events",
Expand Down
182 changes: 182 additions & 0 deletions docs/sandbox/durability.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
---
title: Sandbox Instance Durability
id: sandbox-durability
order: 9
description: "Durable sandbox instance resume across processes with SandboxInstanceStore and withSandboxInstanceStore."
---

Your agent runs behind more than one server instance, or at the edge. A run
spins up a sandbox, clones the repo, installs deps, does its work. The next run
for the same thread should pick that sandbox back up. Instead it builds a fresh
one every time and pays the whole cold-start cost again.

[Lifecycle & Snapshots](./lifecycle) already knows how to resume, but its
bookkeeping is in-memory, so it only holds within one process. The moment a run
lands on a different replica (or a fresh isolate), that instance has never seen
the sandbox and re-creates it.

**Sandbox instance durability** is runtime placement — not chat history. It is
owned by `@tanstack/ai-sandbox`, independent of `@tanstack/ai-persistence`
(transcript / runs / interrupts). You may share a database with chat stores, but
you compose a separate middleware.

Two pieces:

- **`SandboxInstanceStore`**: map of compound key → provider sandbox id (+
optional snapshot). Durable, shared across instances.
- **`LockStore`** (from `@tanstack/ai`): mutual exclusion around resume-or-create.
Multi-instance needs a distributed lock. See [Locks](../advanced/locks).

## Wire it up

Middleware order: **providers before** `withSandbox`.

```ts
import { chat, InMemoryLockStore, withLocks } from '@tanstack/ai'
import { grokBuildText } from '@tanstack/ai-grok-build'
import {
InMemorySandboxInstanceStore,
defineSandbox,
defineWorkspace,
withSandbox,
withSandboxInstanceStore,
} from '@tanstack/ai-sandbox'
import type { ModelMessage } from '@tanstack/ai'

// Single-process: in-memory is fine for local dev.
// Multi-instance: your durable SandboxInstanceStore + distributed LockStore.
const instanceStore = new InMemorySandboxInstanceStore()
const messages: Array<ModelMessage> = [{ role: 'user', content: 'hi' }]

const sandbox = defineSandbox({
id: 'repo',
provider: {
name: 'example',
capabilities: () => ({
fs: true,
exec: true,
env: true,
ports: false,
backgroundProcesses: false,
writableStdin: false,
snapshots: false,
networkPolicy: false,
durableFilesystem: false,
fork: false,
}),
create: () => {
throw new Error('example provider — wire a real SandboxProvider')
},
resume: () => Promise.resolve(null),
destroy: () => Promise.resolve(),
},
workspace: defineWorkspace({ source: { type: 'none' } }),
})

chat({
adapter: grokBuildText('grok-build'),
messages,
middleware: [
withSandboxInstanceStore(instanceStore),
withLocks(new InMemoryLockStore()),
withSandbox(sandbox),
],
})
```

With `reuse: 'thread'` (the default), the first run creates and records the
instance. A later run for the same `threadId` resumes it when the store (and
lock) are shared across processes.

Optional chat persistence is independent:

```ts
import { withLocks, InMemoryLockStore } from '@tanstack/ai'
import { withPersistence, memoryPersistence } from '@tanstack/ai-persistence'
import {
withSandbox,
withSandboxInstanceStore,
} from '@tanstack/ai-sandbox'
import type { SandboxInstanceStore } from '@tanstack/ai-sandbox'
import type { SandboxDefinition } from '@tanstack/ai-sandbox'

declare const instanceStore: SandboxInstanceStore
declare const sandbox: SandboxDefinition

const middleware = [
withPersistence(memoryPersistence()), // chat state only
withSandboxInstanceStore(instanceStore),
withLocks(new InMemoryLockStore()), // multi-instance: distributed LockStore
withSandbox(sandbox),
]
```

## Implement `SandboxInstanceStore`

```ts
import type {
SandboxInstanceRecord,
SandboxInstanceStore,
} from '@tanstack/ai-sandbox'

export const instanceStore: SandboxInstanceStore = {
async get(_key) {
return null
},
async upsert(_record: SandboxInstanceRecord) {
// insert or FULLY replace by record.key
},
async delete(_key) {
// no-op if missing
},
}
```

### Invariants (conformance)

```ts
import {
runSandboxInstanceStoreConformance,
} from '@tanstack/ai-sandbox/testkit'
import type { SandboxInstanceStore } from '@tanstack/ai-sandbox'

declare const instanceStore: SandboxInstanceStore

runSandboxInstanceStoreConformance('my-instance-store', () => instanceStore)
```

| Method | Invariant |
| --- | --- |
| `get` | Missing key → `null` (not throw). |
| `upsert` | **Full replace** by `record.key`. Omitted optionals clear prior values. |
| `delete` | Missing key is a **no-op**. |
| timestamps | `updatedAt` is epoch **milliseconds**. |

### Suggested schema (SQLite)

```sql
CREATE TABLE IF NOT EXISTS sandbox_instances (
key text PRIMARY KEY NOT NULL,
provider text NOT NULL,
provider_sandbox_id text NOT NULL,
latest_snapshot_id text,
thread_id text NOT NULL,
latest_run_id text,
updated_at integer NOT NULL
);
```

You can put this table next to chat tables in the same DB — that is an **app**
choice, not a requirement of `@tanstack/ai-persistence`.

## Locks

A durable instance map without a distributed lock is still wrong across
replicas. Pair `withSandboxInstanceStore` with `withLocks` from `@tanstack/ai`.
Full guide: [Locks](../advanced/locks).

## See also

- [Locks](../advanced/locks)
- [Lifecycle](./lifecycle)
- [Persistence overview](../persistence/overview) — chat state only
9 changes: 5 additions & 4 deletions docs/sandbox/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,8 @@ hands back a live preview URL, see `examples/sandbox-web` — one app with harne
(Claude Code / Codex / OpenCode / Grok) and provider (Docker / local / Vercel /
Daytona) pickers.

> **Persistence-ready:** the sandbox layer ships with in-memory stores for
> resume bookkeeping. A future persistence package can provide durable
> `SandboxStore` / `LockStore` implementations (and event-log replay) by
> supplying those optional capabilities — no changes to the sandbox layer.
> **Durable instance resume:** bookkeeping defaults to in-memory (single-process).
> For cross-process / multi-instance reuse, see
> [Sandbox Instance Durability](./durability): implement a
> `SandboxInstanceStore`, provide it with `withSandboxInstanceStore`, and pair a
> distributed `LockStore` via `withLocks` from `@tanstack/ai`.
2 changes: 1 addition & 1 deletion packages/ai-sandbox/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,4 +180,4 @@ Full guides on [tanstack.com/ai](https://tanstack.com/ai/latest/docs/sandbox/ove

Use a sandbox when the agent needs to **act on a real codebase** — run commands, edit files, clone repos, start dev servers. For read-only Q&A over code you already have in context, a normal `chat()` with server tools is enough.

Persistence (durable `SandboxStore` / `LockStore`, event-log replay) is out of scope for v1 but every seam is persistence-ready via optional capabilities.
Persistence (durable `SandboxInstanceStore` / `LockStore`, event-log replay) is out of scope for v1 but every seam is persistence-ready via optional capabilities.
13 changes: 11 additions & 2 deletions packages/ai-sandbox/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@
"./ngrok": {
"types": "./dist/esm/ngrok.d.ts",
"import": "./dist/esm/ngrok.js"
},
"./testkit": {
"types": "./dist/esm/testkit/conformance.d.ts",
"import": "./dist/esm/testkit/conformance.js"
}
},
"files": [
Expand All @@ -55,16 +59,21 @@
},
"peerDependencies": {
"@ngrok/ngrok": "^1.0.0",
"@tanstack/ai": "workspace:^"
"@tanstack/ai": "workspace:^",
"vitest": "^4.1.10"
},
"peerDependenciesMeta": {
"@ngrok/ngrok": {
"optional": true
},
"vitest": {
"optional": true
}
},
"devDependencies": {
"@ngrok/ngrok": "^1.7.0",
"@tanstack/ai": "workspace:*",
"@vitest/coverage-v8": "4.0.14"
"@vitest/coverage-v8": "4.0.14",
"vitest": "^4.1.10"
}
}
32 changes: 32 additions & 0 deletions packages/ai-sandbox/skills/ai-sandbox/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,38 @@ const policy = defineSandboxPolicy({
provider + workspace hash + tenant so changing the repo/setup/image starts
fresh. Ensure order: resume running → restore snapshot → create + bootstrap.

## Instance durability (durable resume)

Resume bookkeeping defaults to in-memory (single-process). For cross-process /
multi-instance resume, implement a durable `SandboxInstanceStore` (BYO) and
provide it with `withSandboxInstanceStore`. Pair multi-instance with
`withLocks` from `@tanstack/ai`. Order: providers **before** `withSandbox`.

```typescript
import { chat, InMemoryLockStore, withLocks } from '@tanstack/ai'
import {
InMemorySandboxInstanceStore,
withSandbox,
withSandboxInstanceStore,
} from '@tanstack/ai-sandbox'
// Production: your BYO store — docs/sandbox/durability.md
import { instanceStore } from './sandbox-instance-store'

chat({
adapter,
messages,
middleware: [
withSandboxInstanceStore(instanceStore),
withLocks(new InMemoryLockStore()), // multi-instance: distributed lock
withSandbox(sandbox),
],
})
```

Chat transcript durability (`withPersistence`) is independent — compose both
when the app needs history _and_ instance reuse. Prove adapters with
`runSandboxInstanceStoreConformance` from `@tanstack/ai-sandbox/testkit`.

## File-event hooks

Watch the workspace for create/change/delete events. Provider-agnostic: native
Expand Down
11 changes: 3 additions & 8 deletions packages/ai-sandbox/src/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,17 @@
*
* - `SandboxCapability` is PROVIDED by `withSandbox` and REQUIRED by harness
* adapters (`requires: [SandboxCapability]`).
* - `SandboxStoreCapability` is OPTIONALLY required by `withSandbox` (in-memory
* fallback when absent).
* - `LocksCapability` lives in `@tanstack/ai` and is not re-exported here.
* - `SandboxInstanceStoreCapability` / `withSandboxInstanceStore` live in
* `./instance-store` (same package). `LocksCapability` / `withLocks` stay in
* `@tanstack/ai` and are not re-exported here.
*/
import { createCapability } from '@tanstack/ai'
import type { SandboxHandle } from './contracts'
import type { SandboxStore } from './store'
import type { SandboxPolicy } from './policy'
import type { ToolBridgeProvisioner } from './tool-bridge'

export const SandboxCapability = createCapability<SandboxHandle>()('sandbox')

export const SandboxStoreCapability =
createCapability<SandboxStore>()('sandbox-store')

/**
* The active sandbox policy, provided by `withSandbox` from the definition.
* Harness adapters read it to map allow/ask/deny rules onto their native
Expand All @@ -37,7 +33,6 @@ export const ToolBridgeProvisionerCapability =

/** Destructured accessors for adapters: `getSandbox(ctx)` reads the handle. */
export const [getSandbox, provideSandbox] = SandboxCapability
export const [getSandboxStore, provideSandboxStore] = SandboxStoreCapability
export const [getSandboxPolicy, provideSandboxPolicy] = SandboxPolicyCapability
export const [getToolBridgeProvisioner, provideToolBridgeProvisioner] =
ToolBridgeProvisionerCapability
20 changes: 13 additions & 7 deletions packages/ai-sandbox/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,29 @@
// LockStore / withLocks: import from @tanstack/ai.
export {
SandboxCapability,
SandboxStoreCapability,
SandboxPolicyCapability,
ToolBridgeProvisionerCapability,
getSandbox,
provideSandbox,
getSandboxStore,
provideSandboxStore,
getSandboxPolicy,
provideSandboxPolicy,
getToolBridgeProvisioner,
provideToolBridgeProvisioner,
} from './capabilities'

// Durable instance map (resume-or-create across processes)
export {
SandboxInstanceStoreCapability,
getSandboxInstanceStore,
provideSandboxInstanceStore,
InMemorySandboxInstanceStore,
withSandboxInstanceStore,
} from './instance-store'
export type {
SandboxInstanceStore,
SandboxInstanceRecord,
} from './instance-store'

// Workspace projection capability (provided by withSandbox, consumed by harness adapters)
export {
ProjectionCapability,
Expand Down Expand Up @@ -98,10 +108,6 @@ export type {
SandboxDestroyInput,
} from './contracts'

// Instance map (interfaces + in-memory default)
export { InMemorySandboxStore } from './store'
export type { SandboxStore, SandboxRecord } from './store'

// Bootstrap engine (exported for provider/adapter authors + tests)
export {
bootstrapWorkspace,
Expand Down
Loading
Loading