teachyou.ai academy
← All posts
AI Agents

Agent Handoff Patterns: When One Agent Passes Work to Another

Pramod Dutta · Jul 1, 2026 · 15 min read

Somewhere around the third time a support-triage agent silently dropped a customer's order ID before passing the ticket to a refund agent, we stopped calling it a bug and started calling it what it actually was: a missing protocol. Multi-agent systems fail less often because a single agent reasons badly and more often because two agents disagree, implicitly, about what "done" means and what the next agent is owed. The handoff — the moment work crosses from one agent to another — is where most production incidents in agentic systems actually live. This article is about designing that moment on purpose instead of leaving it to whatever the last person who touched the orchestrator happened to type into a prompt.

Why Handoffs Are the Hard Part

Single-agent systems don't have a handoff problem because there's nothing to hand off to. The moment you introduce a second agent — a specialist for coding, a specialist for research, a specialist for customer refunds — you introduce a boundary, and boundaries are where information gets lost, misinterpreted, or duplicated.

Three failure modes show up constantly in production multi-agent systems:

  • Context loss — the receiving agent doesn't get the reasoning that led to its task, only the task itself, so it re-derives (and sometimes contradicts) decisions already made upstream.
  • Silent partial completion — the sending agent believes it finished its part, but the handoff payload is missing a field the receiver assumes exists, and nobody notices until the receiver crashes or hallucinates a substitute value.
  • Duplicate or conflicting actions — both agents believe they own a side effect (like sending an email or charging a card), and the handoff doesn't make ownership unambiguous.

None of these are model-capability problems. GPT-4-class and Claude-class models are perfectly capable of following instructions handed to them cleanly. The failures are almost entirely protocol failures — the interface between agents is underspecified, and underspecified interfaces fail exactly like they do in distributed systems: intermittently, expensively, and usually in production rather than in your test suite.

Treating agent handoffs as an API contract problem, not a prompting problem, is the single highest-leverage mental shift you can make when building multi-agent systems.

The Anatomy of a Handoff

Before looking at patterns, it helps to name the parts of a handoff explicitly, because most teams design half of them and leave the rest implicit.

  • Trigger — what condition causes agent A to stop and hand off to agent B. A completed sub-task? A confidence threshold? An explicit tool call?
  • Payload — the actual data crossing the boundary: task description, accumulated context, artifacts (files, IDs, retrieved documents), and constraints.
  • Contract — the schema the payload must satisfy, and what the receiving agent is allowed to assume without re-verifying.
  • Acknowledgment — how the receiving agent confirms it accepted the handoff and understood the contract, before doing expensive work.
  • Fallback — what happens when the receiving agent rejects the handoff, times out, or produces output that fails validation.

Most home-grown multi-agent systems implement the payload and skip everything else. That's the equivalent of writing a REST API with a request body but no status codes, no validation, and no retry semantics. It works in the demo. It does not work at 2am when agent B gets a payload agent A never intended to send.

Pattern 1: The Structured Handoff Envelope

The simplest fix with the highest payoff is to stop passing free-text summaries between agents and start passing a structured envelope with explicit fields. This is not a new idea — it's just applying "typed interfaces" to agent-to-agent communication instead of leaving it as prose that the next agent has to re-parse with its own judgment.

from dataclasses import dataclass, field
from typing import Any, Literal
from datetime import datetime
import uuid

@dataclass
class HandoffEnvelope:
    handoff_id: str = field(default_factory=lambda: str(uuid.uuid4()))
    from_agent: str = ""
    to_agent: str = ""
    task: str = ""
    # Everything the receiver needs to avoid re-deriving upstream decisions
    context: dict[str, Any] = field(default_factory=dict)
    # Concrete artifacts: file paths, record IDs, retrieved docs
    artifacts: dict[str, Any] = field(default_factory=dict)
    # Hard constraints the receiver must not violate
    constraints: list[str] = field(default_factory=list)
    confidence: float = 1.0
    status: Literal["pending", "accepted", "rejected", "completed", "failed"] = "pending"
    created_at: str = field(default_factory=lambda: datetime.utcnow().isoformat())

    def validate(self) -> list[str]:
        errors = []
        if not self.task:
            errors.append("task is required")
        if not self.to_agent:
            errors.append("to_agent must be specified")
        if self.confidence < 0 or self.confidence > 1:
            errors.append("confidence must be between 0 and 1")
        return errors

The constraints field is doing more work than it looks like. It's where agent A encodes things like "do not issue a refund above $200 without escalation" or "the customer has already been told a 48-hour SLA" — facts that change what a correct answer looks like but that agent B has no way to know unless they're carried across the boundary explicitly. Free-text handoffs bury this in a paragraph the receiver may or may not attend to. A structured field makes it checkable.

