Your agent runs in a sandbox, and you have to decide what survives a server restart, a second replica, or a user closing the tab. The answer is not one switch. A sandboxed run has two halves that persist separately:
You can keep both, either one, or neither. This page is the sandbox side, plus the one place the two meet. It is the third of the adapter walkthroughs, next to chat and generation, and it needs neither of their store contracts.
| You keep | Wire | You get | You give up |
|---|---|---|---|
| Everything | withPersistence + withSandbox with instances, runs, durability | Reopen a thread on any device and see the transcript, the tool cards, and a run still in flight | Store size: one run's tool output 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 instead of 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 dies with its socket |
"Sandbox only" is the posture to reach for when the text itself is sensitive. Run records and the instance map hold ids and timestamps, not what anyone typed.
There is a finer knob inside a stored transcript, because the harness's tool calls are stored as messages: keep them and reopening a thread rebuilds the tool cards, or drop them and keep the conversation alone. See Trim what you keep.
withPersistence owns the conversation. withSandbox owns the sandbox. They meet at one place: pass the same RunStore to both, so one record describes the run instead of two that disagree.
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,
}),
],
})
}Add durability to make the run detachable and replayable; that is Takeover & Detached Runs.
Drop withPersistence and the sandbox half still works. It needs two stores of its own, and neither 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 its remainder. It cannot repaint what came before, because nothing stored it.
withPersistence alone. Leave instances, runs and durability off withSandbox and it keeps today's simplest behavior: a fresh sandbox per run, destroyed when the socket closes. Nothing to implement on the sandbox side at all.
This is the sandbox's own contract: a map from a compound key to the provider sandbox that should be resumed. Three methods, and each one has an invariant the conformance suite checks.
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),
})| Method | Invariant |
|---|---|
| get | A missing key returns null. It does not throw. |
| upsert | Full replace by record.key. Omitted optionals clear 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
);Put it in the same database as your chat tables if you like. That is your choice, not a requirement: the sandbox never reads a chat table and chat never reads this one.
updated_at is yours to sweep on. The library never deletes rows on a schedule, so old placements are garbage you collect (see Reaping & Retention).
Hand it to the middleware, or provide it ambiently when a platform layer 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 you already use for chat. Nothing outside the sandbox packages writes them, and a chat-only app can leave the columns out of its schema entirely.
| Field | Written by | Dropping it breaks |
|---|---|---|
| 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 nobody watches from a live one |
| cancelRequested | requestRunCancel, out of band | A Stop button cannot reach a run driven by another replica |
| driverEpoch | each host that claims the run | Takeover has no fence, so two hosts can drive one run |
One rule matters more than the four names: update must treat an omitted key and a key carrying undefined differently. Omitted means "leave the column alone". Explicit undefined means "clear it", which is how a re-attaching viewer clears detachedSince. A backend that filters undefined out of its SET clause passes every other test and then reports healthy runs as permanently detached.
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 it rather than reading the table twice. This suite is separate from runPersistenceConformance precisely 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 also optional. Skip it and reapDetachedRuns feature-detects the gap, logs one line and sweeps nothing. See Reaping & Retention.
Do not hand-write these assertions. @tanstack/ai-sandbox/testkit ships the suite that pins every invariant above, including the full-replace rule that is easy to get wrong with an INSERT ... ON CONFLICT DO UPDATE:
import { runSandboxInstanceStoreConformance } from '@tanstack/ai-sandbox/testkit'
import { freshDb } from './test-db'
runSandboxInstanceStoreConformance('postgres', async () => {
const db = await freshDb()
return db.sandboxInstances
})Three more suites cover the durable-run path, each aimed at one failure that is painful to find by hand: