TanStack
Sandboxes (Advanced)

Takeover & Detached Runs (Advanced)

Takeover & Detached Runs

A sandboxed coding agent can work for ten minutes. A browser tab does not last ten minutes. Users refresh, close the laptop, lose wifi, and hit a load balancer that lands the next request on a different replica.

Without durability wired, the disconnect is fatal: withSandbox's abort path destroys the sandbox on every abort, deliberately, because closing the agent's IO stream does not kill the agent process (a Docker exec survives its client), so destroying the container is the only reliable way to stop it burning tokens. Correct for a cancel. Ruinous for a refresh.

Durable runs split those two cases apart. A disconnect detaches: the agent keeps working, the sandbox stays up, and the run record remembers that nobody is watching. A later request takes the run over, replays what was already delivered, and continues streaming the rest.

This page is the wiring. It builds on two pages you should read first: The Run Journal (where the agent's output lives, and why it is a file rather than a pipe) and Resumable Streams (the delivery log a client reconnects to).

Durability is one opt-in, not two

withSandbox takes runs and durability. A run is durable only when both are present, because either alone is useless: a record with no event log cannot be replayed, and a log with no record cannot be found, claimed, or reaped. There is no half-configured state. Pass one and you get exactly today's behavior, silently and with no warning, because you have not asked for durability.

Pass the same RunStore chat persistence uses, so one record describes the run instead of two that disagree.

Both routes must address the same backend stream

Read this before the snippets. A StreamDurability is bound to one run, and durableStream(request, options) resolves which run through core's resolveResumeRunId: the X-Run-Id header first, then ?runId on the request URL. Core's own memoryStream uses the same resolver, so no two durability adapters can disagree about which run a request names.

  • A POST from @tanstack/ai-client carries the run id in the X-Run-Id header, not the URL. The POST URL stays byte-identical to a plain, non-durable chat request.
  • A GET join carries it in the URL instead: joinRun requests ?offset=-1&runId=<runId>.

So durableStream(request, durableOptions) on both routes resolves the same agent-runs/<runId> stream, whichever way the run id arrived. Nothing to rewrite, nothing to force onto a copy of the URL:

ts
import { durableStream } from '@tanstack/ai-durable-stream'

// The external Durable Streams backend every replica can reach. See
// ../resumable-streams/advanced for the full option set (auth headers, batch
// size, reconnect tuning).
export const durableOptions = {
  server: 'https://streams.example.com',
  streamPrefix: 'agent-runs',
}

A request naming no run at all (neither header nor query) throws rather than silently producing into a stream no attach request could ever name:

plaintext
durableStream: a runId is required: send it as an X-Run-Id header or a
?runId query param

That only bites a client that bypasses @tanstack/ai-client (a custom fetch, a hand-rolled reconnect): make sure it sends one of the two, an X-Run-Id header on the POST or ?runId on the URL. A mid-stream SSE reconnect to the POST route still works. It arrives carrying Last-Event-ID and the same X-Run-Id header the original POST used, so durableStream resolves the run and honors the resume offset.

And a caller with no live Request at all (a cron, a Durable Object alarm()) synthesizes one from scratch instead. See the reaper's own durabilityFor in Reaping & Retention.

Server: start a durable run

ts
import {
  chat,
  chatParamsFromRequest,
  toServerSentEventsResponse,
} from '@tanstack/ai'
import { withLocks } from '@tanstack/ai/locks'
import { claudeCodeText } from '@tanstack/ai-claude-code'
import { memoryPersistence, withPersistence } from '@tanstack/ai-persistence'
import { withSandbox } from '@tanstack/ai-sandbox'
import { durableStream } from '@tanstack/ai-durable-stream'
// The options from the section above.
import { durableOptions } from './durability'
// Your distributed LockStore. `InMemoryLockStore` is NOT enough here. See
// "Requirements" below.
import { locks } from './locks'
// Your `defineSandbox(...)` result.
import { sandbox } from './sandbox'

// Development stand-in. A durable run needs a store every replica can read;
// see ../persistence/build-your-own-adapter.
const persistence = memoryPersistence()
const { runs } = persistence.stores

export async function POST(request: Request) {
  const { messages, threadId, runId } = await chatParamsFromRequest(request)
  // ONE adapter instance, handed to both the middleware and the transport, so
  // the journal path and the delivery log describe the same run.
  // `@tanstack/ai-client` sends `X-Run-Id` on every POST, which is exactly what
  // `durableStream` resolves first, so this already addresses
  // `agent-runs/<runId>`: the same stream the attach route below reads.
  const adapter = durableStream(request, durableOptions)

  const stream = chat({
    adapter: claudeCodeText('claude-opus-4-8'),
    messages,
    threadId,
    // Required for a durable run. Omit it and `chatStream` throws
    // `DurableRunIdRequiredError`. See "Requirements".
    runId,
    middleware: [
      withPersistence(persistence),
      withLocks(locks),
      withSandbox(sandbox, {
        runs,
        durability: { adapter },
      }),
    ],
  })

  return toServerSentEventsResponse(stream, { durability: { adapter } })
}

Three things changed compared with a plain sandboxed chat endpoint, and each one is load bearing:

  • runs + durability turn on detach-on-disconnect and publish DetachableRunCapability on the bus, which is how withPersistence learns that an abort on this run is a detach rather than a cancel.
  • runId is forwarded, not generated. The journal path, the deterministic message-id generator, and the backend stream name are all derived from it, so a successor host can only resume a run whose runId it can recompute.
  • The adapter instance is shared between withSandbox and the response, and it is keyed by that same runId. That is what makes the attach route below address this very stream.

One thing must not be there: an abortController that mirrors request.signal.

A plain sandboxed endpoint mirrors it, because there a disconnect should end the run. On a durable run it destroys the thing you are trying to protect. Aborting the run makes chat() return at its cancellation check right after middleware setup, so the harness adapter's chatStream is never called and the agent inside the sandbox you just spent minutes building is never launched. Switch tabs while the UI still says "starting the sandbox" and you come back to an empty log for a run that did nothing. No takeover can recover it either, because an agent that never started wrote no journal to replay.

The disconnect still reaches withSandbox without it. The durable transport notifies the run the moment the response body is cancelled, without aborting it, and then:

  • withSandbox stamps detachedSince and sandboxKey, and publishes the detach verdict.
  • The run keeps draining into its still-open delivery log.
  • A rejoining client tails that log.

Passing runs plus durability buys all of that. There is nothing else to wire.

A genuine stop is unaffected, because it arrives out of band (see Detach vs cancel). That is the only channel that can tell "the user wants this stopped" from "the user closed a tab", since both are the same socket close on the wire.

On memoryStream, raise the first-chunk deadline

A rejoin during the sandbox build fails with Memory stream run produced no data within 100ms, and the run it calls gone is healthy. memoryStream gives up on a from-start rejoin when the run produces no chunk within firstChunkDeadlineMs, which defaults to 100ms. That default fits chat, where an in-flight run's log already holds chunks. A sandboxed run emits nothing until ensure has built the sandbox and cloned the repo.

Raise it on every handle for the run. The GET that serves the rejoin is the call that actually applies it:

ts
import { memoryStream } from "@tanstack/ai";

// Longer than your slowest `ensure`.
const FIRST_CHUNK_DEADLINE_MS = 15 * 60_000;

// POST (the producer) and GET (the rejoin) alike.
export function adapterFor(request: Request) {
  return memoryStream(request, {
    firstChunkDeadlineMs: FIRST_CHUNK_DEADLINE_MS,
  });
}

Failing fast buys nothing once you gate the rejoin on findActiveRun, which already excludes a run that is really gone before the join is attempted.

Two things make this a backstop rather than the main defence:

  • durableStream runs no first-chunk deadline at all. Its read parks for a live reader, so an empty in-flight log simply waits.
  • A fresh durable producer appends one CUSTOM run.accepted chunk (RUN_ACCEPTED_EVENT) before it pulls the producer stream, so a rejoin attaches in milliseconds instead of waiting on the harness. That chunk also keeps the client from abandoning the join, since ai-client gives up on a rejoin that receives nothing for 2s and a sandbox build always takes longer.

Server: take the run over

The takeover happens in the GET handler that already serves resumes. Add a driver and the same request that replays the log also claims the run and keeps driving it.

Every adapter here comes from the same durableStream(request, durableOptions) the POST route used, so the log this route replays, the log the drive appends to, and the log the producing route wrote are provably one stream: agent-runs/<runId>.

ts
import { chat, resumeServerSentEventsResponse } from '@tanstack/ai'
import { withLocks } from '@tanstack/ai/locks'
import { claudeCodeText } from '@tanstack/ai-claude-code'
import { durableStream } from '@tanstack/ai-durable-stream'
import { memoryPersistence, withPersistence } from '@tanstack/ai-persistence'
import { sandboxRunDriver, withSandbox } from '@tanstack/ai-sandbox'
// The same backend options as the POST route.
import { durableOptions } from './durability'
import { locks } from './locks'
import { sandbox } from './sandbox'
import type { StreamChunk } from '@tanstack/ai'

const persistence = memoryPersistence()
const { messages: messageStore, runs } = persistence.stores

/**
 * The claim hands `drive` an `AbortSignal` that fires the moment this host loses
 * ownership; `chat()` takes an `AbortController`. Mirror one onto the other so a
 * lost claim actually stops the drive.
 */
function controllerFor(signal: AbortSignal): AbortController {
  const controller = new AbortController()
  const abort = (): void => controller.abort(signal.reason)
  if (signal.aborted) abort()
  else signal.addEventListener('abort', abort, { once: true })
  return controller
}

export function GET(request: Request) {
  async function* driveRun(input: {
    runId: string
    threadId: string
    signal: AbortSignal
  }): AsyncIterable<StreamChunk> {
    // The client sent no history: it is reconnecting, not asking a question.
    const stored = await messageStore.loadThread(input.threadId)
    const stream = chat({
      adapter: claudeCodeText('claude-opus-4-8'),
      messages: stored,
      threadId: input.threadId,
      runId: input.runId,
      abortController: controllerFor(input.signal),
      middleware: [
        withPersistence(persistence),
        withLocks(locks),
        withSandbox(sandbox, {
          runs,
          // `attach: true` is the whole difference: the harness tails the run's
          // EXISTING journal instead of starting a second agent. It belongs
          // here and never on `chat()`, which has no sandbox vocabulary. It
          // is set only by an attach route, never by a POST handler.
          durability: {
            // `request` already names this run (`?runId` on an attach GET),
            // so `durableStream` resolves the same `agent-runs/<runId>` the
            // journal replay aligns against.
            adapter: durableStream(request, durableOptions),
            attach: true,
          },
        }),
      ],
    })
    for await (const chunk of stream) yield chunk
  }

  return resumeServerSentEventsResponse({
    // The replaying adapter is the one adapter here whose `resumeFrom()` matters,
    // and the offset it must return (`?offset=-1` from a join, or an SSE
    // reconnect's `Last-Event-ID`) lives on the incoming request. `durableStream`
    // resolves the run the same way on every route, `X-Run-Id` header first,
    // then `?runId`, so this addresses the same `agent-runs/<runId>` the POST
    // route wrote, whichever way a given request names the run.
    adapter: durableStream(request, durableOptions),
    driver: sandboxRunDriver({
      request,
      runs,
      locks,
      // Per-run log factory. Core resolves the id from this same request
      // through the same `resolveResumeRunId` `durableStream` uses, so every
      // call for this run talks to the same backend stream and `snapshot()`
      // sees this host's own appends. The state lives in the Durable Streams
      // backend, not this process.
      durability: () => durableStream(request, durableOptions),
      drive: driveRun,
    }),
  })
}

The response is byte-identical whether or not you pass driver: it still replays from the durability log. The drive runs beside it, appending to the run's producer-side log, and the response tails what lands. That separation is what lets a taken-over run keep chat()'s normal middleware path, so withPersistence's onFinish still saves the transcript of a run that finished while detached.

Everything about the takeover is total by construction. Every failure is logged and swallowed, and the response still serves the log:

SituationWhat happens
No run id on the request, or no recordServe the log, drive nothing.
The record is already terminalServe the log, drive nothing. A second tab attaching to a finished run must still see the transcript.
Another host holds the claimServe the log, drive nothing. Two tabs attaching at once: one wins the lease and drives, the other tails.
The drive throwsLogged server-side. It cannot be reported to a response that is already streaming; the run's own RUN_ERROR event is that channel.

Serverless platforms need a keep-alive for the background drive. Pass waitUntil: (promise) => ctx.waitUntil(promise).

Client: reconnect and continue

A mid-stream drop needs nothing: useChat reconnects with the last offset and the server replays from the log.

tsx
import { fetchServerSentEvents, useChat } from '@tanstack/ai-react'

export function Chat() {
  const chat = useChat({
    connection: fetchServerSentEvents('/api/chat'),
  })

  return (
    <button onClick={() => void chat.sendMessage('Refactor the auth module')}>
      Send ({chat.messages.length} messages)
    </button>
  )
}

A full reload is different. The page comes back with no Last-Event-ID, so it has to ask which run is still going and then join it from the start with joinRun (a read-only GET with offset=-1), which is exactly the handler above. That GET is what claims the run, so joining and taking over are the same request.

tsx
import { fetchServerSentEvents } from '@tanstack/ai-client'
import { useEffect, useState } from 'react'
import type { StreamChunk } from '@tanstack/ai'

export function ResumeInFlight({ threadId }: { threadId: string }) {
  const [chunks, setChunks] = useState<Array<StreamChunk>>([])

  useEffect(() => {
    const controller = new AbortController()
    const connection = fetchServerSentEvents('/api/chat')

    async function rejoin(): Promise<void> {
      const response = await fetch(
        `/api/chat/active?threadId=${encodeURIComponent(threadId)}`,
        { signal: controller.signal },
      )
      const body: unknown = await response.json()
      if (typeof body !== 'object' || body === null || !('runId' in body)) return
      const runId = body.runId
      if (typeof runId !== 'string') return
      for await (const chunk of connection.joinRun(runId, controller.signal)) {
        setChunks((previous) => [...previous, chunk])
      }
    }

    void rejoin().catch(() => {
      // The run finished, or there was none. Nothing to rejoin.
    })
    return () => controller.abort()
  }, [threadId])

  return <p>{chunks.length} events replayed</p>
}

The "which run" endpoint is RunStore.findActiveRun, which resolves a live run from the stable threadId rather than the ephemeral run id a single turn may mint several of. It is an optional store method, so feature-detect it.

ts
import { memoryPersistence } from '@tanstack/ai-persistence'

const persistence = memoryPersistence()
const { runs } = persistence.stores

export async function GET(request: Request) {
  const threadId = new URL(request.url).searchParams.get('threadId')
  if (threadId === null) {
    return new Response('threadId is required', { status: 400 })
  }
  // Optional on the RunStore contract, so a backend that omits it degrades to
  // "no active run" instead of throwing.
  const active = await runs.findActiveRun(threadId)
  return Response.json({ runId: active?.runId ?? null })
}

All four HTTP connection adapters (fetchServerSentEvents, fetchHttpStream, xhrServerSentEvents, xhrHttpStream) expose joinRun. For NDJSON, pair it with resumeHttpResponse on the server; the driver wiring is identical.

Detach vs cancel

This is the part that is easy to get wrong, and getting it wrong is expensive. A user pressing Stop and a user closing the tab produce the identical connection close. There is nothing in the disconnect to tell them apart.

So intent is never inferred from the disconnect. It arrives out of band, and there are exactly two bands, and either one is authoritative:

  1. Durable: requestRunCancel(runs, runId) records cancelRequested on the run record. This is the only channel that reaches a run being driven by a different host than the one the cancel request landed on, which is the normal case for a detached run.
  2. In-process: abort the run's own AbortController with RUN_CANCEL_REASON. Core reads that reason back when it builds AbortInfo, so AbortInfo.cancelRequested is true for that abort and false for a plain disconnect. Fast path when the cancel reaches the driving host.

A cancel endpoint should do both. requestRunCancel deliberately writes no status: recording intent is not the same as the run having stopped, and only the driver knows when the agent is actually dead and the sandbox is gone.

ts
import { RUN_CANCEL_REASON, requestRunCancel } from '@tanstack/ai'
import { memoryPersistence } from '@tanstack/ai-persistence'

const persistence = memoryPersistence()
const { runs } = persistence.stores

/**
 * Runs this process is currently driving. Only ever a fast path: a run driven by
 * another replica is absent here, and the durable band is what reaches it.
 */
const driving = new Map<string, AbortController>()

export async function POST(request: Request) {
  const body: unknown = await request.json()
  if (typeof body !== 'object' || body === null || !('threadId' in body)) {
    return new Response('threadId is required', { status: 400 })
  }
  const threadId = body.threadId
  if (typeof threadId !== 'string') {
    return new Response('threadId must be a string', { status: 400 })
  }

  const active = await runs.findActiveRun(threadId)
  if (!active) return new Response(null, { status: 204 })

  // Band 1: durable, so a remote driver observes it on its next teardown.
  await requestRunCancel(runs, active.runId)
  // Band 2: in-process, so a co-located driver stops immediately.
  driving.get(active.runId)?.abort(RUN_CANCEL_REASON)

  return new Response(null, { status: 204 })
}

Populate driving where you create the run's AbortController: in the POST handler for a fresh run, and in controllerFor on the takeover path.

On the client, chat.stop() alone is not a cancel. It aborts a local AbortController and sends the server nothing, which on a durable run is indistinguishable from a refresh, so the agent keeps running. Call the endpoint too:

tsx
import { fetchServerSentEvents, useChat } from '@tanstack/ai-react'

export function StoppableChat({ threadId }: { threadId: string }) {
  const chat = useChat({
    threadId,
    connection: fetchServerSentEvents('/api/chat'),
  })

  async function stopRun(): Promise<void> {
    // Local: stop rendering the stream immediately.
    chat.stop()
    // Remote: tell the server this was intent, not a lost connection.
    await fetch('/api/chat/cancel', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ threadId }),
    })
  }

  return <button onClick={() => void stopRun()}>Stop</button>
}

