This page describes the calls a reload makes. To set persistence up, use the overview.
| Layer | Answers | Lives in | Docs |
|---|---|---|---|
| Delivery durability | "How do I reconnect to a stream that still runs?" | A log per run, keyed by runId | Resumable Streams |
| State persistence | "What is the conversation, and is it still there later?" | A durable store, on the client, the server, or both | This section |
Delivery durability replays a live byte stream, so a dropped connection resumes where it stopped. State persistence stores the conversation itself, so it survives a reload and opens on another device. A replayable stream is not a saved conversation. A saved conversation is not a live stream.
A thread (threadId) is the conversation. It is stable, survives reloads, and exists on every device. A run (runId) is one execution inside the thread, made fresh for each streamed answer. A thread collects many runs. Delivery durability logs one run. State persistence stores the whole thread.
flowchart TB
subgraph thread ["One thread, threadId (stable, the conversation)"]
direction LR
run1["run r1
completed"] --> run2["run r2
completed"] --> run3["run r3
running"]
end
subgraph delivery ["Delivery durability, one byte log per run"]
log["log for r3
replays the live stream to a reconnecting client"]
end
subgraph state ["State persistence, durable store per thread"]
store["transcript, run records, interrupts"]
end
run3 -. "a dropped connection tails" .-> log
thread -- "saved on finish, loaded on mount" --> storeA reloading client does not always know the current run id, so it cannot reconnect by run. It reconnects from the stable threadId. The store answers "does this thread have a live run?" with findActiveRun. Only then does the client read that run's log. Id map is the short version of both ids.
Store APIs take a bare threadId string. That keeps adapters simple, and it makes multi-user isolation your job:
Scope is re-exported from @tanstack/ai-persistence, next to the store contracts.
withPersistence merges the incoming messages into the stored thread by id:
saveThread then replaces the stored thread with the merged list.
There are two postures:
With a client storage adapter, useChat reads the client record on load. Then it does one of three things:
If the connection drops while the page stays open, delivery durability reconnects by itself. Persistence matters when the page itself is gone.
In server-authoritative mode, the client paints from a server read, not from localStorage. A GET with limit returns the newest window and a page object. An older window uses before. The delivery log cannot supply that history, because it holds one run, not the thread.
Both layers expect the work to be over when the client comes back. Replaying a log and reading a transcript both read something that already exists. A long-running sandboxed agent is different: the run still goes on, and its producer was the host that disappeared. That needs a later request that adopts the run and keeps driving it. See Takeover & Detached Runs.
sequenceDiagram
participant Hook as useChat (persistence: true)
participant Route as GET /api/chat
participant Store as Durable store
participant Log as Delivery log
Note over Hook: page reloads while a run is streaming
Hook->>Route: ?threadId=support-chat
Route->>Store: reconstructChat, loadThread + findActiveRun
Store-->>Route: messages + activeRun (runId)
Route-->>Hook: transcript + activeRun cursor
Note over Hook: transcript paints
Hook->>Route: ?runId=...&offset=-1
Route->>Log: resumeServerSentEventsResponse
Log-->>Hook: replay + live tail of the run
Note over Hook: reply finishes in placeServer state persistence is one of three boundaries that share no code:
State middleware never changes chunks to add delivery offsets. It stores server event state, not the messages that the client rendered.
withPersistence(persistence) makes a plan from the stores that are present. Then it acts at these hooks:
Usage at the interrupt boundary has two cases. A direct adapter terminal arrives before onUsage, so the handler includes its usage and ignores the next onUsage. A synthesized tool boundary arrives after the original terminal's onUsage, so the handler reuses that total.
Before onFinish, the chat engine adds the completed terminal assistant messages to ctx.messages:
onError terminalizes the run record and does not replace the transcript. onFinish, onError, and a terminal onAbort all keep the usage known so far. One exception: on a run that another middleware has declared detachable, a plain disconnect (no cancel recorded in either band) writes nothing. The record stays 'running' for a later takeover. See Takeover & Detached Runs.
Accepted resumes are committed (interrupts marked resolved or cancelled) only at a successful boundary. If a provider failure or an abort happens between accepting a resume and reaching that boundary, the interrupt stays pending. A retry with the same resume then succeeds.
The AG-UI chunk stream does not change. Persistence does not create a second event stream.
Your own middleware often needs the stores that withPersistence already holds: an audit step that writes to metadata, or a guard that checks pending interrupts. If you pass the persistence object in twice, the two copies can drift apart. Read the stores as capabilities instead. Declare them in requires, then read them from the context:
import {
chat,
chatParamsFromRequest,
defineChatMiddleware,
toServerSentEventsResponse,
} from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
import {
InterruptsCapability,
PersistenceCapability,
getInterrupts,
getPersistence,
memoryPersistence,
withPersistence,
} from '@tanstack/ai-persistence'
import type { ChatMiddlewareContext } from '@tanstack/ai'
const persistence = memoryPersistence()
const auditPending = defineChatMiddleware({
name: 'audit-pending',
// Fails at setup when no middleware provided the capability.
requires: [PersistenceCapability, InterruptsCapability],
async setup(ctx: ChatMiddlewareContext) {
const stores = getPersistence(ctx).stores
const interrupts = getInterrupts(ctx)
const pending = await interrupts.listPending(ctx.threadId)
await stores.metadata?.set(ctx.threadId, 'pending-count', {
count: pending.length,
})
},
})
export async function POST(request: Request) {
const { messages, threadId, runId } = await chatParamsFromRequest(request)
const stream = chat({
adapter: openaiText('gpt-6.1-sol'),
messages,
threadId,
runId,
// Order matters: the provider comes before the consumer.
middleware: [withPersistence(persistence), auditPending],
})
return toServerSentEventsResponse(stream)
}The capabilities that withPersistence publishes:
| Capability | Read with | Gives you | Published when |
|---|---|---|---|
| PersistenceCapability | getPersistence(ctx) | The whole AIPersistence object | Always |
| InterruptsCapability | getInterrupts(ctx) | The interrupts store | The backend has an interrupts store |
| PersistenceCompletionCapability | getPersistenceCompletion(ctx) | waitForRunCompletion() | Always |
| MetadataCapability (from @tanstack/ai) | getMetadata(ctx) | The metadata store | The backend has a metadata store |
providePersistence and provideInterrupts are the write halves. Use them in a middleware of your own that supplies the stores in place of withPersistence. Locks are a separate capability from @tanstack/ai/locks.
withGenerationPersistence(persistence) records the job at three points:
When artifacts and blobs are both present, it also stores the generated media. Then it merges the durable refs onto the result and onto the run record.
Generation uses its own generationRuns store (GenerationRunStore), never the chat runs or messages stores. A generation has no conversation, so the run is keyed on its own runId (ctx.runId ?? ctx.requestId). The threadId is never the primary identity of the job.
The threadId is still required. It is the slot that the run is filed under, and GenerationRunRecord.threadId is a required field. The middleware resolves it as opts.threadId ?? ctx.threadId: normally the threadId that the caller passed to the activity, with the option as an override. When neither gives one, the middleware throws. It never makes one up from the request id. Nothing can find a run that is filed under an invented scope, so restore returns nothing forever.
import {
composePersistence,
memoryPersistence,
} from '@tanstack/ai-persistence'
const base = memoryPersistence()
const replacement = base.stores.messages
const result = composePersistence(base, {
overrides: {
messages: replacement,
metadata: undefined,
interrupts: false,
},
})Composition copies the store map. It does not change or dispose of either input. The return type works out which keys are required, optional, replaced, or removed. Unknown store keys fail at compile time and at runtime.
Each entrypoint adds its own runtime check:
| Entrypoint | Rule |
|---|---|
| withPersistence | Requires messages. Rejects interrupts without runs. |
| withGenerationPersistence | Requires generationRuns. Requires artifacts and blobs together. |
| reconstructChat | Requires messages. |
| reconstructGeneration | Requires generationRuns. |
The runtime checks are necessary because plain JavaScript, loaded configuration, and widened types can get past the static checks.
An adapter owns its resources: the connection lifecycle, when migrations run, and how each store record maps to rows. The middleware only calls store methods. It never opens a connection or reads a table. A backend can provide any subset of the stores (for example, no metadata), and the return type shows exactly the stores that it has. Build a chat adapter shows this end to end for SQLite.
composePersistence does not add distributed transactions. When related stores use different systems, the adapter author defines the retry, idempotency, and consistency behavior.
Two RunStore rules apply to durable runs: