A review command needs an explorer to collect facts, then a reviewer to judge them. A research task needs three explorers at once. In a harness, plugins, commands, and agents start agents from code, and a whole harness can be a child of another one. A router can also send a whole turn to an agent. Each run knows the run that started it, its user, its limits, and its budget, and the session log keeps all of it.
ctx.agents runs any agent of the session, or an agent you pass in. Input and result types come from the agent.
import { defineAgent } from '@tanstack/ai'
import { defineCommand, definePlugin } from '@tanstack/ai-harness'
import { openaiText } from '@tanstack/ai-openai'
import { z } from 'zod'
const explorer = defineAgent({
name: 'explorer',
description: 'Collects facts about a topic',
inputSchema: z.object({ task: z.string() }),
run: (ctx) =>
ctx.chat({
adapter: openaiText('gpt-6.1-sol'),
messages: [{ role: 'user', content: ctx.input.task }],
stream: false,
}),
})
export const review = definePlugin({
name: 'acme/review',
setup: (ctx) => ({
agents: [explorer],
commands: {
review: defineCommand({
description: 'Review a topic',
input: z.object({ topic: z.string() }),
run: async ({ topic }) => {
const [auth, sessions] = await ctx.agents.group({ onFailure: 'cancel-siblings' }, (g) => [
g.start(explorer, { task: `${topic}: auth checks` }),
g.start(explorer, { task: `${topic}: session expiry` }),
])
return `${auth}\n\n${sessions}`
},
}),
},
}),
})The input type comes from the inputSchema of the agent, and the result type comes from its output. A wrong input is a type error. An agent name instead of a definition gives a result of type unknown. ctx.agents in a plugin and ctx.harness.agents in agent code have the same type, AgentActions.
The turn that startTurnWhenDone: true starts sends the model a user message from the harness, for example Background agent pricer finished: .... The user did not write it. So the transcript marks it with metadata.harness.wake: true, and a view from createSessionView does not show it. In your own chat UI, skip the user messages with this mark.
To track usage or apply a policy in each of these runs, see Middleware in every agent.
The harness saves the images, audio, and video that these agents make. See Media that agents make.
A research agent has ten topics. It can explore them all at once, but your provider allows only two requests at a time. Run the explorers as a group with a concurrency:
const researcher = defineAgent({
name: 'researcher',
description: 'Explores each topic, two at a time',
inputSchema: z.object({ topics: z.array(z.string()) }),
run: async (ctx) => {
if (!ctx.harness) throw new Error('This agent must run in a harness.')
const notes = await ctx.harness.agents.group({ concurrency: 2 }, (g) =>
ctx.input.topics.map((topic) => g.start(explorer, { task: topic })),
)
return notes.join('\n\n')
},
})The body starts each child with g.start and returns the runs. group resolves to their results, in the same order. Every child that the body started settles before group returns.
To run agents in series, await each run. The next agent starts after the one before it ends:
const checker = defineAgent({
name: 'checker',
description: 'Explores a topic, then checks the notes',
inputSchema: z.object({ topic: z.string() }),
run: async (ctx) => {
if (!ctx.harness) throw new Error('This agent must run in a harness.')
const { agents } = ctx.harness
const notes = await agents.run(explorer, { task: ctx.input.topic })
return await agents.run(explorer, { task: `Find the errors in these notes:\n${notes}` })
},
})A background agent can work for a long time. When its host stops, for example in a deploy, the next host fails the run. Start it with runAgainAfterCrash: true, and the next host runs it again instead. A background child that the model starts runs again by default (see Let the model run agents in the background):
// Your own services.
async function startExport(customer: string) {
return `export-${customer}`
}
async function importInto(region: string, exportId: string) {
return `${exportId} is in ${region}`
}
const migrator = defineAgent({
name: 'migrator',
description: 'Moves the data of one customer to the new region',
inputSchema: z.object({ customer: z.string() }),
run: async (ctx) => {
if (!ctx.harness) throw new Error('This agent must run in a harness.')
const { step } = ctx.harness
// Each side effect is a step. A finished step does not run again.
const exportId = await step.do('export', () => startExport(ctx.input.customer))
await step.do('import', () => importInto('eu-west', exportId))
return ctx.chat({
adapter: openaiText('gpt-6.1-sol'),
messages: [{ role: 'user', content: `Write a short report about ${exportId}.` }],
stream: false,
})
},
})
export const migrations = definePlugin({
name: 'acme/migrations',
setup: (ctx) => ({
agents: [migrator],
commands: {
migrate: defineCommand({
description: 'Move one customer',
input: z.object({ customer: z.string() }),
run: ({ customer }) => {
ctx.agents.start(migrator, { customer }, { runAgainAfterCrash: true, startTurnWhenDone: true })
return `Moving ${customer}.`
},
}),
},
}),
})ctx.harness gives the agent step, agents, and state. A harness gives it to every agent run in a session:
What the next host does:
The agent code runs again from its start, so it calls start, run, and group again. The harness does not start those children a second time:
If the order of the start, run, or group calls can change between runs, give each call a key. The children in one group always keep their order:
const auditor = defineAgent({
name: 'auditor',
description: 'Explores the services, then writes a summary',
inputSchema: z.object({ services: z.array(z.string()) }),
run: async (ctx) => {
if (!ctx.harness) throw new Error('This agent must run in a harness.')
const { agents } = ctx.harness
const notes = await agents.group({ key: 'services' }, (g) =>
ctx.input.services.map((service) => g.start(explorer, { task: service })),
)
return await agents.run(explorer, { task: `Summarize:\n${notes.join('\n')}` }, { key: 'summary' })
},
})This works only for a run that started with runAgainAfterCrash: true, because only that run runs again on the next host.
A background agent drafts a post for ten minutes. Halfway through, the user sees that it cites blogs, not papers. Or the draft is done and they want one more change. Send the run a message, and the agent uses it.
start() returns an AgentRun. Call send on it:
import { defineAgent } from '@tanstack/ai'
import { createHarnessHost, defineHarness } from '@tanstack/ai-harness'
import { memoryPersistence } from '@tanstack/ai-persistence'
import { openaiText } from '@tanstack/ai-openai'
const drafter = defineAgent({
name: 'drafter',
description: 'Drafts a blog post',
run: (ctx) => ctx.chat({ adapter: openaiText('gpt-6.1-sol'), stream: false }),
})
export const studio = defineHarness({
name: 'acme/studio',
adapter: openaiText('gpt-6.1-sol'),
agents: [drafter],
})
const host = createHarnessHost({ persistence: memoryPersistence() })
const session = await host.open(studio, { threadId: 'user-1-thread' })
const run = session.agents.drafter.start()
// Joins the next model call of this run.
await run.send('Cite papers, not blogs.')
// Runs the agent again after this run ends.
const receipt = await run.send('Add a title.', { mode: 'followUp' })
console.log(receipt.status, receipt.operationId)The mode decides when the agent reads the message:
For a follow-up, receipt.operationId is the id of the new run. While the run before it still runs, receipt.status is queued.
A steer joins only a run of the same sender. A steer from another sender runs as a follow-up for that sender. As a result, no message runs with the credentials of another person.
Who sends:
An agent written as plain code, with no ctx.chat call, makes no model call. It gets a steer only as a follow-up.
To message a run from the browser, see Send a message to a running agent.
Your server often has only the id of a run, for example from a button in the UI. Use the session:
for (const info of session.agentRuns()) {
console.log(info.operationId, info.agent, info.status)
}
if (session.agentRun(run.id)) {
await session.send('Make it shorter.', { to: run.id, mode: 'followUp' })
}Each item of agentRuns() has these fields:
agentRuns() reads the run tree of the session. See The run tree.
These three methods also read the log. After a restart, they still find each run, and a follow-up continues a run that ended before the restart. See Durable sessions.
A run and all of its follow-ups keep their messages in one thread: <threadId>:<name>:<input id of the first run>. So each follow-up reads the full history of the runs before it. To read those messages, pass the run id or its subagentRunId:
const history = await session.agentTranscript({ runId: run.id })
const same = await session.agentTranscript({ subagentRunId: run.subagentRunId })The list starts with the messages that the first run started from. See Show what a child wrote after a reload.
An agent can start other agents in the background with ctx.harness.agents.start(agent, input, options?). It returns a run that you can await, message, and cancel. run and group work here too, as in Run agents in parallel or in series:
const editor = defineAgent({
name: 'editor',
description: 'Starts two drafts and joins them',
run: async (ctx) => {
if (!ctx.harness) throw new Error('This agent must run in a harness.')
const { agents } = ctx.harness
const intro = agents.start(drafter)
const outro = agents.start(drafter)
await intro.send('Keep it under 100 words.')
return [await intro, await outro].join('\n\n')
},
})A child run acts for the same person as the run that started it, and it stops when that run is stopped:
A background run that a turn starts keeps the plugins of that turn, for example the agentMiddleware of a plugin with lifetime: 'turn'. The turn plugins are cleaned up when the turn and every run that it started have settled, or when the session closes.
When the host stops during a run, the messages that wait for the run are in the log:
A background run can need a person, for example an approval of permissions() before it edits a file. The run then stops with status waiting. Its wait is in session.snapshot().pendingWaits, with the runId of the run:
const [wait] = session.snapshot().pendingWaits.filter(
(open) => open.runId === run.id,
)
if (wait) {
await session.resolve({
resume: [{ interruptId: wait.waitId, status: 'resolved', payload: 'once' }],
})
}Each agent in subagents.agents is a tool of the main model, with the name of the agent. In a long session, the model can need a follow-up from a child that it started earlier. It can also need to start slow work and answer the user while the work runs. A harness gives each agent tool both:
import { defineAgent } from '@tanstack/ai'
import { defineHarness } from '@tanstack/ai-harness'
import { openaiText } from '@tanstack/ai-openai'
import { z } from 'zod'
const researcher = defineAgent({
name: 'researcher',
description: 'Researches one question and returns sourced findings',
inputSchema: z.object({ question: z.string() }),
run: (ctx) =>
ctx.chat({
adapter: openaiText('gpt-6.1-sol'),
messages: [{ role: 'user', content: ctx.input.question }],
stream: false,
}),
})
const writer = defineAgent({
name: 'writer',
description: 'Writes and edits drafts',
run: (ctx) => ctx.chat({ adapter: openaiText('gpt-6.1-sol'), stream: false }),
})
export const studio = defineHarness({
name: 'acme/studio',
adapter: openaiText('gpt-6.1-sol'),
subagents: { agents: [researcher, writer] },
})The model gets a researcher tool and a writer tool. Each tool takes these fields:
To keep every child inside the turn that calls it, set subagents: { agents, background: false }. The tools then have no background field.
For the tool input in plain chat(), see The tool input.
When a background child ends, the session starts a new turn. Its message starts with Background agent researcher finished: or Background agent researcher failed:. While the child runs, it is in session.agentRuns().
When the host stops while a background child runs, or before the child starts, the next host continues the child with the same subagentRunId, as a start with runAgainAfterCrash: true does:
To fail a child that ran at the stop instead, set durability: { resumeBackgroundAgents: false } in defineHarness. A child that did not start yet still starts, because none of its code ran.
With a session index, each child gets an entry with its parentThreadId and parentToolCallId. See Turn on the session index.
The subagents option of a harness has no binding and no keys, because the session binds its own provider keys.
Known limits:
The main model calls a writer agent as a tool, and the writer wants to save a file. permissions() asks the user first. The turn stops for that approval, and the writer goes on after the answer:
import { writeFile } from 'node:fs/promises'
import { defineAgent, toolDefinition } from '@tanstack/ai'
import { createHarnessHost, defineHarness } from '@tanstack/ai-harness'
import { permissions } from '@tanstack/ai-harness/plugins'
import { openaiText } from '@tanstack/ai-openai'
import { memoryPersistence } from '@tanstack/ai-persistence'
import { z } from 'zod'
const saveFile = toolDefinition({
name: 'save_file',
description: 'Write a file',
inputSchema: z.object({ path: z.string(), text: z.string() }),
}).server(async ({ path, text }) => {
await writeFile(path, text)
return `Saved ${path}.`
})
const writer = defineAgent({
name: 'writer',
description: 'Writes files',
run: (ctx) =>
ctx.chat({ adapter: openaiText('gpt-6.1-sol'), tools: [saveFile], stream: false }),
})
export const studio = defineHarness({
name: 'acme/studio',
adapter: openaiText('gpt-6.1-sol'),
subagents: { agents: [writer] },
plugins: () => [permissions({ rules: [{ tool: 'save_file', decision: 'ask' }] })],
})
const host = createHarnessHost({ persistence: memoryPersistence() })
const session = await host.open(studio, { threadId: 'user-1-thread' })
await session.send('Write a short README.')
const waits = session.snapshot().pendingWaits
for (const wait of waits) console.log(wait.kind, wait.agent) // approval writer
await session.resolve({
resume: waits.map((wait) => ({
interruptId: wait.waitId,
status: 'resolved',
payload: 'once',
})),
})Agent code asks the user with ctx.harness.step.ask. The question is a wait of the turn, and you answer it with resolve:
const namer = defineAgent({
name: 'namer',
description: 'Names a project',
run: async (ctx) => {
if (!ctx.harness) throw new Error('This agent must run in a harness.')
const { step } = ctx.harness
const audience = await step.ask({
name: 'audience',
message: 'Who reads the project?',
schema: z.string(),
})
return step.do('name', () => `A name for ${audience}`)
},
})The resolve runs the agent code again from its start. An answered step.ask returns its answer, and a finished step.do returns its stored value.
Known limits:
Your UI shows each child's text and tool calls while they stream. Then the user reloads the page. The thread's transcript has only the agent tool call and its result, because each child keeps its own messages. session.agentTranscript({ subagentRunId }) gives you those messages back.
Each agent tool result names its child with subagentRunId. Read the result, then ask for the child:
import { defineAgent } from '@tanstack/ai'
import { createHarnessHost, defineHarness } from '@tanstack/ai-harness'
import { memoryPersistence } from '@tanstack/ai-persistence'
import { openaiText } from '@tanstack/ai-openai'
const writer = defineAgent({
name: 'writer',
description: 'Writes and edits drafts',
run: (ctx) => ctx.chat({ adapter: openaiText('gpt-6.1-sol'), stream: false }),
})
const studio = defineHarness({
name: 'acme/studio',
adapter: openaiText('gpt-6.1-sol'),
subagents: { agents: [writer] },
})
const host = createHarnessHost({ persistence: memoryPersistence() })
const session = await host.open(studio, { threadId: 'user-1-thread' })
await session.send('Write a short intro, then have the writer edit it.')
for (const message of await session.transcript()) {
if (message.role !== 'tool' || typeof message.content !== 'string') continue
const result: unknown = JSON.parse(message.content)
if (typeof result !== 'object' || result === null) continue
if (!('subagentRunId' in result)) continue
if (typeof result.subagentRunId !== 'string') continue
const child = await session.agentTranscript({ subagentRunId: result.subagentRunId })
console.log(child.map((item) => item.content))
}What you get back:
In the browser, client.agentTranscript reads the same messages through your handler:
import { createHarnessClient } from '@tanstack/ai-harness/client'
import type { studio } from './harness'
const client = createHarnessClient<typeof studio>({
url: '/api/harness',
threadId: 'user-1-thread',
headers: { authorization: 'Bearer my-token' },
})
export async function childMessages(subagentRunId: string) {
return client.agentTranscript({ subagentRunId })
}The handler route is GET .../agent-transcript?threadId=&subagentRunId=, or &runId= for a run id.
You get an empty list when the id is unknown, or when the child belongs to another thread. The id can come from a prompt, so a session never reads the children of another thread. A fork copies the agent results, but their children stay with the thread that ran them. So in the fork, agentTranscript gives an empty list for them.
Your harness has a writer and a pricer, but the main model answers every turn. Add routing.router to send a turn to the agent that can answer it. The router runs at the start of each new turn: a new message, a follow-up, or the wake turn of a background agent.
import { chat, decide, defineAgent, subagentRoute } from '@tanstack/ai'
import { defineHarness } from '@tanstack/ai-harness'
import { openaiText } from '@tanstack/ai-openai'
import { typesafeDecider } from '@tanstack/ai-typesafe'
import { z } from 'zod'
const writer = defineAgent({
name: 'writer',
description: 'Writes blog posts',
run: (ctx) => ctx.chat({ adapter: openaiText('gpt-6.1-sol'), stream: false }),
})
const pricer = defineAgent({
name: 'pricer',
description: 'Compares the plans and prices of one vendor',
inputSchema: z.object({ vendor: z.string() }),
run: (ctx) =>
ctx.chat({
adapter: openaiText('gpt-6.1-sol'),
messages: [{ role: 'user', content: `Compare the plans of ${ctx.input.vendor}` }],
stream: false,
}),
})
export const studio = defineHarness({
name: 'acme/studio',
adapter: openaiText('gpt-6.1-sol'),
agents: [writer, pricer],
routing: {
router: async ({ agents, messages, adapter, session }) => {
const route = subagentRoute(agents)
const result = await decide({
adapter: typesafeDecider('jev-latest'),
state: messages,
questions: route.questions,
})
const inputs = Object.fromEntries(
await Promise.all(
route.needsInput(result).map(async ({ name, inputSchema }) => [
name,
await chat({
adapter,
threadId: session.threadId,
messages,
outputSchema: inputSchema,
}),
]),
),
)
return route.pick(result, { inputs })
},
},
})The router can also return a pick of its own: 'main', a name, a list of names, { names, order }, or { steps }. Anywhere a name goes, { name, input } can go too, for example { name: 'pricer', input: { vendor: 'Acme' } }. If a picked agent has an inputSchema and no input, the turn fails with Agent "pricer" needs input. Return { name: 'pricer', input } from the router. See Route, or let the model pick for each pick.
The router also gets session, the input of the turn as the user sent it, operationId, inputId, abortSignal, activeAgent, and model. Next to router, routing takes order, limits, sandbox, afterAgentsAnswer, allowHandoffToHuman, and giveModelsHandoffTool. The first three work the same as in subagents. giveModelsHandoffTool: false gives no model the handoff tool. See Hand off a conversation.
Your router asks a model which agent fits. A plain chat() call does not count in session.usage(), and it keeps running after the user cancels the turn. Call the model with model.chat instead:
export const helpdesk = defineHarness({
name: 'acme/helpdesk',
adapter: openaiText('gpt-6.1-sol'),
agents: [writer, pricer],
routing: {
router: async ({ model, messages }) => {
const { text } = await model.chat({
label: 'pick',
options: {
messages,
systemPrompts: ['Answer "writer" for a blog post, else "main". Answer one word.'],
},
})
return text.trim() === 'writer' ? 'writer' : 'main'
},
},
})What the turn does with the pick:
A routed agent works like the main model in a turn:
Some limits:
A user works with the writer for five turns. The router does not have to pick the writer again each time. The log keeps the active agent, and the router reads it as activeAgent:
export const desk = defineHarness({
name: 'acme/desk',
adapter: openaiText('gpt-6.1-sol'),
agents: [writer, pricer],
routing: {
router: ({ activeAgent, input }) => {
if (input === 'Thanks, that is all.') return 'main'
if (activeAgent === undefined && typeof input === 'string' && input.includes('blog')) {
return { handoff: 'writer', note: 'The user wants a blog post.' }
}
return undefined
},
},
})How each pick changes the active agent:
Agents can also hand the turn to each other, or to a person. See Hand off a conversation.
harnessAgent turns a harness into an agent. Put it in subagents.agents, and the main model calls it as a tool. The child harness keeps its own tools, plugins, and history.
import { defineHarness, harnessAgent } from '@tanstack/ai-harness'
const reviewer = defineHarness({
name: 'acme/reviewer',
description: 'Reviews a change and lists the risks',
adapter: openaiText('gpt-6.1-sol'),
})
export const lead = defineHarness({
name: 'acme/lead',
adapter: openaiText('gpt-6.1-sol'),
subagents: { agents: [harnessAgent(reviewer)] },
})The tool name comes from the harness name, with characters other than letters, digits, _, and - changed to _. Pass name to pick another.
In a session, the child is a run of the session tree. It opens a session of the child harness on its own thread, <root thread id>/agent/<run id>, in the log of the root thread. The child gets these from the run that calls it:
What you see in the parent session:
Outside a session, for example in a plain chat(), harnessAgent runs the child harness on the host that you pass as host. Without host, it makes a host with a memory log the first time it runs.
A harness always limits its children. Without subagents.limits, it uses a depth of 2, 3 children at once, and 12 children per tree. Agents started from code count against the same limits:
export const careful = defineHarness({
name: 'acme/careful',
adapter: openaiText('gpt-6.1-sol'),
subagents: {
agents: [explorer],
limits: { maxDepth: 1, maxConcurrent: 2, maxCalls: 6, timeoutMs: 60_000 },
},
})See Limit the tree for what each limit does. In a harness session, they count like this:
The log keeps the counts, so a restart does not reset them. A queued start also survives a host stop. The next host keeps its run id and its place in the order, and starts it when a slot is free. A start that was cancelled before the stop ends cancelled.
A drafter can loop on a hard task and use a lot of tokens. Give the run a budget and a time limit. When it goes over, the run fails, and the rest of the session goes on:
const limited = session.agents.drafter.start(undefined, {
limits: { timeoutMs: 5 * 60_000, maxAttempts: 3 },
budget: { maxTokens: 50_000 },
onModelError: { retries: 2 },
})
try {
await limited
} catch {
const info = session.agentRuns().find((item) => item.runId === limited.id)
console.log(info?.error?.code) // 'budget' or 'timeout'
}Every way to start an agent takes these options: start, run, and g.start, on session.agents, ctx.agents, and ctx.harness.agents.
A budget holds for the whole tree under the run:
onModelError takes two forms:
The log keeps every run of a session as a node of one tree. A node has the type RunNode:
session.agentRuns() lists the agent nodes. The tree comes from the log, so a second host lists the same runs, also after a restart. To read every node yourself, see Find the runs in the log.
A crawler starts many agents, and each one must skip the pages that another agent already read. Give them a shared state with ctx.harness.state:
const reader = defineAgent({
name: 'reader',
description: 'Reads one page, once per session',
inputSchema: z.object({ url: z.string() }),
run: async (ctx) => {
if (!ctx.harness) throw new Error('This agent must run in a harness.')
const seen = ctx.harness.state<Array<string>>({ key: 'seen-urls', initial: [] })
if ((await seen.get()).includes(ctx.input.url)) return 'Already read.'
await seen.update((urls) => [...urls, ctx.input.url])
return ctx.chat({
adapter: openaiText('gpt-6.1-sol'),
messages: [{ role: 'user', content: `Summarize ${ctx.input.url}` }],
stream: false,
})
},
})