A subagent runs one job, such as research or a sandbox task, inside the same conversation. The user still sees one thread.
A subagent is a named child agent. Give it to chat({ subagents }), and the parent model can start it like a tool. The client shows the work of the child as a card inside the assistant message.
Want a full TanStack Start app with a researcher, an SEO agent, and a writer? Open the Subagents tutorial.
run is the work of the child. Here it is a chat call with its own model. ctx.chat fills in the ids, the parent conversation, and the abort signal for you.
// api/chat.ts
import {
chat,
chatParamsFromRequest,
defineAgent,
toServerSentEventsResponse,
} from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
const researcher = defineAgent({
name: 'researcher',
description: 'Looks up facts and sources',
run: (ctx) => ctx.chat({ adapter: openaiText('gpt-6.1-sol') }),
})
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,
subagents: { agents: [researcher] },
})
return toServerSentEventsResponse(stream)
}A child arrives as a part with type: 'subagent'. Show its name, its status, and its text:
import { useState } from 'react'
import { fetchServerSentEvents, useChat } from '@tanstack/ai-react'
export function Chat() {
const [input, setInput] = useState('')
const { messages, sendMessage } = useChat({
connection: fetchServerSentEvents('/api/chat'),
threadId: 'support-chat',
})
return (
<>
{messages.map((message) => (
<div key={message.id}>
{message.parts.map((part, index) => {
if (part.type === 'text') {
return <p key={index}>{part.content}</p>
}
if (part.type === 'subagent') {
return (
<section key={part.subagent.id}>
<strong>
{part.subagent.name} ({part.subagent.status})
</strong>
{part.subagent.messages.flatMap((child) =>
child.parts.map((childPart, childIndex) =>
childPart.type === 'text' ? (
<p key={`${child.id}-${childIndex}`}>
{childPart.content}
</p>
) : null,
),
)}
</section>
)
}
return null
})}
</div>
))}
<form
onSubmit={(event) => {
event.preventDefault()
if (input.trim() === '') {
return
}
sendMessage(input)
setInput('')
}}
>
<input value={input} onChange={(event) => setInput(event.target.value)} />
<button type="submit">Send</button>
</form>
</>
)
}Send "Find three facts about squids". The parent model calls the researcher tool. A researcher card appears with the status running, fills with the text of the child, and then shows finished.
A child can use tools, MCP, interrupts, and its own nested subagents. It can also return a plain value or make an image. See Return a value, or call any activity.
Without a router, the parent model picks the child. With a router, your code picks it, and the library sends no subagent tools to the model.
Put all spawn options on subagents. Do not put agents or router on the root of chat().
Here a Jev decider picks the target. Its choice options must include main plus every agent name:
import { chat, choice, decide, defineAgent } from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
import { typesafeDecider } from '@tanstack/ai-typesafe'
const messages = [{ role: 'user' as const, content: 'Capital of France?' }]
const researcher = defineAgent({
name: 'researcher',
description: 'Looks up facts and sources',
run: (ctx) => ctx.chat({ adapter: openaiText('gpt-6.1-sol') }),
})
const stream = chat({
adapter: openaiText('gpt-6.1-sol'),
messages,
threadId: 'thread-1',
subagents: {
agents: [researcher],
strategy: 'exclusive',
router: async ({ agents }) => {
const result = await decide({
adapter: typesafeDecider('jev-latest'),
state: messages,
questions: {
target: choice({
instructions: 'Who must handle this turn?',
options: {
main: 'General chat',
...Object.fromEntries(
agents.map((agent) => [agent.name, agent.description]),
),
},
}),
},
})
return result.target.value
},
},
})The router returns 'main' to keep the turn on the parent, or an agent name. strategy sets what happens after the child:
A router can also start several agents, in parallel or one after another, and give each one an input. See Router picks.
In a long thread, the child gets the full conversation at the tool call, with every earlier tool result. Each parallel child gets its own copy, and the child must find its task in it.
Add inputSchema to the agent. The parent model then writes the input of the child when it calls the tool. run reads that input as ctx.input:
import { chat, defineAgent } from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
import { z } from 'zod'
const messages = [
{ role: 'user' as const, content: 'Compare pricing for the vendors we picked' },
]
const researcher = defineAgent({
name: 'researcher',
description: 'Researches one focused question and returns sourced findings',
inputSchema: z.object({
task: z
.string()
.describe('What to find and what to return. The researcher sees only this text.'),
}),
run: (ctx) =>
chat({
adapter: openaiText('gpt-6.1-sol'),
messages: [{ role: 'user', content: ctx.input.task }],
threadId: ctx.threadId,
runId: ctx.runId,
parentRunId: ctx.parentRunId,
subagentRunId: ctx.subagentRunId,
resume: ctx.resume,
}),
})
const stream = chat({
adapter: openaiText('gpt-6.1-sol'),
messages,
threadId: 'thread-1',
subagents: { agents: [researcher] },
})When you call the plain chat() in run, pass every ctx id and resume to it, as in this sample. A child that stops for an approval needs parentRunId and resume to continue. ...ctx.forward passes all of them in one spread.
To show the brief on the card, see Show the brief on a card.
Each tool call starts a new child. Sometimes the model must send a follow-up to a child that already answered, like "make it shorter".
Add withPersistence to the parent chat(). Each result then gives the model the subagentRunId of the child, and the model continues that child with sessionId:
import {
chat,
chatParamsFromRequest,
defineAgent,
toServerSentEventsResponse,
} from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
import { memoryPersistence, withPersistence } from '@tanstack/ai-persistence'
const writer = defineAgent({
name: 'writer',
description: 'Writes and edits drafts',
run: (ctx) => ctx.chat({ adapter: openaiText('gpt-6.1-sol') }),
})
// A continued child loads from this store. Use a durable backend in production.
const persistence = memoryPersistence()
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,
middleware: [withPersistence(persistence)],
subagents: { agents: [writer] },
})
return toServerSentEventsResponse(stream)
}The model continues the writer like this:
The writer gets the task of its first call, its stored transcript, then Make it shorter as the last user message. The stream uses the same subagentRunId, so the client shows the new work on the same card.
For every field of the tool input, and the errors a wrong call gets, see The tool input.
Some child work needs a person first, for example deleting a file. Give that child tool needsApproval: true. The child stops, and the parent run ends with the interrupt of the child. The client answers it like any other interrupt, and the next run continues the same child.
import { toolDefinition } from '@tanstack/ai'
import { z } from 'zod'
export const deleteFile = toolDefinition({
name: 'deleteFile',
description: 'Delete a file',
needsApproval: true,
inputSchema: z.object({ path: z.string() }),
})import {
chat,
chatParamsFromRequest,
defineAgent,
toServerSentEventsResponse,
} from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
import { deleteFile } from './tools'
const cleaner = defineAgent({
name: 'cleaner',
description: 'Deletes old files',
run: (ctx) =>
chat({
adapter: openaiText('gpt-6.1-sol'),
messages: ctx.messages,
threadId: ctx.threadId,
runId: ctx.runId,
parentRunId: ctx.parentRunId,
subagentRunId: ctx.subagentRunId,
resume: ctx.resume,
tools: [deleteFile.server(({ path }: { path: string }) => ({ deleted: path }))],
}),
})
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,
...(params.parentRunId ? { parentRunId: params.parentRunId } : {}),
...(params.resume ? { resume: params.resume } : {}),
subagents: { agents: [cleaner], router: () => 'cleaner' },
})
return toServerSentEventsResponse(stream)
}import { fetchServerSentEvents, useChat } from '@tanstack/ai-react'
import { deleteFile } from './tools'
export function CleanupPanel() {
const chat = useChat({
connection: fetchServerSentEvents('/api/chat'),
threadId: 'support-chat',
tools: [deleteFile.client()],
})
return (
<section>
<button onClick={() => chat.sendMessage('Clean up old files')}>
Clean up
</button>
{chat.interrupts.map((interrupt) =>
interrupt.kind === 'tool-approval' ? (
<button key={interrupt.id} onClick={() => interrupt.resolveInterrupt(true)}>
Approve delete
</button>
) : null,
)}
</section>
)
}While the child waits, its card has status: 'suspended' and interruptIds. After you approve, the card shows the tool result and the reply of the child. Client tools in a child work the same way.
How the resume continues:
A child can have children of its own, and a model can call a child many times. Set limits so one request cannot start a tree with no end:
import { chat, defineAgent } from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
const researcher = defineAgent({
name: 'researcher',
description: 'Looks up facts',
run: (ctx) => ctx.chat({ adapter: openaiText('gpt-6.1-sol') }),
})
const stream = chat({
adapter: openaiText('gpt-6.1-sol'),
messages: [{ role: 'user', content: 'Research squids in depth' }],
threadId: 'thread-1',
subagents: {
agents: [researcher],
limits: { maxDepth: 2, maxConcurrent: 3, maxCalls: 12, timeoutMs: 120_000 },
},
})A refused start reaches the model as a tool error, for example subagent limit reached (maxCalls 12). For the full list, see Limit details.
Put withPersistence on the parent chat() only. The parent stores each child:
A reload then shows every card, and a resume continues a waiting child. See Subagent cards on reload.
A resume continues a waiting child from its stored messages, not from the card that the client sends back. This is also true for a nested child, for example a child that an agent tool of another child started. The card gives only the child id. A changed card cannot change a tool call before its approval runs it.
Warning
Do not add withPersistence to a child chat() too. The child does not know that it runs as a subagent, so it stores the same child again under its own run and thread ids. Its interrupt records then conflict with the records of the parent, and a reload can lose the pending approval.
For router plans, the tool input, value results, keys, middleware, sandboxes, and custom cards with createChatHook, see Subagents: Advanced.