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:
- Agent A stops processing
- The conversation history is transferred to Agent B (configurable)
- 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_INSTRUCTIONSblock. - 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.
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.