You want a conversation to outlive a single request: the transcript, whether each run finished or is still waiting on an interrupt, all still there after the process restarts. withPersistence is a chat middleware that writes that state to a store you choose, so the server owns an authoritative copy of every thread.
pnpm add @tanstack/ai-persistence
npx @tanstack/intent@latest installThe second command wires this package's Agent Skills into your coding assistant. Run it before you start, because the recipes read your existing database setup and write the adapter to match, and they encode the invariants (full-overwrite saveThread, insert-if-absent run and interrupt creates) that are easy to get wrong and expensive to debug.
Add the middleware to chat() and point it at a backend. Here persistence is a local ./persistence module: an adapter you build on the core over the database you already run. Build your own adapter walks through a complete SQLite version end to end.
import {
chat,
chatParamsFromRequestBody,
toServerSentEventsResponse,
} from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
import { withPersistence } from '@tanstack/ai-persistence'
import { persistence } from './persistence'
export async function POST(request: Request) {
const params = await chatParamsFromRequestBody(await request.json())
const stream = chat({
adapter: openaiText('gpt-5.5'),
messages: params.messages,
threadId: params.threadId,
runId: params.runId,
// Forward the resume batch so a thread with pending interrupts continues.
...(params.resume ? { resume: params.resume } : {}),
middleware: [withPersistence(persistence)],
})
return toServerSentEventsResponse(stream)
}The middleware uses whichever state stores the backend provides, no feature flags. messages is required; the rest are optional:
Need a mutex across workers? Add withLocks when other middleware needs multi-instance coordination; see Locks.
Creating tables on open is convenient for local development. In production, apply schema changes through your deployment workflow instead. See Migrations.
Threads and runs are protocol concepts, not persistence ones. A thread (threadId) is the stable conversation, a run (runId) one RUN_STARTED → RUN_FINISHED execution, and one user-visible turn can span several runs. Threads and runs in the streaming guide covers the anatomy. What persistence adds is the durable record of them, anchored on the thread:
Id map is the practical companion to this: how to choose a thread id, why both client and server must file under the same one, when to read useChat's runId, and what the same two ids mean on the generation hooks.
withPersistence follows one rule, the authoritative-history contract:
withPersistence writes at four moments so a reload never loses a turn:
| Moment | What is written | Best-effort? |
|---|---|---|
| Start of a run (onStart) | Pending turn (just-submitted user message + prior history) so a reload mid-generation still shows the question | Yes. Failure does not abort the run; finish is authoritative |
| Interrupt boundary | New interrupt records, run status interrupted, and a thread snapshot of current messages | No. Store failures propagate |
| Finish (onFinish) | Complete transcript (including the terminal assistant reply with its stream messageId for in-place reload identity), run status completed, and commit of consumed resumes | No. The transcript is saved before the run is marked completed |
| Optionally while streaming | Throttled partial assistant text when snapshotStreaming: true | Yes |
const streamingMiddleware = [
withPersistence(persistence, { snapshotStreaming: true }),
]Streaming snapshots default off (finish is the authoritative save); enable them to trade extra writes for partial-output durability. Tune the interval with snapshotIntervalMs (default 1000).
How a run that does not finish cleanly is recorded:
Resumes accepted in onConfig are not consumed until a success boundary (an interrupt or a finish), so a failed run leaves pending interrupts retryable with the same resume batch.
Every run record moves through this lifecycle. All three end states are terminal for that record, because a continuation after an interrupt is a new run with a fresh runId:
stateDiagram-v2
[*] --> running : run starts (idempotent createOrResume)
running --> completed : finish, transcript saved first
running --> failed : error
running --> interrupted : interrupt boundary, or abort
completed --> [*]
failed --> [*]
interrupted --> [*] : continuation runs under a new runIdWhen a run pauses on an interrupt (a tool approval, a client-side tool, a generic wait), the middleware records it. A later request on that thread must carry a resume batch that answers the pending interrupts before new input is accepted, otherwise it is rejected, which is why the example above forwards params.resume.
Persistence is the server-authoritative resume path: the middleware validates the resume batch against pending interrupts, builds ChatResumeToolState (approvals / client-tool results), and clears config.resume so the chat engine skips its ephemeral reconstruction (which needs client message history the persistence flow deliberately omits). Resumes are committed (resolved/cancelled in the store) only once the run reaches a successful interrupt or finish boundary.
An interrupt record is born pending and only a commit moves it, which is why a failed continuation leaves it answerable again:
stateDiagram-v2
[*] --> pending : run pauses, interrupt recorded
pending --> resolved : resume answers it, committed at a success boundary
pending --> cancelled : resume cancels it
resolved --> [*]
cancelled --> [*]