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:
- You track the current stage in your agent's state (a plain variable like
current_step). - Before each model call, you check that variable and swap in the right system prompt and the right set of tools.
- 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")
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 = ""
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",
}
)
When the model calls record_issue, three things happen at once:
- A
ToolMessagegets added to the conversation (so the model sees the tool ran) -
issue_typegets saved -
current_stepflips 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."
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)
@wrap_model_call is middleware that runs right before every single model call. Every time, it:
- Reads
current_stepout of state - Looks up the matching prompt and tool list from the
configsdictionary - Overrides the request with that prompt and those tools
- 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(),
)
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=SupportStatetells the agent to use your custom state class instead of the default one, which is howcurrent_stepandissue_typebecome available. -
checkpointer=MemorySaver()lets the agent remember state across multiple calls in the same conversation thread. Without this, state would reset every time you calledagent.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,
)
Here's the sequence:
-
current_stepstarts at"triage"(the default). - The middleware loads the triage prompt and gives the model only
record_issue. - The model reads "charged twice" and calls
record_issue(issue="billing"). - That tool returns a
Commandsettingissue_type = "billing"andcurrent_step = "resolve". - 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 modelresolve_billing,resolve_technical,resolve_other, andend_conversation. - The model calls
resolve_billing(), gets back the refund message, then callsend_conversation(). - That last call sets
current_step = "done". - 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
configsand 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
configswith 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)