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 →

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.

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

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

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.

Example
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

Handoffagent.as_tool
Wired withhandoffs=[...]tools=[agent.as_tool(...)]
Control afterthe other agent keeps itreturns to the caller
last_agentthe other agentthe caller
Who writes the answerthe other agentthe caller
Good forrouting to a specialista sub-step in a larger task
Can chain more workonly on the new agentyes, 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_tool when you need one answer from a specialist and then more work from the caller, like translate then reply.
Watch out. Picking a handoff when you needed control back leaves the caller unable to finish its own steps, because the run has already moved on. Check last_agent when a run ends somewhere you did not expect.
Try it yourself
  • Send "where is my order?" to by_handoff and check last_agent stays Triage.
  • Add a handoff to the Manager as 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.