Skip to content
Harness

Hand off a conversation

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.

1. Let agents hand off

Give the harness its root agents and routing. A router that returns undefined lets the agent that answers now keep the turn:

ts
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:

  • The log keeps the active agent. Later turns go to billing until it hands off again, or until the router picks another agent.
  • The main model and each root agent get a handoff tool. Its to lists the root agents. A root agent can also hand back to 'main', the main model.
  • One turn can hand off 10 times at most.
  • An agent that is only in subagents cannot be a target. A child that an agent starts cannot hand off either.
  • If the app has its own tool named handoff, the turn fails with a duplicate tool error. Set routing: { router, giveModelsHandoffTool: false } to give no model the handoff tool. The router and your code can still hand off.

2. Hand off from code

An agent can decide in code. In a root agent that answers a turn, call ctx.harness.handoff:

ts
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:

ts
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 part that handed off ends. Then the target answers, in the same turn.
  • Outside a turn of a harness with routing, both calls reject.
  • A target that is not a root agent rejects, and the agent that called it keeps the turn.

The router can hand off too. Return { handoff: 'billing', note } from routing.router. See Route a turn to an agent.

What the target gets

  • Its own system prompts and tools. The tools of the agent that handed off do not go with it.
  • The saved transcript of the thread, the same messages that the members see.
  • The note, in the transcript, in the log, and in the harness.handoff event.
  • The provider keys of the user who sent the turn.

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:

  • An assistant message with a handoff tool call. Its arguments are { to, note }.
  • The tool result, for example Handed off to billing. billing answers next.

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.

3. Show who answers

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:

tsx
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:

  • from and to: an agent name, null for the main model, or { human: true } for a person.
  • note: the note, when there is one.
  • by: 'tool', 'code', 'router', or 'operator'.
  • runId: the turn. A handoff to a person also has waitId.

4. Hand off to a person

Some users need a person. Set routing.allowHandoffToHuman: true. Then the handoff tool also takes { human: true }:

ts
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:

  • The handoff opens a human wait. The note is its message. The wait does not expire.
  • The turn ends interrupted, and the session pauses.
  • A user message during the pause goes into the transcript. No model answers it.

ctx.harness.handoff({ to: { human: true } }) and run.handoff work the same way. Without routing.allowHandoffToHuman, a handoff to a person rejects.

Reply as the operator

A person from your team replies with as: 'operator':

  1. Share the thread with the operator, with the role operator. Ada owns the thread here. See Share a thread between people.
  2. Send the reply as the operator.
ts
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' })
}
  • The reply goes into the transcript as an assistant message, with metadata.harness.operator set to the id of the operator. No model runs.
  • The sender needs the operator access action. With membersOnly(), only the role operator has it. The owner and members do not, so the user who waits cannot act as the person. To change this, pass membersOnly({ operator: { roles: ['owner', 'operator'] } }).
  • Files in the reply go into a user message after it, because a model takes files only from a user. metadata.harness.operatorAttachment marks it, and a view shows the files with the reply.
  • Without an open human wait, the receipt is rejected with no_human_wait.

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.

End the pause

The operator ends the pause with an answer to the human wait:

ts
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' } })
}
  • { resume: true } gives the thread back to the agent that answered before the handoff.
  • { handBack: 'billing' } gives it to that agent. { handBack: 'main' } gives it to the main model.
  • Any other value is rejected with invalid_answer. An unknown agent is rejected with unknown_agent.
  • The wait can also end with session.resolve or a cancel. Then the agent that answered before the handoff gets the thread back. Each of these needs the operator action.
  • The answer starts no turn. The held user messages wait for the next message, or for session.continueWithoutMessage().

5. Move a conversation to another harness

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:

ts
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:

  • 'none' (default): no message.
  • 'summary': one summary message. The model of the source harness writes it, with the keys of the user. The summary call stops after 2 minutes, and then the transfer fails. Its usage counts in the source thread under the source transfer. See Usage of the router and transfers.
  • 'all': the settled transcript, with new message ids and a copy of each media file.

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:

ts
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.')

Rules of a transfer

  • Both threads are in the tenant of user. A transfer to another tenant cannot be named.
  • With access, the user needs transfer on the source. With membersOnly(), only the owner has it. A target that exists needs send too.
  • A new target has the user as its only member, the owner. A thread that exists must belong to to.harness.
  • A retry with the same transferId makes no second thread and copies nothing again.
  • If another host owns the target log, the transfer throws LogOwnedElsewhereError.

How the two threads stay linked:

  • Both logs get a harness.transferred record with the same transferId. See Read the log.
  • The index entry of the target has metadata['harness:transferredFrom']. The source has metadata['harness:transferredTo']. The host writes these keys itself, and your metadataSchema does not check them.
  • host.events() yields a transferred event. Copy your own access rows for the target there.

A fork stays in its harness. host.fork with a thread of another harness throws, so use host.transfer for that move.

What you have now

  • Agents that hand the conversation to each other, from a tool, from code, or from the router.
  • A log that keeps who answers, also after a restart.
  • A UI that shows the name of the agent on each message.
  • A pause for a person, operator replies, and a way to give the thread back.
  • A conversation that moves to another harness with only the envelope that you name.