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 →

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

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.