Handoff vs agent-as-tool
Handoff versus agent-as-tool is the design choice that decides whether a second agent takes over the run or hands control back: a handoff gives the run away, as_tool returns it.
Last updated: 28 Sep, 2026 · openai-agents 0.22.3
You met both ways to reach a second agent: the handoff in Handoffs: passing control to another agent, and as_tool in An agent as a tool with as_tool. They look similar and differ in one thing: whether control comes back.
What transfers in a handoff
A handoff moves the rest of the run to the other agent. The first agent is done, and the second produces the answer, so last_agent becomes the second agent.
by_handoff = Agent(name="Triage", handoffs=[specialist], model=ShopModel())
r1 = Runner.run_sync(by_handoff, "I need a refund")
# specialist finished the run
r1.last_agent.name # "Refunds"What returns with as_tool
An agent-as-tool runs as one step. The specialist answers, that answer comes back as the tool result, and the caller keeps the run, so last_agent stays the caller.
by_tool = Agent(name="Manager",
tools=[worker.as_tool(tool_name="ask_worker",
tool_description="Ask the worker")],
model=CallToolModel())
r2 = Runner.run_sync(by_tool, "do the task")
r2.last_agent.name # "Manager", control came backView 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)
Reading last_agent for both routes
One program runs both. The handoff ends on the specialist; the tool ends on the caller. last_agent.name is the tell.
from agents import Agent, Runner, set_tracing_disabled
from agents.models.interface import Model
from agents.items import ModelResponse
from agents.usage import Usage
from openai.types.responses import (
ResponseOutputMessage, ResponseOutputText, ResponseFunctionToolCall,
)
from shop_model import ShopModel
set_tracing_disabled(True)
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):
return ResponseFunctionToolCall(
id="fc", call_id="call_1", name=name, arguments=arguments, type="function_call",
)
class FixedModel(Model):
async def get_response(self, *a, **k):
return ModelResponse(output=[_message("done")], usage=Usage(), response_id=None)
async def stream_response(self, *a, **k):
raise NotImplementedError
class CallToolModel(Model):
async def get_response(self, system_instructions, input, model_settings, tools,
output_schema, handoffs, tracing, **k):
if isinstance(input, list):
for it in reversed(input):
d = it if isinstance(it, dict) else it.__dict__
if d.get("type") == "function_call_output":
return ModelResponse(output=[_message("parent finished")],
usage=Usage(), response_id=None)
return ModelResponse(output=[_tool_call(tools[0].name, '{"input": "x"}')],
usage=Usage(), response_id=None)
async def stream_response(self, *a, **k):
raise NotImplementedError
# Handoff: the specialist takes over, so the run ends on the specialist.
specialist = Agent(name="Refunds", instructions="refunds", model=ShopModel())
by_handoff = Agent(name="Triage", instructions="route", handoffs=[specialist],
model=ShopModel())
r1 = Runner.run_sync(by_handoff, "I need a refund")
print("handoff -> last agent:", r1.last_agent.name)
# as_tool: the specialist answers, then control returns to the parent.
worker = Agent(name="Worker", instructions="work", model=FixedModel())
by_tool = Agent(name="Manager", instructions="delegate",
tools=[worker.as_tool(tool_name="ask_worker",
tool_description="Ask the worker")],
model=CallToolModel())
r2 = Runner.run_sync(by_tool, "do the task")
print("as_tool -> last agent:", r2.last_agent.name)handoff -> last agent: Refunds as_tool -> last agent: Manager
What last_agent told us
- Handoff ended on Refunds: the run left Triage and never came back, so the specialist owns the answer.
- as_tool ended on Manager: the worker ran and returned, and the manager finished the run.
- The same specialist, two outcomes: the difference is only how you wired it, handoff or tool.
Handoff vs agent-as-tool
| Handoff | agent.as_tool | |
|---|---|---|
| Wired with | handoffs=[...] | tools=[agent.as_tool(...)] |
| Control after | the other agent keeps it | returns to the caller |
| last_agent | the other agent | the caller |
| Who writes the answer | the other agent | the caller |
| Good for | routing to a specialist | a sub-step in a larger task |
| Can chain more work | only on the new agent | yes, the caller continues |
Choosing between them
- Reach for a handoff when the other agent should own the conversation from here, like a refund desk or an escalation.
- Reach for
as_toolwhen you need one answer from a specialist and then more work from the caller, like translate then reply.
last_agent when a run ends somewhere you did not expect.Related
- Previous: An agent as a tool with as_tool
- Next: Input guardrails and the tripwire
- Reference: Handoffs
- Send
"where is my order?"toby_handoffand checklast_agentstays Triage. - Add a handoff to the
Manageras well and see which route the message takes. - Print
[type(i).__name__ for i in r2.new_items]and find the tool call and its output.
You understood something today that you didn't yesterday.