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

Add durable **sandbox persistence**: cross-process / multi-instance resume for `@tanstack/ai-sandbox`, provided by the same `withPersistence` used for chat.

`withSandbox` consumes `SandboxStore` (which sandbox to resume) and `LockStore` (mutual exclusion around ensure) as optional capabilities, defaulting to in-memory (single-process). This makes them durable without a sandbox-specific middleware:

- `withPersistence` now provides the `SandboxStoreCapability` (and the shared `LocksCapability`) whenever its store set includes them. Compose `[withPersistence(persistence), withSandbox(sandbox)]`.
- `AIPersistenceStores` gains an optional `sandbox?: SandboxStore`. The backends carry it out of the box: `sqlitePersistence` / `drizzlePersistence(db)` (new `sandboxes` table in the shipped schema + migration), `prismaPersistence(prisma)` (new `Sandbox` model; the delegate resolves lazily so chat-only clients without it keep working), and `cloudflarePersistence({ d1 })` (D1, delegating to Drizzle). `memoryPersistence()` includes an in-memory sandbox store.
- The Cloudflare Durable-Object lock (`durableObjects`) doubles as the distributed sandbox lock.

**Shared tokens in core.** `SandboxStore` / `SandboxRecord` / `SandboxStoreCapability` / `InMemorySandboxStore` and the `LockStore` / `LocksCapability` / `InMemoryLockStore` primitives now live in core `@tanstack/ai` (their neutral home). `@tanstack/ai-sandbox` and `@tanstack/ai-persistence` re-export them, so one shared token reference lets a persistence-provided store and lock reach `withSandbox` with no dependency between the two packages. A `SandboxStore` conformance testkit is exported from `@tanstack/ai-sandbox/testkit` (`runSandboxStoreConformance`); every backend runs it.
7 changes: 6 additions & 1 deletion docs/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -463,7 +463,7 @@
"label": "Overview",
"to": "sandbox/overview",
"addedAt": "2026-06-16",
"updatedAt": "2026-07-03"
"updatedAt": "2026-07-23"
},
{
"label": "Quick Start",
Expand Down Expand Up @@ -508,6 +508,11 @@
"addedAt": "2026-06-29",
"updatedAt": "2026-07-09"
},
{
"label": "Persistence",
"to": "sandbox/persistence",
"addedAt": "2026-07-23"
},
{
"label": "Events",
"to": "sandbox/events",
Expand Down
7 changes: 7 additions & 0 deletions docs/persistence/cloudflare.md
Original file line number Diff line number Diff line change
Expand Up @@ -217,3 +217,10 @@ export function createComposedPersistence(env: Env) {
D1 continues to own messages and metadata. Locks stay on `withLocks` if you
need them. Cross-backend transactions are not added by composition; design
retries and consistency explicitly.

## Sandbox persistence

The same D1 binding and Durable Object lock back durable **sandbox** resume at
the edge: `createD1SandboxStore(env.AI_STATE)` and
`createDurableObjectLockStore(env.AI_LOCKS)` with `withLocks`. See
[Sandbox Persistence](../sandbox/persistence).
9 changes: 9 additions & 0 deletions docs/persistence/sql-backends.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,3 +86,12 @@ For Cloudflare D1, use the Drizzle SQLite path above (or the thin
`cloudflarePersistence({ d1 })` wrapper). Durable Object locks live in
[Cloudflare Persistence](./cloudflare). For another SQL library, start with
[Custom Stores](./custom-stores).

## Sandbox persistence

Both adapters also export a durable `SandboxStore` for
[`@tanstack/ai-sandbox`](../sandbox/persistence) resume —
`createDrizzleSandboxStore(db, sandboxesTable)` and
`createPrismaSandboxStore(prisma)`. The `sandboxes` table/model is **optional**
and stays out of the chat BYO schema contract. See
[Sandbox Persistence](../sandbox/persistence).
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 resume:** the sandbox layer ships with in-memory stores for resume
> bookkeeping (single-process). For cross-process / multi-instance resume,
> [Sandbox Persistence](./persistence) provides durable `SandboxStore` /
> `LockStore` implementations (SQLite/Drizzle, Prisma, Cloudflare D1 + Durable
> Object lock) via `withSandboxPersistence` — no changes to the sandbox layer.
178 changes: 178 additions & 0 deletions docs/sandbox/persistence.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
---
title: Sandbox Persistence
id: sandbox-persistence
order: 9
description: "Make sandbox resume durable across processes and instances with a SandboxStore and a shared lock."
---

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 persistence makes resume durable across instances. Two pieces:

- **`SandboxStore`**: the record of which provider sandbox (and snapshot) to
resume for a given key. Durable, shared across instances. When present on a
`withPersistence` bag, the shared `sandbox-store` capability is provided for
`withSandbox`.
- **`LockStore`**: mutual exclusion around resume-or-create, so two runs for the
same thread don't both create a sandbox. Provided separately with
`withLocks`. Across instances this has to be a distributed lock.

Both capability tokens live in core `@tanstack/ai` so persistence and sandbox
share the same references — no package-to-package dependency.

## Wire it up

Node / single process (SQLite convenience factory includes a sandbox store):

```ts
import { chat } from '@tanstack/ai'
import { grokBuildText } from '@tanstack/ai-grok-build'
import { withSandbox } from '@tanstack/ai-sandbox'
import { withLocks, withPersistence } from '@tanstack/ai-persistence'
import { InMemoryLockStore } from '@tanstack/ai-persistence'
import { sqlitePersistence } from '@tanstack/ai-persistence-drizzle/sqlite'
import { sandbox } from './sandbox'
import { messages } from './chat-context'

const persistence = sqlitePersistence({
url: 'file:.tanstack-ai/state.sqlite',
})

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

With `reuse: 'thread'` (the default), the first run creates and records the
sandbox. A later run for the same `threadId` resumes it, even on a different
instance (when the store and lock are distributed).

Chat-only `drizzlePersistence` / `prismaPersistence` / `cloudflarePersistence`
do **not** force a sandbox store into the chat schema contract. Attach one
when you need it:

```ts
import {
createDrizzleSandboxStore,
defaultSqliteSandboxes,
drizzlePersistence,
} from '@tanstack/ai-persistence-drizzle'

const chat = drizzlePersistence(db, { provider: 'sqlite', schema })
const sandboxStore = createDrizzleSandboxStore(db, defaultSqliteSandboxes)

withPersistence({
stores: { ...chat.stores, sandbox: sandboxStore },
})
```

## Choose a backend

### SQLite / Drizzle (Node)

`sqlitePersistence` opens Node's built-in SQLite, bootstraps the stock chat
tables **and** the optional `sandboxes` table, and returns
`ChatAndSandboxPersistence`:

```ts
import { sqlitePersistence } from '@tanstack/ai-persistence-drizzle/sqlite'

export const persistence = sqlitePersistence({
url: 'file:.tanstack-ai/state.sqlite',
})
// persistence.stores.sandbox is ready for withPersistence
```

BYO Drizzle database: migrate the stock (or custom) `sandboxes` table yourself,
then call `createDrizzleSandboxStore(db, sandboxesTable)`.

### Prisma

Add the `Sandbox` model from the [Prisma fragment](../persistence/prisma),
migrate, then:

```ts
import { createPrismaSandboxStore } from '@tanstack/ai-persistence-prisma'
import { prisma } from './prisma'

export const sandboxStore = createPrismaSandboxStore(prisma)
```

A chat-only client that never adds `withSandbox` does not need the `Sandbox`
model; the store is resolved lazily on first use.

### Cloudflare (edge)

At the edge every run can hit a different isolate, so the distributed lock earns
its keep. D1 carries the sandbox mapping; Durable Objects provide the lock:

```ts
import {
createD1SandboxStore,
createDurableObjectLockStore,
} from '@tanstack/ai-persistence-cloudflare'
import { withLocks, withPersistence } from '@tanstack/ai-persistence'
import { withSandbox } from '@tanstack/ai-sandbox'
import { env } from './env'
import { chatPersistence } from './chat-persistence'

const sandboxStore = createD1SandboxStore(env.DB)

chat({
// ...
middleware: [
withPersistence({
stores: { ...chatPersistence.stores, sandbox: sandboxStore },
}),
withLocks(createDurableObjectLockStore(env.AI_LOCKS)),
withSandbox(sandbox),
],
})
```

Migrate the `sandboxes` table into your D1 journal alongside chat tables (see
the stock definition in `@tanstack/ai-persistence-drizzle`).

## Custom store

Implement `SandboxStore` and pass it on the persistence bag:

```ts
import type { SandboxStore } from '@tanstack/ai'

export const sandboxStore: SandboxStore = {
async get(key) {
/* load SandboxRecord | null */
},
async upsert(record) {
/* insert or overwrite by record.key */
},
async delete(key) {
/* remove */
},
}
```

Pair it with a distributed `LockStore` via `withLocks` in multi-instance
deployments. The in-memory lock is only correct within one process.

## See also

- [Persistence overview](../persistence/overview)
- [Custom stores](../persistence/custom-stores)
- [Cloudflare persistence](../persistence/cloudflare)
- [Sandbox lifecycle](./lifecycle)
10 changes: 10 additions & 0 deletions examples/persistent-chat-drizzle/drizzle/0000_loving_snowbird.sql
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,13 @@ CREATE TABLE `runs` (
`error` text,
`usage_json` text
);
--> statement-breakpoint
CREATE TABLE `sandboxes` (
`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
);
16 changes: 16 additions & 0 deletions examples/persistent-chat-drizzle/src/db/tanstack-ai-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,10 +82,26 @@ export const metadata = sqliteTable(
(table) => [primaryKey({ columns: [table.scope, table.key] })],
)

/**
* Persisted sandbox resume records (`@tanstack/ai-sandbox`'s `SandboxStore`).
* Keyed by the compound sandbox key; used by `createDrizzleSandboxStore`,
* independent of the chat stores.
*/
export const sandboxes = sqliteTable('sandboxes', {
key: text('key').primaryKey(),
provider: text('provider').notNull(),
providerSandboxId: text('provider_sandbox_id').notNull(),
latestSnapshotId: text('latest_snapshot_id'),
threadId: text('thread_id').notNull(),
latestRunId: text('latest_run_id'),
updatedAt: integer('updated_at').notNull(),
})

/** The full state schema, for `drizzlePersistence(db, { schema })` and drizzle-kit. */
export const schema = {
messages,
runs,
interrupts,
metadata,
sandboxes,
}
1 change: 1 addition & 0 deletions packages/ai-persistence-cloudflare/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
"@tanstack/ai": "workspace:*",
"@tanstack/ai-persistence": "workspace:*",
"@tanstack/ai-persistence-drizzle": "workspace:*",
"@tanstack/ai-sandbox": "workspace:*",
"@vitest/coverage-v8": "4.0.14",
"drizzle-orm": "^0.45.0",
"miniflare": "^4.20260609.0"
Expand Down
19 changes: 18 additions & 1 deletion packages/ai-persistence-cloudflare/src/d1.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import { drizzle } from 'drizzle-orm/d1'
import {
createDefaultSqliteSchema,
createDrizzleSandboxStore,
defaultSqliteSandboxes,
drizzlePersistence,
} from '@tanstack/ai-persistence-drizzle'
import type { SandboxStore } from '@tanstack/ai'

/**
* Create the structured stores over a Cloudflare D1 binding.
* Create the structured chat stores over a Cloudflare D1 binding.
*
* Thin wrapper: stock SQLite schema + `drizzle-orm/d1` +
* {@link drizzlePersistence}. This package does **not** ship or apply DDL —
Expand All @@ -26,3 +29,17 @@ export function createD1Stores(d1: D1Database) {
metadata: persistence.stores.metadata,
}
}

/**
* Durable {@link SandboxStore} over a migrated Cloudflare D1 binding (delegates
* to the Drizzle sandbox store). Pair with `createDurableObjectLockStore` for a
* multi-instance-correct sandbox resume on the edge. The `sandboxes` table is
* **not** part of the chat BYO schema — migrate it separately (or use the stock
* definition from `@tanstack/ai-persistence-drizzle`).
*/
export function createD1SandboxStore(d1: D1Database): SandboxStore {
return createDrizzleSandboxStore(
drizzle(d1, { schema: { sandboxes: defaultSqliteSandboxes } }),
defaultSqliteSandboxes,
)
}
8 changes: 5 additions & 3 deletions packages/ai-persistence-cloudflare/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { createD1Stores } from './d1'
import type { ChatPersistence } from '@tanstack/ai-persistence'

export { createD1Stores } from './d1'
export { createD1Stores, createD1SandboxStore } from './d1'
export {
CloudflareLockDurableObject,
createDurableObjectLockStore,
Expand All @@ -25,7 +25,8 @@ export type {
*
* Locks are a separate concern: use {@link createDurableObjectLockStore} with
* `withLocks` from `@tanstack/ai-persistence` when you need multi-instance
* coordination.
* coordination. Sandbox resume uses {@link createD1SandboxStore} + the same
* shared lock token.
*/
export interface CloudflarePersistenceOptions {
d1: D1Database
Expand All @@ -38,7 +39,8 @@ export interface CloudflarePersistenceOptions {
* `@tanstack/ai-persistence-drizzle`. Prefer owning that schema in your app
* and calling `drizzlePersistence` directly when you need renames or extra
* columns. Durable Object locks are separate via
* {@link createDurableObjectLockStore}.
* {@link createDurableObjectLockStore}. Sandbox resume is separate via
* {@link createD1SandboxStore}.
*/
export function cloudflarePersistence(
options: CloudflarePersistenceOptions,
Expand Down
Loading
Loading