> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orbit.devotel.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Agent handoff targets

> Allowlist which agents an agent can hand a conversation off to.

# 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

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/agents \
  -H "X-API-Key: dv_live_sk_..." \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Triage Router",
    "description": "Classifies inbound messages and routes to a specialist.",
    "model": "claude-sonnet-4-6",
    "system_prompt": "You are a customer service router for Acme Corp. Classify the inbound message and use transfer_to_agent to hand off to the right specialist.",
    "handoff_targets": [
      "agent_billing_specialist",
      "agent_support_specialist",
      "agent_returns_specialist"
    ]
  }'
```

## Configure on agent update

```bash theme={null}
curl -X PUT https://api.orbit.devotel.io/api/v1/agents/agent_router \
  -H "X-API-Key: dv_live_sk_..." \
  -H "Content-Type: application/json" \
  -d '{
    "handoff_targets": [
      "agent_billing_specialist",
      "agent_support_specialist"
    ]
  }'
```

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

### Update semantics

| Body                                        | Behaviour                                                     |
| ------------------------------------------- | ------------------------------------------------------------- |
| `"handoff_targets": ["agent_a", "agent_b"]` | Replaces the current list with the supplied array.            |
| `"handoff_targets": []`                     | Explicitly clears the list. The agent can no longer hand off. |
| Field omitted from PUT                      | Leaves the existing list untouched.                           |

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

```json theme={null}
{
  "error": "HANDOFF_TARGET_NOT_ALLOWED",
  "message": "Agent agent_unknown is not in your handoff_targets allowlist."
}
```

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:

```http theme={null}
HTTP/1.1 403 Forbidden
Content-Type: application/json

{
  "error": {
    "code": "FORBIDDEN",
    "message": "The target agent is not on the source agent's handoff_targets allowlist.",
    "status": 403
  }
}
```

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)

```text theme={null}
agent_router.handoff_targets = [agent_billing, agent_support, agent_returns]
agent_billing.handoff_targets   = []   # specialists do not re-route
agent_support.handoff_targets   = []
agent_returns.handoff_targets   = []
```

This is the simplest topology and the recommended starting point.

### Two-way (specialists can return to router)

```text theme={null}
agent_router.handoff_targets    = [agent_billing, agent_support]
agent_billing.handoff_targets   = [agent_router]
agent_support.handoff_targets   = [agent_router]
```

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

### Mesh

```text theme={null}
agent_billing.handoff_targets = [agent_support]
agent_support.handoff_targets = [agent_billing]
```

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 exist** — `PUT /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

* [Creating Agents](/agents/creating-agents) — full agent-create reference.
* [Cost controls](/agents/cost-controls) — `max_cost_per_run_cents` / `max_cost_per_conversation_cents`.
* [Webhooks → Events](/webhooks/events) — `agent.handoff_occurred` payload.
