- MCP Server ToolsUpdated
- Managed MCP with chat()Updated
- Manual MCPUpdated
- MCP Type GenerationUpdated
- MCP Client InputUpdated
- MCP Server Tools: AdvancedNew
- MCP EventsNew
- MCP AppsUpdated
- Serve Tools over HTTPUpdated
- Serve Resources and PromptsUpdated
- Ask for InputUpdated
- Draft Text from a ToolUpdated
- MCP Server TasksUpdated
- MCP Server AuthUpdated
- MCP Server SessionsUpdated
- MCP Server on stdioUpdated
Persistence (Advanced)
- GenerationsUpdated
- Realtime Voice ChatUpdated
- Text-to-SpeechUpdated
- TranscriptionUpdated
- Transcription: AdvancedNew
- Voice CreationUpdated
- Audio RecordingUpdated
- Audio GenerationUpdated
- Image GenerationUpdated
- Image Generation: AdvancedNew
- Video GenerationUpdated
- Video Generation: AdvancedNew
- Live GenerationUpdated
- World GenerationUpdated
- Generation HooksUpdated
- Build your first harnessUpdated
- Host optionsNew
- Send and show mediaUpdated
- Connect clientsUpdated
- Write a client in any languageNew
- Access and tenantsNew
- Run in the terminalUpdated
- Deploy a harnessUpdated
- Send inputs safelyUpdated
- Run a tool without a model
- Durable sessionsUpdated
- Run background work on workersNew
- List, rename, and fork sessionsUpdated
- Lifecycle and retentionNew
- Run side effects onceUpdated
- Wait for a personNew
- Build on the session logUpdated
- Control how a turn endsUpdated
- Store settings per threadUpdated
- Compact a long sessionUpdated
- Fork and reset a threadUpdated
- One log for a session treeUpdated
- Write a pluginUpdated
- Track usage and costUpdated
- Ask before risky tool callsUpdated
- Give your agent coding toolsUpdated
- Sandbox and extend the coding toolsUpdated
- Run the coding tools for many threadsNew
- Undo a turnUpdated
- Build a coding agentUpdated
- Delegate to coding agentsUpdated
- Switch between agent profilesUpdated
- Auth and connectorsUpdated
- Share a thread between peopleUpdated
- Connect model providersUpdated
- Build your own UIUpdated
- Code mode in a harnessUpdated
- Work until a goal is metUpdated
- Run agents from a harnessUpdated
- Hand off a conversationNew
- Add skillsUpdated
- Use MCP serversUpdated
- Give your agent a computerNew
- Use from any MCP clientUpdated
- Use a harness from an editor (ACP)Updated
- OverviewUpdated
- Quick StartUpdated
- ProvidersUpdated
- HarnessesUpdated
- Harness AuthUpdated
- WorkspaceUpdated
- ToolsUpdated
- PolicyUpdated
- Lifecycle & SnapshotsUpdated
- Portable SnapshotsUpdated
- Keep Files After ReloadUpdated
- Save a Named VersionUpdated
- Branch From a VersionUpdated
- Send a Frozen FileUpdated
- Let the Agent Save and ForkUpdated
- Pick Which Files to KeepUpdated
- What a Snapshot StoresUpdated
- Instance DurabilityUpdated
- Durable RunsUpdated
- EventsUpdated
Sandboxes (Advanced)
- Runtime ContextUpdated
- Bring Your Own KeyUpdated
- Keep Keys on the ServerNew
- Debug LoggingUpdated
- Multimodal ContentUpdated
- Files APIUpdated
- Per-Model Type SafetyUpdated
- Runtime Adapter SwitchingUpdated
- Tree-ShakingUpdated
- Extend AdapterUpdated
- Extend Adapter: AdvancedNew
- Typed Pre-Configured OptionsUpdated
- Test with a Fake ModelUpdated
- Prompt CachingUpdated
- Mid-Conversation ChangesUpdated
- Approval Flow ProcessingUpdated
Build a Sandbox Adapter (Advanced)
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:
- The sandbox side. Which provider sandbox to resume for a thread, if a run is still going, and the event log of the run.
- The conversation. The messages that your UI shows when someone opens a thread again.
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.
Quick setup: keep everything#
Write a SandboxInstanceStore. It maps a key to the provider sandbox to resume. Replace db with your own database handle:
ts// 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:
tsimport { 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:
tsimport { 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.
How it works#
- withSandbox builds a compound key from the thread and the sandbox setup. It reads instances to find the provider sandbox for that key, and it writes a record when it creates or resumes one.
- withLocks makes sure that only one request runs resume-or-create for a key at a time.
- withPersistence owns the conversation. withSandbox owns the sandbox. Because they share one RunStore, the run status never disagrees between them.
Decide what you store#
| 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:
- Keep them, and opening a thread again rebuilds the tool cards.
- Drop them, and you keep only the conversation.
See Trim what you keep.
Keep only the sandbox side#
Remove withPersistence, and the sandbox half still works. It needs two stores of its own, and neither one is a chat store:
- A RunStore. This is a core contract from @tanstack/ai, not one from the persistence package.
- A SandboxInstanceStore, the store from the quick setup.
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.
Keep only the conversation#
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.
Advanced#
SandboxInstanceStore rules#
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 })]The four run fields#
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:
- Omitted means "leave the column alone".
- Explicit undefined means "clear it". A viewer that attaches again clears detachedSince this way.
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.
More conformance suites#
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:
- runJournalConformance: the output of the agent survives as a file that a successor can replay. See The Run Journal.
- runTakeoverConformance: a second host adopts a detached run and delivers the remainder exactly once. See Takeover & Detached Runs.
- runReaperConformance: a run that nobody came back for is finalized or expired, and a run that still produces output is left alone. See Reaping & Retention.
Next#
- Sandbox Instance Durability: the wiring and the locking rules for this store.
- Events: what a stored transcript holds, and how to trim it.
- Durable Runs Explained: the same subject in plain language with no code.