def build_handoff(sender: str, receiver: str, task: str,
                   conversation_history: list[dict], case_data: dict) -> HandoffEnvelope:
    envelope = HandoffEnvelope(
        from_agent=sender,
        to_agent=receiver,
        task=task,
        context={
            "summary": summarize(conversation_history),
            "prior_decisions": extract_decisions(conversation_history),
        },
        artifacts={"case_id": case_data["id"], "customer_id": case_data["customer_id"]},
        constraints=derive_constraints(case_data),
    )
    errors = envelope.validate()
    if errors:
        raise ValueError(f"Invalid handoff: {errors}")
    return envelope

Notice that validation happens before the handoff leaves agent A, not after agent B fails. Catching a malformed handoff at the source is cheap. Catching it after the receiving agent has spent three tool calls acting on bad data is not.

Pattern 2: Explicit Acknowledgment Before Work Begins

A handoff without acknowledgment is a fire-and-forget message, and fire-and-forget is the wrong delivery guarantee for anything that costs money or time to redo. The receiving agent should have a distinct "accept" step where it checks the envelope against its own capabilities before committing to the task.

class ReceivingAgent:
    def __init__(self, name: str, capabilities: set[str]):
        self.name = name
        self.capabilities = capabilities

    def acknowledge(self, envelope: HandoffEnvelope) -> tuple[bool, str]:
        if envelope.to_agent != self.name:
            return False, f"Envelope addressed to {envelope.to_agent}, not {self.name}"

        required_capability = classify_task(envelope.task)
        if required_capability not in self.capabilities:
            return False, f"{self.name} lacks capability: {required_capability}"

        missing_artifacts = check_required_artifacts(envelope.task, envelope.artifacts)
        if missing_artifacts:
            return False, f"Missing required artifacts: {missing_artifacts}"

        return True, "accepted"

    def process(self, envelope: HandoffEnvelope) -> HandoffEnvelope:
        accepted, reason = self.acknowledge(envelope)
        if not accepted:
            envelope.status = "rejected"
            envelope.context["rejection_reason"] = reason
            return envelope

        envelope.status = "accepted"
        result = self.run_task(envelope)
        envelope.status = "completed" if result.ok else "failed"
        envelope.context["result"] = result.data
        return envelope

This looks like boilerplate until you've watched a triage agent hand a legal-review task to a coding agent because the orchestrator's routing logic had an off-by-one in an intent classifier. The acknowledgment step is the receiving agent's chance to say "this isn't for me" before it burns tokens improvising an answer it was never equipped to give.

Pattern 3: Context Compression, Not Context Dumping

A tempting shortcut is to hand the entire conversation history to the next agent so nothing gets lost. This backfires in two ways: it blows through context windows on long-running workflows, and it forces the receiving agent to re-read and re-interpret material that was already resolved upstream, sometimes reaching a different conclusion the second time.

The better approach is deliberate compression: agent A distills the conversation into decisions, open questions, and relevant facts, and discards the rest.

def compress_context(history: list[dict], task_for_next_agent: str) -> dict:
    """
    Produce a compact context object instead of forwarding raw transcript.
    """
    decisions = [turn for turn in history if turn.get("type") == "decision"]
    facts = [turn for turn in history if turn.get("type") == "fact"]
    open_questions = [turn for turn in history if turn.get("type") == "unresolved"]

    return {
        "decisions_made": [d["content"] for d in decisions],
        "relevant_facts": [f["content"] for f in facts if is_relevant(f, task_for_next_agent)],
        "open_questions": [q["content"] for q in open_questions],
        "turn_count_discarded": len(history) - len(decisions) - len(facts) - len(open_questions),
    }

The turn_count_discarded field is a small but useful signal — it lets you audit, in production, how much your compression step is throwing away, so you can catch a compression prompt that's being too aggressive before it causes a downstream failure. Treat context compression itself as something that can silently degrade, because summarization is a place where models are prone to dropping the one caveat that mattered.

A related rule worth stating explicitly: never let the receiving agent be the first to discover that a decision was already made. If agent A decided the customer is not eligible for a refund and hands off to a "draft response" agent, that decision belongs in decisions_made, not left for the drafting agent to re-evaluate from raw transcript, where it might reach a different, contradictory answer.

Pattern 4: Ownership and Idempotency for Side Effects

The scariest handoff bugs aren't the ones where an agent gives a wrong answer — they're the ones where two agents both execute a side effect because ownership of the action was ambiguous during the handoff. If agent A "hands off" a task to send a confirmation email but doesn't clearly relinquish that responsibility, and agent B also decides to send one because its instructions say "make sure the customer is notified," the customer gets two emails, and now you have a debugging session instead of a feature.

