Turn it on with the snippet below. The rest of the page is the mental model, in plain language, and it is worth reading before The Run Journal, Takeover & Detached Runs and Reaping & Retention, which go deeper into each piece.
Two options on withSandbox and you have durable runs. Passing only one of them leaves you with the default destroy-on-disconnect behavior, silently, because you have not asked for durability:
import {
chat,
chatParamsFromRequest,
memoryStream,
toServerSentEventsResponse,
} 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, your `defineSandbox(...)` result, your distributed LockStore.
import { locks } from './locks'
import { persistence } from './persistence'
import { sandbox } from './sandbox'
export async function POST(request: Request) {
const { messages, threadId, runId } = await chatParamsFromRequest(request)
// ONE adapter, handed to both the middleware and the response, so the journal
// and the delivery log describe the same run.
const adapter = memoryStream(request)
const stream = chat({
adapter: claudeCodeText('claude-opus-4-8'),
messages,
threadId,
// Required: the journal path and the log name are both derived from it.
runId,
middleware: [
withPersistence(persistence),
withLocks(locks),
withSandbox(sandbox, {
runs: persistence.stores.runs,
durability: { adapter },
}),
],
})
return toServerSentEventsResponse(stream, { durability: { adapter } })
}That is the producing half. A returning client needs a GET that replays the log and adopts the run, which is Takeover & Detached Runs.
Then two things people forget, both of which look fine until they are not:
The rest of this page is why it works that way, and what it costs.
You are building something like ChatGPT, except the assistant is a coding agent that works inside a sandbox and can take ten minutes to finish a task.
Ten minutes is a long time for a browser tab. The user refreshes. Closes the laptop. Loses wifi. Or their next request lands on a different replica than the one running the job.
The connection dropping kills everything. The sandbox is destroyed, the work is thrown away, and the user comes back to nothing.
That is not a bug. It is the least-bad option available by default. When you close the pipe to an agent running in a sandbox, the agent does not stop. It keeps working and keeps spending money on tokens. So destroying the sandbox is the only reliable way to be sure a disconnected job stops burning cash.
Which is exactly right if the user pressed Stop. And completely wrong if they just refreshed the page.
Durable runs teach the system to tell those two situations apart, and to handle the refresh case properly. Four pieces:
It detaches: the agent keeps working, the sandbox stays up, and the run record notes "nobody is watching this, as of 3:42pm."
This is the key trick. If the agent talks directly to the browser, its words vanish the moment the browser leaves. If it writes to a file inside the sandbox, the words are still sitting there when someone comes back. That file is the journal.
A new request, possibly on a different replica, reads that file, works out how much the user already saw, and streams only the part they missed. No repeated paragraphs, no gaps. That is takeover.
Otherwise a sandbox runs forever on a job nobody will ever read. So there is a sweeper: it finds abandoned runs, checks whether the agent finished on its own, and either wraps them up or shuts them down. That is the reaper.
If a user opens the same thread in two tabs, or a load balancer sends a retry elsewhere, two replicas could both try to continue one run, and the user would see doubled text and contradictory "finished" messages.
So a replica has to take a numbered ticket to drive a run. If a newer replica takes a higher number, the older one is locked out of writing anything at all, not merely discouraged, but unable to append to the log or mark the run finished.
The way we know a run ended is that a special line appears at the end of the journal file. But the agent itself writes that file, and agents write whatever the model says.
If a model happened to print that line, the reaper would believe a running job had ended and would shut down a live sandbox. So the line includes a secret value derived from the run's own id. The agent cannot produce it, so it cannot get its own sandbox destroyed.
A run's output crosses two pipes on its way to the user, and everything on this page is a fix for one of them breaking:
agent (in sandbox) ──[capture]──▶ server host ──[delivery]──▶ client
│
└──▶ durable delivery logDelivery (host → client) breaks when the browser refreshes or the connection drops. The delivery log fixes it: the host appends every chunk as it pumps, and a returning client tails the log from where it left off. That is resumable streams, and it is complete for this half.
Capture (agent → host) breaks when the host dies mid-run. The agent keeps emitting, into a pipe whose other end is gone, and no durability on the delivery log helps, because the problem is upstream of the log's writer. The journal fixes it by moving the write inside the sandbox: the agent writes to a file, so the producer of the bytes and their storage share fate, and there is no host in that path to die.
The obvious question is why the capture fix is a file rather than pointing the agent at the same durable log, and the answer starts with: the agent cannot speak the protocol. It is somebody else's CLI printing raw text; the chunks, offsets, and cursors in the delivery log only exist after the host translates that output. Teaching the sandbox to write the log would mean shipping a translator, a network client, and credentials into the container, and the log is what clients render, so that hands a model-driven process write access to client-facing truth and bypasses the numbered-ticket fencing above (the same concern that makes the exit sentinel unforgeable). A file needs none of it: no network, no credentials, nothing the agent can corrupt but its own output, which the host verifies on the way back out anyway.
Normal persistence is neither pipe: the message store holds the finalized conversation, written after the fact, and it restores thread history across runs, not a run in flight.
Everything above is one protocol with two deployment tiers, and the only difference between them is where the durable copy of the output lives.
Journal-only is the zero-infrastructure default. The only durable copy of the run's output is the journal file inside the sandbox, so the log's durability equals the sandbox's lifetime: as long as the sandbox is up, any host can reconstruct the whole run from the file. You deploy nothing extra, this is what you get from the wiring pages as written.
Log-first adds a durable delivery log outside the sandbox, and every chunk is written to both. Clients only ever tail the log; a reconnect never touches the sandbox at all. The journal does not go away. It is demoted to driver recovery: warm reattach, and working out where a resuming driver picks the run back up.
When both copies exist, precedence is one sentence: the log wins for what clients see; the journal wins for where the driver resumes. That is exactly what alignment implements: the stored log is treated as already-delivered truth, and the journal replay is only used to derive the position to append from.
Cloudflare is the log-first tier with Durable-Object-backed implementations (a DO owns the run and persists its event log, and clients tail the DO), not a parallel architecture. See Cloudflare (Edge). The cost worth knowing before choosing it: log-first double-writes every chunk (journal + log), which is why journal-only stays the default.