How to Architect Agent Handoffs and State Passing Without Losing Context or Forcing Expensive Replanning

When a multi-agent AI system fails in production, the culprit is almost never the model. It is the handoff. Context gets lost between a researcher and a drafter; a reviewer receives a bloated 200-message thread it has to process from scratch; a constraint changes mid-workflow and the whole plan re-runs from step one. These are orchestration failures, not capability failures, and they are entirely avoidable with the right architecture.

The core insight: specialized AI agents should pass structured, filtered state objects to each other, not raw conversation history. Combined with explicit handoff contracts, three-tier memory design, and selective replanning, this pattern eliminates the two biggest costs in multi-agent systems: quadratic token spend at handoff boundaries and cascading replanning when goals shift. This article shows you how to build it.

Why Full-Context Forwarding Destroys Your Budget

The naive approach to agent handoffs is also the most tempting: hand the next agent the full conversation history and let it figure out what it needs. This feels safe because nothing is lost. The problem is cost.

Consider a pipeline with five agents and a 50-message working thread. If each agent receives the cumulative history from all predecessors, the fifth agent processes roughly 200 messages. Scale that to a real workload: a 100-message thread with eight stages means the final agent reads 800 messages, most of which are irrelevant to its role. Because each successive handoff inherits one more agent's output, the context size grows linearly with each crossing and total pipeline token spend becomes quadratic in the number of stages.

The fix is to make each handoff constant in context size by passing only role-relevant fields in a typed state object instead of the raw thread. Each agent reads what it needs and writes its output to a named field. The next agent gets that field, not the chain of reasoning that produced it.

# Fragile: full context forwarding
def handoff_to_drafter(conversation_history: list[dict]) -> str:
    # Passes everything. Agent 2 now re-processes researcher's search loop.
    return drafter_agent.run(messages=conversation_history)

# Better: structured state object, role-filtered at the boundary
class PipelineState(TypedDict):
    topic: str
    research_findings: str    # researcher writes here
    draft: str                # drafter writes here
    review_notes: str         # reviewer writes here
    acceptance_criteria: str  # set at pipeline start

def handoff_to_drafter(state: PipelineState) -> str:
    drafter_context = {
        "topic": state["topic"],
        "research_findings": state["research_findings"],
        "acceptance_criteria": state["acceptance_criteria"],
    }
    return drafter_agent.run(context=drafter_context)

The drafter gets exactly what it needs to write: a topic, the research, and the bar it must meet. It does not get the researcher's 30-step web search log.

Three Patterns for Moving State Across Agent Boundaries

Not every multi-agent system should use the same coordination style. Three patterns dominate production use.

Explicit handoff. Agent A calls a special handoff tool or emits a structured handoff request. An orchestration layer validates the request, filters the context, and routes to agent B with a clean, scoped payload. This is the most token-efficient and auditable pattern. It enforces single responsibility and makes the call graph inspectable.

Shared scratchpad. All agents read and write to a shared memory object. No filtering happens at boundaries; every agent sees everything. This is the simplest to implement and easiest to reason about in small teams of two or three agents. It breaks down past that point because state grows unbounded and concurrent writes create conflicts that look exactly like software race conditions.

Tool-calling. Agents treat each other as callable APIs: agent A invokes agent B as a tool, receives structured output, and continues. This pattern composes naturally with existing tool infrastructure and keeps each agent stateless from the caller's perspective. The tradeoff is that context must be serialized into tool arguments on every call, which introduces its own surface for data loss.

Most production systems use explicit handoffs for cross-role transitions (researcher to drafter, drafter to reviewer) and tool-calling for lightweight sub-tasks within a role. The shared scratchpad survives in small systems where simplicity matters more than scale.

Designing the Typed State Channel

A typed state channel is a schema that defines exactly what information exists in the pipeline and which agents are authorized to read or write each field. It is the single most important architectural decision in a multi-agent system.

A minimal state channel for a three-agent writing pipeline might look like this:

from typing import TypedDict, Optional

class ArticleState(TypedDict):
    # Pipeline metadata (read by all, written by orchestrator)
    topic: str
    slug: str
    acceptance_criteria: str

    # Researcher domain (written by researcher, read by drafter)
    research_findings: Optional[str]
    source_urls: Optional[list[str]]

    # Drafter domain (written by drafter, read by reviewer)
    draft: Optional[str]
    word_count: Optional[int]

    # Reviewer domain (written by reviewer, read by orchestrator/UI)
    final_article: Optional[str]
    review_notes: Optional[str]
    approved: Optional[bool]

