DEV Community

Syeed Talha
Syeed Talha

Posted on

Understanding "Handoffs" in LangChain(One Agent, Many Personalities)

If you've been building with LangChain agents, you've probably run into a situation like this: you want your agent to behave differently depending on where the conversation is. Maybe it should ask clarifying questions at first, then switch to solving the problem, then wrap things up politely.

The tempting solution is to build three separate agents and pass the conversation between them. But there's a simpler pattern that gets you the same result with a single agent: handoffs.

In this post, I'll walk through a small support-bot example that uses this pattern, and explain what's actually happening under the hood.

What is a "handoff," really?

A handoff is not a new agent taking over. It's the same agent, but its instructions and available tools change based on a piece of state.

Think of it like a single customer service rep who has a different script taped to their desk depending on which "stage" a ticket is in. The rep doesn't change. The script does.

In code, this means:

  1. You track the current stage in your agent's state (a plain variable like current_step).
  2. Before each model call, you check that variable and swap in the right system prompt and the right set of tools.
  3. A tool call can update that variable, which moves the conversation into the next stage.

That's the whole idea. No sub-agents, no orchestrator, no complicated routing logic. Just state and a middleware function that reads it.

The example: a simple support bot

"""
Handoffs Demo
=============
A single agent changes behavior based on a state variable (current_step).
Tools update the state to trigger transitions between steps.

Flow:
  Step "triage"   -> Collects issue type from user
  Step "resolve"  -> Provides solution based on issue type
  Step "done"     -> Conversation ends
"""

import os
from dotenv import load_dotenv

load_dotenv()

from langchain.agents import AgentState, create_agent
from langchain.agents.middleware import wrap_model_call, ModelRequest, ModelResponse
from langchain.messages import ToolMessage
from langchain.tools import tool, ToolRuntime
from langchain_openai import ChatOpenAI
from langgraph.checkpoint.memory import MemorySaver
from langgraph.types import Command
from typing import Callable

# =============================================
# 1. STATE — tracks which step we're on
# =============================================

class SupportState(AgentState):
    current_step: str = "triage"
    issue_type: str = ""

# =============================================
# 2. TOOLS — each updates current_step (handoff)
# =============================================

@tool
def record_issue(
    issue: str,
    runtime: ToolRuntime[None, SupportState],
) -> Command:
    """Record the user's issue type and handoff to the resolution step."""
    return Command(
        update={
            "messages": [
                ToolMessage(
                    content=f"Issue recorded: {issue}",
                    tool_call_id=runtime.tool_call_id,
                )
            ],
            "issue_type": issue,
            "current_step": "resolve",
        }
    )

@tool
def resolve_billing(
    runtime: ToolRuntime[None, SupportState],
) -> str:
    """Resolve billing issue."""
    return "We have issued a refund. It will reflect in 3-5 business days."

@tool
def resolve_technical(
    runtime: ToolRuntime[None, SupportState],
) -> str:
    """Resolve technical issue."""
    return "Please clear your cache and restart the app. If the issue persists, reinstall."

@tool
def resolve_other(
    runtime: ToolRuntime[None, SupportState],
) -> str:
    """Resolve general inquiry."""
    return "Thank you for reaching out. A support agent will contact you within 24 hours."

@tool
def end_conversation(
    runtime: ToolRuntime[None, SupportState],
) -> Command:
    """Mark the conversation as done."""
    return Command(
        update={
            "messages": [
                ToolMessage(
                    content="Conversation ended.",
                    tool_call_id=runtime.tool_call_id,
                )
            ],
            "current_step": "done",
        },
    )

# =============================================
# 3. MIDDLEWARE — changes behavior per step
# =============================================

@wrap_model_call
def apply_step_config(
    request: ModelRequest,
    handler: Callable[[ModelRequest], ModelResponse],
) -> ModelResponse:
    step = request.state.get("current_step", "triage")

    configs = {
        "triage": {
            "prompt": (
                "You are a support agent. Your job is to identify the user's issue type.\n"
                "Available issue types: billing, technical, other.\n"
                "Call record_issue once you know the issue type."
            ),
            "tools": [record_issue],
        },
        "resolve": {
            "prompt": (
                "You are a support agent. The user's issue type is: {issue_type}.\n"
                "Call the appropriate resolve_ tool, then call end_conversation."
            ),
            "tools": [resolve_billing, resolve_technical, resolve_other, end_conversation],
        },
        "done": {
            "prompt": "The conversation is complete. Say goodbye.",
            "tools": [],
        },
    }

    config = configs.get(step, configs["triage"])
    request = request.override(
        system_prompt=config["prompt"].format(**request.state),
        tools=config["tools"],
    )
    return handler(request)

