A sandbox persistence adapter stores the sandbox id for a thread. The next request, including one on another replica, reconnects to that sandbox.
A sandboxed run has two halves, and each one persists on its own:
You can keep both, one, or neither. This page builds the sandbox side and the one place where the two halves meet. It needs no chat or generation store contract.
To rebuild completed workspace files after the provider sandbox is gone, use Keep Files After Reload. Pass it the same persistence object that withPersistence uses.
Write a SandboxInstanceStore. It maps a key to the provider sandbox to resume. Replace db with your own database handle:
// instances.ts
import { defineSandboxInstanceStore } from '@tanstack/ai-sandbox'
import { db } from './db'
export const instances = defineSandboxInstanceStore({
// A missing key returns null. It never throws.
get: (key) => db.sandboxInstances.findByKey(key),
// A FULL replace, not a merge: omitted optional fields must clear the stored
// value, or a create-without-snapshot leaves a stale `latestSnapshotId` behind
// and `ensure` resumes from a snapshot that no longer describes the workspace.
upsert: (record) => db.sandboxInstances.replace(record),
// Deleting a key that is not there is a no-op.
delete: (key) => db.sandboxInstances.remove(key),
})Add withPersistence and withSandbox to the run. Pass the same RunStore to both, so one record describes the run:
import { chat } from '@tanstack/ai'
import { withLocks } from '@tanstack/ai/locks'
import { claudeCodeText } from '@tanstack/ai-claude-code'
import { withPersistence } from '@tanstack/ai-persistence'
import { withSandbox } from '@tanstack/ai-sandbox'
// Your stores and your `defineSandbox(...)` result.
import { persistence } from './persistence'
import { instances } from './instances'
import { locks } from './locks'
import { sandbox } from './sandbox'
export function agentRun(input: {
messages: Array<{ role: 'user'; content: string }>
threadId: string
runId: string
}) {
return chat({
adapter: claudeCodeText('claude-opus-4-8'),
messages: input.messages,
threadId: input.threadId,
runId: input.runId,
middleware: [
withPersistence(persistence),
// Before `withSandbox`: it serializes resume-or-create for one key.
withLocks(locks),
withSandbox(sandbox, {
instances,
// The SAME store chat persistence uses.
runs: persistence.stores.runs,
}),
],
})
}Prove the store with the conformance suite:
import { runSandboxInstanceStoreConformance } from '@tanstack/ai-sandbox/testkit'
import { freshDb } from './test-db'
runSandboxInstanceStoreConformance('postgres', async () => {
const db = await freshDb()
return db.sandboxInstances
})Start a run, then start a second run on the same thread. The second run reuses the provider sandbox of the first run, because the instance store kept its id.
To make the run detachable and replayable, add durability to withSandbox. See Takeover & Detached Runs.
| You keep | Wire | You get | You give up |
|---|---|---|---|
| Everything | withPersistence + withSandbox with instances, runs, durability | Open a thread on any device and see the transcript, the tool cards, and a run still in flight | Store size: the tool output of one run can be hundreds of kilobytes |
| Sandbox only | withSandbox with instances, runs, durability, and no message store | Runs survive a refresh and can be taken over. Sandboxes are reused, not rebuilt | No transcript. A returning client sees the live remainder, not the history |
| Chat only | withPersistence alone | The conversation comes back | No sandbox reuse and no takeover. A disconnect destroys the sandbox |
| Neither | Neither middleware | Nothing to operate | Every run starts from a cold sandbox and stops with its socket |
Use "Sandbox only" when the text itself is sensitive. Run records and the instance map hold ids and timestamps, not what anyone typed.
A stored transcript has one more setting. The tool calls of the harness are stored as messages:
See Trim what you keep.
Remove withPersistence, and the sandbox half still works. It needs two stores of its own, and neither one is a chat store:
import { chat } from '@tanstack/ai'
import { withLocks } from '@tanstack/ai/locks'
import { claudeCodeText } from '@tanstack/ai-claude-code'
import { withSandbox } from '@tanstack/ai-sandbox'
import { instances } from './instances'
import { locks } from './locks'
// Your own `RunStore`. Nothing here stores a message.
import { runs } from './runs'
import { sandbox } from './sandbox'
export function agentRun(input: {
messages: Array<{ role: 'user'; content: string }>
threadId: string
runId: string
}) {
return chat({
adapter: claudeCodeText('claude-opus-4-8'),
messages: input.messages,
threadId: input.threadId,
runId: input.runId,
middleware: [
withLocks(locks),
withSandbox(sandbox, { instances, runs }),
],
})
}A returning client can find the run (findActiveRun) and tail the rest of it. It cannot show what came before, because nothing stored it.
Use withPersistence alone. Leave instances, runs, and durability off withSandbox. Each run then gets a fresh sandbox, and the sandbox is destroyed when the socket closes. You implement nothing on the sandbox side.
The store has three methods. The conformance suite checks the rule of each one:
| Method | Rule |
|---|---|
| get | A missing key returns null. It does not throw. |
| upsert | Full replace by record.key. Omitted optional fields clear the prior values. |
| delete | A missing key is a no-op. |
| timestamps | updatedAt is epoch milliseconds. |
One row per key is enough:
CREATE TABLE sandbox_instances (
key TEXT PRIMARY KEY,
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 in the same database as your chat tables, but you do not have to. The sandbox never reads a chat table, and chat never reads this one.
The library never deletes rows on a schedule. Use updated_at to sweep old rows yourself. See Reaping & Retention.
You can pass the store to the middleware, or provide it from a platform layer that owns the wiring:
import { withSandbox } from '@tanstack/ai-sandbox'
import { instances } from './instances'
import { sandbox } from './sandbox'
export const middleware = [withSandbox(sandbox, { instances })]Durable runs add four optional fields to the RunStore record that you already use for chat. Only the sandbox packages write them. A chat-only app can leave the columns out of its schema.
| Field | Written by | If you drop it |
|---|---|---|
| sandboxKey | withSandbox on detach | Nothing can find the detached sandbox again, so it is never reclaimed |
| detachedSince | withSandbox on detach, cleared on re-attach | The reaper cannot tell a run that nobody watches from a live one |
| cancelRequested | requestRunCancel, out of band | A Stop button cannot reach a run that another replica drives |
| driverEpoch | each host that claims the run | Takeover has no fence, so two hosts can drive one run |
update must treat an omitted key and a key with the value undefined differently:
A backend that filters undefined out of its SET clause passes every other test. Then it reports healthy runs as detached forever.
import type { RunRecord } from '@tanstack/ai'
// One branch of your own `update(runId, patch)`. Key presence, not a value check.
export function detachedSinceColumn(
patch: Partial<RunRecord>,
sets: Array<string>,
params: Array<unknown>,
): void {
if ('detachedSince' in patch) {
sets.push('detached_since = ?')
params.push(patch.detachedSince ?? null)
}
}Prove the fields with their own suite. It is separate from runPersistenceConformance because most apps never need it:
import { runDurableRunFieldsConformance } from '@tanstack/ai-sandbox/testkit'
import { persistence } from './persistence'
runDurableRunFieldsConformance('my postgres runs', () => persistence.stores.runs)listReclaimable on the same store is also sandbox-only and optional. If you skip it, reapDetachedRuns detects that it is missing, logs one line, and sweeps nothing. See Reaping & Retention.
Do not write these assertions by hand. @tanstack/ai-sandbox/testkit ships runSandboxInstanceStoreConformance (from the quick setup), which pins every rule above. That includes the full-replace rule, which is easy to get wrong with an INSERT ... ON CONFLICT DO UPDATE.
Three more suites cover the durable-run path. Each one finds one failure that is hard to find by hand: