Skip to main content

Agent handoff targets

A multi-agent setup typically has one routing / triage agent and several specialised agents (billing, support, sales, returns). When the routing agent decides “this is a billing question, hand off”, you want the destination to be a small explicit set — not “any agent in this org”. handoff_targets is that allowlist.

What it gates

Setting handoff_targets on agent A declares: “Agent A may hand off ONLY to the agents listed here.” The allowlist is enforced in two places:
  1. Runtime — the transfer_to_agent tool is always registered, so the LLM always sees it in its tool list. The allowlist is checked inside the tool handler after the model calls it: if target_agent_id is not on the source agent’s allowlist (or the allowlist is empty), the call returns an error result and no handoff occurs. The model receives that error and continues the turn — it does not crash the conversation.
  2. Internal handoff API (POST /api/v1/agents/internal/handoffs) — even with a valid internal token, a handoff is rejected with 403 FORBIDDEN if the target is not on the source agent’s allowlist. This protects the audit ledger and the agent.handoff_occurred webhook from forged entries.
When the allowlist is empty or absent, the agent has no peers it can hand off to: any transfer_to_agent call the model attempts is rejected by the runtime. Set the allowlist explicitly to enable inter-agent routing.

Configure on agent create

Configure on agent update

handoff_targets is persisted under the agent’s config.handoff_targets so the runtime can read it on every executor rebuild.

Update semantics

What a runtime handoff looks like

The router agent’s LLM always has transfer_to_agent in its tool list — the tool is registered unconditionally. Its target_agent_id parameter is a free-form string, so the model can name a target that isn’t on the allowlist; the runtime is what stops it. When the model calls transfer_to_agent, the handler validates target_agent_id against the source agent’s handoff_targets before resolving the target. If it isn’t on the list (or the list is empty), the call returns an error result and the handoff is dropped. A successful handoff:
  1. Persists an agent_handoffs row.
  2. Fans out a webhook event agent.handoff_occurred to the tenant’s subscription endpoint with handoff_id, source_agent_id, target_agent_id, conversation_id, reason, and summary.
  3. Re-enters the runtime with the target agent’s executor, carrying the conversation history forward.

What a refused handoff looks like

There are two refusal paths, depending on where the disallowed target originates. From the model (runtime). When the agent’s LLM calls transfer_to_agent with a target_agent_id that isn’t on the allowlist (or when the allowlist is empty), the tool handler returns an error result to the model rather than routing:
The model receives this as the tool’s output and continues the turn — typically by answering the user itself. The conversation is not interrupted, and no agent_handoffs row or agent.handoff_occurred webhook is produced. From the internal API. If a caller (most commonly an internal automation invoking POST /api/v1/agents/internal/handoffs directly) tries to record a handoff to an agent that isn’t on the source agent’s list:
Refusals also emit a structured log line under agents.handoff_allowlist_violation with the source / target / allowlist size, so you can monitor for abuse.

Patterns

One-way fan-out (router → specialists)

This is the simplest topology and the recommended starting point.

Two-way (specialists can return to router)

Useful when a specialist hits a question outside its scope and wants to bounce back to the router.

Mesh

Two specialists can hand off to each other directly without going through the router.

Common pitfalls

  • Forgetting to set handoff_targets — the model still sees the transfer_to_agent tool (it is always registered) and may try to use it, but every call is rejected by the runtime because the allowlist is empty. Symptom: the agent keeps trying to hand off and gets HANDOFF_TARGET_NOT_ALLOWED back, then answers itself anyway. Set the allowlist to make the handoff actually route.
  • Listing an agent that doesn’t existPUT /api/v1/agents/:id validates every supplied id against the tenant’s existing agents and rejects the whole request with 422 INVALID_HANDOFF_TARGETS if any id is missing (the response message lists the offending ids). The only case in which an unknown target is a harmless runtime no-op is one that was already persisted before the agent it pointed at was deleted — transfer_to_agent then simply can’t route to it. Note the create/update asymmetry: POST /api/v1/agents does not run this existence check, so a create call can persist an unknown id that a later PUT would reject.
  • Listing the agent’s own id — allowed but pointless. The runtime handles transfer_to_agent with the same agent as a no-op.
  • Cross-tenant ids — every id is tenant-scoped. You cannot hand off to an agent in a different organisation.

See also