---
title: "Agent Handoffs: Building Triage and Routing Systems"
description: "Master the handoff mechanism in the OpenAI Agents SDK — from basic handoffs to advanced triage systems with custom tool names, descriptions, and on_handoff callbacks."
canonical: https://callsphere.ai/blog/agent-handoffs-building-triage-routing-systems-openai-agents-sdk
category: "Learn Agentic AI"
tags: ["OpenAI", "Handoffs", "Triage", "Routing", "Multi-Agent"]
author: "CallSphere Team"
published: 2026-03-14T00:00:00.000Z
updated: 2026-05-06T05:42:08.078Z
---

# Agent Handoffs: Building Triage and Routing Systems

> Master the handoff mechanism in the OpenAI Agents SDK — from basic handoffs to advanced triage systems with custom tool names, descriptions, and on_handoff callbacks.

## Understanding Agent Handoffs

A handoff is the mechanism by which one agent transfers conversational control to another agent. In the OpenAI Agents SDK, handoffs are first-class primitives — they are not hacks layered on top of tool calls, but a dedicated abstraction designed for multi-agent routing.

When Agent A hands off to Agent B, three things happen:

1. Agent A stops processing
2. The conversation history is transferred to Agent B (configurable)
3. Agent B becomes the active agent and continues the interaction

This post covers every aspect of the handoff API and builds a full triage system by the end.

## The handoffs Parameter

Every Agent accepts a `handoffs` parameter — a list of agents or handoff objects that the agent can transfer control to:

```mermaid
flowchart LR
    INPUT(["User input"])
    AGENT["Agent
name plus instructions"]
    HAND{"Handoff to
another agent?"}
    SUB["Sub-agent
specialist"]
    GUARD{"Guardrail
passed?"}
    TOOL["Tool call"]
    SDK[("Tracing
OpenAI dashboard")]
    OUT(["Final output"])
    INPUT --> AGENT --> HAND
    HAND -->|Yes| SUB --> GUARD
    HAND -->|No| GUARD
    GUARD -->|Yes| TOOL --> AGENT
    GUARD -->|Block| OUT
    AGENT --> OUT
    AGENT --> SDK
    style AGENT fill:#4f46e5,stroke:#4338ca,color:#fff
    style GUARD fill:#f59e0b,stroke:#d97706,color:#1f2937
    style SDK fill:#ede9fe,stroke:#7c3aed,color:#1e1b4b
    style OUT fill:#059669,stroke:#047857,color:#fff
```

```python
from agents import Agent, handoff

support_agent = Agent(
    name="SupportAgent",
    instructions="Handle customer support inquiries.",
    model="gpt-4o",
)

sales_agent = Agent(
    name="SalesAgent",
    instructions="Handle sales and pricing inquiries.",
    model="gpt-4o",
)

# Simple handoff — just pass the agent directly
triage_agent = Agent(
    name="TriageAgent",
    instructions="Route customers to support or sales.",
    model="gpt-4o",
    handoffs=[support_agent, sales_agent],
)
```

When you pass agents directly, the SDK automatically creates handoff objects with default settings. The handoff appears as a tool to the source agent — the LLM decides when to invoke it based on the conversation context.

## The handoff() Function

For more control, use the `handoff()` function explicitly. Here is the full signature with all parameters:

```python
from agents import Agent, handoff

billing_agent = Agent(
    name="BillingAgent",
    instructions="Handle billing questions with precision.",
    model="gpt-4o",
)

configured_handoff = handoff(
    agent=billing_agent,
    description="Transfer to billing for payment, invoice, and subscription questions",
    tool_name_override="transfer_to_billing",
    tool_description_override="Use this when the customer has questions about payments, invoices, or subscriptions",
    on_handoff=None,  # callback function (covered below)
    input_filter=None,  # filter conversation history (covered in next post)
    input_type=None,  # Pydantic model for structured handoff data
)
```

### description vs tool_description_override

These two parameters serve different purposes:

- **description**: A short label shown in the system prompt to help the agent understand what each handoff target does. It appears in the `HANDOFF_INSTRUCTIONS` block.
- **tool_description_override**: The tool-level description that the LLM sees when deciding whether to call this particular tool. If not provided, the SDK generates one from the agent name and description.

```python
# The description appears in the agent's system prompt like:
# "You can handoff to: transfer_to_billing: Transfer to billing for payment questions"

# The tool_description_override appears in the tool schema like:
# {"name": "transfer_to_billing", "description": "Use this when the customer..."}
```

### tool_name_override

By default, the handoff tool is named `transfer_to_{agent_name}`. You can override this:

```python
# Default: tool name would be "transfer_to_BillingAgent"
# With override:
configured_handoff = handoff(
    agent=billing_agent,
    tool_name_override="route_billing",
    description="Billing specialist for payment issues",
)
```

This is useful when you want cleaner tool names or need to avoid naming conflicts between handoff targets.

## The on_handoff Callback

The `on_handoff` callback lets you execute custom logic when a handoff is triggered — before the target agent starts processing. This is invaluable for logging, analytics, state initialization, and context preparation.

### Basic on_handoff Usage

```python
from agents import Agent, handoff, RunContextWrapper
import asyncio

async def log_billing_handoff(context: RunContextWrapper[dict]) -> None:
    """Called when a handoff to the billing agent is triggered."""
    print(f"Handoff to billing triggered")
    print(f"Context data: {context.context}")
    # You could log to a database, send a webhook, etc.

billing_agent = Agent(
    name="BillingAgent",
    instructions="Handle billing questions.",
    model="gpt-4o",
)

triage_agent = Agent(
    name="TriageAgent",
    instructions="Route to billing for payment questions.",
    model="gpt-4o",
    handoffs=[
        handoff(
            billing_agent,
            description="Billing specialist",
            on_handoff=log_billing_handoff,
        ),
    ],
)
```

### on_handoff with input_type

When you specify an `input_type`, the on_handoff callback receives the structured data that the source agent provided:

```python
from agents import Agent, handoff, RunContextWrapper
from pydantic import BaseModel

class HandoffMetadata(BaseModel):
    reason: str
    priority: str
    customer_sentiment: str

async def on_billing_handoff(
    context: RunContextWrapper[dict],
    input_data: HandoffMetadata
) -> None:
    print(f"Handoff reason: {input_data.reason}")
    print(f"Priority: {input_data.priority}")
    print(f"Sentiment: {input_data.customer_sentiment}")

    # Store metadata for the target agent to use
    context.context["handoff_metadata"] = input_data.model_dump()

billing_agent = Agent(
    name="BillingAgent",
    instructions="Handle billing questions. Check context for handoff metadata.",
    model="gpt-4o",
)

triage_agent = Agent(
    name="TriageAgent",
    instructions="""Route to billing for payment questions.
    When handing off, assess the reason, priority (low/medium/high),
    and customer sentiment (positive/neutral/negative).""",
    model="gpt-4o",
    handoffs=[
        handoff(
            billing_agent,
            description="Billing specialist",
            input_type=HandoffMetadata,
            on_handoff=on_billing_handoff,
        ),
    ],
)
```

The `input_type` Pydantic model is exposed to the LLM as the tool's input schema. The source agent fills in the fields before handing off, giving the callback (and potentially the target agent) structured metadata about why the handoff occurred.

## Building a Customer Service Triage System

Let us put everything together into a production-grade triage system with four departments:

```python
from agents import Agent, Runner, handoff, RunContextWrapper
from pydantic import BaseModel
from datetime import datetime
import asyncio
import json

# ─── Handoff Metadata Models ───

class SupportHandoffData(BaseModel):
    issue_category: str
    urgency: str  # low, medium, high, critical
    summary: str

class SalesHandoffData(BaseModel):
    interest_area: str
    budget_mentioned: bool
    summary: str

class BillingHandoffData(BaseModel):
    billing_issue_type: str  # charge_dispute, refund, upgrade, downgrade
    amount_mentioned: str | None = None
    summary: str

class EscalationHandoffData(BaseModel):
    reason: str
    previous_department: str
    attempts_so_far: str

# ─── Handoff Callbacks ───

async def on_support_handoff(
    context: RunContextWrapper[dict],
    data: SupportHandoffData
) -> None:
    context.context["department"] = "support"
    context.context["metadata"] = data.model_dump()
    print(f"[ROUTE] Support | Urgency: {data.urgency} | {data.summary}")

async def on_sales_handoff(
    context: RunContextWrapper[dict],
    data: SalesHandoffData
) -> None:
    context.context["department"] = "sales"
    context.context["metadata"] = data.model_dump()
    print(f"[ROUTE] Sales | Interest: {data.interest_area} | {data.summary}")

async def on_billing_handoff(
    context: RunContextWrapper[dict],
    data: BillingHandoffData
) -> None:
    context.context["department"] = "billing"
    context.context["metadata"] = data.model_dump()
    print(f"[ROUTE] Billing | Type: {data.billing_issue_type} | {data.summary}")

async def on_escalation_handoff(
    context: RunContextWrapper[dict],
    data: EscalationHandoffData
) -> None:
    context.context["department"] = "escalation"
    context.context["metadata"] = data.model_dump()
    print(f"[ESCALATE] Reason: {data.reason} | From: {data.previous_department}")

# ─── Specialist Agents ───

escalation_agent = Agent(
    name="EscalationManager",
    instructions="""You are a senior escalation manager. You handle cases
    that other departments could not resolve. Review the conversation
    history carefully, acknowledge the customer's frustration, and
    provide a concrete resolution path with a timeline.""",
    model="gpt-4o",
)

support_agent = Agent(
    name="TechnicalSupport",
    instructions="""You are a technical support specialist. Troubleshoot
    product issues step by step. If you cannot resolve the issue after
    two attempts, escalate to the escalation manager.""",
    model="gpt-4o",
    handoffs=[
        handoff(
            escalation_agent,
            description="Escalate unresolved technical issues",
            tool_name_override="escalate_to_manager",
            input_type=EscalationHandoffData,
            on_handoff=on_escalation_handoff,
        ),
    ],
)

sales_agent = Agent(
    name="SalesSpecialist",
    instructions="""You are a sales specialist. Help customers understand
    pricing, plans, and features. Be consultative, not pushy.
    Focus on understanding their needs before recommending a plan.""",
    model="gpt-4o",
    handoffs=[
        handoff(
            escalation_agent,
            description="Escalate complex sales situations",
            tool_name_override="escalate_to_manager",
            input_type=EscalationHandoffData,
            on_handoff=on_escalation_handoff,
        ),
    ],
)

billing_agent = Agent(
    name="BillingSpecialist",
    instructions="""You are a billing specialist. Handle charge disputes,
    refunds, plan changes, and invoice questions. Always verify the
    customer's account before making changes.""",
    model="gpt-4o",
    handoffs=[
        handoff(
            escalation_agent,
            description="Escalate unresolved billing disputes",
            tool_name_override="escalate_to_manager",
            input_type=EscalationHandoffData,
            on_handoff=on_escalation_handoff,
        ),
    ],
)

# ─── Triage Agent ───

triage_agent = Agent(
    name="TriageAgent",
    instructions="""You are the front-line triage agent for Acme Corp
    customer service. Your job is to:
    1. Greet the customer warmly
    2. Understand their issue in 1-2 questions maximum
    3. Route them to the correct department

    Routing rules:
    - Product bugs, errors, how-to questions → TechnicalSupport
    - Pricing, plans, new purchases, demos → SalesSpecialist
    - Charges, refunds, invoices, plan changes → BillingSpecialist

    Do NOT attempt to solve the problem yourself. Route quickly.""",
    model="gpt-4o",
    handoffs=[
        handoff(
            support_agent,
            description="Technical support for product issues and bugs",
            tool_name_override="route_to_support",
            tool_description_override="Route to technical support when the customer has product issues, errors, or how-to questions",
            input_type=SupportHandoffData,
            on_handoff=on_support_handoff,
        ),
        handoff(
            sales_agent,
            description="Sales for pricing and plan inquiries",
            tool_name_override="route_to_sales",
            tool_description_override="Route to sales when the customer asks about pricing, plans, or wants a demo",
            input_type=SalesHandoffData,
            on_handoff=on_sales_handoff,
        ),
        handoff(
            billing_agent,
            description="Billing for payment and invoice issues",
            tool_name_override="route_to_billing",
            tool_description_override="Route to billing when the customer has charge disputes, refund requests, or invoice questions",
            input_type=BillingHandoffData,
            on_handoff=on_billing_handoff,
        ),
    ],
)

# ─── Run the System ───

async def handle_customer(message: str):
    context = {
        "customer_id": "cust_12345",
        "timestamp": datetime.now().isoformat(),
    }

    result = await Runner.run(
        triage_agent,
        input=message,
        context=context,
    )

    print(f"\nFinal agent: {result.last_agent.name}")
    print(f"Department: {context.get('department', 'triage')}")
    print(f"Response: {result.final_output}")
    return result

# Test with different customer messages
async def main():
    # This should route to billing
    await handle_customer(
        "Hi, I was charged $49.99 twice on my last invoice and I need a refund"
    )

    print("\n" + "=" * 60 + "\n")

    # This should route to support
    await handle_customer(
        "The export feature is broken — I get a 500 error every time I click it"
    )

    print("\n" + "=" * 60 + "\n")

    # This should route to sales
    await handle_customer(
        "I am evaluating your enterprise plan for a 200-person team"
    )

asyncio.run(main())
```

## How the SDK Presents Handoffs to the LLM

Under the hood, the SDK converts each handoff into a tool definition. When the triage agent's prompt is assembled, it includes a section like:

```
HANDOFF_INSTRUCTIONS:
You can hand off to the following agents:
- route_to_support: Technical support for product issues and bugs
- route_to_sales: Sales for pricing and plan inquiries
- route_to_billing: Billing for payment and invoice issues

When you hand off, the target agent will continue the conversation.
```

Each handoff also appears in the tools array as a function the LLM can call. The LLM decides to call `route_to_billing` the same way it decides to call any other tool — based on the tool name, description, and conversation context.

## Handoff Chains

Agents can hand off to agents that themselves have handoffs, creating chains:

```
User → TriageAgent → BillingSpecialist → EscalationManager
```

The SDK tracks these transitions in the `RunResult`. You can inspect the full chain:

```python
result = await Runner.run(triage_agent, input="I need a refund but your billing team was unhelpful last time")

# Check which agents were involved
for item in result.raw_responses:
    print(f"Agent: {item.agent.name if hasattr(item, 'agent') else 'unknown'}")

print(f"Started with: {triage_agent.name}")
print(f"Ended with: {result.last_agent.name}")
```

## Best Practices for Handoff Design

**1. Write clear handoff descriptions.** The LLM uses these to decide when to trigger each handoff. Vague descriptions lead to misrouting.

```python
# Bad — too vague
handoff(billing_agent, description="Billing stuff")

# Good — specific triggers
handoff(billing_agent, description="Transfer for charge disputes, refund requests, invoice questions, and plan upgrade or downgrade requests")
```

**2. Limit handoff targets to 5 or fewer.** Too many options confuse the LLM. If you have more than 5 departments, create a two-level triage:

```python
# Level 1: broad categories
triage_agent = Agent(
    name="Triage",
    handoffs=[
        handoff(technical_triage, description="Any technical or product issue"),
        handoff(business_triage, description="Any billing, sales, or account issue"),
    ],
)

# Level 2: specific routing within category
technical_triage = Agent(
    name="TechnicalTriage",
    handoffs=[
        handoff(frontend_support, description="UI, browser, and display issues"),
        handoff(backend_support, description="API, integration, and data issues"),
        handoff(mobile_support, description="iOS and Android app issues"),
    ],
)
```

**3. Use input_type for structured routing metadata.** This creates an audit trail and lets target agents understand why they received the handoff.

**4. Implement on_handoff callbacks for observability.** Log every handoff for analytics. You will want to know your routing accuracy, average handoffs per conversation, and escalation rates.

**5. Always provide an escalation path.** Every specialist agent should have a way to escalate to a human or senior agent. Dead-end agents frustrate users.

## Summary

Handoffs are the backbone of multi-agent routing in the OpenAI Agents SDK. The `handoff()` function gives you fine-grained control over tool naming, descriptions, callbacks, and input schemas. By combining these features, you can build triage systems that route customers accurately, log every transition, and maintain clean conversation handoffs between specialized agents.

---

Source: https://callsphere.ai/blog/agent-handoffs-building-triage-routing-systems-openai-agents-sdk
