Handoffs: passing control to another agent
A handoff is a transfer that lets one agent give the rest of the run to another: Agent(handoffs=[refunds]) lets the model move control to refunds, which then produces the answer.
Last updated: 28 Sep, 2026 · openai-agents 0.22.3
One agent should not carry every skill. A triage agent can route, and a refund specialist can handle refunds. A handoff moves the run from the first to the second, and the second finishes it.
The handoffs list
List other agents in handoffs. The SDK gives the model a transfer tool for each one. When the model calls it, that agent takes over the rest of the run.
triage = Agent(name="Triage", instructions="Route the customer.",
handoffs=[refunds]) # refunds is another Agent
# when the model emits the transfer, refunds finishes the runView the code here
"""A deterministic stand-in Model for the OpenAI Agents SDK course.
It implements the Model interface so an Agent runs with no API key. It reads the
last user message and the tool results out of `input`, and returns either a tool
call, a handoff, or a final message. Swap it for a real model at the end.
"""
from agents.models.interface import Model
from agents.items import ModelResponse
from agents.usage import Usage
from openai.types.responses import (
ResponseOutputMessage, ResponseOutputText, ResponseFunctionToolCall,
ResponseCompletedEvent, ResponseTextDeltaEvent, Response,
)
def _message(text):
return ResponseOutputMessage(
id="msg", role="assistant", type="message", status="completed",
content=[ResponseOutputText(text=text, type="output_text", annotations=[])],
)
def _tool_call(name, arguments, call_id="call_1"):
return ResponseFunctionToolCall(
id="fc", call_id=call_id, name=name, arguments=arguments, type="function_call",
)
def last_user_text(input):
if isinstance(input, str):
return input
for item in reversed(input):
d = item if isinstance(item, dict) else item.__dict__
if d.get("role") == "user":
content = d.get("content")
if isinstance(content, str):
return content
if isinstance(content, list):
for part in content:
pd = part if isinstance(part, dict) else part.__dict__
if pd.get("text"):
return pd["text"]
return ""
def tool_output(input):
if isinstance(input, str):
return None
for item in reversed(input):
d = item if isinstance(item, dict) else item.__dict__
if d.get("type") == "function_call_output":
return d.get("output")
return None
class ShopModel(Model):
async def get_response(self, system_instructions, input, model_settings, tools,
output_schema, handoffs, tracing, *, previous_response_id=None,
conversation_id=None, prompt=None):
result = tool_output(input)
if result is not None:
# A handoff transfer looks like {"assistant": "..."}; the specialist answers for real.
if result.strip().startswith('{"assistant"'):
if "refund" in (system_instructions or "").lower():
return ModelResponse(
output=[_message(
"Your refund is approved and will be processed in 5 to 7 days.")],
usage=Usage(), response_id=None)
return ModelResponse(output=[_message("Handled by the specialist.")],
usage=Usage(), response_id=None)
return ModelResponse(output=[_message(result)], usage=Usage(), response_id=None)
text = last_user_text(input).lower()
if handoffs and "refund" in text:
return ModelResponse(output=[_tool_call(handoffs[0].tool_name, "{}")],
usage=Usage(), response_id=None)
if tools and "order" in text:
return ModelResponse(output=[_tool_call("lookup_order", '{"order_id": "A17"}')],
usage=Usage(), response_id=None)
return ModelResponse(output=[_message("How can I help with your order?")],
usage=Usage(), response_id=None)
async def stream_response(self, system_instructions, input, model_settings, tools,
output_schema, handoffs, tracing, *, previous_response_id=None,
conversation_id=None, prompt=None):
text = "How can I help with your order?"
for i, word in enumerate(text.split()):
yield ResponseTextDeltaEvent(
type="response.output_text.delta", delta=word + " ",
content_index=0, item_id="msg", output_index=0,
sequence_number=i, logprobs=[],
)
response = Response(
id="r", created_at=0.0, model="shop-standin", object="response",
output=[_message(text)], parallel_tool_calls=False,
tool_choice="auto", tools=[],
)
yield ResponseCompletedEvent(type="response.completed", response=response,
sequence_number=99)
The specialist agent
The agent that will take over is an ordinary Agent. It uses the canonical shop stand-in, imported from shop_model.py.
from shop_model import ShopModel
refunds = Agent(name="Refunds", instructions="Handle refund requests.",
model=ShopModel())The triage agent that can hand off
The triage agent lists refunds in its handoffs. The shop stand-in emits a transfer to the first handoff when it sees the word refund in the message.
triage = Agent(name="Triage", instructions="Route the customer to the right team.",
handoffs=[refunds], model=ShopModel())
# ShopModel transfers to handoffs[0] on a message containing "refund"Seeing who finished the run
result.last_agent is the agent that produced the final answer. result.new_items lists what happened, so you can see the transfer itself.
result = Runner.run_sync(triage, "I need a refund for order A17")
print(result.last_agent.name) # Refunds, not Triage
for item in result.new_items:
print(type(item).__name__) # the handoff call, its output, the messageRouting a refund to the specialist
The whole run. It starts on Triage and ends on Refunds, and the items show the transfer between them.
from agents import Agent, Runner, set_tracing_disabled
from shop_model import ShopModel
set_tracing_disabled(True)
refunds = Agent(name="Refunds", instructions="Handle refund requests.",
model=ShopModel())
triage = Agent(name="Triage", instructions="Route the customer to the right team.",
handoffs=[refunds], model=ShopModel())
result = Runner.run_sync(triage, "I need a refund for order A17")
print("Started with:", triage.name)
print("Ended with: ", result.last_agent.name)
print("Answer: ", result.final_output)
for item in result.new_items:
print(type(item).__name__)Started with: Triage Ended with: Refunds Answer: Your refund is approved and will be processed in 5 to 7 days. HandoffCallItem HandoffOutputItem MessageOutputItem
What the items tell you
- last_agent is Refunds: the run ended on the specialist, so the transfer took effect.
- HandoffCallItem is the model asking to transfer, the same way it would call a tool.
- HandoffOutputItem is the SDK carrying out the transfer; after it, Refunds is running.
- MessageOutputItem is the final message, produced by Refunds, not Triage.
Handoff vs a plain tool call
| A tool call | A handoff | |
|---|---|---|
| What is added | a function | another Agent |
| After it runs | control returns to the same agent | the other agent takes over |
| last_agent | unchanged | becomes the specialist |
| The answer comes from | the calling agent | the agent handed to |
When to hand off
- Specialisation: a refund, a booking, or an escalation each handled by its own agent.
- Routing: a front agent that only decides which specialist should answer.
last_agent is no longer the one you started with. Read result.last_agent if you need to know who answered.Related
- Previous: Instructions from a function
- Next: An agent as a tool with as_tool
- Reference: Handoffs
- Send
"where is my order?"instead and checklast_agent.namestays Triage. - Add a second specialist and put it first in
handoffs, then read where "refund" now goes. - Print
len(result.new_items)and match it to the three item lines.
Every expert started right here.