Skip to content
Learn Agentic AI
Learn Agentic AI13 min read10 views

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:

flowchart TD
    START["Agent Handoffs: Building Triage and Routing Syste…"] --> A
    A["Understanding Agent Handoffs"]
    A --> B
    B["The handoffs Parameter"]
    B --> C
    C["The handoff Function"]
    C --> D
    D["The on_handoff Callback"]
    D --> E
    E["Building a Customer Service Triage Syst…"]
    E --> F
    F["How the SDK Presents Handoffs to the LLM"]
    F --> G
    G["Handoff Chains"]
    G --> H
    H["Best Practices for Handoff Design"]
    H --> DONE["Key Takeaways"]
    style START fill:#4f46e5,stroke:#4338ca,color:#fff
    style DONE fill:#059669,stroke:#047857,color:#fff
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:

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

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

See AI Voice Agents Handle Real Calls

Book a free demo or calculate how much you can save with AI voice automation.

Basic on_handoff Usage

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:

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:

flowchart TD
    ROOT["Agent Handoffs: Building Triage and Routing …"] 
    ROOT --> P0["The handoff Function"]
    P0 --> P0C0["description vs tool_description_override"]
    P0 --> P0C1["tool_name_override"]
    ROOT --> P1["The on_handoff Callback"]
    P1 --> P1C0["Basic on_handoff Usage"]
    P1 --> P1C1["on_handoff with input_type"]
    style ROOT fill:#4f46e5,stroke:#4338ca,color:#fff
    style P0 fill:#e0e7ff,stroke:#6366f1,color:#1e293b
    style P1 fill:#e0e7ff,stroke:#6366f1,color:#1e293b
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:

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.

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

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

Share
C

Written by

CallSphere Team

Expert insights on AI voice agents and customer communication automation.

Try CallSphere AI Voice Agents

See how AI voice agents work for your industry. Live demo available -- no signup required.