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 →

A refund specialist by handoff

A handoff is a tool call that passes the whole conversation to another agent, so a refund is handed to a specialist agent and the run continues there.

Last updated: 28 Sep, 2026 · openai-agents 0.22.3

The desk from The desk agent and its tools answers order questions. A refund is a different job, so it belongs to a different agent. Handoffs, from the lesson on Handoffs: passing control to another agent, move the run there.

The pieces, and where each came from

PieceFrom lesson
The desk with its lookup toolThe desk agent and its tools
A second Agent, the refund specialistAgent: name, instructions and tools
handoffs=[refund] on the deskHandoffs: passing control to another agent
result.last_agent to read who answeredRunner and the RunResult object

The refund specialist

The specialist is a second Agent. It only handles refunds, so its instructions say so.

python
refund = Agent(
    name="Refund specialist",
    instructions="Handle refund requests.",
    model=ShopModel(),
)

The desk that can hand off

The desk lists the specialist in handoffs. That turns the specialist into a transfer target the model can pick.

python
desk = Agent(
    name="Shop desk",
    instructions="Help shoppers; send refunds to the specialist.",
    tools=[lookup_order],
    handoffs=[refund],   # the desk may transfer to this agent
    model=ShopModel(),
)

Reading who answered

After the run, result.last_agent is whichever agent produced the final answer.

python
result = Runner.run_sync(desk, "I want a refund")
print("Answered by:", result.last_agent.name)
print("Reply:", result.final_output)
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 desk handing a refund to the specialist

The desk starts the run, the word refund triggers the transfer, and the specialist finishes it.

Example
from agents import Agent, Runner, function_tool, set_tracing_disabled
from shop_model import ShopModel
set_tracing_disabled(True)

@function_tool
def lookup_order(order_id: str) -> str:
    "Look up an order by its id."
    return f"Order {order_id}: shipped on 3 March, arriving 7 March."

refund = Agent(
    name="Refund specialist",
    instructions="Handle refund requests.",
    model=ShopModel(),
)
desk = Agent(
    name="Shop desk",
    instructions="Help shoppers; send refunds to the specialist.",
    tools=[lookup_order],
    handoffs=[refund],
    model=ShopModel(),
)
result = Runner.run_sync(desk, "I want a refund")
print("Answered by:", result.last_agent.name)
print("Reply:", result.final_output)

What the handoff produced

  • The stand-in saw the word refund and returned a call to the specialist's transfer tool rather than a reply.
  • result.last_agent.name is Refund specialist, so control moved off the desk for the rest of the run.
  • The reply was composed by the specialist after the transfer, from its own refund instructions, so it reads as a refund answer and not as the desk's; Swapping in a real model puts a real model behind the same specialist.

Handoff vs one agent for everything

ApproachWhat happens
One desk handles refunds tooRefund rules crowd the desk's instructions and every tool sits on one agent
Handoff to a specialistEach agent stays focused; result.last_agent tells you who took over

When to hand off

  • A task with its own rules or tools, a refund, a cancellation, a fraud check, is cleaner as a separate agent.
  • You want the transcript to show which agent handled a request.
Watch out. A handoff hands over the whole conversation, not one message. After the transfer the specialist owns every following turn until it hands back or the run ends.
Try it yourself
  • Send an order question instead and confirm last_agent.name stays Shop desk.
  • Rename the specialist and watch the printed agent name change.
  • Add a second specialist to handoffs and see the stand-in still picks the first on the word refund.

Little by little, you're building something great.