This page covers the details of subagents that you need only for special cases. Set up the basics first with Subagents.
The router can return:
Anywhere a name goes, { name, input } can go too, for example { name: 'pricer', input: { vendor: 'Acme' } }. These rules apply:
One step holds one input for each agent. To give one agent two inputs, put each pick in its own step of { steps }.
subagents.order is the default order for an array:
{ names, order } overrides that default for one turn. If some turns are parallel and some are serial, use it.
{ steps } runs one group, then the next group. Each group has names and an optional order. The next group reads the text from the earlier group. If two agents start together and a later agent must read both, use { steps }:
const plan = {
steps: [
{ names: ['researcher', 'seo'], order: 'parallel' },
{ names: ['writer'] },
],
}A later user message can also read that child text. The assistant message keeps the name and text of each child, so the next turn still has the notes.
subagentRoute makes one yes/no question for each agent and asks Jev for the order. Jev picks parallel when no agent must read text from another agent. The same topic is not a reason to wait. Jev picks sequence only when a later agent must read the earlier text, such as research notes and then a draft.
import { chat, decide, defineAgent, subagentRoute } from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
import { typesafeDecider } from '@tanstack/ai-typesafe'
const researcher = defineAgent({
name: 'researcher',
description: 'Looks up facts',
run: async function* () {},
})
const writer = defineAgent({
name: 'writer',
description: 'Writes the post',
run: async function* () {},
})
const messages = [
{ role: 'user' as const, content: 'Research squids and write an article' },
]
const stream = chat({
adapter: openaiText('gpt-6.1-sol'),
messages,
threadId: 'thread-1',
subagents: {
agents: [researcher, writer],
router: async ({ messages: turnMessages, agents }) => {
const state = turnMessages.at(-1)
if (state === undefined) {
throw new Error('No message')
}
const route = subagentRoute(agents)
const result = await decide({
adapter: typesafeDecider('jev-latest'),
state,
questions: route.questions,
})
return route.pick(result)
},
},
})An agent with inputSchema gets its input from the router:
Here the model writes each input, with the schema as outputSchema:
import { chat, decide, defineAgent, subagentRoute } from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
import { typesafeDecider } from '@tanstack/ai-typesafe'
import { z } from 'zod'
const writer = defineAgent({
name: 'writer',
description: 'Writes the post',
run: async function* () {},
})
const pricer = defineAgent({
name: 'pricer',
description: 'Compares the plans and prices of one vendor',
inputSchema: z.object({ vendor: z.string() }),
run: (ctx) =>
chat({
adapter: openaiText('gpt-6.1-sol'),
messages: [{ role: 'user', content: `Compare the plans of ${ctx.input.vendor}` }],
threadId: ctx.threadId,
runId: ctx.runId,
parentRunId: ctx.parentRunId,
subagentRunId: ctx.subagentRunId,
resume: ctx.resume,
}),
})
const messages = [
{ role: 'user' as const, content: 'What does Acme cost? Write a post about it.' },
]
const threadId = 'thread-1'
const stream = chat({
adapter: openaiText('gpt-6.1-sol'),
messages,
threadId,
subagents: {
agents: [pricer, writer],
router: async ({ messages: turnMessages, agents }) => {
const route = subagentRoute(agents, { then: ['writer'] })
const result = await decide({
adapter: typesafeDecider('jev-latest'),
state: turnMessages,
questions: route.questions,
})
const inputs = Object.fromEntries(
await Promise.all(
route.needsInput(result).map(async ({ name, inputSchema }) => [
name,
await chat({
adapter: openaiText('gpt-6.1-sol'),
messages: turnMessages,
threadId,
outputSchema: inputSchema,
}),
]),
),
)
return route.pick(result, { inputs })
},
},
})Warning
A turn with a router input cannot resume after an interrupt. Do not give a tool that needs approval, or a client tool, to an agent in such a turn.
Without a router, every agent tool takes the fields of the agent, plus two more:
When the run uses sessionId or background, that name is reserved. If an inputSchema has a field with that name, chat() throws before the run starts.
A wrong call goes back to the model as a tool error, and the run continues. For example:
A child does not have to be a chat. It can make an image, or return a plain value that the parent model reads as the tool result. Call the activity on ctx and return what it gives you:
import { chat, defineAgent } from '@tanstack/ai'
import { openaiImage, openaiText } from '@tanstack/ai-openai'
import { z } from 'zod'
const heroImage = defineAgent({
name: 'heroImage',
description: 'Makes a hero image for a post',
produces: 'image',
inputSchema: z.object({ prompt: z.string() }),
run: (ctx) =>
ctx.generateImage({
adapter: openaiImage('gpt-image-2.5-sunburst'),
prompt: `${ctx.input.prompt}. Brand colors: blue and white.`,
size: '1536x1024',
}),
})
const stream = chat({
adapter: openaiText('gpt-6.1-sol'),
messages: [{ role: 'user', content: 'Make a hero image about squids' }],
threadId: 'thread-1',
subagents: { agents: [heroImage] },
})What the activities on ctx do:
What happens to the result:
produces says what the agent makes ('image', 'text', and more). It does not change how the agent runs.
When you call the plain chat() instead, spread ctx.forward. It passes the ids and the abort controller of the child in one line:
const researcher = defineAgent({
name: 'researcher',
description: 'Looks up facts',
run: (ctx) =>
chat({
adapter: openaiText('gpt-6.1-sol'),
messages: ctx.messages,
...ctx.forward,
}),
})The whole tree shares one budget. A child that calls ctx.chat({ subagents }) passes the budget on, so a child cannot reset it.
A refused start reaches the model as a tool error:
| Cause | Tool error |
|---|---|
| Too many calls | subagent limit reached (maxCalls 12) |
| A child runs past timeoutMs | subagent timed out after 120000 ms |
| The parent has no time left | subagent limit reached (no time left) |
| timeoutMs: 0 | subagent limit reached (timeoutMs 0) |
An agent that calls a model needs a provider key. An adapter such as openaiText reads its env var, for example OPENAI_API_KEY.
To use the key of the user, pass it on subagents.keys. The agent then builds its adapter with ctx.keys. See Keys inside agents.
A child is its own chat() call. Put the middleware of the child in that call. The middleware list of the parent does not reach the child.
The middleware context of a child has these fields:
When a resume answers an interrupt from an agent tool call, the middleware context of the parent has childResumeIds. These are the interrupt ids in resume that the child answers. The parent run does not answer them. When no child resumes, childResumeIds is undefined.
On a routed turn where a child runs and main does not, the middleware of the parent does not run. Only withPersistence records that turn. A turn where main runs, including a handoff, runs the middleware of the parent as usual.
A server job can run an agent with no parent chat turn. If the job must read a pause or the token counts, pass a sink.
import {
chat,
createSubagentId,
createSubagentSink,
defineAgent,
runAgentStream,
} from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
const researcher = defineAgent({
name: 'researcher',
description: 'Looks up facts and sources',
run: (ctx) =>
chat({
adapter: openaiText('gpt-6.1-sol'),
messages: ctx.messages,
...ctx.forward,
}),
})
const subagentRunId = createSubagentId()
const sink = createSubagentSink()
for await (const chunk of runAgentStream(
researcher,
{
input: undefined,
messages: [{ role: 'user', content: 'Find three facts about squids' }],
threadId: 'thread-1',
runId: `job-1:${subagentRunId}`,
parentRunId: 'job-1',
subagentRunId,
},
sink,
)) {
if (chunk.type === 'TEXT_MESSAGE_CONTENT') console.log(chunk.delta)
}The sink holds the pause and the token counts:
After the loop, read sink.interrupts to show the pause.
Some things that chat({ subagents }) does for you, runAgentStream does not do:
withSandbox keys the workspace by threadId. Set subagents.sandbox so the child gets the right thread id. Pass ctx.threadId into the child chat().
If sandbox is 'inherit' and the router returns two or more names, chat() throws before the start. Otherwise two children write the same files at the same time.
import { chat, defineAgent } from '@tanstack/ai'
import { grokBuildText } from '@tanstack/ai-grok-build'
import {
defineSandbox,
defineWorkspace,
withSandbox,
} from '@tanstack/ai-sandbox'
import { dockerSandbox } from '@tanstack/ai-sandbox-docker'
const messages = [{ role: 'user' as const, content: 'Fix the tests' }]
const threadId = 'parent-thread'
const repoSandbox = defineSandbox({
id: 'repo-agent',
provider: dockerSandbox({ image: 'node:22' }),
workspace: defineWorkspace({
source: { type: 'none' },
packageManager: 'pnpm',
}),
lifecycle: { reuse: 'thread' },
})
const coder = defineAgent({
name: 'coder',
description: 'Edits the repo in a sandbox',
run: (ctx) =>
chat({
adapter: grokBuildText('grok-build'),
messages: ctx.messages,
threadId: ctx.threadId,
runId: ctx.runId,
parentRunId: ctx.parentRunId,
subagentRunId: ctx.subagentRunId,
resume: ctx.resume,
middleware: [withSandbox(repoSandbox)],
}),
})
const ownStream = chat({
adapter: grokBuildText('grok-build'),
messages,
threadId,
middleware: [withSandbox(repoSandbox)],
subagents: {
agents: [coder],
sandbox: 'own',
strategy: 'exclusive',
router: () => 'coder',
},
})
const inheritStream = chat({
adapter: grokBuildText('grok-build'),
messages,
threadId,
middleware: [withSandbox(repoSandbox)],
subagents: {
agents: [coder],
sandbox: 'inherit',
strategy: 'exclusive',
router: () => 'coder',
},
})See Sandboxes for withSandbox and lifecycle.reuse.
The nested type: 'subagent' part and useChat().subagents[i] are the same live object. When you call stop() on either one, this happens:
part.subagent.messages holds everything the child did:
part.subagent.status is 'running', 'finished', 'error', or 'suspended'.
Pass the agents that you give to chat() to useChat too. The hook uses them for types only. It does not call run.
import { defineAgent } from '@tanstack/ai'
import { fetchServerSentEvents, useChat } from '@tanstack/ai-react'
const researcher = defineAgent({
name: 'researcher',
description: 'Looks up facts',
run: async function* () {},
})
const writer = defineAgent({
name: 'writer',
description: 'Drafts posts',
run: async function* () {},
})
function Desk() {
const chat = useChat({
connection: fetchServerSentEvents('/api/chat'),
threadId: 'support-chat',
subagents: [researcher, writer],
})
const part = chat.messages[0]?.parts[0]
if (part?.type === 'subagent' && part.subagent.name === 'researcher') {
return part.subagent.status
}
return null
}part.subagent.name is 'researcher' | 'writer'. After you check the name, the messages of that child use the tools and the output schema of that agent.
When run imports server code, do not import the agent into the browser. Do these steps:
If you want the factory to draw the cards, use createChatHook from @tanstack/ai-react/ui:
Warning
If a started child has a name with no subagentsComponents entry, rendering that card throws.
import { defineAgent } from '@tanstack/ai'
import { fetchServerSentEvents } from '@tanstack/ai-react'
import { createChatHook } from '@tanstack/ai-react/ui'
import type { LayoutProps, SubagentProps } from '@tanstack/ai-react/ui'
const researcher = defineAgent({
name: 'researcher',
description: 'Looks up facts',
run: async function* () {},
})
const writer = defineAgent({
name: 'writer',
description: 'Drafts posts',
run: async function* () {},
})
const chatOptions = {
connection: fetchServerSentEvents('/api/chat'),
subagents: [researcher, writer],
}
function SubagentCard({
subagent,
Parts,
}: SubagentProps<typeof chatOptions, 'researcher' | 'writer'>) {
return (
<section>
<strong>{subagent.name}</strong>
<span>{subagent.status}</span>
{subagent.status === 'running' ? (
<button type="button" onClick={() => subagent.stop?.()}>
Stop
</button>
) : null}
<Parts />
</section>
)
}
const { useAppChat, useChatContext } = createChatHook({
options: chatOptions,
components: {
layout: ({ Messages, Input }: LayoutProps<typeof chatOptions>) => (
<main>
<Messages />
<Input />
</main>
),
message: ({ Parts }) => <article><Parts /></article>,
input: function Input() {
const chat = useChatContext()
return (
<form
onSubmit={(event) => {
event.preventDefault()
const field = event.currentTarget.elements.namedItem('message')
if (!(field instanceof HTMLInputElement)) return
void chat.sendMessage(field.value)
field.value = ''
}}
>
<input name="message" />
<button type="submit">Send</button>
</form>
)
},
},
partsComponents: {
text: ({ part }) => <p>{part.content}</p>,
fallback: () => null,
},
subagentsComponents: {
researcher: SubagentCard,
writer: SubagentCard,
},
})
export function ChatScreen() {
const chat = useAppChat({ threadId: 'support-chat' })
return <chat.AppChat />
}By default, the child messages use the same parts components as the parent. To change that for one card, see Style one child's parts.
When an agent has inputSchema, the card keeps the checked input that the parent model wrote, as part.subagent.input. Put inputSchema in the shared declaration, and pass the declaration to useChat to type it:
import { z } from 'zod'
export const researcher = {
name: 'researcher',
description: 'Researches one focused question and returns sourced findings',
inputSchema: z.object({ task: z.string() }),
} as constimport { fetchServerSentEvents, useChat } from '@tanstack/ai-react'
import { researcher } from './agents'
export function Briefs() {
const { messages } = useChat({
connection: fetchServerSentEvents('/api/chat'),
threadId: 'support-chat',
subagents: [researcher],
})
return messages.flatMap((message) =>
message.parts.map((part) =>
part.type === 'subagent' ? (
<p key={part.subagent.id}>{part.subagent.input?.task}</p>
) : null,
),
)
}The reasoning and tool calls of the researcher use the root widgets by default. To make them look different on the researcher card only, pass widgets to the Parts of that card:
import { toolDefinition } from '@tanstack/ai'
import { fetchServerSentEvents } from '@tanstack/ai-react'
import { createChatHook, ThinkingPart } from '@tanstack/ai-react/ui'
import type { SubagentPartsProps, SubagentProps } from '@tanstack/ai-react/ui'
import { z } from 'zod'
const lookupWikipedia = toolDefinition({
name: 'lookupWikipedia',
description: 'Get the Wikipedia summary of one topic',
inputSchema: z.object({ title: z.string() }),
outputSchema: z.object({ extract: z.string() }),
})
// Shared with the server, which passes defineAgent({ ...researcher, run }).
const researcher = {
name: 'researcher',
description: 'Looks up facts',
tools: [lookupWikipedia],
} as const
const chatOptions = {
connection: fetchServerSentEvents('/api/chat'),
subagents: [researcher],
}
type ResearcherWidgets = SubagentPartsProps<typeof chatOptions, 'researcher'>
const researcherParts: ResearcherWidgets['partsComponents'] = {
thinking: ({ part }) => (
<ThinkingPart content={part.content} className="research-notes" />
),
}
const researcherTools: ResearcherWidgets['toolsComponents'] = {
lookupWikipedia: ({ part }) => (
<details>
<summary>
{part.input?.title} ({part.state})
</summary>
{part.output?.extract}
</details>
),
}
function Researcher({
Parts,
}: SubagentProps<typeof chatOptions, 'researcher'>) {
return (
<section>
<Parts partsComponents={researcherParts} toolsComponents={researcherTools} />
</section>
)
}
export const { useAppChat } = createChatHook({
options: chatOptions,
components: {
layout: ({ Messages }) => (
<main>
<Messages />
</main>
),
message: ({ Parts }) => (
<article>
<Parts />
</article>
),
},
partsComponents: {
text: ({ part }) => <p>{part.content}</p>,
thinking: ({ part }) => <ThinkingPart content={part.content} />,
fallback: () => null,
},
subagentsComponents: { researcher: Researcher },
})Define these maps outside the component. A new object on each render makes every part in the card render again.
In the AI devtools, the Conversation tab shows each child as a card with its name and status:
When the server events reach the devtools (the devtools() plugin from @tanstack/devtools-vite), the steps of the child are its real server iterations, with the model, the system prompts, and the token usage. Without the plugin, the steps come from the messages in the browser.
See Stream Events for SUBAGENT_* and subagentRunId.