What each path writes

EventwithSandboxwithPersistenceDelivery log
Disconnect, durable, detachOnDisconnect on, no cancel recordedKeeps the sandbox; writes detachedSince and sandboxKeyWrites nothing; the record stays 'running'Left open, no terminal appended
Cancel (either band)Destroys the sandbox, always, regardless of destroyOnCompleteWrites 'aborted' with finishedAtTerminal RUN_ERROR, then closed
Disconnect on a non-durable runDestroys the sandboxWrites 'aborted'Terminal RUN_ERROR, then closed

The delivery-log column is what makes takeover work at all. A detached run's log has to stay open and terminal-free: a closed log ends the attaching client's replay at the prefix, and a stored synthetic RUN_ERROR is a chunk the takeover's journal replay cannot reproduce, so alignment diverges and a perfectly healthy run is recorded as 'failed'. See RunDetachedCapability for how the verdict reaches the transport.

keepAlive / destroyOnComplete: false govern successful completion only. They never keep a sandbox alive through a cancel.

Get this backwards in either direction and it hurts: a Stop button that only calls chat.stop() leaks running sandboxes that keep spending tokens with nobody watching, and treating every disconnect as a cancel kills a user's ten-minute refactor on a wifi blip.

What cancel means on a provider that cannot kill

On a provider whose capabilities declare killableProcesses: false (Daytona, Vercel, and Cloudflare among the bundled ones (see the measured table) and no signal reaches the agent process: kill() does not stop it, and no AbortSignal crosses the provider boundary. There, the sandbox destroy the cancel path performs is not cleanup, it is the cancel. It is the only mechanism that actually stops the agent. Wire a cancel to anything less (mark the record 'aborted', close the log, skip the destroy) and what you have built is "marked cancelled, still running": the UI says stopped while the agent keeps working and the sandbox keeps billing.

Single-writer safety

Only one host may write a run, and the client has no safety net below its offset de-dup. If two hosts both snapshot the log, both compute a "remainder", and both append it, the same logical chunk lands twice under two different offsets, so it looks new to the client, and the stream processor applies text and tool-argument deltas unconditionally. You get doubled prose and {"a":1}{"a":1} tool arguments. Takeover is by definition two hosts wanting one run, so the exclusion has to be real.

sandboxRunDriver wires it for you, in three layers:

  1. A lease. The entire drive runs inside LockStore.withLock on a per-run key, so the snapshot and every append after it are one critical section. A lease-backed lock aborts the drive's signal the moment ownership is lost.
  2. An epoch. Each successful claim bumps RunRecord.driverEpoch. The log re-reads it periodically and refuses to append once a higher epoch exists. This covers what a lease cannot: a lock whose renewal is coarser than the run's append rate, or one whose signal never fires at all.
  3. Quiescence. The successor waits for the stored log to stop growing before its first append, so a predecessor that is still writing is observed rather than raced. The window defaults to DEFAULT_FENCE_QUIET_MS (5 seconds) and is configurable with fenceQuietMs. If the log never quiesces, the drive fails instead of appending into a log another host is still writing.

Both authoritative channels are fenced

A run's facts live in two places: its event log and its record. Fencing only the log does not remove the harm, it relocates it. A superseded driver whose append is refused folds that refusal into a terminal runs.update, and the record then reads 'failed' for a run the successor is healthily streaming. Every consumer that branches on terminal status (isTerminalRunStatus, findActiveRun, a status poller, a reaper) would then believe a live run had died, on the authority of a host that no longer owns it.

So both seams are closed, over the same claim:

  • The log. A superseded driver's append is refused. The first refusal latches the fence permanently shut: every later append refuses immediately, without a re-read. That permanence matters, because the refused append is followed directly by the recovery path's own terminal RUN_ERROR, and that log belongs to the successor, so a dead host's error chunk would fail the stream for every client attached to the healthy run.
  • The record. A terminal-status update from a lost claim is suppressed: it resolves without writing. Non-terminal writes still pass through, including a stale detachedSince or sandboxKey. Those cannot make a live run look finished, and the successor owns and overwrites them anyway.

The practical upshot for you: a terminal status on the record is trustworthy. A status poller may believe it.

close() is outside both fences, deliberately. It runs on every teardown path including the teardown caused by losing the claim, and a fenced close would wedge the record at 'running' with every live tailer parked forever: a durability read only ends when the log closes. A wedged run with parked clients is worse than the write being prevented.

Errors you can branch on

ts
import {
  RunClaimLostError,
  RunClaimNotAcquiredError,
  RunDriverPipeOutsideClaimError,
} from '@tanstack/ai-sandbox'

function describeDriveFailure(error: unknown): string {
  if (error instanceof RunClaimNotAcquiredError) {
    // 'terminal' | 'unknown' | 'superseded': normal, not a bug.
    return `not driving ${error.runId}: ${error.reason}`
  }
  if (error instanceof RunClaimLostError) {
    return `superseded mid-drive at epoch ${error.heldEpoch}`
  }
  if (error instanceof RunDriverPipeOutsideClaimError) {
    // A programming error: the options object was taken apart and `pipe` called
    // outside `claim`, so there is no epoch to fence with.
    return `run ${error.runId}: pipe ran outside its claim`
  }
  throw error
}

The first two are ordinary outcomes of a contended takeover and resumeServerSentEventsResponse already swallows both. You will see them in logs, not in responses.

What this is not

It is not airtight fencing. A predecessor paused (GC, VM suspend) for longer than the quiescence window, between its last fence check and its append landing at the backend, can still write one batch. Closing that needs a compare-and-set on the durability write, which StreamDurability.append does not offer. The mitigation is deployment-level: use a lease-backed distributed LockStore, and keep fenceQuietMs above the lease's renewal interval.

Replay and divergence

A takeover does not resume the journal from where the dead host stopped. It re-reads the journal from byte zero and re-translates it, which reproduces chunks the client already has. Alignment is what makes that safe: the stored log is read once, the replay is verified against it by fingerprint, the matching prefix is suppressed, and only the remainder is appended and delivered. The log is the checkpoint, so there is no window in which a checkpoint and the log can disagree. The precedence rule this implements: the log wins for what clients see; the journal wins for where the taking-over driver resumes.

Two properties make the comparison possible, and both are already true on the journaled path:

  • Ids are deterministic. A journaled run mints message ids from a run-scoped counter (<runId>-0, <runId>-1, …) rather than a timestamp plus randomness.
  • Alignment runs only on an attach. On a fresh run the transform's premise ("this stream is a replay of what is already stored") is false, and aligning anyway would match a fresh run's own chunks against pre-existing log entries and silently suppress them. That is data loss, not a slow path.

JournalReplayDivergedError

If the replay produces a different chunk than the log already holds at that index, JournalReplayDivergedError is thrown with the index and both fingerprints.

ts
import { JournalReplayDivergedError } from '@tanstack/ai-sandbox'

function report(error: unknown): string {
  if (error instanceof JournalReplayDivergedError) {
    return `diverged at ${error.index}: stored ${error.stored}, replayed ${error.replayed}`
  }
  throw error
}

Read it plainly: the agent's replay produced a different sequence of events than the log already delivered. Translation stopped being deterministic. The realistic causes are an id generator that is not run-scoped, a translator that consults the clock, or a journal that was rewritten (most often a reused runId).

What to do about it: treat it as a bug to fix, not a condition to recover from. Do not catch it and continue. The log is authoritative and already went to the client, so forwarding past a mismatch delivers a stream whose prefix and suffix disagree about message identity, and the client cannot survive that. Log the index and both fingerprints, let the run fail, and check runId uniqueness first.

One tolerance exists. On adapters that splice host-tool-bridge events into their output (@tanstack/ai-codex, @tanstack/ai-claude-code), the log holds CUSTOM chunks fired by live tool execution, which a replay runs no tools to reproduce. Alignment skips those as out-of-band, up to DEFAULT_MAX_OUT_OF_BAND_SKIP (64) consecutive entries. The bound is what keeps this a tolerance rather than a search: unbounded, a genuine determinism regression would scan forward looking for any fingerprint that happens to match.

Configuration

All of these live under withSandbox(sandbox, { durability: { … } }).

OptionDefaultWhat it does
adapterrequiredThe run's delivery-durable event log. Same instance you hand the transport.
journal/tmp/tanstack-runs (DEFAULT_JOURNAL_DIR)Journal directory inside the sandbox.
detachOnDisconnecttrue whenever durability is wiredWhether a disconnect detaches instead of destroying the sandbox.
attachfalseRead an existing run's journal instead of starting an agent. Set by an attach route's drive, never by a POST handler.
pollIntervalMsadapter defaultJournal poll interval for providers that cannot follow a file.

There is deliberately no detachedRunTtl here. The only actor that enforces a TTL is reapDetachedRuns, which runs from a cron with no chat request in flight, so it cannot read anything withSandbox publishes on the per-request capability bus, so a TTL stored here could only ever go unread, while the sweep took its own detachedRunTtlMs, and the two would silently disagree. So the sweep's detachedRunTtlMs (milliseconds, no default, no string parsing) is the single source of truth; see Reaping & Retention for sizing it.

The reaper ships, but nothing schedules it. reapDetachedRuns from @tanstack/ai-sandbox is the sweep. It reads the optional RunStore.listReclaimable({ now, ttlMs }), which returns runs that are 'running', have a detachedSince, and whose detachedSince <= now - ttlMs (inclusive). It drives each run its out-of-band journal probe says already finished to a terminal status so the transcript lands, cancels and terminalizes the ones past detachedRunTtlMs, and destroys the sandbox named by sandboxKey through sandboxReclaimer. It never clears detachedSince: that marker is the evidence its TTL accounting selected the run on. (The takeover path clears it, because a viewer is attached again.)

It is a plain async function with no timer and no daemon, so calling it on a schedule is your job: a cron route, a queue consumer, a Durable Object alarm(), a waitUntil. Treat that as a hard requirement of wiring durability, not a nice-to-have: until something invokes it, nothing closes a detached run's delivery log, so every attached client parks forever, detachedRunTtlMs is enforced by nothing, and the sandbox bills indefinitely.

Reaping & Retention is the whole picture: the sweep's outcomes, why it never drives a run to find out whether it finished, pruneJournals, reclaimSandbox, ready-to-paste schedules for Node, Vercel Cron, and a Cloudflare alarm(), and how to size the TTL against the sweep interval.

Set detachOnDisconnect: false to keep today's destroy-on-disconnect cost profile while still getting resumable delivery: a reload replays the log, but the agent does not survive the disconnect. An explicit cancel destroys the sandbox either way.

DetachableRunCapability

A neutral boolean that core owns. withSandbox provides it as true only when a run is genuinely durable; withPersistence reads it with getOptional to decide whether an abort is terminal ('aborted') or a detach (write nothing). It lives in core so the two packages can agree without either importing the other. A persistence → sandbox import would invert the layering. Read it in your own middleware if you need the same distinction; absent means "not detachable", which is every app that has not wired durability.

RunDetachedCapability

Its past-tense counterpart, also owned by core. DetachableRunCapability is published at setup and only says a disconnect may be survived; RunDetachedCapability is published on the abort path by withSandbox's detach branch and says the run was detached. The agent is still working and a later attach can take it over.

Its consumer is the durable delivery sink behind toServerSentEventsResponse / toHttpResponse. Without the verdict the sink terminalizes every abort, which defeats takeover (see the table above). The fact travels on the stream object itself, so there is nothing to wire: passing chat()'s stream to the response helper is already mandatory, and both sides hold the same object.

ts
import { provideRunDetached } from '@tanstack/ai'
import type { CapabilityContext } from '@tanstack/ai'

// You do not write this. `withSandbox` does, on its detach branch, from a hook
// that already holds the middleware context. Shown only so the fact is legible.
function markRunDetached(ctx: CapabilityContext): void {
  provideRunDetached(ctx, true)
}

Only a plain, intentless disconnect of a detachable run publishes it. An explicit cancel in either band, a disconnect on a non-detachable run, detachOnDisconnect: false, a provider failure, and a normal finish all leave it unpublished, and the sink appends its terminal and closes the log exactly as it always has. Core additionally refuses to treat an abort carrying RUN_CANCEL_REASON as a detach, whatever a middleware publishes. A user pressing Stop always gets a closed, terminal log.

Requirements

A real LockStore. InMemoryLockStore cannot coordinate across hosts: it serializes claims within one process, and the signal it hands out is a fresh AbortController().signal that is never aborted, so the lease can never report a loss. Two replicas can then drive one run and duplicate its log. withSandbox emits a warning when durability is wired over an in-memory lock (including when no lock is wired at all, since the fallback is in-memory). See Locks.

A caller-supplied runId. A durable run throws DurableRunIdRequiredError at the start of chatStream when none is passed. That is deliberately loud, because the alternative is invisible: an adapter-generated id produces a journal path no successor host can recompute, so the run streams normally, records normally, and is silently unrecoverable. You would only discover it during an incident.

The run record's threadId, on an attaching run. An ATTACHING durable run throws DurableThreadIdRequiredError when driven without the run record's threadId. threadId rides in every emitted chunk, so an attach that generates a fresh one replays a stream whose very first chunk already differs from the stored log. Alignment fails at index 0 with JournalReplayThreadIdMismatchError, a subclass of JournalReplayDivergedError reported above, even though the agent behaved identically. This is the asymmetry to keep straight: a durable fresh run needs no caller threadId, because that run is what establishes it, but a durable run that is attaching must reuse the one already on the record, which is exactly the threadId: input.threadId forwarded by driveRun above.

A RunStore that persists every durable-run field. createOrResume, update, and get are required; update must accept and round-trip all of status, finishedAt, error, usage, sandboxKey, detachedSince, cancelRequested, and driverEpoch. The last four are exactly what a hand-written backend tends to omit, and each one breaks a specific mechanism: no driverEpoch means no fencing, no cancelRequested means Stop cannot reach a remote driver, no detachedSince/sandboxKey means nothing can reclaim the sandbox. Two invariants also hold: createOrResume returns an existing record unchanged (that is what makes resuming safe), and update on an unknown runId is a no-op that must not throw.

findActiveRun is required: it is how you rejoin by thread. listReclaimable is optional and feature-detected, but reapDetachedRuns needs it to have anything to sweep: a store without it cannot be reaped at all.

Adapter authors

If you are writing a harness adapter rather than an application, three exports are yours: getSandboxDurability reads the resolved durability off the capability bus, journalOptionsFor turns it into the journal option spawnNdjson takes, and alignedIfAttaching applies alignment on attach only. Wrap the merged output stream, never the pre-merge translator, or you compare against a stream the log never contained. See Harnesses.

See also