Each agent receives a filtered view. The reviewer never sees source_urls. The drafter never sees review_notes. This is not just an optimization: it prevents agents from reasoning about information outside their domain, which reduces hallucination and makes failures easier to diagnose.

Versioning the schema matters too. If the acceptance_criteria field changes shape between pipeline runs, downstream agents handed old-format state will fail silently. Treat the state schema like an API contract and version it explicitly.

Memory Tiers: What Each Agent Actually Needs

Beyond the structured handoff object, agents in long-running or multi-session systems need access to memory at three different time scales. Giving every agent access to every tier is expensive and counterproductive.

Short-term memory is the working context for the current task: the live state channel, recent tool outputs, and the agent's own reasoning trace. This lives in the active session and is discarded when the task completes.

Long-term memory is persistent knowledge that spans sessions: user preferences, project conventions, historical quality feedback, recurring topics to avoid. A reviewer agent might query long-term memory to check whether a similar article was published six months ago.

Episodic memory is a semantic index of specific past interactions, retrieved by similarity rather than direct lookup. A researcher agent might query episodic memory to find work from a related topic rather than re-running the same web searches.

The key scoping rule: an agent should read only the tier relevant to its role. A drafter working on a single article does not need to query episodic memory of past articles; that retrieval step adds latency and token cost with no benefit. An orchestrator assigning topics, however, might query episodic memory specifically to avoid repetition.

class AgentMemoryView:
    """
    Scoped memory interface. Each agent gets a view
    restricted to what its role needs.
    """
    def __init__(self, agent_role: str, session_id: str, memory_store):
        self.role = agent_role
        self.session_id = session_id
        self.store = memory_store

    def get_working_context(self) -> dict:
        # Short-term: current session only
        return self.store.get_session(self.session_id)

    def get_user_preferences(self) -> dict:
        # Long-term: available to reviewer and orchestrator, not researcher
        if self.role not in ("reviewer", "orchestrator"):
            raise PermissionError(f"{self.role} cannot access long-term user memory")
        return self.store.get_long_term("user_preferences")

    def query_past_articles(self, query: str, top_k: int = 3) -> list[dict]:
        # Episodic: available to orchestrator (topic selection) only
        if self.role != "orchestrator":
            raise PermissionError(f"{self.role} cannot query episodic article history")
        return self.store.semantic_search(query, collection="articles", top_k=top_k)

Enforcing these boundaries in code, rather than relying on prompt instructions, catches access violations at the boundary where they occur rather than after they silently corrupt downstream reasoning.

Handoff Contracts: Preventing Silent Corruption

The most damaging multi-agent failure mode is a quiet one: agent A writes malformed or incomplete state, agent B reads it without complaint, and the error only surfaces in the final output, three agents downstream. By that point, tracing the cause is expensive.

Explicit handoff contracts prevent this by validating state at every boundary. A contract defines:

  • Which agent is allowed to invoke the handoff
  • Which downstream agents are valid targets
  • The schema and required fields of the payload
  • Version constraints on the state object

Here is a minimal implementation of a validated handoff request:

from dataclasses import dataclass

ALLOWED_TRANSITIONS = {
    "researcher": ["drafter"],
    "drafter": ["reviewer"],
    "reviewer": ["orchestrator"],
}

@dataclass
class HandoffRequest:
    from_agent: str
    to_agent: str
    payload: dict
    schema_version: str = "1.0"

    def validate(self):
        allowed = ALLOWED_TRANSITIONS.get(self.from_agent, [])
        if self.to_agent not in allowed:
            raise ValueError(
                f"{self.from_agent} is not allowed to hand off to {self.to_agent}. "
                f"Allowed targets: {allowed}"
            )
        if not self.payload.get("topic"):
            raise ValueError("Handoff payload must include 'topic'")
        # Add field-level validation per schema_version here

class Orchestrator:
    def route(self, request: HandoffRequest) -> str:
        request.validate()
        agent = self.agents[request.to_agent]
        return agent.run(context=request.payload)

This pattern catches topology violations immediately and keeps agent A from directly calling agent C, which would bypass validation and break the audit trail.

Choosing Your Orchestration Topology

Three coordination topologies appear in production multi-agent systems, and the right choice depends on your task structure, fault-tolerance budget, and team size.

Centralized: One orchestrator routes all handoffs. It holds the state channel, validates every transition, and decides which agent runs next. This topology is the easiest to audit and debug: the call graph is linear and observable. The downside is a single point of failure and a potential bottleneck when many agents run in parallel.

Decentralized: Agents negotiate handoffs directly with each other using a shared protocol, such as the Agent-to-Agent (A2A) standard. This topology is more resilient and scales to large agent networks, but it is significantly harder to debug because no single component holds the full picture of what happened and why.

