Agent handoffs sound simple: agent A finishes, agent B takes over. In practice, handoffs are where multi-agent systems break: lost context, duplicated work, infinite ping-pong, state desync. This article covers the handoff patterns that production AI systems in 2026 actually use: explicit tool-based handoff, context-passing handoff, state-shared handoff, and escalation handoff. For each: implementation, when it fits, and the specific failure modes that catch teams out.
Why handoffs matter more than people realize
When one agent hands off to another, three things need to transfer cleanly:
- Context: what was the user asking, what has been done so far, why are we handing off now
- State: data accumulated during the first agent’s work (search results, intermediate decisions, open questions)
- Control: which agent is now responsible for the user interaction
Fail on any of these and the second agent either starts over (duplicated work), produces unrelated answers (lost context), or returns control back to the first agent (ping-pong). All three cost tokens and user patience.
Pattern 1: Explicit tool-based handoff
The handoff is modeled as a tool call. Agent A has a transfer_to_B tool; calling it signals the orchestrator to switch control to B.
def transfer_to_billing():
return {"next_agent": "billing", "reason": "User question is about billing"}
triage_agent = Agent(
name="triage",
tools=[transfer_to_billing, transfer_to_technical, resolve_directly],
instructions="If the user's question is clearly billing-related, call transfer_to_billing. "
"If technical, call transfer_to_technical. If simple enough, call resolve_directly."
)When it fits: clean routing workflows (customer support triage, document routing by type). Agent A does nothing except decide which other agent handles the task.
Pattern used by: OpenAI Swarm (native), AutoGen (via function_call), raw Claude SDK (via custom tool).
Pitfall: context does not auto-transfer. If agent A learned something during its turn (e.g., “user mentioned they are a Business tier subscriber”), that info is NOT in agent B’s conversation unless the handoff tool explicitly passes it. Fix: handoff tool returns a summary string that becomes part of agent B’s initial message.
Pattern 2: Context-passing handoff
Handoff includes a structured summary of what agent A learned, decided, or attempted. Agent B starts with full context.
def transfer_to_billing(user_tier: str, prior_inquiry: str, open_questions: list[str]):
"""Transfer to billing specialist with structured context."""
return {
"next_agent": "billing",
"context": {
"user_tier": user_tier,
"prior_inquiry": prior_inquiry,
"open_questions": open_questions,
}
}
# Billing agent sees the context in its initial message:
# "Previous agent determined: user is Business tier, inquiry was about double-charge on
# Oct 15, open questions: refund timing, future prevention."When it fits: specialist handoff where context carryover matters (support, medical triage, legal routing). The context summary saves specialist from re-asking what the triage already discovered.
Pitfall: the structured context schema must match specialist expectations. If triage includes “payment_method” but billing specialist expects “payment_source”, the field is dropped silently. Fix: use strict schemas (Pydantic models) that both sides share.
Pattern 3: State-shared handoff
Instead of passing context explicitly, both agents read + write the same shared state. The “handoff” is just “stop running agent A, start running agent B against the same state.”
from langgraph.graph import StateGraph
from typing import TypedDict
class State(TypedDict):
messages: list
user_tier: str
inquiry_type: str
research_results: dict
final_answer: str
def triage_node(state):
# Analyze and populate state fields
return {"inquiry_type": classify(state["messages"][-1])}
def billing_node(state):
# Read inquiry_type and research_results from shared state
answer = handle_billing(state["inquiry_type"], state["research_results"])
return {"final_answer": answer}
def route(state):
return state["inquiry_type"] # routes to billing or technicalWhen it fits: any multi-agent system in LangGraph, CrewAI, or custom frameworks where you control the state model. Clean, no context loss.
Pattern used by: LangGraph (first-class), CrewAI (via task context), AutoGen (via shared context variables).
Pitfall: state can grow large across many agents. 20 agents each adding fields = 20-field state object that every agent must know about. Fix: namespace state fields by agent (billing: {…}, technical: {…}) so agents only read + write their own namespace.
Pattern 4: Escalation handoff
Agent A tries to handle the task; if it fails or hits a condition it cannot resolve, it escalates to agent B (usually a more capable or human agent).
def escalate_to_human(reason: str, context: dict) -> dict:
"""Escalate to human support when confidence is low."""
return {"escalate": True, "reason": reason, "context": context}
specialist_agent = Agent(
name="specialist",
tools=[resolve_tool, escalate_to_human],
instructions="Try to resolve the user's issue. If you lack information or confidence, "
"call escalate_to_human with a clear reason and the context."
)
# Orchestrator detects escalate=True in agent output and routes to human review queueWhen it fits: user-facing agents (customer support, medical info, financial advice). Guardrail for cases the agent should not handle alone.
Pitfall: agents escalate too often (over-cautious) or not often enough (over-confident). Calibrate with examples in the system prompt: “escalate when: X, Y, Z. Do NOT escalate when: A, B, C.”
The ping-pong failure mode
Classic handoff failure: Agent A hands off to Agent B. Agent B doesn’t know what to do, hands back to Agent A. Agent A hands off again. Loop.
Three fixes applied in combination:
- Hard turn cap: orchestrator counts total agent turns, aborts after N (10-20 typical)
- Handoff cooldown: an agent cannot be handed off to twice in a row
- Hand-off reason required: every handoff tool requires a reason field; empty reason = malformed and rejected
handoff_history = []
def guarded_handoff(target_agent: str, reason: str):
if not reason or len(reason) < 20:
return {"error": "Reason required and must be substantive"}
if len(handoff_history) >= 2 and handoff_history[-2:] == [target_agent, "someone-else"]:
return {"error": "Cannot ping-pong. Resolve or escalate to human."}
handoff_history.append(target_agent)
return {"next_agent": target_agent, "reason": reason}User experience during handoff
Users notice when control switches between agents. Three UX patterns to make handoffs feel smooth:
- Transparent handoff: tell the user (“Transferring you to our billing specialist…”). Builds trust; users understand different voices.
- Opaque handoff: hide the handoff; user sees one persistent assistant that happens to be powered by different agents under the hood. Simpler mental model.
- Semi-transparent: visual cue (color change, “Billing agent” label) but no explicit announcement. Compromise between the two.
Pick based on your audience. B2C users often prefer opaque (just want the answer). B2B / enterprise users often prefer transparent (want to know who they are talking to).
Observability for handoff debugging
When something goes wrong in production, the handoff log is where you look. Log for every handoff:
- Source agent + target agent
- Timestamp
- Reason (if explicit)
- Context payload (full structured data passed)
- User-visible transition (what the UI showed)
- Target agent’s first response (did it understand the handoff?)
Store in a structured log table (not scattered across LLM traces) so you can query handoff patterns. Common queries: “how often does triage hand off to billing?”, “what percentage of billing handoffs end up re-routed?”, “which handoffs correlate with user frustration (abandoned conversations)?”
Testing handoff logic
Unit test handoff rules with scripted conversations:
def test_triage_routes_billing_to_billing():
result = triage_agent.run([{"role": "user", "content": "My card was charged twice"}])
assert result.handoff_target == "billing"
assert "billing" in result.handoff_reason.lower()
def test_billing_can_escalate():
result = billing_agent.run(
[{"role": "user", "content": "I need a refund for a 5-year-old purchase"}]
)
assert result.escalated is True
assert "policy" in result.escalation_reason.lower()Set up 20-50 scripted scenarios covering normal routes, edge cases, and known-pathological inputs. Run on every change to agent prompts.
Decision matrix: which handoff pattern
| Use case | Best pattern |
|---|---|
| Customer support triage | Context-passing handoff |
| Document routing by type | Explicit tool-based handoff |
| Multi-step research + write + review | State-shared handoff |
| High-stakes financial/medical | Escalation handoff to human |
| Research pipeline (steps always same order) | State-shared with sequential edges |
Frequently Asked Questions
Official documentation
Can I combine handoff patterns in one system?
Yes. Common: explicit tool handoff at the top level (route to specialist) + state-shared within each specialist’s sub-workflow. Different patterns for different levels of the agent hierarchy.
How do I prevent lost conversation history during handoff?
Use state-shared pattern, keep conversation messages in shared state, every agent reads them. For explicit tool-based handoff, pass the full message history as part of the handoff context. Never let an agent lose track of what has already been said.
What is the cost of handoffs?
Each handoff is an LLM call (the source agent decides to hand off). Explicit tool-based: 1 extra call per handoff. Context-passing: 1 extra call plus the context tokens added to the next agent’s prompt. State-shared: no extra LLM call, just a state read. At scale (millions of agent runs), these differences matter.
Can a human be one of the agents in the handoff chain?
Yes. Model human as an agent that pauses the system and waits for input. LangGraph’s interrupt_before fits this cleanly. Human responds, system continues with the human’s input as part of state.
How do I handle handoff to an unavailable agent?
Fallback chain. Triage tries to route to specialist A; if A is at capacity (rate limit, unavailable), orchestrator picks specialist B instead. For human escalation, if no human is available in business hours, queue the task for next-day review and reply to user with ETA.
Is handoff pattern worth the complexity for small systems?
Depends. If you have 2-3 agents, explicit tool-based or state-shared is low overhead. If you have one monolithic agent that handles everything, do not introduce handoffs just for architecture purity. Add handoffs when specialist agents measurably outperform the monolith on their specific tasks.
