Handoffs
handoff() — transfer the whole run to another agent, history and all. The counterpart to agentTool, which delegates and comes back.
Die Dokumentationsseiten selbst sind auf Englisch. Navigation, Suche und UI-Texte folgen der gewählten Sprache.
handoff() mints one transfer_to_<name> tool per target agent. When the model calls one, the loop swaps the active agent: the system prompt, the tool set and the model become the target's for every following step, and the entire conversation travels with it.
import { generateText, handoff } from '@deuz-sdk/core';
const agents = handoff({
billing: { model: gpt5, instructions: 'You handle billing. Be precise.', tools: { refund } },
support: supportAgent, // a createAgent() value works too
});
const { text, providerMetadata } = await generateText({
model: triageModel,
instructions: 'Route the user to the right specialist.',
messages,
tools: { ...agents, search },
maxSteps: 8,
});
providerMetadata?.deuz?.handoffs; // [{ to: 'billing', toolCallId: '…', reason: '…', stepIndex: 1 }]handoff ships from @deuz-sdk/core, @deuz-sdk/core/agent and @deuz-sdk/core/edge — it is pure construction, so it is edge-safe.
Transfer vs. delegation
This is the whole point, and it is not a nuance. agentTool and handoff look similar from the call site and mean opposite things.
handoff() — transfer | agentTool() — delegation | |
|---|---|---|
| Conversation history | Travels with the transfer. The target sees everything. | Not carried. The sub-agent starts on a fresh { system, prompt } context. |
| Who drives afterwards | The target, for the rest of the run. | The parent — it never stopped. |
| System prompt | Replaced wholesale by the target's instructions. | The sub-agent has its own; the parent's is untouched. |
| Tool set | Replaced by the target's tools (+ the other transfer tools). | Parent keeps its own; the sub-agent has its own. |
| Model | Replaced by the target's model for every following step. | Parent's model keeps driving the parent loop. |
| How the result comes back | It does not "come back" — the answer is the run's answer. | As a tool_result the parent reads and reasons about. |
| Loop shape | One loop, changing identity. | Two loops, nested. |
| Coming back | Only by transferring again (targets keep each other's transfer tools, including one back). | Automatic — the sub-agent finishes and the parent continues. |
| Stream signal | handoff part | sub-agent parts (the child's live stream, forwarded) |
| Metadata | providerMetadata.deuz.handoffs | usage attribution per agentPath |
| Guardrails | Kept — they are run-level, and the run did not change. | Not inherited by the sub-agent loop. |
| Token cost of the switch | Zero extra calls; the next step just uses a different model. | One nested run per call, with its own steps and usage. |
Pick a handoff when the conversation should change hands — triage → specialist, sales → support. Pick a sub-agent when a task should be farmed out and answered — research this, summarize that.
Two heuristics that resolve most real cases:
- Does the user keep talking to the new agent? Yes → handoff. If the answer has to come back to the first agent for it to continue reasoning, that is delegation.
- Would you want the specialist to see the whole transcript? A billing agent usually must (the user already explained the problem). A "summarize this PDF" worker usually must not — a fresh context is cheaper and less confusing.
A worked example: support triage
A cheap router reads the first message and hands off; each specialist keeps a way back to the router, so a mis-routed conversation self-corrects.
import { generateText, handoff, tool } from '@deuz-sdk/core';
import { z } from 'zod';
const lookupOrder = tool({
description: 'Fetch an order by id.',
parameters: z.object({ orderId: z.string() }),
execute: async ({ orderId }) => db.orders.find(orderId),
});
const refund = tool({
description: 'Refund an order. Requires a human OK.',
parameters: z.object({ orderId: z.string(), amountUsd: z.number() }),
needsApproval: true,
execute: async (args) => payments.refund(args),
});
const agents = handoff(
{
// The router itself is a target, so a specialist can hand the user back.
triage: {
model: haiku,
instructions: 'You route customers. Ask at most one clarifying question, then transfer.',
},
billing: {
model: sonnet,
instructions: 'You handle billing and refunds. Confirm the order before refunding.',
tools: { lookupOrder, refund },
},
shipping: {
model: haiku,
instructions: 'You handle delivery status and address changes.',
tools: { lookupOrder },
},
},
{
maxHandoffs: 4,
describe: (name) => `Transfer when the customer's problem is clearly about ${name}.`,
onHandoff: ({ from, to, reason }) => analytics.track('handoff', { from, to, reason }),
},
);
const { text, providerMetadata } = await generateText({
model: haiku, // the ROOT agent — the router
instructions: 'You route customers. Ask at most one clarifying question, then transfer.',
messages,
tools: agents, // the router has nothing but transfers
maxSteps: 10,
approveToolCall: async (call) => ui.confirm(call), // survives every transfer
});
for (const hop of providerMetadata?.deuz?.handoffs ?? []) {
console.log(`${hop.from ?? 'triage'} → ${hop.to}: ${hop.reason ?? '(no reason given)'}`);
}Three things this example is doing on purpose:
- The root agent is duplicated as a
triagetarget.handoff()has no concept of "the agent that started the run", so the only way back to the router is to list it like any other target. The root's own identity isundefined— that is whyHandoffPart.fromis absent on the first transfer. describereplaces the default sentence (the target'sinstructions), which is usually written for the target, not for the router deciding where to send someone.approveToolCallis not in thehandoff()call — it belongs to the run, sorefundstays gated no matter which agent is driving.
Defining targets
function handoff(
agents: Record<string, DeuzAgent | HandoffAgentDef>,
options?: HandoffOptions,
): ToolSet;
interface HandoffAgentDef {
model: LanguageModel;
instructions?: string;
tools?: ToolSet;
name?: string;
}The record key is what names the transfer tool (billing → transfer_to_billing) and what appears in HandoffPart.to, the metadata and the checkpoint. name is only a label for logs.
Four fields, and no more, because those are exactly the ones a transfer can carry across. Everything else about a run — deps, timeouts, memory, session, guardrails, budgets — belongs to the run, not to the agent driving it, and stays yours. A handoff must never silently re-point transport or defenses mid-run.
A createAgent value is accepted directly, and only those same four fields are read off it. Its own deps / session / memory are deliberately not dragged into the swap.
import { createAgent } from '@deuz-sdk/core/agent';
const supportAgent = createAgent({
name: 'support',
model: sonnet,
instructions: 'You are a terse support agent.',
tools: { lookupOrder },
session: { store, runId }, // ← ignored by handoff(); it belongs to a call
});
const agents = handoff({ support: supportAgent });handoff() validates eagerly: an empty record key or a target with no model throws a TypeError at construction, not on the step where the model happens to call it.
Options
interface HandoffOptions {
maxHandoffs?: number; // default 5
describe?: (name: string) => string; // default: the target's `instructions`
onHandoff?: (info: { from?: string; to: string; reason?: string }) => void;
}describe writes the sentence appended to each transfer tool's description — the model's only clue about when to transfer. onHandoff fires once per accepted transfer; it is caller code, so a throw propagates (the onStepFinish contract — a routing audit log that silently swallowed its own failure would go missing without a trace).
Options are shared by the whole group: they are read off the transfer tool the model actually called, so two handoff() groups spread into one run can carry different maxHandoffs budgets. Mixing them is rarely what you want — the counter is per run, and whichever group's tool triggers the check supplies the limit.
What the model sees
Each transfer tool is an ordinary tool with one optional parameter:
{
description: "Transfer the conversation to the 'billing' agent. You handle billing. Be precise.",
parameters: {
type: 'object',
properties: { reason: { type: 'string', description: 'Why this agent should take over, in one sentence.' } },
additionalProperties: false,
},
}reason is not required. A model that transfers without explaining itself must still produce a valid call — failing argument validation would turn a routing decision into an error turn. An empty or whitespace-only reason is read as "none given" and simply omitted from the part and the metadata.
The interception is deterministic
A transfer is decided by the loop before any tool executes, by looking at the call — never by a control-flow exception thrown out of an execute. The transfer tools carry a hidden, non-enumerable marker that the loop reads; Object.keys, JSON.stringify and every wire builder never see it.
Order within a step: handoff interception → guardrails → approval gate → executeTools. So an onToolCall guardrail never sees a transfer call, and a transfer is never queued for human approval.
Consequences worth stating:
- One transfer per step. If the model emits two, the first in its own emission order wins; every sibling transfer is answered with an
is_errorsaying so. Applying two would mean the second agent never saw the first exist. - Every transfer call still gets a
tool_result. The accepted one getsTransferred to 'billing'. <reason>; the ignored ones get anis_error. No exceptions — an unansweredtool_use_idis a 400. - The rest of the batch runs normally. A model that calls
searchandtransfer_to_billingin one step gets both honored:searchexecutes, the transfer is answered, and the results are re-ordered back into the model's own call order. - The swap happens after the turn is complete, so the tool-result message is written first and the history stays valid.
activeToolsfiltering applies normally. Transfer tools are ordinary entries intools; filter them like anything else.prepareStepstill has the last word. It runs after the active agent's model is applied, so aprepareStepthat returns amodeloverrides the target's for that step.- The transfer tool's
executethrows on purpose. It is unreachable inside a handoff-aware loop; if you ever see that error, the tool reached a loop (or a bareexecuteTools) that does not intercept transfers.
Transfers and suspension
A step can end early for reasons that have nothing to do with the transfer: a tool needs human approval, a client tool has to round-trip, a durable sub-agent suspended. The transfer is still committed on every one of those exits (2.0 — an earlier build dropped it).
Concretely, when the step breaks:
- every transfer
tool_use_idis answered and the{ role: 'tool' }turn is appended, so the provider never sees an unanswerable history on resume; - the swap is applied — active model, tool set, and the rewritten system turn — before the checkpoint is written;
AgentCheckpoint.handoffrecords{ to, count }, so the resume leg comes back as the target agent, not the root one;- the streaming loop publishes the same
tool-result/tool-state/handoffparts the completed path would, so a UI cannot tell the two apart.
The one asymmetry: the executed tools of that batch are not answered on a sub-agent suspension. Their tool_use ids stay open and the resume leg's settle re-runs them (which is what resumes the child's checkpoint). The transfer is not one of them — it never executes, so deferring it would leave a tool_use no producer could ever answer.
On the resume leg the order is: restore the checkpointed agent → settle pending approvals → then the first model call. A transfer that was left unanswered by the previous leg is applied during that settle, and from that point the leg is the target agent.
The maxHandoffs loop guard
Two agents that keep transferring to each other would otherwise burn the whole maxSteps budget on nothing but transfers. maxHandoffs (default 5) bounds transfers per run.
Reaching it is self-healing, never a throw: every transfer call in that step comes back as an is_error — Handoff limit (5) reached; continue yourself. — and the current agent carries on with its own tools. Like an approval denial, a refused transfer is a policy verdict, not a tool failure, so it is excluded from the runaway-error guard: a model that keeps trying to transfer will not trip the three-consecutive-errors hard stop, it will just keep being told no until maxSteps runs out.
The count is stored on the checkpoint and restored on resume, so the budget bounds the whole durable run rather than each leg.
The target's tool set
The target gets its own tools, plus every transfer tool except its own — an agent that can transfer to itself is a loop with extra steps. The outgoing agent's non-transfer tools are gone; that is the difference between a handoff and a delegation.
The transfer catalog is captured once, from the run's root tool set. It has to be: after A → B the active set no longer contains transfer_to_B, so re-deriving the list later would make B → C the step where "back to B" quietly stops existing.
The target's own tools are applied last, so an agent that deliberately defines a name colliding with a transfer tool wins on its own turf.
MCP tools are re-merged, not lost
Zero-config mcp: tools are merged on top of whichever agent is active, and the wire lists are rebuilt at the swap. A transfer therefore keeps the run's MCP tools while replacing the local half of the set.
The system prompt is rewritten, not appended
instructions replaces the run's leading system turn (one is inserted when the history had none, and removed when the target declares none).
A rewrite, not an append — the same discipline compaction follows. The new turn must not enter response.messages / appended / the chat record, or your response delta and the transcript would grow a system message you never sent. It lives in the effective model history, and therefore in checkpoints, which is exactly where a resume leg needs to find it.
The consequence to plan for: a target with no instructions strips the run's system prompt entirely for every following step. If you want a specialist to inherit the run's framing, restate it in that target's instructions.
HandoffPart on the stream
streamChat emits one part per accepted transfer, before the next step-start:
interface HandoffPart {
type: 'handoff';
from?: string; // absent on the first transfer out of the root agent
to: string;
toolCallId: string; // the transfer_to_* call — its tool_result is still emitted
reason?: string; // the model's stated reason, when it gave one
stepIndex: number;
}for await (const part of result.fullStream) {
if (part.type === 'handoff') {
ui.showDivider(`${part.from ?? 'triage'} → ${part.to}${part.reason ? `: ${part.reason}` : ''}`);
}
}The buffered twin is providerMetadata.deuz.handoffs — the same entries minus the type discriminant, in order. Both are built from one object, so the streaming and buffered readouts cannot describe the same transfer differently.
The StreamPart union is open
handoff is new in 2.0. StreamPart is documented as an open union precisely so that adding a member cannot break an exhaustive switch — keep a default case in your part handler and a new part kind is a no-op for old code.
Durable resume
A handoff is checkpointed as { to, count } on AgentCheckpoint.handoff, and the resume leg re-applies the overlay before the first step: the target's model and tool set are restored, so the run continues as the agent it was suspended in rather than snapping back to the root. The system message needs nothing — it already rode in with the restored history.
The spent budget is restored too, so maxHandoffs still bounds the run across legs.
Degradation is deliberate. If the resume call registers no transfer tool for the checkpointed target (you passed different tools), the loop warns and continues with the root model and tools, keeping the identity and the spent count. Refusing to resume would strand a durable run over a call-site detail. The practical rule: pass the same handoff({…}) group on every leg, exactly as you pass the same tools.
The field is absent on every checkpoint written before 2.0 and on every run that never handed off, and the checkpoint codecs carry it through untouched — old checkpoints load unchanged.
What still belongs to the root
Two things surprise people, and one of them used to be three:
- Pricing uses the root model. Cumulative cost for
costExceeds/budget.usdstop conditions is priced againstoptions.model.modelIdfor the whole run, and so is thecostUsdon the terminal observation event. A transfer to a more expensive model does not re-price the run. If cost control matters and your targets differ wildly in price, bound the run withbudget.tokens(which is model-agnostic) as well. - Observation reports the root model on
run.started/run.completed. Per-step events carry the effective model, sostep.startedis where you see who actually drove.
Compaction, by contrast, follows the active agent (2.0). At the swap the compaction runner is re-pointed at the target: its contextWindow becomes the target's, and the summarize side call uses policy.summarizeModel ?? the active model — not the root one. The token estimator is kept across the swap on purpose, because its calibration measures the provider's request framing, which the transfer did not change.
Known limit: a resume leg does not re-point compaction
Restoring a checkpointed handoff at the start of a resume leg rebuilds the active model and tool set, but it does not re-run the compaction retarget. So a durable run that suspended inside agent B and resumes there measures compaction against the root model's contextWindow, and summarizes with the root model, until the next transfer happens on that leg.
Pin compaction.summarizeModel (and, if the two models' windows differ a lot, capabilities.contextWindow) when you combine handoffs with durable resume and compaction.
See also
- Sub-agents —
agentTool, the delegation counterpart. - createAgent — agents as frozen values, usable directly as handoff targets.
- Guardrails — the hooks a transfer is deliberately intercepted before.
- Tool loop — step boundaries, the runaway guard, and denial handling.
- Durable runtime — checkpoints and
resumeFromCheckpoint. - Compaction — the summarize layer a transfer re-points.
Sub-Agents
agentTool wraps a focused agentic loop as a callable Tool — with a live-forwarded stream and inherited tool approval, and no new runtime.
Guardrails
Three hooks around the agentic loop — onInput, onToolCall, onOutput — each returning pass, block or rewrite, with built-ins on @deuz-sdk/core/guardrails.