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
| Piece | From lesson |
|---|---|
| The desk with its lookup tool | The desk agent and its tools |
| A second Agent, the refund specialist | Agent: name, instructions and tools |
| handoffs=[refund] on the desk | Handoffs: passing control to another agent |
| result.last_agent to read who answered | Runner and the RunResult object |
The refund specialist
The specialist is a second Agent. It only handles refunds, so its instructions say so.
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.
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.
result = Runner.run_sync(desk, "I want a refund")
print("Answered by:", result.last_agent.name)
print("Reply:", result.final_output)View 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 desk handing a refund to the specialist
The desk starts the run, the word refund triggers the transfer, and the specialist finishes it.
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)
Answered by: Refund specialist Reply: Your refund is approved and will be processed in 5 to 7 days.
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
| Approach | What happens |
|---|---|
| One desk handles refunds too | Refund rules crowd the desk's instructions and every tool sits on one agent |
| Handoff to a specialist | Each 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.
Related
- Previous: The desk agent and its tools
- Next: Guarding the desk's input and output
- Reference: Handoffs
- Send an order question instead and confirm
last_agent.namestays Shop desk. - Rename the specialist and watch the printed agent name change.
- Add a second specialist to
handoffsand see the stand-in still picks the first on the word refund.
Little by little, you're building something great.