TanStack
Persistence (Advanced)

Build a Sandbox Adapter (Advanced)

Build a Sandbox Adapter

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:

  • The sandbox side. Which provider sandbox to resume for a thread, whether a run is still going, and the run's event log.
  • The conversation. The messages your UI paints when someone reopens a thread.

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.

Decide what you store

You keepWireYou getYou give up
EverythingwithPersistence + withSandbox with instances, runs, durabilityReopen a thread on any device and see the transcript, the tool cards, and a run still in flightStore size: one run's tool output can be hundreds of kilobytes
Sandbox onlywithSandbox with instances, runs, durability, and no message storeRuns survive a refresh and can be taken over; sandboxes are reused instead of rebuiltNo transcript. A returning client sees the live remainder, not the history
Chat onlywithPersistence aloneThe conversation comes backNo sandbox reuse and no takeover. A disconnect destroys the sandbox
NeitherNeither middlewareNothing to operateEvery 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.

Keep everything

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.

ts
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.

Keep only the sandbox side

Drop withPersistence and the sandbox half still works. It needs two stores of its own, and neither is a chat store:

  • a RunStore, which is a core contract, not a persistence-package one.
  • a SandboxInstanceStore, which is the sandbox's own (see below).
ts
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.

Keep only the conversation

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.

Implement SandboxInstanceStore

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.

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),
})
MethodInvariant
getA missing key returns null. It does not throw.
upsertFull replace by record.key. Omitted optionals clear prior values.
deleteA missing key is a no-op.
timestampsupdatedAt is epoch milliseconds.

One row per key is enough:

sql
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:

ts
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 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.

FieldWritten byDropping it breaks
sandboxKeywithSandbox on detachNothing can find the detached sandbox again, so it is never reclaimed
detachedSincewithSandbox on detach, cleared on re-attachThe reaper cannot tell a run nobody watches from a live one
cancelRequestedrequestRunCancel, out of bandA Stop button cannot reach a run driven by another replica
driverEpocheach host that claims the runTakeover 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.

ts
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:

ts
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.

Prove it with the conformance suite

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:

ts
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:

  • runJournalConformance: the agent's output survives as a file 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 nobody came back for is finalized or expired, and a still-producing one is left alone. See Reaping & Retention.

Where to go next