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 — 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.
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 — 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).
On error, the run is marked failed. On abort, the run is marked interrupted. Resumes accepted in onConfig are not consumed until a success boundary (interrupt or finish), so a failed run leaves pending interrupts retryable with the same resume batch.
When 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.