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
Chat Persistence
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.
Quick setup#
1. Install the package#
npm i @tanstack/ai-persistencepnpm add @tanstack/ai-persistenceyarn add @tanstack/ai-persistencebun add @tanstack/ai-persistence2. Create the persistence object#
Start with the in-memory backend. Replace it with your database adapter later:
// persistence.ts
import { memoryPersistence } from '@tanstack/ai-persistence'
export const persistence = memoryPersistence()3. Add the middleware to the server route#
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)
}4. Load the thread in the client#
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.
How it works#
The middleware uses the stores that your backend has. There are no feature flags.
- messages (required): loads and saves the full model-message thread.
- runs: records the status of each run: running, interrupted, completed, failed, or aborted.
- interrupts: records pending tool approvals, client tools, and generic waits. Needs runs.
withPersistence writes at these moments:
- Start of a run. It saves the new user message, so a reload during generation still shows the question.
- Interrupt. It saves the new interrupt records and marks the run interrupted.
- Finish. It saves the full transcript, then marks the run completed.
On an error, the run is marked failed. On an abort, the run is marked aborted.
Before you go to production:
- Replace memoryPersistence() with an adapter for your database. Build your own adapter walks through a complete SQLite version.
- Apply the schema through your deploy workflow, not when the adapter opens. See Migrations.
Threads, runs, and turns#
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.
Interrupts survive a restart#
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.
Choose which stores to run#
Each store is independent, so give the backend only the stores you need. Controls shows how to mix backends per store.
Advanced#
These topics are on Chat persistence: advanced:
- Subagent cards and AG-UI activity rows after a reload.
- How incoming messages merge with the stored thread.
- Compaction, streaming snapshots, and structured output.
- The full run and interrupt lifecycles, including detached runs.