This tutorial saves the subagent thread on the server. A refresh shows the cards again, and an in-progress run continues.
Save the thread on the server. Then a refresh shows the cards again, and a run that is still in progress continues in the same card. Finish the Subagents tutorial first. This page takes about 20 minutes.
Note
In this tutorial, the saved chat lives in the memory of the dev server process. If you restart that process, the chat is gone. A database that saves the same fields keeps the chat.
You already have @tanstack/ai and @tanstack/ai-react. Add the store package:
npm i @tanstack/ai-persistencepnpm add @tanstack/ai-persistenceyarn add @tanstack/ai-persistencebun add @tanstack/ai-persistenceCreate src/lib/thread.ts:
export const THREAD_ID = 'blog-desk'Use this id on the client and on the server. A new id on each load starts an empty chat.
Warning
This example has no login, so every visitor shares the blog-desk thread. In a real app, make sure that the caller owns the thread before POST or GET reads or writes it.
In src/chat-ui.tsx, replace chatOptions with this object. Keep the rest of the file. Step 7 changes the researcher card.
import { fetchServerSentEvents } from '@tanstack/ai-react'
import { blogAgents } from '@/lib/blog-agents'
import { byok } from '@/lib/byok'
export const chatOptions = {
connection: fetchServerSentEvents('/api/chat'),
byok,
persistence: true,
subagents: blogAgents,
}In src/routes/index.tsx, pass THREAD_ID to useAppChat():
import { createFileRoute } from '@tanstack/react-router'
import { useAppChat } from '@/chat-ui'
import { THREAD_ID } from '@/lib/thread'
function ChatPage() {
const chat = useAppChat({ threadId: THREAD_ID })
return <chat.AppChat />
}
export const Route = createFileRoute('/')({
component: ChatPage,
})In the layout, change the heading to Saved blog desk. Under the heading, add two sentences: "A refresh keeps this chat. A refresh during a run continues the stream."
Replace src/routes/api.chat.ts with this file. It is the full route from the example.
import { createFileRoute } from '@tanstack/react-router'
import {
chat,
chatParamsFromRequest,
decide,
memoryStream,
resumeServerSentEventsResponse,
subagentRoute,
toServerSentEventsResponse,
} from '@tanstack/ai'
import {
memoryPersistence,
reconstructChat,
withPersistence,
} from '@tanstack/ai-persistence'
import {
createOpenRouterDecider,
createOpenRouterText,
} from '@tanstack/ai-openrouter'
import { openrouterByok } from '@tanstack/ai-openrouter/byok'
import { byokMissing, getByokKey } from '@tanstack/ai/byok/server'
import { createBlogAgents } from '@/lib/agents'
const FIRST_CHUNK_MS = 30_000
const persistence = memoryPersistence()
export const Route = createFileRoute('/api/chat')({
server: {
handlers: {
POST: async ({ request }) => {
const params = await chatParamsFromRequest(request)
const apiKey = getByokKey(request, openrouterByok.id)
if (!apiKey) return byokMissing(openrouterByok)
const agents = createBlogAgents(apiKey)
const stream = chat({
adapter: createOpenRouterText('openai/gpt-5.5', apiKey),
messages: params.messages,
threadId: params.threadId,
runId: params.runId,
middleware: [withPersistence(persistence)],
...(params.parentRunId !== undefined
? { parentRunId: params.parentRunId }
: {}),
...(params.resume !== undefined ? { resume: params.resume } : {}),
subagents: {
agents,
strategy: 'exclusive',
router: async ({ messages, agents: routed, abortSignal }) => {
const state = messages.at(-1)
if (state === undefined) return 'main'
const route = subagentRoute(routed, { then: ['writer'] })
const result = await decide({
adapter: createOpenRouterDecider(
'~typesafe/jev-latest',
apiKey,
),
state,
questions: route.questions,
abortSignal,
})
return route.pick(result)
},
},
})
return toServerSentEventsResponse(stream, {
durability: { adapter: memoryStream(request) },
})
},
GET: ({ request }) => {
const durability = memoryStream(request, {
firstChunkDeadlineMs: FIRST_CHUNK_MS,
})
if (durability.resumeFrom() !== null) {
return resumeServerSentEventsResponse({ adapter: durability })
}
return reconstructChat(persistence, request)
},
},
},
})One memoryPersistence() at module level is the store for every request:
At this point, a refresh keeps the cards. The next steps give the researcher more to restore than text.
In src/lib/blog-agents.ts, import toolDefinition from @tanstack/ai. Define the tool, then add it to the tools of the researcher:
export const lookupWikipedia = toolDefinition({
name: 'lookupWikipedia',
description:
'Get the Wikipedia summary of one topic. Pass a short page title, for example "Octopus".',
inputSchema: {
type: 'object',
properties: { title: { type: 'string' } },
required: ['title'],
},
})
export const researcher = {
name: 'researcher',
description:
'Does this turn need facts or sources? Answer yes when the user asks to look something up, even if they also ask for a draft or for SEO. Answer no when they do not ask to look anything up.',
tools: [lookupWikipedia],
} as constThe definition holds no server code, so the browser reads it too. It gives types to the tool calls of the researcher in the UI.
In src/lib/agents.ts, import lookupWikipedia. Add the server side of the tool above createBlogAgents:
const lookupWikipediaTool = lookupWikipedia.server(async (input) => {
const title =
typeof input === 'object' &&
input !== null &&
'title' in input &&
typeof input.title === 'string'
? input.title
: ''
if (title.length === 0) {
throw new Error('lookupWikipedia needs a page title')
}
const response = await fetch(
`https://en.wikipedia.org/api/rest_v1/page/summary/${encodeURIComponent(title)}`,
{ headers: { 'user-agent': 'tanstack-ai-subagents-persisted-example' } },
)
if (response.status === 404) return { title, found: false }
if (!response.ok) {
throw new Error(`Wikipedia returned ${response.status} for ${title}`)
}
const page: unknown = await response.json()
const extract =
typeof page === 'object' &&
page !== null &&
'extract' in page &&
typeof page.extract === 'string'
? page.extract
: ''
if (extract.length === 0) return { title, found: false }
return {
title,
found: true,
extract,
url: `https://en.wikipedia.org/wiki/${encodeURIComponent(title)}`,
}
})A 404, or a page with no summary, returns { found: false }. Any other HTTP status throws, so the card shows the error.
In the researcher chat() call, add the tool and turn on reasoning. Keep subagentRunId: ctx.subagentRunId on each child chat(). Replace the system prompt so the model uses the tool:
reasoning: 'medium',
tools: [lookupWikipediaTool],
systemPrompts: [
'You research for a blog desk. Call lookupWikipedia once for each topic before you reply. Then reply in Markdown with short notes, and give the Wikipedia URL as the source. Use a list. Do not write the full post.',
],In src/chat-ui.tsx, import ThinkingPart from @tanstack/ai-react/ui. Add a thinking entry to partsComponents:
thinking: ({ part }) => (
<ThinkingPart
content={part.content}
className="mb-2 rounded border border-gray-700 bg-gray-800/60 p-2 text-xs text-gray-400"
/>
),In src/components/subagent-card.tsx, import the SubagentPartsProps type from @tanstack/ai-react/ui. Add the tool widget above the cards:
const researcherTools: SubagentPartsProps<
BlogChatOptions,
'researcher'
>['toolsComponents'] = {
lookupWikipedia: ({ part, result }) => (
<details className="mb-2 rounded border border-emerald-500/30 bg-emerald-500/5 p-2 text-xs text-emerald-300">
<summary className="cursor-pointer font-mono">
lookupWikipedia({part.arguments}) ({part.state})
</summary>
<pre className="mt-1 whitespace-pre-wrap text-gray-400">
{result === undefined
? 'No result yet'
: typeof result.content === 'string'
? result.content
: JSON.stringify(result.content)}
</pre>
</details>
),
}Replace the researcher card with this function. The notes stay open, so the tool row is visible:
export function Researcher({
subagent,
Parts,
}: SubagentProps<BlogChatOptions, 'researcher'>) {
return (
<section className="mt-3 rounded-lg border border-orange-500/30 bg-gray-900/80 p-3 text-gray-100">
<AgentHeader
name={subagent.name}
status={subagent.status}
onStop={subagent.stop}
/>
{subagent.error ? (
<p className="text-xs text-red-400">{subagent.error.message}</p>
) : null}
<div className="mt-2">
<Parts toolsComponents={researcherTools} />
</div>
</section>
)
}How toolsComponents works on this card:
To replace other widgets on one card, see Style one child's parts.
Your blog desk now survives a refresh, and a live run continues where it was.
persistence: true makes the hook load the thread from the server when the page opens:
On the server:
chat.stop() stops the view in this tab only. The server run continues, and a refresh joins it again.
withPersistence writes two kinds of rows.
The message store holds the transcript for blog-desk. saveThread replaces that transcript.
The run store holds one row for each run:
Inside a child chat():
The id of the parent assistant message is assistant: plus the parent run id. metadata.tanstack.runId is that same id. The saved text is researcher: and the notes, then seo: and the notes.
reconstructChat reads metadata.tanstack.runId. It calls listByParentRun. It puts one card on the assistant message for each child. The card is the only place that the notes show.
Your own database must save parentRunId, subagentRunId, and name. It must also implement listByParentRun, because reconstructChat uses that method to build the cards.
chatParamsFromRequest reads the body. The body has messages, threadId, and runId. The client also sends the run id in the X-Run-Id header. memoryStream(request) reads that header and opens the log for this run.
getByokKey reads the OpenRouter key from the header that the page sends. The call passes openrouterByok.id, so the server does not read OPENROUTER_API_KEY. This example has no login. With a server key, any caller can spend that key.
memoryStorage() keeps the key in memory only. After a refresh, the cards come back without a key. Paste the key again before you send the next message.
chat() receives middleware: [withPersistence(persistence)]. On this path, the router runs before the main model. The persistence middleware records the child events:
toServerSentEventsResponse does the delivery:
memoryStream(request) looks at the URL.
If the URL has a resume offset, resumeFrom() is not null, and the request is a join. When the offset is -1, resumeServerSentEventsResponse replays the log from the start. The hook uses that replay to continue a live run.
If there is no resume offset, the request is a normal load. reconstructChat(persistence, request) reads ?threadId=. It returns the saved messages and activeRun.
activeRun is the parent run while its status is running. After the run is complete, activeRun is null. Then the hook shows the cards from messages.
The hook sends the full thread on the next POST. Each card becomes text in the shape name: plus the notes. The system prompt of the writer tells the model to use earlier research text, so the writer receives the saved notes in messages.