By Sagar Shankaran, Founder of CallSphere
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.
Key takeaways
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:
This post covers every aspect of the handoff API and builds a full triage system by the end.
Every Agent accepts a handoffs parameter — a list of agents or handoff objects that the agent can transfer control to:
flowchart LR
INPUT(["User input"])
AGENT["Agent<br/>name plus instructions"]
HAND{"Handoff to<br/>another agent?"}
SUB["Sub-agent<br/>specialist"]
GUARD{"Guardrail<br/>passed?"}
TOOL["Tool call"]
SDK[("Tracing<br/>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
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.
For more control, use the handoff() function explicitly. Here is the full signature with all parameters:
Hear it before you finish reading
Talk to a live CallSphere AI voice agent in your browser — 60 seconds, no signup.
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
)
These two parameters serve different purposes:
HANDOFF_INSTRUCTIONS block.# 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..."}
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 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.
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,
),
],
)
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.
Let us put everything together into a production-grade triage system with four departments:
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())
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:
Still reading? Stop comparing — try CallSphere live.
CallSphere ships complete AI voice agents per industry — 14 tools for healthcare, 10 agents for real estate, 4 specialists for salons. See how it actually handles a call before you book a demo.
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.
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}")
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.
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
Sagar Shankaran· Founder, CallSphere
LinkedInSagar Shankaran is the founder of CallSphere, where he builds production AI voice and chat agents deployed across healthcare, hospitality, real estate, and home services. He writes about agentic AI, LLM engineering, and shipping voice agents that handle real calls in production.
See how AI voice agents work for your industry. Live demo available -- no signup required.
OpenAI's Frontier platform makes model-native orchestration the default. What that means for agent builders, voice/chat buyers, and the build-vs-buy decision.
How to design a multi-agent system using MCP for tools and A2A for cross-vendor coordination, with a CallSphere voice agent as a participating node.
The 2026 desktop AI agent landscape — ServiceNow Project Arc, Anthropic Claude offerings, OpenAI agents, and Google Mariner. A buyer's map.
May 2026's biggest agent-architecture shift: planning, tool selection, and self-correction move inside the model. Framework code shrinks. Here is what changes.
A three-way comparison of Gemini Enterprise, Anthropic managed agents and OpenAI Frontier Platform after Cloud Next 2026 — strengths, gaps, buyer fit.
A2A is the open standard for agent-to-agent coordination. Here is how the Agent Card JSON works, how discovery happens, and what to publish.
© 2026 CallSphere Inc. All rights reserved.
Made within San Francisco
Watch how CallSphere handles real customer calls, schedules appointments, and processes payments — live.
Try Live DemoBook a DemoCalculate Your ROI