Two disciplines fix most of this: single-writer ownership per side effect, and idempotency keys on anything that isn't naturally idempotent.

import hashlib

def idempotency_key(action: str, envelope: HandoffEnvelope) -> str:
    basis = f"{envelope.handoff_id}:{action}:{envelope.artifacts.get('case_id', '')}"
    return hashlib.sha256(basis.encode()).hexdigest()

class SideEffectGuard:
    def __init__(self, store):
        self.store = store  # any key-value store shared across agents

    def execute_once(self, action: str, envelope: HandoffEnvelope, fn):
        key = idempotency_key(action, envelope)
        if self.store.get(key):
            return {"skipped": True, "reason": "already executed", "key": key}
        result = fn()
        self.store.set(key, {"executed_at": datetime.utcnow().isoformat(), "result": result})
        return {"skipped": False, "result": result}

The ownership rule is simpler to state than to enforce culturally on a team: every side effect (send email, charge card, write to database, call an external API) has exactly one agent role responsible for triggering it, and that responsibility is stated in the handoff contract, not inferred from context. When you're designing the handoff envelope, add an explicit owns_side_effects: list[str] field if your system does anything irreversible. It costs one field and prevents an entire category of production incidents.

Pattern 5: Confidence-Gated Handoffs and Escalation

Not every handoff should happen automatically. When agent A is uncertain — genuinely uncertain, not just hedging — the right move is often to route to a human or a more capable agent rather than hand off confidently to a peer that will compound the uncertainty.

def route_handoff(envelope: HandoffEnvelope, confidence_threshold: float = 0.7):
    if envelope.confidence < confidence_threshold:
        return escalate_to_human(envelope)

    ambiguous_task = classify_task(envelope.task) == "ambiguous"
    high_stakes = "financial_impact" in envelope.constraints or "irreversible" in envelope.constraints

    if ambiguous_task and high_stakes:
        return escalate_to_human(envelope)

    return dispatch_to_agent(envelope)

def escalate_to_human(envelope: HandoffEnvelope):
    envelope.status = "pending"
    envelope.context["escalation_reason"] = (
        f"confidence={envelope.confidence:.2f} below threshold, "
        f"or task is ambiguous and high-stakes"
    )
    notify_human_reviewer(envelope)
    return envelope

The two-part check here matters: low confidence alone doesn't always warrant escalation (a low-stakes task with modest confidence is fine to let an agent attempt and retry), but the combination of an ambiguous classification and a high-stakes constraint is a strong signal that automating the handoff is the wrong call. Building this gate directly into your routing logic — rather than hoping the receiving agent notices it's out of its depth — is what separates systems that fail gracefully from ones that fail expensively.

Pattern 6: Handling Rejected and Failed Handoffs

A handoff protocol isn't complete until you've decided what happens when it doesn't work — the receiving agent rejects the envelope, times out, or completes but produces output that fails validation. Treat this the way you'd treat error handling in any distributed system: with retries that have limits, and a terminal path that doesn't just silently disappear.

class HandoffOrchestrator:
    def __init__(self, agents: dict, max_retries: int = 2):
        self.agents = agents
        self.max_retries = max_retries

    def dispatch(self, envelope: HandoffEnvelope, attempt: int = 0):
        agent = self.agents.get(envelope.to_agent)
        if agent is None:
            return self._terminal_failure(envelope, "no such agent registered")

        result = agent.process(envelope)

        if result.status == "completed":
            return result

        if result.status == "rejected":
            reroute_target = self._find_alternate_agent(envelope)
            if reroute_target and attempt < self.max_retries:
                envelope.to_agent = reroute_target
                return self.dispatch(envelope, attempt + 1)
            return self._terminal_failure(result, "no alternate agent available")

        if result.status == "failed" and attempt < self.max_retries:
            return self.dispatch(envelope, attempt + 1)

        return self._terminal_failure(result, "max retries exceeded")

    def _terminal_failure(self, envelope: HandoffEnvelope, reason: str):
        envelope.status = "failed"
        envelope.context["terminal_reason"] = reason
        notify_human_reviewer(envelope)
        return envelope

    def _find_alternate_agent(self, envelope: HandoffEnvelope) -> str | None:
        required = classify_task(envelope.task)
        for name, agent in self.agents.items():
            if name != envelope.to_agent and required in agent.capabilities:
                return name
        return None

The two details worth calling out: retries are bounded (unbounded retry loops between agents are a classic way to burn an API budget overnight), and every terminal failure notifies a human rather than vanishing into a log file nobody reads until a customer complains. A handoff protocol that can fail silently is not meaningfully better than no protocol at all.