Hierarchical: Multi-level delegation, where a top-level orchestrator delegates to sub-orchestrators that each manage a cluster of specialists. This scales to large systems but adds coordination overhead and makes tracing failures across levels complex.

For most teams building pipelines of three to five agents, centralized orchestration wins on simplicity and debuggability. Move to hierarchical only when the pipeline genuinely splits into independent sub-workflows that can run in parallel. Consider decentralized only if you are building federated, cross-organization agent networks where no single party should own the orchestration layer.

Replanning Without Starting Over

Goal changes mid-workflow are expensive in naive systems because most implementations treat any change as an instruction to discard all prior work and replan from scratch. If your reviewer sends a draft back to the researcher for additional sourcing and your system restarts the researcher from zero, you are paying for a full research pass when you only needed a targeted supplement.

The better approach separates the parts of a plan that remain valid under the new goal from the parts that must change, then rolls back only the latter. Each agent stage checkpoints its output explicitly, and the orchestrator tracks which stages produced output that still satisfies the current constraints. When a reviewer requests changes, the orchestrator asks: does the existing research still meet the new requirements? If yes, skip the researcher and rerun only the drafter with supplementary instructions. If no, restart the researcher with a targeted query, not a clean slate.

class CheckpointedOrchestrator:
    def __init__(self, state: ArticleState, checkpoints: dict):
        self.state = state
        self.checkpoints = checkpoints  # stage_name -> (output, constraints_met)

    def replan(self, changed_constraints: dict) -> list[str]:
        """
        Returns the list of stages that must re-run given changed constraints.
        Stages whose outputs remain valid are preserved.
        """
        stages_to_rerun = []
        for stage in ["researcher", "drafter", "reviewer"]:
            if stage not in self.checkpoints:
                stages_to_rerun.append(stage)
                continue
            output, original_constraints = self.checkpoints[stage]
            if self._constraints_still_satisfied(output, original_constraints, changed_constraints):
                # Output is still valid; skip this stage
                continue
            # This stage and all downstream stages must re-run
            stages_to_rerun.append(stage)
            downstream_index = ["researcher", "drafter", "reviewer"].index(stage)
            stages_to_rerun.extend(
                ["researcher", "drafter", "reviewer"][downstream_index + 1:]
            )
            break
        return stages_to_rerun

    def _constraints_still_satisfied(self, output, original, updated) -> bool:
        # Domain-specific check: does the existing output meet the new constraints?
        # For example: word count, tone, topic scope
        raise NotImplementedError

The key discipline: every stage must record its output AND the constraints it was given, so the orchestrator can evaluate validity against new constraints later.

Measuring Cost at the Boundary

The final architectural habit that distinguishes production systems from prototypes is measuring token spend per handoff boundary rather than per pipeline run. Per-run totals hide where the money goes.

Track three metrics at each boundary:

  1. Input tokens delivered to the agent (reveals context bloat)
  2. Wall-clock time from handoff to first output token (reveals replanning latency)
  3. Number of model calls the agent makes (reveals agents doing work that belongs to a different stage)

When these numbers are visible per boundary, expensive handoffs become immediately obvious. A drafter consuming 40% of total pipeline tokens usually means it is receiving too much raw context from the researcher. A reviewer with a high model call count usually means it is performing tasks (like fact-checking) that should belong to the researcher stage.

Anthropic's multi-agent harness for long-running software development tracks exactly these metrics per phase and uses strategic context resets between stages to keep each agent operating well below its context limit. The pattern applies to any sequential agent pipeline: measure at the boundary, cut context that does not belong, and reset when cumulative context would push the model into cautious, slower reasoning.

Conclusion

Architecting multi-agent handoffs is fundamentally a data modeling problem disguised as an AI problem. The models are capable. What fails is the plumbing between them: unbounded context forwarding, absent validation, flat memory structures with no scoping, and orchestrators that treat every goal change as a full restart.

The patterns that work are consistent across frameworks: typed state channels, role-filtered context views, three-tier memory with enforced access boundaries, validated handoff contracts, and checkpointed stages that support surgical replanning. None of these require a specific framework, though LangGraph, CrewAI, and the Anthropic Agent SDK all provide scaffolding for them.

Get the handoffs right and the rest of the system becomes a question of prompting and model selection, which is the pleasant problem. Get them wrong and you will spend your optimization budget fighting token costs and cascading failures that have nothing to do with how well your agents reason.

Subscribe to Marvin G6 | Engineering, AI & Automation

Don’t miss out on the latest issues. Sign up now to get access to the library of members-only issues.
jamie@example.com
Subscribe