Persistence (Advanced)
- Build your first harness
- Host options
- Send and show media
- Connect clients
- Write a client in any language
- Access and tenants
- Run in the terminal
- Deploy a harness
- Send inputs safely
- Run a tool without a model
- Durable sessions
- Run background work on workers
- List, rename, and fork sessions
- Lifecycle and retention
- Run side effects once
- Wait for a person
- Build on the session log
- Control how a turn ends
- Store settings per thread
- Compact a long session
- Fork and reset a thread
- One log for a session tree
- Write a plugin
- Track usage and cost
- Ask before risky tool calls
- Give your agent coding tools
- Sandbox and extend the coding tools
- Run the coding tools for many threads
- Undo a turn
- Build a coding agent
- Delegate to coding agents
- Switch between agent profiles
- Auth and connectors
- Share a thread between people
- Connect model providers
- Build your own UI
- Code mode in a harness
- Work until a goal is met
- Run agents from a harness
- Hand off a conversation
- Add skills
- Use MCP servers
- Give your agent a computer
- Use from any MCP client
- Use a harness from an editor (ACP)
- Overview
- Quick Start
- ProvidersUpdated
- Harnesses
- Harness Auth
- Workspace
- Tools
- Policy
- Lifecycle & Snapshots
- Portable Snapshots
- Keep Files After Reload
- Save a Named Version
- Branch From a Version
- Send a Frozen File
- Let the Agent Save and Fork
- Pick Which Files to Keep
- What a Snapshot Stores
- Instance Durability
- Durable Runs
- Events
Sandboxes (Advanced)
- Runtime Context
- Bring Your Own Key
- Keep Keys on the Server
- Debug Logging
- Multimodal Content
- Files API
- Per-Model Type Safety
- Runtime Adapter Switching
- Tree-Shaking
- Extend Adapter
- Extend Adapter: Advanced
- Typed Pre-Configured Options
- Test with a Fake Model
- Prompt Caching
- Mid-Conversation Changes
- Approval Flow Processing
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.