Designing Handoff Boundaries: Where to Split Agents

A question that comes up constantly once teams accept that handoffs need protocol: where should the boundaries between agents actually be? Two heuristics hold up well in practice.

  • Split along capability, not along conversation turns. An agent boundary should correspond to a genuinely different skill set or tool access — a research agent versus a code-writing agent versus a agent with database write access — not an arbitrary point in a dialogue. Splitting by turn count creates handoffs with no natural contract, because there's no capability difference to define one.
  • Split where accountability should change. If a task moves from "draft a recommendation" to "execute an irreversible action," that's a natural and valuable boundary, because it's exactly where you want an explicit acknowledgment step and possibly a human check, per Pattern 5. If two "agents" would otherwise always agree and never need to validate each other's work, you may not need two agents — you need one agent with two phases in the same prompt, and no handoff overhead at all.

A useful gut check: if you can't articulate what agent B is allowed to assume without re-checking, agent A and agent B don't have a boundary yet — they have a fuzzy zone that will eventually produce a bug ticket.

Testing Handoffs Like You'd Test an API

Because a handoff is a contract, it can and should be tested like one — independent of whether either agent's internal reasoning is "good." Write tests that construct malformed envelopes and assert the receiver rejects them cleanly, not tests that only exercise the happy path where everything is populated correctly.

def test_receiver_rejects_missing_capability():
    agent = ReceivingAgent(name="refund_agent", capabilities={"refunds", "billing"})
    envelope = HandoffEnvelope(
        to_agent="refund_agent",
        task="rewrite this legal contract clause",
    )
    accepted, reason = agent.acknowledge(envelope)
    assert not accepted
    assert "capability" in reason

def test_receiver_rejects_missing_artifacts():
    agent = ReceivingAgent(name="refund_agent", capabilities={"refunds"})
    envelope = HandoffEnvelope(
        to_agent="refund_agent",
        task="process refund",
        artifacts={},  # missing case_id, customer_id
    )
    accepted, reason = agent.acknowledge(envelope)
    assert not accepted
    assert "artifacts" in reason

def test_idempotent_side_effect_runs_once():
    guard = SideEffectGuard(store=InMemoryStore())
    envelope = HandoffEnvelope(handoff_id="abc123", artifacts={"case_id": "case_1"})
    calls = {"count": 0}

    def send_email():
        calls["count"] += 1
        return "sent"

    guard.execute_once("send_confirmation", envelope, send_email)
    guard.execute_once("send_confirmation", envelope, send_email)
    assert calls["count"] == 1

These tests catch exactly the bugs that make it to production in real systems: a receiver that silently accepts a task it can't do, an envelope missing a field the receiver assumed would always be there, and a side effect that fires twice because idempotency wasn't actually wired up. None of this requires mocking an LLM call — it's testing the plumbing, and the plumbing is what breaks.

Putting It Together

The patterns above aren't independent tricks — they compose into a single discipline: treat every agent-to-agent boundary as a versioned, validated, observable interface, the same way you'd treat a service boundary in any distributed system. Concretely, that means:

  • A structured envelope instead of free-text summaries, with fields for context, artifacts, and constraints.
  • An explicit acknowledgment step where the receiver checks fit before doing work.
  • Deliberate context compression instead of forwarding raw history.
  • Clear ownership of side effects, backed by idempotency keys.
  • Confidence and stakes-based routing to escalate rather than auto-handoff when uncertainty is high.
  • Bounded retries and a terminal failure path that always reaches a human.
  • Tests written against the contract, not just against the happy path.

None of this requires exotic infrastructure. Most of the code above is a few dataclasses, a validation function, and a key-value store you probably already have. The discipline is in refusing to let "the model will probably figure it out" stand in for an actual interface. Models are good at filling in reasonable defaults, and that's precisely the problem — reasonable defaults chosen independently by two agents are exactly how you end up with a duplicated refund, a dropped constraint, or a customer getting two contradictory emails in the same afternoon.

If you're building systems where agents genuinely divide labor — not just chain prompts, but hand off ownership of a task mid-flight — this is the part of the system that will determine whether it's reliable enough to run unattended. It's less glamorous than prompt engineering and considerably more load-bearing.

We cover this exact design space — orchestration, handoff contracts, failure recovery, and the testing discipline around them — hands-on in 30 Days of Hermes Agent, our cohort course on building production multi-agent systems from scratch. If handoffs are the part of your agent stack currently held together with hope, that's where we'd start.

Agent Handoff Patterns: When One Agent Passes Work to Another · TeachYou Academy