- MCP Server ToolsUpdated
- Managed MCP with chat()Updated
- Manual MCPUpdated
- MCP Type GenerationUpdated
- MCP Client InputUpdated
- MCP Server Tools: AdvancedNew
- MCP EventsNew
- MCP AppsUpdated
- Serve Tools over HTTPUpdated
- Serve Resources and PromptsUpdated
- Ask for InputUpdated
- Draft Text from a ToolUpdated
- MCP Server TasksUpdated
- MCP Server AuthUpdated
- MCP Server SessionsUpdated
- MCP Server on stdioUpdated
Persistence (Advanced)
- GenerationsUpdated
- Realtime Voice ChatUpdated
- Text-to-SpeechUpdated
- TranscriptionUpdated
- Transcription: AdvancedNew
- Voice CreationUpdated
- Audio RecordingUpdated
- Audio GenerationUpdated
- Image GenerationUpdated
- Image Generation: AdvancedNew
- Video GenerationUpdated
- Video Generation: AdvancedNew
- Live GenerationUpdated
- World GenerationUpdated
- Generation HooksUpdated
- OverviewUpdated
- Quick StartUpdated
- ProvidersUpdated
- HarnessesUpdated
- Harness AuthUpdated
- WorkspaceUpdated
- ToolsUpdated
- PolicyUpdated
- Lifecycle & SnapshotsUpdated
- Portable SnapshotsUpdated
- Keep Files After ReloadUpdated
- Save a Named VersionUpdated
- Branch From a VersionUpdated
- Send a Frozen FileUpdated
- Let the Agent Save and ForkUpdated
- Pick Which Files to KeepUpdated
- What a Snapshot StoresUpdated
- Instance DurabilityUpdated
- Durable RunsUpdated
- EventsUpdated
Sandboxes (Advanced)
- Runtime ContextUpdated
- Bring Your Own KeyUpdated
- Debug LoggingUpdated
- Multimodal ContentUpdated
- Files APIUpdated
- Per-Model Type SafetyUpdated
- Runtime Adapter SwitchingUpdated
- Tree-ShakingUpdated
- Extend AdapterUpdated
- Extend Adapter: AdvancedNew
- Typed Pre-Configured OptionsUpdated
- Test with a Fake ModelUpdated
- Prompt CachingUpdated
- Mid-Conversation ChangesUpdated
- Approval Flow ProcessingUpdated
Client Persistence
Client persistence writes useChat messages to storage in the browser. A reload keeps the conversation and a reply that was still streaming.
On reload, the persistence option also brings back a pending approval.
Quick setup#
Pick who owns the history. Then copy that path.
The browser owns the chat#
Use this for a single-page app, an offline-first app, or any app with no server store. Pass a storage adapter and a stable threadId:
import {
fetchServerSentEvents,
localStoragePersistence,
useChat,
} from '@tanstack/ai-react'
export function Chat() {
const { messages, sendMessage } = useChat({
threadId: 'support-chat',
connection: fetchServerSentEvents('/api/chat'),
persistence: localStoragePersistence(),
})
return (
<div>
{messages.map((message) => (
<div key={message.id}>{message.role}</div>
))}
<button type="button" onClick={() => void sendMessage('hi')}>
Send
</button>
</div>
)
}localStoragePersistence() needs no type argument and no codec. It uses the chat record shape and a JSON codec by default.
Send a message and reload. The conversation is there, with no network call.
The server owns the chat#
Use this for large transcripts, for the same chat on more than one device, or to keep message content out of the browser. You need Chat persistence on the server first.
Set persistence: true in the client:
tsximport { fetchServerSentEvents, useChat } from '@tanstack/ai-react' export function Chat({ threadId }: { threadId: string }) { const { messages, isHydrating, sendMessage } = useChat({ threadId, connection: fetchServerSentEvents('/api/chat'), persistence: true, }) if (isHydrating) return <p>Loading conversation...</p> return ( <div> {messages.map((message) => ( <div key={message.id}>{message.role}</div> ))} <button type="button" onClick={() => void sendMessage('hi')}> Send </button> </div> ) }Add a GET next to your chat POST:
tsimport { memoryStream, resumeServerSentEventsResponse } from '@tanstack/ai' import { reconstructChat } from '@tanstack/ai-persistence' import { persistence } from './persistence' export function GET(request: Request): Response | Promise<Response> { const durability = memoryStream(request) // A reconnecting client sends a resume cursor. Replay the log. if (durability.resumeFrom() !== null) { return resumeServerSentEventsResponse({ adapter: durability }) } // Otherwise return the stored thread, plus any run that still streams. return reconstructChat(persistence, request, { // Without this, anyone who guesses a thread id gets the transcript. authorize: async (threadId, req) => ownsThread(req, threadId), }) } async function ownsThread(request: Request, threadId: string): Promise<boolean> { void request void threadId return true // Replace with your session and ownership check. }
The replay branch works only when your POST records the stream. To record it, pass { durability: { adapter: memoryStream(request) } } to toServerSentEventsResponse in the POST. The overview quick setup shows the full route.
Reload the page. useChat loads the thread from the server. Open the same threadId on another device, and you get the same chat.
You do not fetch or seed the transcript yourself. You need no loader and no initialMessages. The threadId is the stable key, and the server finds everything from it.
Pick a stable thread id#
The threadId is the key that the record is saved under. A new id on each mount restores nothing. Take it from your own data, for example a conversation id or a route param. Id map explains how to choose one, and how it differs from the runId that the hook reports.
What a reload restores#
- The transcript. It shows again from storage or from the server.
- A pending interrupt. An approval prompt comes back as it was.
- A reply that was still streaming. It finishes in place. This needs a durability-backed connection: a route that records the stream and has a replay handler. See Resumable streams.
Replay rebuilds reasoning, tool activity, and text from the durable log. This includes runs that emit reasoning before their first tool or text event. The completed activity stays in the transcript that the next message sends.
Choose a mode#
persistence takes a storage adapter or a boolean:
| Value | Who owns the history | What the client caches | Use it for |
|---|---|---|---|
| A storage adapter | The client | The transcript and a resume pointer | Single-page and offline apps, one device, small to medium history |
| true | The server | Nothing | Large histories, many devices, no transcripts in the browser |
| false or omitted | Nobody | Nothing | A chat that can start empty after a reload |
The generation hooks take the same option, but as a boolean only. See Generation persistence.
Client and server persistence are independent:
- Client persistence restores what one browser showed.
- Server persistence (Chat persistence) keeps the true copy for every user, and survives a server restart.
For the setup that most apps use, see Which setup do I want?.
Choose a storage backend#
Three adapters ship in @tanstack/ai-client. Every framework package exports them again. They all have the same shape:
| Adapter | How long it keeps data | Notes | Use it for |
|---|---|---|---|
| localStoragePersistence | Across reloads and browser restarts | Synchronous, about 5MB quota, JSON codec | The default: keep a conversation for next time |
| sessionStoragePersistence | One tab, until the tab closes | Same shape as localStorage | A chat that must not outlive the tab |
| indexedDBPersistence | Across reloads and restarts | Async, structured clone (a Date comes back exactly), room for large data | Big transcripts, or values that a JSON codec changes |
import { indexedDBPersistence } from '@tanstack/ai-react'
const persistence = indexedDBPersistence()Each adapter throws only when you use it and its browser store is missing, for example during server-side rendering. So you can create one on the server safely.
Advanced#
Page a long thread#
With persistence: true, history: { pageSize } loads only the newest messages on hydrate. Call loadOlderMessages() for more:
import { fetchServerSentEvents, useChat } from '@tanstack/ai-react'
const connection = fetchServerSentEvents('/api/chat')
function Chat({ threadId }: { threadId: string }) {
const {
messages,
isHydrating,
hasOlderMessages,
loadOlderMessages,
sendMessage,
} = useChat({
threadId,
connection,
persistence: true,
history: { pageSize: 50 },
})
if (isHydrating) return <p>Loading conversation...</p>
return (
<div>
{hasOlderMessages ? (
<button
type="button"
onClick={() => {
void loadOlderMessages().catch(() => {
// Show a retry. The messages on screen stay.
})
}}
>
Load older
</button>
) : null}
{messages.map((m) => (
<div key={m.id}>{m.role}</div>
))}
<button type="button" onClick={() => void sendMessage('hi')}>
Send
</button>
</div>
)
}The library does not watch the scrollbar. Call loadOlderMessages() from your own scroll handler.
- history is valid only with persistence: true. Without history, hydrate loads the full thread.
- hasOlderMessages follows page.truncated from the last hydrate or older-page response.
- With history.pageSize, a send posts only the new turn. A reload posts the last user message, because the old assistant message is already gone in the client. A resume posts from that user message through the assistant message on screen, so the stored tool call stays.
- If loadOlderMessages() fails, the promise rejects, the messages on screen stay, and hasOlderMessages stays true. Catch the rejection in your UI.
Loading state#
isHydrating is true from the first render until the transcript is in place.
- If a run still generates, isLoading is already true when isHydrating turns false.
- If the load fails, isHydrating turns false and error is set.
When the transcript appears depends on the adapter and the framework:
- A direct ChatClient and most framework wrappers load sync adapters (localStorage, sessionStorage) during construction.
- React is different. The first committed render uses initialMessages and initialResumeSnapshot, and browser persistence applies after the hook attaches. This keeps the server snapshot stable during SSR hydration.
- IndexedDB always loads asynchronously, after the database opens.
What reconstructChat returns#
reconstructChat returns a JSON body with these fields:
- messages: the transcript as UI messages.
- activeRun: { runId } when a run still generates for the thread, else null. The client follows it through the replay branch of the GET.
- interrupts: the pending interrupts and the run they paused, else null. Needs an interrupts store.
- page: { truncated, cursor }, only when the request has a valid limit query param. reconstructChat reads limit and before from the query.
You never handle a run id. A second device resumes the live run the same way the first tab does.
Show how long each turn took#
A live turn can time itself on the client. After a reload, that time is gone. Pass includeRuns: true to get it back from the server:
import { reconstructChat } from '@tanstack/ai-persistence'
import { persistence } from './persistence'
export function GET(request: Request): Promise<Response> {
return reconstructChat(persistence, request, { includeRuns: true })
}Each assistant message of a finished run then has the run timings, in epoch ms, on metadata.tanstack.run:
import type { UIMessage } from '@tanstack/ai'
function TurnDuration({ message }: { message: UIMessage }) {
const run = message.metadata?.tanstack?.run
if (!run?.finishedAt) return null
const seconds = Math.round((run.finishedAt - run.startedAt) / 1000)
return <span>Worked for {seconds}s</span>
}The response also has a top-level runs list with runId, status, startedAt, and finishedAt for each finished run. The timings need:
- A runs store that implements listByThread.
- Messages that withPersistence saved with this version or later.
AG-UI activity rows#
AG-UI activity events show as role: 'activity' rows in messages.
- A client storage adapter keeps each row with the same id and content.
- persistence: true restores the rows from the server only when the server has an activities store. See AG-UI activity rows on reload.
The next sendMessage does not send activity rows as model input.
Handle restored client tools#
A live client tool runs when its call arrives from the stream. Hydration restores its pending execution, but it does not run the browser code again, because that work is not always safe to repeat. The execution stays an internal interrupt, and it is not in interrupts.
Use onInterruptStateChange to tell restored interrupt state from live updates. By default, a restored batch stays pending. This example cancels every restored batch:
import { useEffect, useState } from 'react'
import {
fetchServerSentEvents,
localStoragePersistence,
useChat,
} from '@tanstack/ai-react'
function Chat() {
const [restoredBatch, setRestoredBatch] = useState(false)
const { cancelInterrupts } = useChat({
threadId: 'support-chat',
connection: fetchServerSentEvents('/api/chat'),
persistence: localStoragePersistence(),
onInterruptStateChange(_state, { source }) {
setRestoredBatch(source === 'hydrate')
},
})
useEffect(() => {
if (restoredBatch) cancelInterrupts()
}, [cancelInterrupts, restoredBatch])
return null
}- source is hydrate for state from an initial resume snapshot, a client storage adapter, or server hydration.
- source is live for streamed changes and changes that the client starts.
cancelInterrupts() cancels the full internal batch. That includes visible approvals and hidden client-tool executions. It cannot cancel only one kind of interrupt.
Write your own storage adapter#
Any object with getItem, setItem, and removeItem works. The record is one { messages, resume? } object per chat id: the transcript, plus the pointer that lets a reload rejoin a running reply. So setItem gets that full record, not a bare message array:
import type {
ChatClientPersistence,
ChatPersistedState,
} from '@tanstack/ai-client'
function isPersistedState(value: unknown): value is ChatPersistedState {
return (
typeof value === 'object' &&
value !== null &&
'messages' in value &&
Array.isArray(value.messages)
)
}
const persistence: ChatClientPersistence = {
getItem(id) {
const raw = localStorage.getItem(id)
if (raw === null) return null
const parsed: unknown = JSON.parse(raw)
// Wrap a bare message array saved by a 0.x app.
if (Array.isArray(parsed)) return { messages: parsed }
return isPersistedState(parsed) ? parsed : null
},
setItem(id, state) {
localStorage.setItem(id, JSON.stringify(state))
},
removeItem(id) {
localStorage.removeItem(id)
},
}Warning
Reads are best effort. If getItem throws or returns null, the client treats it as "nothing stored". So an adapter that parses the wrong shape fails silently, and the conversation does not come back. Do one real reload with your adapter before you ship it.