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 backReading 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)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.