withPersistence writes the transcript and a waiting approval outside the request. A process restart then keeps both.
withPersistence is a chat middleware. You choose the store, and the server keeps the true copy of every conversation.
npm i @tanstack/ai-persistencepnpm add @tanstack/ai-persistenceyarn add @tanstack/ai-persistencebun add @tanstack/ai-persistenceStart with the in-memory backend. Replace it with your database adapter later:
// persistence.ts
import { memoryPersistence } from '@tanstack/ai-persistence'
export const persistence = memoryPersistence()Add withPersistence to chat(). Then add a GET that returns the stored thread:
// api/chat.ts
import {
chat,
chatParamsFromRequest,
toServerSentEventsResponse,
} from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
import { reconstructChat, withPersistence } from '@tanstack/ai-persistence'
import { persistence } from './persistence'
export async function POST(request: Request) {
const params = await chatParamsFromRequest(request)
const stream = chat({
adapter: openaiText('gpt-6.1-sol'),
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)
}
export function GET(request: Request): Promise<Response> {
// Add `authorize` in a multi-user app. See Client persistence.
return reconstructChat(persistence, request)
}import { fetchServerSentEvents, useChat } from '@tanstack/ai-react'
export function Chat({ threadId }: { threadId: string }) {
const { messages, sendMessage } = useChat({
threadId,
connection: fetchServerSentEvents('/api/chat'),
persistence: true,
})
return (
<div>
{messages.map((message) => (
<div key={message.id}>{message.role}</div>
))}
<button type="button" onClick={() => void sendMessage('hi')}>
Send
</button>
</div>
)
}Send a message, then reload the page. The thread comes back from the server store. With a database adapter, it also comes back after a server restart.
Client persistence covers the client side in full, including a reply that is still streaming.
Tip
Run npx @tanstack/intent@latest install to give your coding assistant this package's Agent Skills. The skills read your database setup and write the adapter to match. They also know the rules that are easy to get wrong, such as a full overwrite in saveThread and insert-if-absent creates for runs and interrupts.
The middleware uses the stores that your backend has. There are no feature flags.
withPersistence writes at these moments:
On an error, the run is marked failed. On an abort, the run is marked aborted.
Before you go to production:
The transcript is stored per threadId. Each run gets a runs record with its status, timings, and the usage that the provider reported.
So a client that reconnects never has to send a run id. The store finds the live run of the thread with findActiveRun(threadId), and the client follows that run.
Id map explains how to choose a thread id. How persistence works has the full model.
A run can pause on an interrupt: a tool approval, a client-side tool, or a generic middleware request. The middleware records it in the interrupts store.
The next request on that thread must carry a resume batch that answers the pending interrupts. If it does not, the middleware rejects the request. That is why the route forwards params.resume.
The answers are committed only when the run reaches the next interrupt or finishes. If the run fails first, the interrupts stay pending, and the user can answer them again.
Each store is independent, so give the backend only the stores you need. Controls shows how to mix backends per store.
These topics are on Chat persistence: advanced: