OpenAI Agents SDKopenai-agents 0.22 · Python 3.10+
Dashboard
0%
1
Curious builder0 XP earned · 300 to level 2
0 daysFinish a lesson to begin
Badge collection0 of 6 unlocked
27 small wins to finish your pathNext lesson →

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.

python
triage = Agent(name="Triage", instructions="Route the customer.",
               handoffs=[refunds])   # refunds is another Agent
# when the model emits the transfer, refunds finishes the run
Project files used on this pageThis lesson builds on a project from earlier lessons. The code below imports this file. Click a file to see its code, or follow the link to the lesson that wrote it. To run the code yourself, keep it in the same folder.
View the code here
shop_model.py
"""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.

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

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

python
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 message

Routing a refund to the specialist

The whole run. It starts on Triage and ends on Refunds, and the items show the transfer between them.

Example
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__)

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 callA handoff
What is addeda functionanother Agent
After it runscontrol returns to the same agentthe other agent takes over
last_agentunchangedbecomes the specialist
The answer comes fromthe calling agentthe 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.
Watch out. After a handoff, the run keeps going on the other agent, so last_agent is no longer the one you started with. Read result.last_agent if you need to know who answered.
Try it yourself
  • Send "where is my order?" instead and check last_agent.name stays 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.