A user asks your support agent for a refund. The support agent cannot do refunds, but your billing agent can. Then the user wants a person, and a person from your team must take over. Later, the whole case must move to the harness of another team.
A handoff moves the conversation to another agent or to a person. A transfer moves it to another thread, also of another harness. In both cases, control moves on purpose, and only the data that you name moves with it.
Give the harness its root agents and routing. A router that returns undefined lets the agent that answers now keep the turn:
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 billing = defineAgent({
name: 'billing',
description: 'Handles refunds, invoices, and payments',
run: (ctx) =>
ctx.chat({
adapter: openaiText('gpt-6.1-sol'),
systemPrompts: ['You handle refunds, invoices, and payments.'],
}),
})
export const support = defineHarness({
name: 'acme/support',
adapter: openaiText('gpt-6.1-sol'),
systemPrompts: ['You are the support desk. Hand billing questions to billing.'],
agents: [billing],
routing: { router: () => undefined },
})
export const host = createHarnessHost({ persistence: memoryPersistence() })
export async function ask(threadId: string, text: string) {
const session = await host.open(support, { threadId })
return session.send(text)
}Send I want a refund for order 42. The main model calls its handoff tool with { to: 'billing', note }. Then billing answers in the same turn. The turn text is billing:\n....
What the handoff does:
An agent can decide in code. In a root agent that answers a turn, call ctx.harness.handoff:
export const triage = defineAgent({
name: 'triage',
description: 'Sorts new requests',
run: async (ctx) => {
if (!ctx.harness) throw new Error('This agent must run in a harness.')
await ctx.harness.handoff({ to: 'billing', note: 'Refund for order 42' })
return 'I pass you to billing.'
},
})A tool of the main model uses ctx.context.run.handoff. Type the tool with HarnessRunContext:
import { toolDefinition } from '@tanstack/ai'
import { z } from 'zod'
import type { HarnessRunContext } from '@tanstack/ai-harness'
export const escalate = toolDefinition({
name: 'escalate',
description: 'Send the user to billing',
inputSchema: z.object({ orderId: z.string() }),
}).server<HarnessRunContext>(async ({ orderId }, ctx) => {
await ctx.context.run.handoff({ to: 'billing', note: `Refund for order ${orderId}` })
return 'Billing answers next.'
})The router can hand off too. Return { handoff: 'billing', note } from routing.router. See Route a turn to an agent.
The handoff tool call of a model is in the transcript already. A handoff from the router or from code adds the same two messages before the target answers:
Both messages have metadata.harness.handoff.by ('router' or 'code'). When an agent handed off, they also have its name in metadata.harness.agent. The messages go after the messages that the earlier model call got, so the prompt cache of the provider still holds.
With routing.giveModelsHandoffTool: false, the models have no handoff tool. Then the harness adds one assistant message with the same text and the note.
The agents() plugin has its own primary agent, its agent config and /agent. That one changes the prompt and the tools of the main model. A handoff does not change it, and it does not change the active agent. See Switch between agent profiles.
Each message that an agent writes has its name in metadata.harness.agent. In the session view, an assistant message has agent. It is undefined for the main model:
import { useSelector } from '@tanstack/react-store'
import { createHarnessClient } from '@tanstack/ai-harness/client'
import { createSessionView } from '@tanstack/ai-harness/view'
const view = createSessionView(
createHarnessClient({ url: '/api/harness', threadId: 'user-1-thread' }),
)
export function Messages() {
const messages = useSelector(view.store, (state) => state.messages)
return messages.map((message) =>
message.role === 'assistant' ? (
<p key={message.id}>
<strong>{message.agent ?? 'Support'}: </strong>
{message.parts.map((part) => (part.type === 'text' ? part.text : '')).join('')}
</p>
) : null,
)
}Each handoff also streams a CUSTOM event named harness.handoff (HARNESS_EVENTS.handoff). Its value has these fields:
Some users need a person. Set routing.allowHandoffToHuman: true. Then the handoff tool also takes { human: true }:
import { membersOnly } from '@tanstack/ai-harness'
import { createHarnessHandler } from '@tanstack/ai-harness/server'
export const desk = defineHarness({
name: 'acme/desk',
adapter: openaiText('gpt-6.1-sol'),
systemPrompts: ['If the user asks for a person, hand off to { human: true }.'],
agents: [billing],
routing: { router: () => undefined, allowHandoffToHuman: true },
})
// Your own login check: a session token to a user.
const users = new Map([
['Bearer token-ada', { id: 'ada', tenantId: 'acme' }],
['Bearer token-olga', { id: 'olga', tenantId: 'acme' }],
])
export const deskHost = createHarnessHost({
persistence: memoryPersistence(),
access: {
getUser: ({ request }) => users.get(request.headers.get('authorization') ?? '') ?? null,
rules: membersOnly(),
},
})
export const handler = createHarnessHandler({ host: deskHost, harness: desk })When an agent hands off to a person:
ctx.harness.handoff({ to: { human: true } }) and run.handoff work the same way. Without routing.allowHandoffToHuman, a handoff to a person rejects.
A person from your team replies with as: 'operator':
const ada = { id: 'ada', tenantId: 'acme' }
const olga = { id: 'olga', tenantId: 'acme' }
export async function addOperator(threadId: string) {
await deskHost.sessions.share({ threadId, userId: 'olga', role: 'operator', user: ada })
}
export async function reply(threadId: string, text: string) {
const session = await deskHost.open(desk, { threadId, user: olga })
await session.send(text, { as: 'operator' })
}In the browser, the operator uses the same option: client.send(text, { as: 'operator' }), or view.send(text, { as: 'operator' }) in a session view. The reply shows in each open view at once.
The operator ends the pause with an answer to the human wait:
import { createHarnessClient } from '@tanstack/ai-harness/client'
import type { desk } from './harness'
const client = createHarnessClient<typeof desk>({
url: '/api/desk',
threadId: 'case-42',
headers: { authorization: 'Bearer token-olga' },
})
const snapshot = await client.snapshot()
const wait = snapshot.pendingWaits.find((open) => open.kind === 'human')
if (wait) {
await client.answer({ questionId: wait.waitId, value: { handBack: 'billing' } })
}The case must leave the support harness. The billing team runs its own harness, with its own tools and plugins. host.transfer makes a new thread under the target harness and copies only the envelope:
export const billingDesk = defineHarness({
name: 'acme/billing',
adapter: openaiText('gpt-6.1-sol'),
})
export const billingHandler = createHarnessHandler({ host: deskHost, harness: billingDesk })
// Mount on POST /api/move-to-billing?threadId=...
export async function moveToBilling(request: Request) {
const user = await deskHost.getUserOfRequest({ request })
if (!user) return new Response('Unauthorized', { status: 401 })
const threadId = new URL(request.url).searchParams.get('threadId')
if (!threadId) return new Response('Missing threadId', { status: 400 })
const result = await deskHost.transfer({
from: { harness: desk, threadId },
to: { harness: billingDesk },
envelope: { note: 'Refund for order 42. The user waits.', messages: 'summary' },
user,
transferId: `refund-${threadId}`,
})
return new Response(result.threadId)
}envelope.note is the first message of the target. envelope.messages picks what comes after it:
Only settled messages move. A tool call with no result yet stays behind. Plugin state, shared state, credentials, waits, settings, and plugin config never move. The source thread keeps running.
The browser then opens the target thread on the handler of the target harness:
import type { billingDesk } from './harness'
const response = await fetch('/api/move-to-billing?threadId=case-42', {
method: 'POST',
headers: { authorization: 'Bearer token-ada' },
})
const threadId = await response.text()
const billingClient = createHarnessClient<typeof billingDesk>({
url: '/api/billing',
threadId,
headers: { authorization: 'Bearer token-ada' },
})
await billingClient.send('Hi, I am still here.')How the two threads stay linked:
A fork stays in its harness. host.fork with a thread of another harness throws, so use host.transfer for that move.