Handoffs are the mechanism that enables multi-agent workflows in the OpenAI Agents SDK. A handoff occurs when one agent delegates a task to another agent. Think of handoffs as "transfer of control" or "referral" - just like a doctor might refer you to a specialist, agents can hand off tasks to other specialized agents.
Core Concepts
What is a Handoff?
A handoff is when an agent decides that another agent is better suited to handle the current task and transfers control to that agent. The handoff includes:
The target agent (who to hand off to)
The context/information to pass along
Optional filtering of what information to pass
Optional custom logic for the handoff
Why Handoffs Matter
Specialization - Different agents can specialize in different domains
Modularity - Build complex workflows from simple, focused agents
Efficiency - Route tasks to the most capable agent
Scalability - Add new capabilities by adding new agents
Clarity - Clear separation of concerns
Handoff vs Agent as Tool
Handoffs and "agents as tools" both involve multiple agents, but they work differently:
Handoffs:
The new agent receives the full conversation history
The new agent takes over the conversation
The original agent is no longer involved
Good for: Domain specialization, expertise routing
Agent as Tool:
The new agent receives generated input (not full history)
The new agent runs as a tool and returns
The original agent continues the conversation
Good for: Subtasks, specific queries, calculations
Handoff Class
Handoff Structure
Python
@dataclassclassHandoff(Generic[TContext, TAgent]):
tool_name: str"""The name of the tool that represents the handoff."""
tool_description: str"""Description of the handoff tool for the LLM."""
input_json_schema: dict[str, Any]
"""JSON schema for handoff arguments."""
on_invoke_handoff: Callable[[RunContextWrapper, str], Awaitable[TAgent]]
"""Function that invokes the handoff."""
agent_name: str"""Name of the target agent."""
input_filter: HandoffInputFilter | None"""Optional filter for input passed to next agent."""
nest_handoff_history: bool | None"""Override for nested handoff history behavior."""
strict_json_schema: bool"""Whether input schema is in strict mode."""
is_enabled: bool | Callable"""Whether the handoff is enabled (can be dynamic)."""
Creating Handoffs
The simplest way is using the handoff() helper:
Python
from agents import Agent, handoff
specialist = Agent(
name="specialist",
instructions="Handle specialized technical issues",
handoff_description="Technical support specialist for debugging",
)
generalist = Agent(
name="generalist",
instructions="Handle general queries and delegate when needed",
handoffs=[handoff(specialist)],
)
What happens:
The handoff() function creates a Handoff object
It generates a tool name (e.g., transfer_to_specialist)
It generates a tool description
It creates an empty JSON schema (no arguments by default)
It sets up the invocation function to return the specialist agent
handoff(
specialist,
tool_description_override="Get help with technical problems and debugging",
)
Handoff with Input
Pass structured input to the handoff:
Python
from dataclasses import dataclass
@dataclassclassHandoffInput:
topic: str
urgency: strdefon_handoff(context: RunContextWrapper, input: HandoffInput) -> None:
"""Called when handoff is invoked."""print(f"Handing off for topic: {input.topic}, urgency: {input.urgency}")
handoff(
specialist,
on_handoff=on_handoff,
input_type=HandoffInput,
)
How it works:
The LLM generates JSON arguments matching HandoffInput
The arguments are validated against the schema
The on_handoff function is called with the validated input
The function can perform side effects (logging, tracking, etc.)
The specialist agent is still returned
Handoff Without Input
Handoff without structured input (simpler):
Python
defon_handoff(context: RunContextWrapper) -> None:
"""Called when handoff is invoked."""print("Handing off to specialist")
handoff(
specialist,
on_handoff=on_handoff,
)
Input Filter
Filter what information is passed to the next agent:
Python
from agents import HandoffInputData
deffilter_handoff_input(data: HandoffInputData) -> HandoffInputData:
"""Filter input passed to next agent."""# Only pass the last 5 itemsreturn data.clone(new_items=data.new_items[-5:])
handoff(
specialist,
input_filter=filter_handoff_input,
)
HandoffInputData structure:
Python
@dataclassclassHandoffInputData:
input_history: str | tuple[TResponseInputItem, ...]
"""The input history before the handoff."""
pre_handoff_items: tuple[RunItem, ...]
"""Items generated before the handoff turn."""
new_items: tuple[RunItem, ...]
"""Items generated during the current turn (including handoff)."""
run_context: RunContextWrapper | None"""Run context at handoff time."""
input_items: tuple[RunItem, ...] | None"""Items to include in next agent's input (optional override)."""
Nested Handoff History
Control how conversation history is passed:
Python
handoff(
specialist,
nest_handoff_history=True, # Collapse history into summary
)
Options:
None (default) - Use run-level configuration
True - Collapse history into a single summary message
False - Pass full conversation history
When to use:
True - For deep handoff chains to reduce token usage
False - When the next agent needs full context
Dynamic Enablement
Handoffs can be dynamically enabled or disabled:
Python
defis_handoff_enabled(
context: RunContextWrapper,
agent: Agent,
) -> bool:
"""Check if handoff should be enabled."""return context.context.user_tier == "premium"
handoff(
specialist,
is_enabled=is_handoff_enabled,
)
Use cases:
Feature flags
User tier-based access
Context-dependent availability
A/B testing
Handoff Execution Flow
Complete Handoff Flow
Python
# 1. Model decides to hand off
tool_call = ResponseFunctionToolCall(
id="call_123",
name="transfer_to_specialist",
arguments='{}',
)
# 2. Find the handoff
handoff = find_handoff(tool_call.name)
# 3. Check if enabledifnot is_handoff_enabled(handoff, context):
skip_handoff(tool_call)
return# 4. Parse arguments (if input_type is set)if handoff.input_type isnotNone:
validated_input = validate_arguments(tool_call.arguments, handoff.input_type)
await handoff.on_invoke_handoff(context, validated_input)
else:
await handoff.on_invoke_handoff(context, None)
# 5. Get the target agent
target_agent = await handoff.on_invoke_handoff(context, arguments)
# 6. Build handoff input data
handoff_input = build_handoff_input_data(
current_state,
handoff,
tool_call,
)
# 7. Apply input filter (if configured)if handoff.input_filter:
filtered_input = await handoff.input_filter(handoff_input)
else:
filtered_input = apply_default_filter(handoff_input)
# 8. Handle nested historyif should_nest_history(handoff, run_config):
filtered_input = nest_handoff_history(filtered_input)
# 9. Switch to target agent
current_agent = target_agent
# 10. Prepare input for next turn
next_turn_input = prepare_handoff_input(filtered_input)
# 11. Continue with next turn
continue_execution(next_turn_input)
Handoff History Management
Default Behavior (Full History)
By default, the full conversation history is passed to the next agent:
Python
# Agent A: "Hello"# Agent A: "How can I help?"# User: "I have a technical problem"# Agent A: [hands off to Agent B]# Agent B sees: ["Hello", "How can I help?", "I have a technical problem"]
Pros:
Full context preserved
No information loss
Agent can reference earlier conversation
Cons:
Higher token usage
Potential for confusion in deep chains
Slower for long conversations
Nested History (Collapsed)
When nest_handoff_history=True, history is collapsed:
Python
# Agent A: "Hello"# Agent A: "How can I help?"# User: "I have a technical problem"# Agent A: [hands off to Agent B]# Agent B sees: [{"type": "assistant", "content": "Summary: User has a technical problem"}]
Pros:
Lower token usage
Cleaner context for deep chains
Faster for long conversations
Cons:
Some detail lost in summary
Agent can't reference specific earlier messages
Depends on quality of summary
Custom History Mapping
Provide a custom function to transform history:
Python
defcustom_history_mapper(
history: list[TResponseInputItem],
) -> list[TResponseInputItem]:
"""Custom history transformation."""# Keep only user and assistant messages
filtered = [
item for item in history
if item.get("role") in ["user", "assistant"]
]
# Add a summary at the beginning
filtered.insert(0, {
"type": "assistant",
"content": "Context: Technical support conversation",
})
return filtered
config = RunConfig(
handoff_history_mapper=custom_history_mapper,
)
Input Filter for Fine-Grained Control
Use input filters for precise control:
Python
defprecise_filter(data: HandoffInputData) -> HandoffInputData:
"""Precise control over what's passed."""# Keep only the last user message and the handoff
relevant_items = [
item for item in data.new_items
ifisinstance(item, (MessageOutputItem, HandoffCallItem))
]
return data.clone(new_items=tuple(relevant_items))
Handoff Patterns
1. Triage Pattern
One agent routes to specialists:
Python
triage_agent = Agent(
name="triage",
instructions="Determine the type of issue and route to appropriate specialist",
handoffs=[
handoff(billing_agent),
handoff(technical_agent),
handoff(sales_agent),
],
)
When to use:
Customer support systems
Help desk workflows
Multi-domain support
2. Supervisor Pattern
One agent supervises and delegates:
Python
supervisor = Agent(
name="supervisor",
instructions="Review work and delegate to appropriate specialists",
handoffs=[
handoff(coder_agent),
handoff(researcher_agent),
handoff(writer_agent),
],
)
from agents import handoff_span
asyncdefmy_handoff_function(context, input):
with handoff_span(name="my_handoff"):
# Handoff logicreturn specialist_agent
Handoff Events
Handoffs emit events during streaming:
Python
asyncfor event in Runner.run_streamed(agent, input):
ifisinstance(event, AgentUpdatedStreamEvent):
print(f"Agent changed to: {event.agent.name}")
Handoff Best Practices
1. Clear Handoff Descriptions
Write clear descriptions for the LLM:
Python
# Good
specialist = Agent(
name="billing_specialist",
instructions="Handle billing and payment issues",
handoff_description=(
"Billing specialist for handling payment issues, ""refunds, subscription management, and account balance inquiries"
),
)
# Avoid
specialist = Agent(
name="billing",
instructions="Handle billing",
handoff_description="Billing", # Too vague
)
2. Appropriate Handoff Granularity
Design handoffs at the right level:
Python
# Good - domain-level handoffs
handoffs=[
handoff(billing_agent),
handoff(technical_agent),
handoff(sales_agent),
]
# Avoid - too granular
handoffs=[
handoff(billing_refund_agent),
handoff(billing_payment_agent),
handoff(billing_subscription_agent),
# Too many similar agents
]
3. Use Input Filters for Context
Use input filters to control context:
Python
# Good - filter to relevant contextdeffilter_context(data):
return data.clone(new_items=data.new_items[-5:])
# Avoid - passing everything# No filter means full history is passed
4. Handle Handoff Failures
Handle cases where handoffs might fail:
Python
# Good - provide fallback
generalist = Agent(
name="generalist",
instructions="Handle general queries",
handoffs=[handoff(specialist)],
)
# If specialist fails, generalist can still respond# Avoid - single point of failure# If only specialist is available and it fails, user gets no response
5. Test Handoff Paths
Test all possible handoff paths:
Python
# Test each handoff
test_triage_to_billing()
test_triage_to_technical()
test_triage_to_sales()
# Test handoff chains
test_billing_to_specialist()
test_technical_to_level2()
Common Handoff Issues
1. Handoff Loops
Problem: Agents hand off back and forth infinitely.
Solution: Use handoff history tracking:
Python
defprevent_loops(data: HandoffInputData) -> HandoffInputData:
"""Prevent handoff loops."""# Check if we're handing back to the same agent
recent_agents = [item.agent.name for item in data.new_items[-5:]]
if current_agent.name in recent_agents:
# Don't hand offreturn data.clone(new_items=data.new_items)
return data
2. Lost Context
Problem: Important context is lost in handoff.
Solution: Use appropriate history management:
Python
# For critical context, don't nest
handoff(specialist, nest_handoff_history=False)
# Or use custom mapperdefpreserve_context(history):
# Always keep user messagesreturn [item for item in history if item["role"] == "user"]
3. Ambiguous Handoffs
Problem: LLM unsure which handoff to use.
Solution: Clear descriptions and maybe input types:
Python
handoff(
billing_agent,
tool_description_override=(
"Use this for billing, payments, refunds, ""and subscription issues only. ""NOT for technical support or sales inquiries."
),
)
4. Too Many Handoffs
Problem: Excessive handoffs slow down response.
Solution: Consolidate similar agents:
Python
# Before - too many
handoffs=[
handoff(billing_refund),
handoff(billing_payment),
handoff(billing_subscription),
]
# After - consolidated
handoffs=[handoff(billing_agent)]
Handoff vs Agent as Tool Decision
When to Use Handoffs
Use handoffs when:
The new agent needs full conversation context
The new agent takes over the conversation
Domain expertise routing is needed
Long-running specialized tasks
When to Use Agent as Tool
Use agent as tool when:
The subtask is self-contained
The parent agent should continue the conversation
The subtask returns specific data
Parallel execution of multiple subtasks
Example Comparison
Handoff:
Python
# User: "Help me debug my code"# Generalist: [hands off to coder]# Coder: Takes over, debugs, provides solution# Conversation continues with coder
Agent as Tool:
Python
# User: "Help me debug my code"# Generalist: Calls coder_tool("debug my code")# Coder: Debugs and returns "Fixed: changed line 42"# Generalist: "I fixed your code by changing line 42"# Conversation continues with generalist