Server chat state with withPersistence from @tanstack/ai-persistence. Authoritative transcript, run lifecycle, durable interrupts/approvals, chatParamsFromRequest, reconstructChat, snapshotStreaming. Use when the server owns history, multi-device, or durable tool approvals. NOT client localStorage (see ai-core/client-persistence in @tanstack/ai) and NOT stream reconnect alone.
Builds on ai-persistence. Package:
@tanstack/ai-persistence.
withPersistence(persistence) is a ChatMiddleware that writes chat state
to a backend: messages, runs, interrupts (optional metadata). It does not
mutate the chunk stream and does not replace delivery durability.
import {
chat,
chatParamsFromRequest,
toServerSentEventsResponse,
} from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
import { withPersistence } from '@tanstack/ai-persistence'
// Your adapter — see ai-persistence/stores.
import { persistence } from './persistence'
export async function POST(request: Request) {
const params = await chatParamsFromRequest(request)
const stream = chat({
adapter: openaiText('gpt-5.5'),
messages: params.messages,
threadId: params.threadId,
runId: params.runId,
...(params.resume ? { resume: params.resume } : {}),
middleware: [withPersistence(persistence)],
})
return toServerSentEventsResponse(stream)
}Always pass threadId and runId from the client (via
chatParamsFromRequest / body helpers). Forward resume when the client
resolves pending interrupts.
For dev and tests, memoryPersistence() from @tanstack/ai-persistence is a
drop-in backend that implements all four stores in process.
| Store | Role | Required? |
|---|---|---|
messages | Full model-message transcript load/save | Yes for withPersistence |
runs | Run status, timing, usage, errors | Optional; needed for interrupt durability |
interrupts | Pending/resolved tool approvals & waits | Optional; requires runs |
metadata | App-owned namespaced key/value | Optional |
Named shapes: ChatTranscriptPersistence (floor), ChatPersistence (all four).
Annotate your factory with one of these, not with bare AIPersistence —
the unparameterized type is the all-optional bag, and withPersistence rejects
it because stores.messages is possibly undefined.
messages → finish overwrites the stored thread with that
array. Post the complete transcript, never a delta.messages → middleware loads the stored thread and continues.| Moment | Writes | Best-effort? |
|---|---|---|
onStart | Pending turn snapshot (user + history) | Yes — failure does not abort |
| Interrupt boundary | New interrupts, run → interrupted, message snapshot | No |
onFinish | Full transcript first, then run → completed, commit resumes | No |
| Stream (optional) | Throttled partial assistant text | Yes if snapshotStreaming: true |
onError | Run → failed | Resumes stay pending |
onAbort | Run → interrupted | Resumes stay pending |
withPersistence(persistence, {
snapshotStreaming: true,
snapshotIntervalMs: 1000, // default
})Streaming snapshots default off (finish is authoritative). Enable only when partial-output durability is worth extra writes.
Resumes accepted in onConfig commit only at a success boundary (interrupt or
finish). A failed run leaves interrupts pending so the same resume batch can
retry.
resume batch or onConfig
throws.resumeToolState and clears
config.resume so the engine does not double-reconstruct from client
history (server owns transcript).reconstructChat)Server-authoritative clients load history by threadId (often GET):
import { reconstructChat } from '@tanstack/ai-persistence'
export async function GET(request: Request) {
return reconstructChat(persistence, request, {
// Multi-user: required in production
authorize: async (threadId, req) => {
const userId = await sessionUserId(req)
return userOwnsThread(userId, threadId)
},
})
}Returns { messages, activeRun, interrupts }:
messages — UI messages for paintactiveRun — { runId } if a run is still generating (runs.findActiveRun)interrupts — pending human-in-the-loop state for re-promptWithout authorize, anyone who guesses ?threadId= gets the transcript.
withGenerationPersistence(persistence) tracks run records for non-chat
activities (image, audio, TTS, video, transcription). Do not fake
threadId = requestId on chat run stores — use the generation helper.
messagesWipes the stored thread down to that delta. Always send full history or [].
threadId / runIdPersistence keys and resume need stable ids. Use chatParamsFromRequest.
runsinterrupts requires runs; withPersistence throws otherwise.
AIPersistenceAIPersistence defaults to the sparse all-optional bag, so withPersistence
and reconstructChat reject the value. Return ChatPersistence (or
ChatTranscriptPersistence) instead.
withPersistence to reconnect a dropped streamThat is delivery durability (resumable streams), not state persistence.
@tanstack/ai) — browser half1120f0f
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.