config.handoffs is where an agent’s outgoing edges live. Each entry becomes one tool the model can call, and the model chooses between them from the descriptions you write. A handoff moves the conversation to another agent; handing the caller to a person is a transfer, which is a different operation with different rules.

The fields

handoffs is available on every channel — voice, video and text.

description is the router

The compiler turns each entry into a tool named handoff_to_<name>, and the entry’s description is that tool’s description. It is the only thing the model has to go on. Write it as what belongs there, not as an instruction: The generated tool takes no arguments — except under context: "summary", where it takes exactly one, the summary the model writes as it hands over.

The tool name

The name is the destination’s name, lowercased, with every run of non-alphanumeric characters replaced by an underscore: Billing becomes handoff_to_billing, Tier 2 support becomes handoff_to_tier_2_support. It is derived from the destination’s name and not from the target agent’s, deliberately: renaming an agent in the dashboard must not change the tool name a published prompt was written against. The dashboard shows the resulting tool name under each destination as you type. Three name rules are enforced at save or publish:
  • Two destinations on one agent cannot share a name.
  • A name with no letters or digits in it has nothing to slug into a tool name and is refused.
  • The generated name shares one namespace with attached tools, hook tools and the model’s own built-in tools. A collision is an error, not a coin flip:

Naming the target

With agent_id, the target is a stored agent and the call enters it at whatever version is published at that moment, with the tools that version pinned. This is different from config.tools, which pins at the source agent’s publish: republish the target and the very next handoff lands on the new version, with nothing to republish on the source. That is usually what you want, and it means a target you break is broken for every agent pointing at it. The target must have a published version. Publish refuses the edge otherwise:
If the target loses its published version between publish and the call, the handoff fails at runtime and the model is handed a sentence to relay: “I’m sorry, that team isn’t available right now.” The call carries on with the source agent. Without agent_id, name resolves against the team defined on the call. A stored agent’s team is unknowable at publish — it depends on which call the agent ends up running on — so this publishes with a warning rather than an error:
On a call that does define it, the name is checked like any other edge, and a name nothing defines is refused when the call is created.

message — what the caller hears at the boundary

The line is queued before the target is built, which is the whole point of it: building the target loads its definition and compiles its prompt, tools and voice, and the caller should hear “connecting you to billing” during that rather than after it. It always finishes playing before the target’s on_enter hook runs.
  • Voice and video, cascade pipeline: spoken verbatim.
  • Voice and video, realtime pipeline: a speech-to-speech model has no text-to-speech to script, so it is asked to say the line and may reword, reorder or trim it. Do not put an exact form of words there on a realtime agent.
  • Text: sent as a message from the agent before the target replies.
Leave it out and the handover is silent — which is fine when the target has a greeting of its own. message, description and summary_prompt are all personalized at build time with {{userdata.…}}, {{system_vars.…}} and {{vars.…}}, exactly as the prompt and greeting are. A token whose root is none of those — a bare {{name}}, a typo like {{userdate.name}} — is a save error, not text.
Nothing validates a delivery tag you write by hand into a message. On a voice that does not speak that dialect it reaches the caller as the word itself. See expressive delivery.

What the target does on arrival

In order:
  1. Its own on_enter hook runs, to completion. The target’s hook, not the source’s — see lifecycle hooks. Its latency lands before the target’s first word, so a hook that fetches something slow should open with a say.
  2. It speaks its own greeting, if it has one, with {{userdata.…}} resolved against the live session bag the hook just wrote to.
  3. If it has no greeting, it opens with a generated line instead — the caller has just asked for something and is waiting on an answer. What the target is told depends on what it actually received: under transcript it is told it can see everything and to address the most recent message; under summary, that it has a summary plus the last few messages; under none with no tail, that it has not been told what came before and should introduce itself briefly and find out what the caller needs.
Text agents cannot have a greeting at all, so a text handoff target always opens with the generated line. The target’s own conversation.context is not consulted. That field decides what a new call starts from (conversation memory); what a handoff target starts from is the edge’s context.

What validation enforces across a team

Every edge is checked at publish, and again at runtime before the switch, because a stored target can be republished in between. These are errors: The expressive rule is conditioned on the source, which makes it transitive: a legal target is itself expressive, so its own outgoing edges get the same check. One expressive agent puts its whole downstream graph on one voice. One check is a warning, not an error:
Whether that edge is a problem depends on which agent answered, which no per-agent check can know — “Support (on) → Payments (off) → Support (on)” is a shape worth building, and its second edge looks exactly like the broken one. Recording switching off mid-call always works; switching on works only if something was already recording. See recording. Run validate before publishing to see all of this at once: POST /v1/agents/{agent_id}/validate.

Handing back, and loops

A target hands back the same way it hands anywhere: with its own handoffs entry pointing at the agent it came from, by agent_id or — on a team — by name. There is nothing special about the return edge. Give the specialist a destination named for the front desk with a description like “anything that is not a billing question”, and set its context to none with a small recent_turns so the front desk does not re-read the whole billing detour on every turn. Nothing prevents a routing cycle, and no single edge can detect one. What stops it is a per-call cap: 25 handoffs on one call. The next attempt fails and the model is handed a sentence to relay to the caller:
The count is per call, not per tool, and covers both handoffs edges and handoff operations. If you are anywhere near it, the routing descriptions are overlapping and two agents are passing the caller back and forth.
On text, the agent that is speaking belongs to the live conversation window. A window closes after about a minute idle, on end_call, or when you close it — and the next message starts again from the agent that opened the thread, not the one that was last handed the conversation. See text conversations.

A worked example

A front desk that routes to two specialists. The write takes the whole config, so read the current one, add the edges, then validate and publish.
The front desk’s prompt says what it does not answer. That matters more than it looks: an agent with a handoff tool and a prompt inviting it to be helpful will answer the billing question badly instead of routing it.

Next

Context policies

transcript, summary and none — what the target starts from.

Patterns

Front desk to specialists, handing back, and what a hop costs.

Conditional handoffs

Routing decided by a tool tree rather than by the model.