# =============================================
# 4. BUILD AGENT
# =============================================

free_llm = ChatOpenAI(
    model="nvidia/nemotron-nano-9b-v2:free",
    base_url="https://openrouter.ai/api/v1",
    api_key=os.environ.get("OPENROUTER_API_KEY"),
)

agent = create_agent(
    model=free_llm,
    tools=[record_issue, resolve_billing, resolve_technical, resolve_other, end_conversation],
    state_schema=SupportState,
    middleware=[apply_step_config],
    checkpointer=MemorySaver(),
)

# =============================================
# 5. RUN — simulate a conversation
# =============================================

print("=" * 60)
print("Handoffs Demo")
print("=" * 60)

config = {"configurable": {"thread_id": "demo-1"}}

response = agent.invoke(
    {"messages": [{"role": "user", "content": "I was charged twice for my subscription."}]},
    config=config,
)

print("\nFull conversation (handoffs in action):")
for msg in response["messages"]:
    role = msg.__class__.__name__.replace("Message", "")
    if hasattr(msg, "content") and msg.content:
        print(f"  [{role}] {msg.content[:200]}")
    elif hasattr(msg, "tool_calls") and msg.tool_calls:
        for tc in msg.tool_calls:
            print(f"  [{role}] Tool call: {tc['name']}")

print("\nState changes:")
for msg in response["messages"]:
    if hasattr(msg, "additional_kwargs") and msg.additional_kwargs:
        continue

print("  Step flow: triage -> resolve -> done")
Enter fullscreen mode Exit fullscreen mode

The demo bot has three stages:

  • triage — figure out what kind of issue the user has (billing, technical, or something else)
  • resolve — actually solve it, based on what was found in triage
  • done — the conversation is wrapped up

Let's break down how each piece makes that happen.

1. State holds the "current step"

class SupportState(AgentState):
    current_step: str = "triage"
    issue_type: str = ""
Enter fullscreen mode Exit fullscreen mode

This is the whole trick. current_step starts at "triage" and will change as the conversation moves forward. issue_type stores what the user's problem actually was, so later stages can refer back to it.

If you're new to LangChain agents, AgentState is just the base class that already tracks things like the message history. Here we're extending it with two extra fields specific to this bot.

2. Tools that trigger the handoff

This is the part that trips people up at first, because the tools don't just return an answer — some of them return a Command that updates state.

@tool
def record_issue(
    issue: str,
    runtime: ToolRuntime[None, SupportState],
) -> Command:
    """Record the user's issue type and handoff to the resolution step."""
    return Command(
        update={
            "messages": [
                ToolMessage(
                    content=f"Issue recorded: {issue}",
                    tool_call_id=runtime.tool_call_id,
                )
            ],
            "issue_type": issue,
            "current_step": "resolve",
        }
    )
Enter fullscreen mode Exit fullscreen mode

When the model calls record_issue, three things happen at once:

  • A ToolMessage gets added to the conversation (so the model sees the tool ran)
  • issue_type gets saved
  • current_step flips from "triage" to "resolve"

That last line is the actual handoff. The very next time the model is called, it will be running under completely different instructions, because current_step changed.

Compare that to the other tools, like resolve_billing:

@tool
def resolve_billing(
    runtime: ToolRuntime[None, SupportState],
) -> str:
    """Resolve billing issue."""
    return "We have issued a refund. It will reflect in 3-5 business days."
Enter fullscreen mode Exit fullscreen mode

This one just returns a plain string. It does its job but doesn't change the stage. The actual stage-ending move happens in end_conversation, which — same as record_issue — returns a Command that sets current_step to "done".

So the pattern is: tools that just do something return a plain value, and tools that move the conversation forward return a Command updating state.

3. Middleware: the part that reads state and reconfigures the agent

This is where the different "scripts" actually get swapped in.

