Module 8: Multi-Agent Orchestration
3. Pattern: Handoffs
Overview
In the previous capsule you saw the Supervisor pattern: a central coordinator that decides which agent works, when, and with what. It's powerful but it has an inherent bottleneck — everything goes through the supervisor. Every routing decision, every piece of information, every result. If the supervisor gets it wrong or hangs, the whole system stops.
The Handoffs pattern solves this by eliminating the central coordinator. Instead of a supervisor assigning tasks, the agents transfer control directly to each other. Think of a call center: the general agent who answers says "I'm going to transfer you to the billing department" — they don't call the manager to decide. The agent with the context makes the transfer decision, passes the conversation along, and the next agent continues from there.
That difference has deep design consequences. Handoffs produce more agile systems with less latency, but they require each agent to be smart enough to know when it can't solve something and who to transfer to. It's decentralization: more autonomy per agent, less global control.
The Handoffs Pattern
The core idea
In a system with handoffs, there's no node that "sees everything." Each agent has visibility into its own conversation, its own tools, and a list of agents it can transfer to. When an agent determines that the current task requires expertise it doesn't have, it invokes a handoff tool — a special function that transfers control to another agent.
SUPERVISOR (capsule 02) HANDOFFS (this capsule)
───────────────────── ──────────────────────
┌────────────┐ ┌─────────┐
│ Supervisor │ │ General │
└─────┬──────┘ └────┬────┘
┌────┼─────┐ │ transfer_to_billing
▼ ▼ ▼ ▼
┌───┐┌───┐┌───┐ ┌─────────┐
│ A ││ B ││ C │ │ Billing │
└───┘└───┘└───┘ └────┬────┘
│ transfer_to_technical
A star: the supervisor ▼
decides everything. ┌──────────┐
│ Technical│
└──────────┘
A chain: each agent
picks the next one.
The key difference: in Supervisor, the workers don't know other workers exist. In Handoffs, each agent knows its neighbors and can transfer control to them directly.
Analogy: the call center
Imagine you call your bank's support line:
- The General Agent answers. You explain your problem: "I was charged twice on my card."
- The general agent detects it's a billing matter: "I'll transfer you to billing."
- The Billing Agent gets the call with the context of what you already said. They don't ask you to repeat everything.
- The billing agent looks and sees there's a technical error in the system: "I need to transfer you to technical support."
- The Technical Agent gets the call, fixes the error, and closes the case.
Nobody called a "manager" to decide. Each agent assessed the situation and transferred. That's handoffs.
When to use Handoffs
Handoffs work best when:
- The flow is relatively predictable. If you know conversations typically go General → Billing → Technical, handoffs are natural.
- Each agent can determine when to transfer. The agents need enough intelligence to recognize the limits of their expertise.
- Latency matters. There's no overhead from a supervisor processing every intermediate step.
- The system is conversational. Handoffs shine in chatbots, customer support, virtual assistants — where a "user" converses and the system decides which agent answers.
Handoffs do not work well when:
- The tasks are independent and parallel (there's no transfer "chain")
- You need a central point of monitoring and control
- The agents don't have enough context to decide who to transfer to
- The agent graph is very dense (everyone can transfer to everyone → chaos)
Implementation with LangGraph
LangGraph has native support for handoffs through create_react_agent and handoff tools. The idea: each agent gets normal tools (web_search, calculate, etc.) plus special transfer tools (transfer_to_billing, transfer_to_technical). When the agent invokes a transfer tool, the framework switches control to the destination agent.
Basic setup
from langchain_openai import ChatOpenAI
from langgraph.prebuilt import create_react_agent
model = ChatOpenAI(model="gpt-4o")
Creating agents with handoff tools
from langgraph.prebuilt import create_react_agent
def search_billing_records(customer_id: str) -> str:
"""Search a customer's billing records."""
return f"Customer {customer_id}: Last charge $150 on 2025-03-01, status: paid."
def search_knowledge_base(query: str) -> str:
"""Search the knowledge base for general questions."""
return f"FAQ result for '{query}': Support hours 9am-6pm Mon-Fri."
def run_diagnostic(system: str) -> str:
"""Run a technical diagnostic on a system."""
return f"Diagnostic of {system}: No errors detected. Latency: 45ms."
billing_agent = create_react_agent(
model,
tools=[search_billing_records],
prompt="You are an agent specialized in billing. "
"You resolve questions about charges, payments and account statements. "
"If the problem is technical rather than billing, transfer to the technical agent.",
name="billing_agent",
)
technical_agent = create_react_agent(
model,
tools=[run_diagnostic],
prompt="You are a technical support agent. "
"You solve technical problems: system errors, connectivity, performance. "
"If the problem is billing, transfer to the billing agent.",
name="technical_agent",
)
general_agent = create_react_agent(
model,
tools=[search_knowledge_base, billing_agent, technical_agent],
prompt="You are the general support agent. "
"You answer general questions. "
"For billing matters, transfer to the billing agent. "
"For technical problems, transfer to the technical agent.",
name="general_agent",
)
Notice the key line: tools=[search_knowledge_base, billing_agent, technical_agent]. By passing an agent as a tool, LangGraph automatically creates a handoff tool that, when invoked, transfers control to that agent. The general agent's LLM sees something like:
Available tools:
1. search_knowledge_base(query: str) — Search the knowledge base
2. transfer_to_billing_agent() — Transfer to the billing agent
3. transfer_to_technical_agent() — Transfer to the technical agent
And it decides which to use based on the conversation's content.
Running the system
from langchain_core.messages import HumanMessage
result = general_agent.invoke({
"messages": [HumanMessage(content="I was charged twice for my monthly subscription")]
})
for msg in result["messages"]:
print(f"{msg.type}: {msg.content[:100]}")
The internal flow:
general_agentreceives "I was charged twice..."- The LLM detects it's billing → invokes
transfer_to_billing_agent - Control passes to
billing_agentwith the message history billing_agentusessearch_billing_recordsto investigatebilling_agentgenerates the final answer
A multi-agent graph with StateGraph
The handoff prompt/tool description is critical — it's what the LLM uses to decide when to transfer. Vague descriptions produce incorrect transfers.
For systems where you need total control over the routing logic, you can build the graph manually:
from langgraph.graph import StateGraph, MessagesState, START, END
def general_node(state: MessagesState):
response = general_chain.invoke(state["messages"])
if "TRANSFER:billing" in response.content:
return {"messages": state["messages"] + [response], "next": "billing"}
elif "TRANSFER:technical" in response.content:
return {"messages": state["messages"] + [response], "next": "technical"}
return {"messages": state["messages"] + [response], "next": END}
def billing_node(state: MessagesState):
response = billing_chain.invoke(state["messages"])
if "TRANSFER:technical" in response.content:
return {"messages": state["messages"] + [response], "next": "technical"}
if "TRANSFER:general" in response.content:
return {"messages": state["messages"] + [response], "next": "general"}
return {"messages": state["messages"] + [response], "next": END}
def technical_node(state: MessagesState):
response = technical_chain.invoke(state["messages"])
if "TRANSFER:billing" in response.content:
return {"messages": state["messages"] + [response], "next": "billing"}
return {"messages": state["messages"] + [response], "next": END}
def route(state: MessagesState):
return state.get("next", END)
graph = StateGraph(MessagesState)
graph.add_node("general", general_node)
graph.add_node("billing", billing_node)
graph.add_node("technical", technical_node)
graph.add_edge(START, "general")
graph.add_conditional_edges("general", route)
graph.add_conditional_edges("billing", route)
graph.add_conditional_edges("technical", route)
app = graph.compile()
This manual approach gives you total control over the routing logic, but it takes more code. create_react_agent with agents as tools is more concise for most cases.
State Transfer
When an agent transfers to another, what information gets passed? This decision directly affects the system's quality. Too much context → context bloat, high costs. Too little → the next agent doesn't understand the situation.
Full Context Transfer
The whole conversation (the complete message history) gets passed to the next agent.
def handoff_full_context(state: MessagesState):
"""Transfer the ENTIRE history to the next agent."""
return {
"messages": state["messages"],
"next_agent": "billing"
}
Advantages: Complete context, no lost details, simple to implement.
Disadvantages: Context bloat after 3-4 transfers, more tokens per invocation, information irrelevant to the destination agent.
Summary Transfer
A summary of the relevant context gets passed instead of the full history.
from langchain_openai import ChatOpenAI
from langchain_core.messages import SystemMessage, HumanMessage
summarizer = ChatOpenAI(model="gpt-4o-mini")
def create_handoff_summary(messages: list, target_agent: str) -> str:
"""Generate a context summary for the destination agent."""
summary_prompt = f"""Summarize the conversation for the {target_agent} agent.
Include ONLY:
- The user's original problem
- What has been tried so far
- Why it's being transferred to {target_agent}
- Data relevant to {target_agent}
Do NOT include greetings, irrelevant context, or details from other departments."""
response = summarizer.invoke([
SystemMessage(content=summary_prompt),
*messages
])
return response.content
def handoff_with_summary(state: MessagesState, target: str):
summary = create_handoff_summary(state["messages"], target)
return {
"messages": [
SystemMessage(content=f"Transfer context:\n{summary}"),
state["messages"][-1] # the user's last message
],
"next_agent": target
}
Advantages: Compact, relevant context, fewer tokens, each agent gets only what it needs.
Disadvantages: The summary can lose details, plus extra cost and latency from the summarizer call.
Hybrid: Summary + the Last N Messages
The most robust strategy: summarize the old history, keep the last N messages intact.
def handoff_hybrid(state: MessagesState, target: str, keep_last_n: int = 5):
messages = state["messages"]
if len(messages) <= keep_last_n:
return {"messages": messages, "next_agent": target}
old_messages = messages[:-keep_last_n]
recent_messages = messages[-keep_last_n:]
summary = create_handoff_summary(old_messages, target)
return {
"messages": [
SystemMessage(content=f"Previous context (summary):\n{summary}"),
*recent_messages
],
"next_agent": target
}
When to use each strategy
| Strategy | When to use it |
|---|---|
| Full context | Short conversations (< 10 messages), quick prototyping |
| Summary | Long conversations, highly specialized agents that don't need the full history |
| Hybrid | Production. The best balance between context and efficiency |
Handoff Conditions
When should an agent decide to transfer? This is the pattern's most important question. A badly calibrated handoff produces two problems: premature transfers (the agent transfers when it could have solved it) or late transfers (the agent tries to solve something outside its expertise, fails, and then transfers with contaminated context).
1. Intent Detection
The agent's LLM detects that the user's intent doesn't match its domain:
general_prompt = """You are the general support agent.
You can answer:
- Questions about hours, locations, general policies
- Product and service information
You MUST transfer when you detect:
- Charges, payments, invoices, refunds → billing_agent
- Errors, bugs, technical problems, slowness → technical_agent
- Cancellations, plan upgrades → billing_agent
If you're not sure, ask the user to clarify BEFORE transferring."""
The prompt explicitly defines the boundaries. Without this, the LLM improvises and transfers inconsistently.
2. Task Completion
The agent finishes its part and knows the next step requires another agent. The researcher finds sources, and once it has at least 3, it transfers to the analyst with a summary — not out of incompetence, but by pipeline design.
3. Capability Mismatch
The agent detects it needs a tool it doesn't have. The billing agent has billing tools, but if the user needs access to server logs or network diagnostics, it transfers to technical because it doesn't have those tools.
4. Confidence Threshold
The agent assesses its confidence in solving the problem. If it's below a threshold (e.g. 60%), it transfers. Implementation: ask the LLM to include a confidence score in its answer and parse the result to decide whether to transfer.
5. User Request
The user explicitly asks to speak with another department ("I want to talk to billing", "put me through to technical support"). Given an explicit request, transfer immediately without trying to solve it.
Anti-pattern: A handoff with no context
Transferring with {"next": "technical"} without explaining why is an anti-pattern. The destination agent needs to know the reason for the transfer and what's been tried. Always include a SystemMessage with context: "Transferred from billing. Reason: error 500 when paying. Already verified there are no duplicate charges."
Bidirectional Handoffs
So far the examples show linear flows: General → Billing → Technical. But what happens when the flow needs to go in reverse?
The legitimate case
A user reports a double charge → General transfers to Billing → Billing discovers it's a display error → transfers to Technical → Technical fixes the bug → transfers back to Billing → Billing processes the refund → END. Technical fixed the bug, but Billing processes the refund. It's legitimate for Technical to transfer back.
The danger: infinite loops
General → Billing: "Looks technical"
Billing → Technical: "Looks like billing"
Technical → Billing: "No, this is billing"
Billing → Technical: "But there's a technical error"
... (infinite loop)
This happens when the agents have no clear boundaries or when a problem falls in a gray zone between two domains.
Loop prevention
Strategy 1: A transfer counter
Add transfer_count to the state. Each transfer increments it. On reaching MAX_TRANSFERS (e.g. 5), the current agent resolves it directly or escalates to a human.
class HandoffState(TypedDict):
messages: Annotated[list, add_messages]
transfer_count: int
transfer_history: list[str]
MAX_TRANSFERS = 5
def billing_node(state: HandoffState):
if state["transfer_count"] >= MAX_TRANSFERS:
return {"messages": [...], "next": "end"}
response = billing_chain.invoke(state["messages"])
if needs_transfer(response):
return {
"messages": state["messages"] + [response],
"transfer_count": state["transfer_count"] + 1,
"transfer_history": state["transfer_history"] + ["billing"],
"next": "technical"
}
return {"messages": state["messages"] + [response], "next": "end"}
Strategy 2: The no-repeat rule
Don't allow transferring back to the agent that just transferred to you. If transfer_history[-1] == target, force the current agent to resolve it.
Strategy 3: An escalation path
Detect ping-pong (A → B → A) and escalate to a supervisor instead of continuing to bounce. Composing patterns: handoffs as the default, supervisor as escalation.
def check_ping_pong(state: HandoffState, target: str) -> str:
history = state.get("transfer_history", [])
if len(history) >= 2 and history[-1] == target:
return "supervisor"
return target
Supervisor vs Handoffs
This comparison is the most frequent design decision in multi-agent systems. There's no universal answer — it depends on your use case.
| Aspect | Supervisor | Handoffs |
|---|---|---|
| Topology | A star (hub-and-spoke) | A chain or graph (peer-to-peer) |
| Control | Centralized | Distributed |
| Routing decision | The supervisor decides | Each agent decides |
| Latency | Higher (everything goes through the supervisor) | Lower (direct transfer) |
| Visibility | The supervisor sees everything | No agent sees everything |
| Point of failure | The supervisor is a SPOF | No SPOF (but loops are possible) |
| Parallel execution | The supervisor can launch workers in parallel | Hard — the flow is typically sequential |
| Debugging | Easy (one observation point) | Hard (distributed tracing) |
| Aggregating results | Natural (the supervisor receives everything) | Hard (who aggregates?) |
| Scaling the number of agents | The supervisor needs to know every worker | Each agent only knows its neighbors |
| Ideal for | Parallel tasks, complex coordination | Conversational flows, linear pipelines |
Practical rules
- A conversational flow → Handoffs (customer support, multi-domain assistants)
- Independent parallel tasks → Supervisor (simultaneous research + analysis + writing)
- Aggregating results → Supervisor (it has the visibility to combine)
- Critical latency → Handoffs (removes the supervisor hop)
- A system that grows frequently → Handoffs (adding an agent = adding it as a neighbor)
Composition: the best of both worlds
In production, systems combine both patterns:
┌──────────────┐
│ Supervisor │ ← coordinates at a high level
└──┬───────┬───┘
│ │
▼ ▼
┌──────┐ ┌──────────────────────────┐
│Writer│ │ Customer Support Team │
└──────┘ │ (internal handoffs) │
│ │
│ General ──► Billing │
│ │ │
│ ▼ │
│ Technical │
└──────────────────────────┘
The supervisor coordinates teams. Inside each team, the agents use handoffs. This pattern shows up frequently in real systems.
Connection to the Project
In this module's project (capsule 08), your Research Agent turns into a multi-agent system with 4 agents: Supervisor, Researcher, Analyst, Writer. Handoffs show up in a specific flow:
- The Researcher searches for information. When it has enough sources, it transfers to the Analyst with a summary of the findings — a handoff by task completion.
- The Analyst processes the data. If it needs more information, it can transfer back to the Researcher — a controlled bidirectional handoff.
- The Analyst completes its analysis and transfers to the Writer — a handoff by task completion.
The Supervisor orchestrates at a high level (what to research, when it's complete), but inside the Research → Analysis → Writing pipeline, the agents transfer directly. It's a composition of Supervisor + Handoffs.
In the next capsule (04), you'll see the Subagents pattern — where an agent delegates to another with isolated context (unlike handoffs, where the context gets transferred). The distinction: handoff = "here, you continue." Subagent = "do this sub-task and bring me the result."
Troubleshooting
Problem 1: The agent never transfers
Symptom: The general agent tries to solve everything itself, even questions clearly from another domain.
Cause: The prompt doesn't define clear boundaries, or the handoff tool's description is too vague.
Solution: Make the handoff tool's description ultra-specific with examples. Instead of "Transfer to billing", use "Transfer when the user asks about charges, payments, refunds, invoices or cancellations". In the agent's prompt, include examples: "I was overcharged" → billing, "I can't log in" → technical.
Problem 2: Ping-pong transfers (an infinite loop)
Symptom: Billing transfers to Technical, Technical transfers back to Billing, indefinitely. The system never answers.
Cause: The problem falls in the gray zone between two domains. Neither agent is confident enough to solve it.
Solution: Implement a transfer counter with a maximum (3-5). On reaching the limit, force the current agent to answer or escalate to a supervisor. Add transfer_history to the state to detect A→B→A patterns.
Problem 3: The destination agent doesn't understand the context
Symptom: After the handoff, the new agent asks questions the user already answered.
Cause: The state transfer doesn't include enough context, or the message history gets lost in the handoff.
Solution: Use the hybrid state transfer strategy. At minimum, pass the last 5 messages intact. Include a SystemMessage with a summary of the reason for the transfer and the relevant data already gathered.
Problem 4: Incorrect transfers from ambiguity
Symptom: "I want to change my plan" — is that billing (a plan change = a different charge) or general (information about plans)?
Cause: Ambiguous phrases that could belong to multiple domains.
Solution: Add a clarification stage before the transfer. The agent can ask: "Do you want information about the available plans, or to change your current plan (which may affect your billing)?" Another option: use a "triage" agent that classifies before routing.
Problem 5: The handoff tools don't show up as options for the LLM
Symptom: You pass the agents as tools with create_react_agent, but the LLM doesn't invoke them. It seems not to know they exist.
Cause: The handoff tools aren't registering correctly, or the agent's name isn't converting into a valid tool name.
Solution: Verify each agent has a name defined. Print the agent's tools to confirm the handoff tools appear: print([t.name for t in agent.tools]). If you use a manual StateGraph, verify the conditional_edges are configured correctly.
Exercises
Exercise 1: Identify when to use handoffs (Conceptual)
An e-commerce company has these needs:
- A sales agent (recommends products)
- An inventory agent (checks stock)
- A returns agent (processes returns)
- A payments agent (charges and refunds)
For each pair, say whether a handoff between them is likely and in which direction:
- Sales ↔ Inventory
- Sales ↔ Returns
- Returns ↔ Payments
- Inventory ↔ Payments
See solution
-
Sales → Inventory: Yes. The customer wants to buy, sales checks the stock. Main direction: Sales → Inventory (inventory doesn't recommend products).
-
Sales → Returns: Unlikely. They're different conversations. An "I want to return this" in sales is re-routing, not a peer handoff.
-
Returns → Payments: Yes. Return approved → payments processes the refund (task completion). Bidirectional: if the refund fails, payments can transfer back.
-
Inventory ↔ Payments: No. They don't share a conversational flow. They connect via business logic, not via agents.
Insight: Handoffs are natural where there's a conversational flow. Not every pair needs a direct handoff.
Exercise 2: Implement a handoff with state transfer (Code)
Create two agents: intake_agent (takes the user's complaint and gathers data: name, account number, problem description) and resolution_agent (solves the problem). Implement the handoff with a summary transfer — resolution_agent should receive a structured summary, not the full history.
See solution
from langchain_openai import ChatOpenAI
from langchain_core.messages import SystemMessage, HumanMessage, AIMessage
from langgraph.graph import StateGraph, MessagesState, START, END
model = ChatOpenAI(model="gpt-4o")
def create_intake_summary(messages: list) -> str:
"""Extract structured data from the intake."""
summary_model = ChatOpenAI(model="gpt-4o-mini")
response = summary_model.invoke([
SystemMessage(content="""Extract from the history:
- The customer's name
- Account number (if mentioned)
- The problem described
- Urgency (high/medium/low)
Format: KEY: VALUE, one per line. If data is missing, put 'Not provided'."""),
*messages
])
return response.content
def intake_node(state: MessagesState):
response = model.invoke([
SystemMessage(content="You are an intake agent. Gather: name, "
"account number and a description of the problem. "
"When you have all 3, answer with READY_TO_TRANSFER."),
*state["messages"]
])
if "READY_TO_TRANSFER" in response.content:
summary = create_intake_summary(state["messages"])
return {
"messages": [
SystemMessage(content=f"Customer data:\n{summary}"),
state["messages"][-1]
],
"next": "resolution"
}
return {"messages": state["messages"] + [response], "next": "intake"}
def resolution_node(state: MessagesState):
response = model.invoke([
SystemMessage(content="You are a resolution agent. Propose a solution."),
*state["messages"]
])
return {"messages": state["messages"] + [response], "next": END}
graph = StateGraph(MessagesState)
graph.add_node("intake", intake_node)
graph.add_node("resolution", resolution_node)
graph.add_edge(START, "intake")
graph.add_conditional_edges("intake", lambda s: s.get("next", END))
graph.add_conditional_edges("resolution", lambda s: s.get("next", END))
app = graph.compile()
The resolution_agent receives a SystemMessage with structured data instead of 10 intake messages.
Exercise 3: Loop prevention (Code)
Modify the following code to prevent infinite loops. Implement: (a) a maximum of 4 transfers, (b) don't allow transferring back to the agent that just transferred, (c) escalate to "supervisor" if there's ping-pong.
class AgentState(TypedDict):
messages: Annotated[list, add_messages]
next: str
def agent_a(state):
# can transfer to B
return {"messages": [...], "next": "agent_b"}
def agent_b(state):
# can transfer to A (a loop!)
return {"messages": [...], "next": "agent_a"}
See solution
from typing import TypedDict, Annotated
from langgraph.graph import add_messages
class AgentState(TypedDict):
messages: Annotated[list, add_messages]
next: str
transfer_count: int
transfer_history: list[str]
MAX_TRANSFERS = 4
def safe_route(state: AgentState, intended_target: str) -> str:
if state.get("transfer_count", 0) >= MAX_TRANSFERS:
return "end"
history = state.get("transfer_history", [])
if history and history[-1] == intended_target:
return "supervisor" # ping-pong detected
return intended_target
def agent_a(state: AgentState):
target = safe_route(state, "agent_b")
return {
"messages": state["messages"] + [AIMessage(content="Passing to B")],
"next": target,
"transfer_count": state.get("transfer_count", 0) + 1,
"transfer_history": state.get("transfer_history", []) + ["agent_a"],
}
safe_route centralizes all three protections: (a) a maximum number of transfers, (b) history[-1] == target detects ping-pong and escalates to the supervisor, (c) transfer_count increments with every transfer. agent_b follows the same pattern with safe_route(state, "agent_a").
Exercise 4: Design handoff conditions (Design)
Design the prompt system for a medical assistant with 3 agents: Triage (classifies urgency), General Consultation (answers common health questions), and Emergency (detects situations requiring immediate attention). Define for each agent: (a) what it can resolve, (b) when it transfers, (c) who it transfers to, (d) what context it passes.
See solution
Triage Agent:
- Resolves: The initial classification (2-3 questions to determine the nature and urgency).
- Transfers: Mild symptoms → General Consultation. Emergency indicators (chest pain, difficulty breathing, severe bleeding) → Emergency.
- Context it passes: The classification (mild/moderate/urgent), the reported symptoms, duration, risk factors.
General Consultation Agent:
- Resolves: Common health questions, OTC medications, when to see a doctor.
- Transfers: If the patient mentions emergency symptoms during the consultation → Emergency (escalation). If it needs reclassification → Triage (a controlled bidirectional handoff).
- Context it passes: The reason for escalation, new vs initial symptoms, the advice given so far.
Emergency Agent:
- Resolves: Immediate instructions ("call 911"), first aid.
- Transfers: NEVER. Emergency is terminal. It receives full context (never a summary — you can't lose details in emergencies).
Design rule: Escalation always goes up in urgency (General → Emergency), never automatic de-escalation.
Exercise 5: A complete 3-agent system with handoffs (Code)
Build a mini technical support system with create_react_agent: greeter_agent (greets, identifies the problem), software_agent (software problems), hardware_agent (hardware problems). Requirements: (a) the greeter can transfer to software or hardware, (b) software and hardware can transfer to each other, (c) implement loop prevention.
See solution
from langchain_openai import ChatOpenAI
from langgraph.prebuilt import create_react_agent
model = ChatOpenAI(model="gpt-4o")
def check_software_status(app_name: str) -> str:
"""Check an application's status."""
return f"{app_name}: version 3.2.1, no known errors."
def restart_service(service: str) -> str:
"""Restart a system service."""
return f"Service '{service}' restarted successfully."
def check_hardware_diagnostics(component: str) -> str:
"""Run a hardware diagnostic."""
return f"{component}: temperature 62°C, usage 45%, health: good."
hardware_agent = create_react_agent(
model,
tools=[check_hardware_diagnostics],
prompt="You are a hardware support agent. "
"You diagnose physical problems: hard drive, RAM, screen, battery. "
"If the problem is software, transfer to the software agent. "
"IMPORTANT: if you were already transferred FROM software, do NOT transfer back.",
name="hardware_agent",
)
software_agent = create_react_agent(
model,
tools=[check_software_status, restart_service, hardware_agent],
prompt="You are a software support agent. "
"You solve application, OS, driver and configuration problems. "
"If the problem is hardware, transfer to the hardware agent. "
"IMPORTANT: if you were already transferred FROM hardware, do NOT transfer back.",
name="software_agent",
)
greeter_agent = create_react_agent(
model,
tools=[software_agent, hardware_agent],
prompt="You are the greeting agent. "
"Identify whether the problem is SOFTWARE or HARDWARE and transfer. "
"Do NOT try to solve the problem yourself.",
name="greeter_agent",
)
result = greeter_agent.invoke({
"messages": [{"role": "user", "content": "My laptop is very slow and making noise"}]
})
Loop prevention is done via the prompt ("if you were already transferred FROM X, do NOT transfer back"). For production, combine that with transfer_history in the state.
Summary
- Handoffs eliminate the central coordinator. Instead of a supervisor deciding everything, each agent decides when to transfer and to whom. It's decentralization: more agility, lower latency, but it requires smarter agents.
- The implementation in LangGraph is direct: pass an agent as a tool to another agent with
create_react_agent. The framework creates automatic handoff tools (transfer_to_X). For more control, use a StateGraph with conditional edges. - State transfer has three strategies: full context (simple but expensive), summary (efficient but can lose details), and hybrid (a summary + the last N messages — the best for production).
- Handoff conditions define when to transfer: intent detection (this problem isn't my domain), task completion (I finished my part), capability mismatch (I don't have the tools), confidence threshold (I'm not sure), or user request (the user explicitly asks).
- Bidirectional handoffs are legitimate but dangerous. Agent A → B → A can be necessary, but without protection it creates infinite loops. Implement: a maximum number of transfers, a no-repeat rule, and escalation to a supervisor.
- Supervisor and Handoffs don't compete — they compose. The supervisor coordinates at a high level, handoffs operate inside teams. Real systems use both.
- The agent's prompt is the critical piece. Clear boundaries, explicit examples of when to transfer, and a precise description of the handoff tools determine whether the system works or turns chaotic.
Next capsule: The Subagents pattern — delegation with isolated context. Unlike handoffs (where the destination agent continues the conversation), a subagent receives a sub-task, solves it in isolation, and returns the result to the main agent.
Additional Resources
- LangGraph Multi-Agent Handoffs — Official documentation for the handoffs pattern in LangGraph
- Agent Handoffs Tutorial — A step-by-step tutorial with create_react_agent
- Multi-Agent Architectures — A comparison of patterns: supervisor, handoffs, custom routing
- LangGraph Prebuilt Agents — The reference for create_react_agent and prebuilt utilities
- OpenAI Agents SDK — Handoffs — An alternative handoffs implementation with OpenAI's SDK
- Swarm by OpenAI — OpenAI's experimental framework based entirely on handoffs between agents