@wrap_model_call
def apply_step_config(
    request: ModelRequest,
    handler: Callable[[ModelRequest], ModelResponse],
) -> ModelResponse:
    step = request.state.get("current_step", "triage")

    configs = {
        "triage": {
            "prompt": (
                "You are a support agent. Your job is to identify the user's issue type.\n"
                "Available issue types: billing, technical, other.\n"
                "Call record_issue once you know the issue type."
            ),
            "tools": [record_issue],
        },
        "resolve": {
            "prompt": (
                "You are a support agent. The user's issue type is: {issue_type}.\n"
                "Call the appropriate resolve_ tool, then call end_conversation."
            ),
            "tools": [resolve_billing, resolve_technical, resolve_other, end_conversation],
        },
        "done": {
            "prompt": "The conversation is complete. Say goodbye.",
            "tools": [],
        },
    }

    config = configs.get(step, configs["triage"])
    request = request.override(
        system_prompt=config["prompt"].format(**request.state),
        tools=config["tools"],
    )
    return handler(request)
Enter fullscreen mode Exit fullscreen mode

@wrap_model_call is middleware that runs right before every single model call. Every time, it:

  1. Reads current_step out of state
  2. Looks up the matching prompt and tool list from the configs dictionary
  3. Overrides the request with that prompt and those tools
  4. Passes the modified request along to the actual model call

Notice something important: during triage, the model literally does not have access to resolve_billing, resolve_technical, or end_conversation. They're not in its tool list. It physically cannot call them yet, because they haven't been handed to it. This isn't the model "choosing" to behave — it's the code controlling what's even possible at each stage.

Also worth noticing: config["prompt"].format(**request.state) fills in {issue_type} from state. So once triage records "billing", the resolve-stage prompt literally becomes "The user's issue type is: billing," dynamically, without you having to write separate hardcoded prompts for every issue type.

4. Wiring it together

agent = create_agent(
    model=free_llm,
    tools=[record_issue, resolve_billing, resolve_technical, resolve_other, end_conversation],
    state_schema=SupportState,
    middleware=[apply_step_config],
    checkpointer=MemorySaver(),
)
Enter fullscreen mode Exit fullscreen mode

A few things to point out for beginners:

  • You still pass all the tools to create_agent. The middleware is what actually restricts which ones are usable at any given moment — the full list here is just the total pool.
  • state_schema=SupportState tells the agent to use your custom state class instead of the default one, which is how current_step and issue_type become available.
  • checkpointer=MemorySaver() lets the agent remember state across multiple calls in the same conversation thread. Without this, state would reset every time you called agent.invoke(...).

Walking through what happens when you run it

config = {"configurable": {"thread_id": "demo-1"}}

response = agent.invoke(
    {"messages": [{"role": "user", "content": "I was charged twice for my subscription."}]},
    config=config,
)
Enter fullscreen mode Exit fullscreen mode

Here's the sequence:

  1. current_step starts at "triage" (the default).
  2. The middleware loads the triage prompt and gives the model only record_issue.
  3. The model reads "charged twice" and calls record_issue(issue="billing").
  4. That tool returns a Command setting issue_type = "billing" and current_step = "resolve".
  5. The agent loops back to call the model again — but now the middleware sees current_step = "resolve", so it loads a completely different prompt and gives the model resolve_billing, resolve_technical, resolve_other, and end_conversation.
  6. The model calls resolve_billing(), gets back the refund message, then calls end_conversation().
  7. That last call sets current_step = "done".
  8. If the model were called again, it would now be in the "done" stage with no tools at all, just told to say goodbye.

All of that happens within a single agent.invoke() call, and it's all one agent object the whole time.

Why bother with this instead of separate agents?

A few reasons this pattern is worth knowing:

  • One set of message history. Everything stays in the same conversation, so the model never loses context switching between "agents."
  • Cheap to reason about. All your stage logic lives in one dictionary (configs) instead of being scattered across multiple agent definitions.
  • Hard boundaries. Because tools are only exposed for the current step, you get built-in guardrails. The model literally can't jump ahead and issue a refund before it's identified the issue.
  • Easy to extend. Adding a new stage means adding one more entry to configs and maybe one more tool. You're not rewiring a multi-agent graph.

A mental model to keep

If multi-agent systems feel like handing a customer off between different departments, this handoffs pattern feels more like one employee with a binder of scripts, flipping to a new page depending on which stage the conversation is at. Same person, same memory of the conversation, different instructions and different tools available at each page.

That's really the whole idea behind handoffs in LangChain: state decides behavior, middleware reads the state, and tools are what move the state forward.

Try it yourself

If you want to experiment, a good next step is adding a fourth stage — maybe an "escalate" step for issues that don't fit billing, technical, or other. You'd just need:

  • One more entry in configs with its own prompt and tools
  • A tool that sets current_step = "escalate"

That's it. No new agent, no new graph. Just one more page in the binder.

Top comments (0)