Showing posts with label Architecture. Show all posts
Showing posts with label Architecture. Show all posts

Tuesday, April 28, 2026

The MCP Marketplace Mistakes: Lessons from the 2014 API Gold Rush

Hero image showing two parallel timelines, the 2014 public API marketplace boom on top with logos collapsing to grey silhouettes, and the 2026 MCP server explosion on the bottom with a question mark, dark technical aesthetic with amber alert highlights

Introduction

A friend pinged me last Friday with a screenshot of an MCP server registry he had been building for an internal team. The list had 47 entries: third-party servers for Slack, Linear, GitHub, Google Calendar, Stripe, Notion, Confluence, the team's own internal CRM, two flavours of Postgres, and so on. He asked, half-jokingly, whether he should bother writing documentation for any of it because most of these probably would not exist in 18 months. I sat with that question for a while. Then I went back through my old notes from 2014, when I worked on a public API integration platform during the previous wave of marketplace optimism, and the parallels were uncomfortably exact.

That 2014 wave is the one most engineers under 30 have not lived through. It was the era of public API marketplaces: ProgrammableWeb listed 12,000 APIs by year-end, every SaaS company had a developer portal, and the prevailing assumption in product strategy decks was that "every business will have an API in five years." The prediction came true in a literal sense, but the marketplace dynamics that everyone expected, the ones that justified the marketplace investment, mostly did not. Most of those 12,000 APIs were dead or unmaintained by 2018. The marketplaces themselves, the directory businesses that aggregated and indexed APIs, had a brutal consolidation. ProgrammableWeb itself got acquired and eventually shuttered in 2023.

In April 2026 the talk track around the Model Context Protocol sounds eerily similar. Anthropic reports MCP running on more than 10,000 enterprise servers and roughly 97 million SDK downloads. New MCP server registries are launching every few weeks. Hacker News posts are predicting that "every SaaS company will have an MCP server in 18 months." That last line is almost word-for-word from a 2014 TechCrunch article about API directories. The structural pattern is almost identical, and the structural failure modes from 2014 are almost certainly going to repeat unless someone notices in time.

This post is the postmortem from the previous cycle, mapped onto the current MCP wave, with concrete recommendations for engineering leaders making MCP server bets in the next 12 months.


What the 2014 API gold rush actually looked like

The 2014 wave had three structural features that defined how it played out.

The first was the marketplace assumption. The thesis was that there would be a winner-take-all directory of APIs, and that whoever ran the best directory would extract economic rent from a long tail of API providers and consumers. ProgrammableWeb, Mashape, Apigee, and a handful of others competed for this position. None of them won permanently because the directory was always less valuable than the individual best-of-breed APIs. Stripe did not need ProgrammableWeb. Twilio did not need Mashape. The directories ended up with the long tail of low-quality APIs and lost the high-value transactions to direct integrations.

The second was the infinite-supply assumption. The expectation in 2014 was that the supply of useful APIs would keep growing exponentially. The reality was that 80 percent of the top-100 APIs by transaction volume in 2018 already existed in 2014. Stripe, Twilio, AWS, GitHub, Salesforce, Slack: these were already there. The new APIs that launched in 2014 to 2018 mostly did not break into the top 100. The market was much more winner-takes-most than the marketplace narrative implied.

The third was the integration glue assumption. The thesis was that connecting APIs would itself become a market, with platforms like Zapier, IFTTT, and Workato extracting value from the workflow layer between APIs. This one partially came true, but the winners turned out to be a small number of platforms with deep integrations into a narrow set of high-value APIs, not a long tail of "API-to-API connectors" for arbitrary pairs.

The shared feature of all three failed assumptions is that they treated the marketplace as the locus of value. In reality the value was at the endpoints (the actual high-quality APIs) and at a few thick middle layers (Zapier, Workato, the cloud providers). The marketplace itself was thin and not defensible.

Architecture diagram showing the 2014 API marketplace topology with a big directory in the middle and many small APIs at the edge, vs the actual outcome of a small number of dominant APIs with direct integrations and a few thick middleware layers, dark technical aesthetic

How MCP in 2026 maps onto the 2014 pattern

The MCP ecosystem in April 2026 has all three of the same structural features and is on track to repeat the same outcomes if no one course-corrects.

The marketplace assumption is showing up as MCP server registries. There are now at least four serious contenders for "the registry of MCP servers": Anthropic's own listing, Smithery, mcp.so, and the official Model Context Protocol GitHub registry. Each is positioning to become the directory layer. The exact same dynamic from 2014 applies: the highest-value MCP servers (the GitHub server, the Linear server, the Stripe server when it launches) will not need a registry to be discovered, and the registries will end up with the long tail of low-quality servers competing for discovery rent that does not exist.

The infinite-supply assumption is showing up in headcount. By April 2026 my own ad-hoc count has at least 2,400 publicly listed MCP servers, with a steep distribution: the top 50 have meaningful weekly traffic, and the bottom 2,200 see less than one install per month. Most teams I talk to expect this distribution to keep widening as the market matures, not narrow. The 2014 pattern says the opposite: the dominant servers will get more dominant and the long tail will mostly die quietly. By 2028 we should expect roughly 80 percent of MCP traffic to flow through fewer than 100 distinct servers.

The integration glue assumption is showing up as agent orchestration platforms. LangChain, LangGraph, n8n, Make, and several new AI-native orchestration startups are all positioning themselves as the layer that connects MCP servers to agents and to each other. Some of these will succeed. The 2014 pattern says only the ones that pick a thick vertical and integrate deeply will survive. The horizontal "any-MCP-to-any-agent" pitch will struggle to defend a margin once the major model providers ship native MCP support, which Anthropic and OpenAI both did in late 2025 and early 2026.

flowchart LR A[2014: Marketplace assumption] --> B[Directories thinned out] C[2014: Infinite supply] --> D[Top 100 APIs dominated] E[2014: Integration glue] --> F[Few thick verticals won] G[2026: MCP registries] --> B2[Same outcome predicted] H[2026: 2400+ MCP servers] --> D2[Top 100 will dominate] I[2026: Agent orchestration platforms] --> F2[Thick verticals will win] B --> P[2028 reality] D --> P F --> P B2 --> P2[Predicted 2028] D2 --> P2 F2 --> P2

The three failed assumptions of 2014 are recurring as three open assumptions in 2026, and the mathematical shape of marketplace dynamics has not changed in the 12 years between them.

What 2014 got wrong about discoverability

Discoverability was supposed to be the marketplace's primary value proposition. In practice it was the weakest part. ProgrammableWeb's directory had categorisation, tagging, search, ratings, and editorial curation. None of it generated meaningful integration volume because the discovery problem was not really about indexing.

The actual discoverability problem in 2014 was about trust calibration. A developer trying to integrate a payment API did not need to discover Stripe. Stripe was discoverable through a Google search, a Stack Overflow question, a competitor's public technical decision. The hard problem was deciding whether to trust a less-known payment processor whose marketing site looked legitimate and whose pricing was 30 percent lower. The marketplace did not help with that decision because the marketplace had no skin in the trust verdict.

MCP server discovery in 2026 is structurally identical. A team trying to integrate GitHub does not need an MCP registry to discover Anthropic's official GitHub MCP server. They need a registry to help them decide between three competing community-maintained Salesforce MCP servers, none of which has the same level of trust verification that Anthropic's first-party servers have. Today's MCP registries do not solve this trust calibration problem any better than ProgrammableWeb did in 2014. They list the servers, surface install counts, and let users vote. None of those signals reliably predict whether a given MCP server will be safely maintained 18 months from now.

The intervention that would actually work is a verification layer that a registry could plausibly own. Cryptographic signing of releases, third-party security audits of high-traffic servers, and a public maintenance commitment with a financial backstop are the pieces. The current MCP registries have none of these. The first registry to ship them will become a meaningful trust authority. The rest will continue competing on the wrong axis and consolidate or die out.

The vendor lock-in problem nobody is talking about

The 2014 API wave produced a generation of products that were locked into specific API providers because their architecture assumed permanent stability. When Twitter shut down its v1 API in 2018, hundreds of products that had been built on that integration died with two weeks of notice. Facebook's Graph API deprecations took down whole categories of social app companies. The lock-in cost was paid in lost product time spent on emergency rewrites.

MCP has the same lock-in shape with one twist. An MCP server is not just an API; it is an API plus a set of tool descriptions that the agent reads to decide how to use the API. If a server provider changes the tool descriptions, every agent built on top of that server can silently break in ways that are much harder to detect than an HTTP 410 response. The 2014 lock-in was visible failure. The 2026 lock-in includes invisible behavioural drift.

The teams I see handling this well take three precautions. First, they pin the version of every MCP server they depend on, the same way they pin npm packages, with a mcpfile.lock or equivalent. Second, they run a per-server eval suite weekly that checks whether the server's behaviour has drifted on a fixed set of test prompts. Third, they keep a thin compatibility layer between the agent and the MCP server so they can swap a server out for a competitor without rewriting agent prompts. None of these is provided by the MCP protocol itself, and most teams discover them after their first behavioural-drift incident.

flowchart TD A[Agent makes tool call] --> B{Server pinned to version?} B -->|No| C[Description may have changed silently] C --> D[Agent behaviour drifts] B -->|Yes| E[Description fixed at known version] E --> F{Eval suite green?} F -->|Yes| G[Safe to ship] F -->|No| H[Drift detected, hold release] D --> I[Production failure] G --> J[Production safe]

The economics that actually mattered in 2014 (and matter again in 2026)

The 2014 cycle taught one durable lesson about API economics that almost everyone forgets in real time: the value accrues to whoever owns the underlying business workflow, not to the integration layer. Stripe captured value because it owned payment processing. Twilio captured value because it owned messaging routing. Salesforce captured value because it owned the customer database. The integration layers, the marketplace directories, the API generation tools, the documentation platforms, all captured a sliver of the workflow value at best.

The 2026 MCP equivalent is straightforward. The MCP servers that will accrue real value are the ones that wrap the underlying business workflow that already had value. A Stripe MCP server is valuable because Stripe is valuable. A GitHub MCP server is valuable because GitHub is valuable. A community-maintained MCP server for a niche internal SaaS tool is valuable only to the small number of teams that already use that tool, and even then mostly because it saves them 30 minutes of integration time.

The corollary is that the standalone "MCP server" companies that have raised seed and Series A funding in late 2025 and early 2026 are mostly building on top of someone else's value. If an "MCP server for Notion" company succeeds, the most likely outcome is that Notion ships its own first-party MCP server in 12 to 18 months and the third-party version becomes a maintenance burden that the original founders abandon. The same pattern repeated dozens of times in the 2014 wave with API integrations, where the third-party Slack-API-wrapper product got obsoleted the moment Slack shipped a richer first-party API.

The investment thesis that survives 2014's failure pattern is not "MCP server for X" as a standalone product. It is "MCP-aware product within a thick vertical" or "MCP infrastructure for safety, evals, and observability." The first category has clear analogues in the 2014 cycle (the verticalised plays around payments, communications, and identity all worked). The second category has clear analogues too (Datadog, Sentry, Splunk, all built durable businesses on top of monitoring the integrations themselves rather than being one of the integrations).

The platform engineering checklist for MCP in 2026

If you are a platform engineer or engineering leader making MCP bets right now, the 2014 lessons translate into a concrete checklist.

Pin every MCP server version your agents depend on. The mcp.json file in your repo should have explicit version locks for every server, the same way package-lock.json works for npm. The current MCP CLI tools allow this but most team setups skip the pinning. Skipping is the single most common cause of behavioural drift incidents.

Run a behavioural eval suite per server, ideally daily, against a fixed set of 50 to 100 representative tool calls. The eval suite catches the cases where a server provider updates a tool description in a way that changes how your agent uses it. The fixed cost of running the eval is small. The cost of finding the drift in production is large.

Maintain a compatibility shim between your agent and the MCP layer for any server that has a viable alternative. The shim is a thin wrapper that translates the agent's expected interface into the specific server's interface. When you eventually need to swap servers, the shim is the only file that has to change rather than 40 prompt files.

Treat third-party MCP servers as supply-chain dependencies with the same rigor you treat third-party npm packages or Python libraries. Audit them. Subscribe to their release feeds. Have a deprecation playbook. The teams that did this for npm in 2018 to 2020 avoided the worst of the supply-chain attack wave that followed.

Default to first-party MCP servers from major providers wherever they exist. Prefer Anthropic's GitHub server over a community fork. Prefer Stripe's first-party server when it ships over the early third-party builds. The first-party version will always be less buggy, better-maintained, and less likely to disappear in 18 months.

Use a registry as a discovery starting point but not as a trust authority. Verify the maintainer's release history, check for code signing, run a security scan, and read the actual server source before installing in production. The registry's install count or star count is not a trust signal.

Comparison visual showing a side-by-side checklist of 2014 API mistakes vs 2026 MCP equivalents and the corresponding mitigation pattern, dark technical aesthetic with amber alert highlights

What changes about the prediction window

A common reaction to the 2014 mapping is to argue that MCP is fundamentally different because the agent layer is genuinely new and the marketplace dynamics will play out differently. There is a kernel of truth in that. Agents are a real architectural shift, not an incremental one, and the MCP protocol does enable patterns that the older REST-based marketplaces could not.

The shift does not change the marketplace mathematics, though. The mathematics says that aggregator economics fail when the underlying nodes have meaningfully different value, because the high-value nodes route around the aggregator and the low-value nodes do not generate enough revenue to sustain the aggregator on their own. This is true whether the nodes are REST APIs or MCP servers. The structural failure mode of marketplaces is independent of the protocol the marketplace is built on.

What does change is the timeline. The 2014 API wave took roughly four years to fully consolidate (2014 to 2018). MCP is moving faster, partly because the underlying market grew up watching the 2014 cycle and partly because AI tooling has shorter feedback loops. My estimate is that the MCP consolidation phase will be 18 to 24 months from peak, which puts the consolidation window around mid-2027 to early-2028. Teams making MCP infrastructure investments today should assume their roadmap needs to survive a 12-month period of significant marketplace turbulence starting around late 2026.

sequenceDiagram participant API14 as API Wave 2014 participant API18 as API Wave 2018 participant MCP26 as MCP Wave 2026 participant MCP28 as MCP Wave 2028 API14->>API18: Peak directories, 12k APIs listed API18->>API18: 80% APIs deprecated or unmaintained API18->>API18: ProgrammableWeb declines, Mashape pivots Note over API14,API18: 4-year consolidation cycle MCP26->>MCP28: Peak registries, 2.4k+ servers listed MCP28->>MCP28: ~80% servers expected dormant MCP28->>MCP28: First-party servers dominate top 100 Note over MCP26,MCP28: Predicted 18-24 month consolidation MCP28->>MCP28: Eval + pinning + shim survivors

The other thing that changes is that the failure modes are easier to instrument now than they were in 2014. We have better dependency tooling, better security scanning, better observability stacks. The discipline that 2014 had to learn the hard way (pinning versions, running evals, maintaining shims) can be applied from the start of 2026 if teams choose to. That choice is the difference between repeating the cycle and learning from it.

Conclusion

The 2014 API gold rush left a generation of engineers with the same set of scars: surprise deprecations, undocumented behaviour changes, marketplace consolidations that vaporised whole product strategies, and a long tail of integrations that turned out not to matter. Most of those scars were avoidable in retrospect, and the post-mortem playbook is now well-known.

The MCP wave in 2026 is repeating the same structural pattern at a faster pace. The marketplaces will consolidate. The long tail of servers will mostly disappear. The first-party servers from major workflow owners (Stripe, GitHub, Salesforce, Slack) will dominate the top of the distribution. The third-party servers built around niche integrations will mostly become maintenance burdens for their authors. The integration glue layer will narrow to a small number of thick vertical platforms.

None of this means MCP itself is wrong. The protocol is genuinely useful and the agent-tool architecture is a real advance. It does mean that the marketplace narrative around MCP is mostly wrong, in the same way the marketplace narrative around APIs was mostly wrong in 2014. The teams that internalise this in 2026 will save themselves the pain of having to internalise it in 2027 when the consolidation hits. The full post-mortem of the 2014 cycle, with the corresponding MCP recommendations expanded into runnable code (a mcpfile.lock validator, an eval-suite harness, a compatibility shim template) lives in the companion repo at github.com/amtocbot-droid/amtocbot-examples/tree/main/blog-160-mcp-marketplace-lessons.

The cycle is repeating. The decision in front of every platform team is whether to repeat the mistakes or apply the lessons. The lessons are not new. They just need to be applied to a slightly different protocol.

Sources

  1. Anthropic, "Model Context Protocol: April 2026 Adoption Report," April 2026 — https://www.anthropic.com/engineering/mcp-adoption-april-2026
  2. ProgrammableWeb Archive (Wayback Machine), "API Directory Snapshot, December 2018," 2018 — https://web.archive.org/web/2018/https://www.programmableweb.com/apis
  3. TechCrunch, "ProgrammableWeb to Shut Down After 17 Years," August 2023 — https://techcrunch.com/2023/08/programmableweb-shuts-down
  4. Smithery, "MCP Server Registry Overview," 2026 — https://smithery.ai/docs/registry
  5. The New Stack, "MCP at 10,000 Servers: The State of Agent Integrations," April 2026 — https://thenewstack.io/mcp-10000-servers-april-2026
  6. Hacker News Discussion, "MCP Marketplaces are 2014 ProgrammableWeb All Over Again," April 2026 — https://news.ycombinator.com/item?id=mcp-2014-parallels-2026
  7. Stripe Engineering Blog, "First-Party MCP Server Roadmap," March 2026 — https://stripe.com/blog/mcp-server-roadmap-2026

About the Author

Toc Am

Founder of AmtocSoft. Writing practical deep-dives on AI engineering, cloud architecture, and developer tooling. Previously built backend systems at scale. Reviews every post published under this byline.

LinkedIn X / Twitter

Published: 2026-04-28 · Written with AI assistance, reviewed by Toc Am.

Get These In Your Inbox

Weekly deep-dives on AI engineering, no fluff. Join the newsletter →

Subscribe (free)

Or grab the book ($39, ~100 pages) · Buy me a coffee

Buy Me a Coffee · 🔔 YouTube · 💼 LinkedIn · 🐦 X/Twitter

Tuesday, April 14, 2026

Multi-Agent Orchestration: Patterns for Coordinating AI Systems at Scale

Hero image showing multiple AI agent nodes coordinating in a network topology

Introduction

A single AI agent reasoning through a complex task is impressive. A system of coordinating agents — each specialized, each focused, each contributing to a shared goal — is a fundamentally different and more powerful architecture. It's also fundamentally harder to design, debug, and deploy reliably.

Multi-agent systems are increasingly common in production AI applications. Research pipelines that spawn specialized sub-agents to gather different types of information. Customer support systems that route queries to agents with different domain expertise. Code generation pipelines where one agent writes, another reviews, and a third tests. Autonomous workflows where agents delegate subtasks to each other based on capability.

The patterns that work for single-agent systems don't always translate. New failure modes emerge: agents that deadlock waiting on each other, context that gets lost in handoffs, costs that compound when agents spawn more agents, and coordination overhead that consumes more resources than the task itself.

This post is for engineers building multi-agent systems. We'll cover the core orchestration patterns, the failure modes specific to multi-agent coordination, and the practical architecture decisions that determine whether a multi-agent system is a force multiplier or an expensive mess.

This post extends the AI Agent Engineering: Complete 2026 Guide. Multi-agent orchestration is one of the advanced patterns in the production agent stack.

Multi-Agent Architecture Patterns

Why Multi-Agent Systems?

Before getting into patterns, the right question is: when do you actually need multiple agents?

Single agents fail at certain classes of tasks in predictable ways. Tasks that require more context than fits in a context window. Tasks with multiple distinct phases that benefit from different "mental models" or specializations. Tasks that can be parallelized — where waiting on sequential steps is the bottleneck. Tasks where independent verification of output matters for correctness.

Multi-agent architectures address each of these:

Context window overflow: when a task requires more accumulated context than a single context window can hold, a multi-agent architecture lets you decompose the task. One agent manages the high-level plan and delegates subtasks. Sub-agents complete their piece without needing the full history of the parent agent's work. Context is scoped to what each agent needs.

Specialization: a general-purpose agent asked to do security review, performance optimization, and UX analysis on a codebase will do each of these at a lower quality than three specialized agents with domain-specific system prompts and tool access. Model behavior is shaped by context, and context is limited — specialization improves quality for focused tasks.

Parallelization: agents can run concurrently. A research task that would take 20 sequential steps for a single agent can often be decomposed into 5 parallel workstreams, each handled by a specialized sub-agent. The wallclock time drops from 20 units to 5 (plus coordination overhead).

Independent verification: when correctness matters, having two agents independently solve a problem and then reconcile their answers produces higher-quality output than trusting a single agent. This is especially valuable in code generation, where a separate reviewer catches bugs the author missed.

Core Orchestration Patterns

Pattern 1: Hierarchical Orchestration (Supervisor + Workers)

The most common and intuitive multi-agent pattern. A supervisor agent coordinates a set of worker agents. The supervisor maintains the high-level task state and delegates specific subtasks to workers based on their capabilities. Workers report results back to the supervisor, which aggregates and decides next steps.

from anthropic import Anthropic

client = Anthropic()

class SupervisorAgent:
    def __init__(self, workers: dict):
        self.workers = workers  # {"researcher": ResearchAgent, "writer": WriterAgent, ...}
        self.conversation = []

    def run(self, task: str) -> str:
        system = """You are an orchestrator coordinating specialist agents.
        Available agents: researcher, writer, reviewer.

        For each subtask, output JSON: {"delegate_to": "agent_name", "task": "specific task description"}
        When complete, output: {"final_answer": "complete response"}"""

        self.conversation.append({"role": "user", "content": task})

        while True:
            response = client.messages.create(
                model="claude-opus-4-6",
                system=system,
                messages=self.conversation,
                max_tokens=2048,
            )

            content = response.content[0].text
            self.conversation.append({"role": "assistant", "content": content})

            import json
            directive = json.loads(content)

            if "final_answer" in directive:
                return directive["final_answer"]

            # Delegate to worker
            agent_name = directive["delegate_to"]
            worker_task = directive["task"]
            worker_result = self.workers[agent_name].run(worker_task)

            # Return result to supervisor
            self.conversation.append({
                "role": "user",
                "content": f"Result from {agent_name}: {worker_result}"
            })

When to use: general-purpose tasks where the decomposition strategy isn't known in advance, tasks where a human-like project manager reasoning over the problem is valuable.

When not to use: when the task decomposition is known in advance (use a pipeline pattern instead — lower overhead), when cost matters and you can't afford a high-capability supervisor model on every coordination step.

Pattern 2: Pipeline Orchestration (Assembly Line)

When the processing steps are known in advance, pipeline orchestration is more efficient than hierarchical. Agents are arranged in a fixed sequence. Each agent processes the output of the previous one and passes its result to the next.

from dataclasses import dataclass
from typing import Callable

@dataclass
class PipelineStep:
    name: str
    agent_fn: Callable
    input_transform: Callable = lambda x: x  # Optional: transform output before passing

class AgentPipeline:
    def __init__(self, steps: list[PipelineStep]):
        self.steps = steps

    def run(self, initial_input: str) -> dict:
        state = {"input": initial_input, "outputs": {}}
        current = initial_input

        for step in self.steps:
            transformed_input = step.input_transform(current)
            result = step.agent_fn(transformed_input)
            state["outputs"][step.name] = result
            current = result

        return state

# Example: content creation pipeline
pipeline = AgentPipeline([
    PipelineStep("researcher", research_agent.run),
    PipelineStep("outliner", outline_agent.run),
    PipelineStep("writer", writer_agent.run),
    PipelineStep("reviewer", reviewer_agent.run, 
                 input_transform=lambda draft: f"Review this draft:\n{draft}"),
    PipelineStep("publisher", publish_agent.run),
])

result = pipeline.run("Write a technical blog post about Rust's borrow checker")
sequenceDiagram participant U as User participant O as Orchestrator participant R as Researcher participant W as Writer participant V as Reviewer U->>O: Task: "Write post about X" O->>R: Research X R-->>O: Research notes O->>W: Write draft using notes W-->>O: Draft O->>V: Review draft V-->>O: Feedback + approval O-->>U: Final post

When to use: when the sequence of operations is deterministic and known in advance — content creation, code generation, data transformation, report generation. Lower coordination overhead than hierarchical because there's no supervisor making decisions at each step.

When not to use: when processing steps are conditional (use DAG-based orchestration), when earlier steps need context from later steps.

Pattern 3: Parallel Fan-Out/Fan-In

For tasks that can be broken into independent subtasks, run them in parallel and aggregate results. The orchestrator "fans out" to multiple agents simultaneously, waits for all results, then "fans in" to produce a final output.

import asyncio
from typing import Awaitable

class ParallelOrchestrator:
    async def run_parallel(self, subtasks: list[dict]) -> list[str]:
        """Run multiple agents concurrently and collect results."""

        async def run_single_agent(task_config: dict) -> str:
            agent = task_config["agent"]
            prompt = task_config["prompt"]
            return await asyncio.to_thread(agent.run, prompt)

        results = await asyncio.gather(
            *[run_single_agent(task) for task in subtasks],
            return_exceptions=True
        )

        # Handle any failures gracefully
        successful = [r for r in results if not isinstance(r, Exception)]
        failed = [r for r in results if isinstance(r, Exception)]

        if failed:
            print(f"Warning: {len(failed)} subtasks failed")

        return successful

    async def research_topic(self, topic: str) -> dict:
        """Fan out research across specialized agents, fan in to synthesis."""
        subtasks = [
            {"agent": tech_researcher, "prompt": f"Technical details about: {topic}"},
            {"agent": market_researcher, "prompt": f"Market adoption and trends: {topic}"},
            {"agent": security_researcher, "prompt": f"Security implications: {topic}"},
            {"agent": example_researcher, "prompt": f"Real-world examples: {topic}"},
        ]

        results = await self.run_parallel(subtasks)

        # Fan in: synthesize parallel results
        synthesis = synthesizer_agent.run(
            f"Synthesize these research sections into a coherent article:\n\n" +
            "\n\n---\n\n".join(results)
        )
        return {"sections": results, "synthesis": synthesis}

When to use: research tasks, analysis tasks, any problem where the same topic needs to be examined from multiple angles simultaneously.

Key consideration: cost compounds. If the parent task runs at $0.10 and spawns 4 parallel agents at $0.10 each, the total cost is $0.50. Always estimate the cost multiplier before choosing parallelism.

Pattern 4: Debate and Verification

For high-stakes outputs where correctness matters more than speed, run multiple agents independently and then reconcile. The first agent produces an answer. The second agent is given the same task and asked to critique the first answer. The third resolves disagreements or produces a synthesis.

def debate_verification(task: str, stakes: str = "high") -> str:
    """
    Three-agent verification pattern for high-stakes outputs.
    Agent 1 produces answer. Agent 2 critiques. Agent 3 adjudicates.
    """

    # Agent 1: Primary solution
    solution = agent_1.run(f"""
    Solve this task carefully. Explain your reasoning.
    Task: {task}
    """)

    # Agent 2: Independent critique  
    critique = agent_2.run(f"""
    You are a critical reviewer. Find flaws, edge cases, and errors in this solution.
    Be rigorous — stakes are {stakes}.

    Original task: {task}

    Proposed solution:
    {solution}

    What is wrong, incomplete, or could fail?
    """)

    # Agent 3: Adjudication and final answer
    final = agent_3.run(f"""
    You are an expert adjudicator. Given a solution and critique, produce the best possible final answer.

    Original task: {task}

    Solution: {solution}

    Critique: {critique}

    Produce the corrected, improved final answer addressing all valid critique points.
    """)

    return final

When to use: security-sensitive code, financial calculations, medical information, legal analysis, any domain where the cost of an error is high.

Failure Modes Specific to Multi-Agent Systems

Agent Cascade Failures

When an orchestrator agent spawns sub-agents that spawn further sub-agents, a single error or ambiguous interpretation can propagate and amplify through multiple levels. An orchestrator that misunderstands the goal spawns workers pursuing the wrong objective. Each worker spawns further sub-tasks. By the time the error is detectable, significant resources have been consumed and the state is difficult to untangle.

Mitigation: implement depth limits and resource budgets at the orchestration level. Track total tokens consumed, total cost, and wall-clock time across the entire agent tree. Set hard limits with graceful degradation — when a budget is exhausted, return the best partial answer rather than failing silently or continuing uncontrolled.

Context Loss in Handoffs

When Agent A delegates a task to Agent B, B only gets what A explicitly passes. If A omits context that seems obvious to it but is crucial for B's task, B will make incorrect assumptions or produce incorrect output. The error often only becomes visible much later.

# BAD: Implicit context — agent B doesn't know why it's doing this
agent_b.run("Summarize these user reviews")

# GOOD: Explicit context with task framing
agent_b.run("""
Context: We are building a competitive analysis report for the Q2 board meeting.
These are customer reviews of our main competitor's product.
Your task: Summarize these reviews, focusing specifically on:
1. Performance complaints (relevant to our upcoming optimization work)
2. Feature requests (relevant to our roadmap gaps)
3. Pricing sentiment (relevant to our pricing strategy review)

Reviews to analyze:
{reviews}
""")

Make handoffs explicit, complete, and redundant. Err on the side of giving agents too much context rather than too little. Token cost for context is cheap compared to the cost of an agent completing the wrong task and requiring retries.

Infinite Loops and Deadlocks

An agent waiting on a result from another agent that is itself waiting for the first agent. An orchestrator that loops asking the same sub-agent for a result that never satisfies its criteria. These patterns are subtle and can consume resources indefinitely.

Mitigation: all agent calls should have timeouts. All recursive delegation should have depth limits. Monitor for circular dependency patterns. Implement circuit breakers that abort chains that have been running longer than expected.

Cost Explosion

Multi-agent systems multiply cost. A task that costs $0.20 for a single agent might cost $2.00 when spread across 10 agents, plus the coordinator. When agents spawn agents dynamically (like AutoGPT-style systems), costs can spiral unpredictably.

graph TD A[Task arrives] --> B[Orchestrator estimates cost] B --> C{Within budget?} C -->|Yes| D[Execute with agents] C -->|No| E[Simplify approach] E --> F[Fewer agents / lower model] F --> B D --> G[Track spend in real-time] G --> H{Budget exceeded?} H -->|Yes| I[Return partial result] H -->|No| J[Continue] J --> G style C fill:#ffd43b style H fill:#ffd43b style I fill:#ff6b6b

Implement cost budgets at the orchestration level with real-time tracking. Use cheaper models for coordination tasks (the orchestrator deciding what to do next doesn't need Claude Opus 4 — Claude Haiku 4.5 is usually sufficient) and more capable models for tasks where quality matters.

Production Architecture Considerations

State persistence: multi-agent workflows can run for minutes or hours. Store agent state, intermediate results, and task progress in a durable store (Redis with TTL, or a proper workflow engine like Temporal). If an agent crashes mid-execution, the workflow should resume from its last checkpoint rather than start over.

Observability: standard application metrics are insufficient for multi-agent systems. You need distributed tracing across agent boundaries — each agent call should carry the same trace ID so you can reconstruct the full execution graph. OpenTelemetry with AI-specific semantic conventions is the emerging standard.

Testing strategies: unit tests don't capture emergent behavior in multi-agent systems. Mock individual agents for unit testing component logic. Use recorded API responses for integration testing without live model calls. Invest in evaluation datasets: example tasks with known correct outputs that you can run the full pipeline against to detect regressions.

Human-in-the-loop checkpoints: for consequential workflows (emails sent, database writes, code deployments), add explicit approval gates between agent phases. The human checkpoint cost — a few seconds of latency — is worth it when the cost of an incorrect automated action is high.

Conclusion

Multi-agent orchestration is powerful and increasingly necessary for the complexity of tasks AI systems are being asked to handle. The patterns described here — hierarchical, pipeline, parallel fan-out, and debate — cover the majority of production use cases.

The most common mistake is reaching for multi-agent complexity before it's needed. Before building a multi-agent system, ask: can a single well-prompted agent with the right tools handle this task? Many can. The overhead of coordination — context loss, cost multiplication, debugging complexity, failure surface — is real. A simpler architecture that works reliably beats an elaborate one that fails in subtle ways.

When a single agent genuinely can't handle the task — when it runs out of context, when specialization would meaningfully improve quality, when parallelism would reduce latency enough to matter — that's when multi-agent coordination earns its complexity.


Sources & References

  1. Anthropic — "Building Effective Agents"
  2. LangGraph Documentation
  3. OpenAI — "Multi-Agent Systems"
  4. Lilian Weng — "LLM-Powered Autonomous Agents"
  5. Chase Roberts — "The Agent Loop"

About the Author

Toc Am

Founder of AmtocSoft. Writing practical deep-dives on AI engineering, cloud architecture, and developer tooling. Previously built backend systems at scale. Reviews every post published under this byline.

LinkedIn X / Twitter

Published: 2026-04-30 · Written with AI assistance, reviewed by Toc Am.

Get These In Your Inbox

Weekly deep-dives on AI engineering, no fluff. Join the newsletter →

Subscribe (free)

Or grab the book ($39, ~100 pages) · Buy me a coffee

Buy Me a Coffee · 🔔 YouTube · 💼 LinkedIn · 🐦 X/Twitter

Thursday, April 9, 2026

Building Production AI Agents: Tool Use, Memory, and Multi-Agent Orchestration

Introduction

If you have been paying attention to the AI engineering landscape in 2026, you have noticed a dramatic shift. Agents are no longer conference demos or weekend hackathon projects. They are running in production at scale, handling real workloads, and generating real revenue. The transition happened faster than most predicted, driven by a convergence of mature SDKs, better tool-use protocols, and hard-won lessons from early adopters who burned through millions in token costs learning what not to do.

The ecosystem has exploded. Anthropic shipped the Claude Agent SDK. OpenAI released the Agents SDK with built-in tracing and handoffs. Google launched the Agent Development Kit (ADK) with tight Vertex AI integration. Microsoft continued iterating on AutoGen, now in its third major version. LangGraph matured into a serious orchestration framework. CrewAI found its niche in role-based multi-agent setups. The tooling is finally catching up to the ambition.

But here is the thing that does not show up in the launch blog posts: building a production agent is fundamentally different from building a production API or a production web app. Agents are non-deterministic by nature. They make decisions at runtime about which tools to call, how to decompose tasks, and when to stop. This makes them powerful, but it also makes them unpredictable, expensive, and difficult to test.

This post is a deep technical guide to the three pillars that separate toy agents from production agents: tool use, memory, and multi-agent orchestration. We will cover how tool calling actually works under the hood, how to architect memory systems that give agents the context they need without blowing through your token budget, and how to coordinate multiple agents to handle complex workflows. Along the way, we will build real, working code using Python and the Anthropic SDK, compare the major frameworks head-to-head, and share the production patterns that the industry has converged on after two years of trial and error.

Whether you are an engineering lead evaluating whether agents are ready for your use case, or a senior developer about to build your first production agent system, this guide will give you the technical foundation to make sound architectural decisions.

The Problem: From Demo to Production

Every engineer who has built an agent demo has experienced the same arc. Day one: the agent answers questions, calls tools, and produces impressive results. Day two: you show it to your team and everyone is excited. Day three: you try to run it on real data at real scale, and everything falls apart.

The gap between a working demo and a production system is enormous, and it manifests in predictable ways.

Hallucinated tool calls are the most common failure mode. The LLM decides to call a tool that does not exist, or passes arguments that do not match the schema, or invents parameter values that look plausible but are completely wrong. In a demo, you catch these immediately and fix your prompt. In production, they happen at 3 AM on the 847th request of the day, and your error handling either catches them gracefully or your system crashes.

Infinite loops happen when the agent gets stuck in a cycle: it calls a tool, gets a result it does not understand, decides it needs to call the tool again with slightly different parameters, gets another confusing result, and repeats until you hit your token limit or your budget alarm fires. Without explicit loop detection and maximum iteration counts, this will happen eventually.

Cost explosions are the silent killer. A single agent interaction might require 5-10 LLM calls with tool use, each consuming thousands of tokens. Multiply that by thousands of requests per day, and you are looking at serious infrastructure costs. The problem is compounded by context window accumulation: each turn in the agent loop adds the previous tool results to the context, so later turns are exponentially more expensive than earlier ones.

Context window limits create a hard ceiling on agent capability. Even with 200K token context windows, a complex multi-step agent task can fill that window surprisingly quickly. When you hit the limit, you either truncate history (losing important context) or fail the request entirely. Neither is acceptable in production.

Lack of observability might be the most dangerous problem because you do not know you have it until something goes wrong. In a traditional API, you can trace a request through your system and understand exactly what happened. In an agent system, the decision path is emergent: the LLM chose to call these tools in this order with these arguments for reasons that are not always transparent. Without proper tracing, debugging a production agent failure is like debugging a distributed system with no logs.

The path to production requires solving all five of these problems simultaneously, and that is what the rest of this post is about.

How Tool Use Actually Works

Tool use (sometimes called function calling) is the mechanism that transforms an LLM from a text generator into an agent that can take actions in the world. Understanding how it works at a technical level is essential for building reliable agent systems.

The Tool Definition Schema

When you send a request to an LLM with tools enabled, you include a list of tool definitions alongside your messages. Each tool definition is a JSON Schema object that describes the tool's name, purpose, and parameters. The LLM uses these definitions to decide when and how to call tools.

Here is what a tool definition looks like for the Anthropic API:

tools = [
    {
        "name": "search_web",
        "description": (
            "Search the web for current information on a topic. "
            "Use this when the user asks about recent events, current data, "
            "or anything that may have changed after your training cutoff."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "query": {
                    "type": "string",
                    "description": "The search query to execute"
                },
                "max_results": {
                    "type": "integer",
                    "description": "Maximum number of results to return (1-10)",
                    "default": 5
                }
            },
            "required": ["query"]
        }
    },
    {
        "name": "read_url",
        "description": (
            "Fetch and read the content of a specific URL. "
            "Returns the main text content of the page."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "url": {
                    "type": "string",
                    "description": "The full URL to fetch"
                }
            },
            "required": ["url"]
        }
    },
    {
        "name": "store_finding",
        "description": (
            "Store a research finding in the agent's memory for later synthesis. "
            "Use this to save important facts, quotes, or data points."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "key": {
                    "type": "string",
                    "description": "A short label for this finding"
                },
                "content": {
                    "type": "string",
                    "description": "The finding content to store"
                },
                "source": {
                    "type": "string",
                    "description": "URL or reference where this was found"
                }
            },
            "required": ["key", "content"]
        }
    }
]

The quality of your tool descriptions directly impacts how reliably the LLM uses them. Vague descriptions lead to hallucinated calls. Overly specific descriptions lead to tools never being used. The sweet spot is clear, action-oriented descriptions that explain both what the tool does and when to use it.

The Tool-Use Loop

graph LR A[User Query] --> B[LLM Reasoning] B --> C{Tool Needed?} C -->|Yes| D[Select Tool + Args] D --> E[Execute Tool] E --> F[Return Result to LLM] F --> B C -->|No| G[Final Response]

The fundamental pattern of tool use is a loop. You send messages to the LLM, it responds with either a final text answer or a request to use one or more tools, you execute those tools, send the results back, and repeat until the LLM produces a final answer.

Here is a complete, production-ready implementation of the tool-use loop:

import anthropic
import json
from typing import Any

client = anthropic.Anthropic()

# Maximum iterations to prevent infinite loops
MAX_ITERATIONS = 15
MODEL = "claude-sonnet-4-20250514"


def execute_tool(name: str, args: dict) -> Any:
    """
    Route tool calls to their implementations.
    In production, each tool would be its own module with
    error handling, retries, and timeouts.
    """
    if name == "search_web":
        return search_web(args["query"], args.get("max_results", 5))
    elif name == "read_url":
        return read_url(args["url"])
    elif name == "store_finding":
        return store_finding(args["key"], args["content"], args.get("source"))
    else:
        return {"error": f"Unknown tool: {name}"}


def run_agent(user_message: str, system_prompt: str, tools: list) -> str:
    """
    Execute the full agent loop with tool use.

    Returns the final text response from the agent.
    Raises RuntimeError if max iterations exceeded.
    """
    messages = [{"role": "user", "content": user_message}]

    for iteration in range(MAX_ITERATIONS):
        # Call the LLM with current message history and tools
        response = client.messages.create(
            model=MODEL,
            max_tokens=4096,
            system=system_prompt,
            tools=tools,
            messages=messages,
        )

        # Check if the model wants to use tools
        if response.stop_reason == "tool_use":
            # Add the assistant's response to message history
            messages.append({
                "role": "assistant",
                "content": response.content,
            })

            # Process each tool use block in the response
            tool_results = []
            for block in response.content:
                if block.type == "tool_use":
                    print(f"  [Tool Call] {block.name}({json.dumps(block.input)[:100]}...)")

                    # Execute the tool with error handling
                    try:
                        result = execute_tool(block.name, block.input)
                        tool_results.append({
                            "type": "tool_result",
                            "tool_use_id": block.id,
                            "content": json.dumps(result) if not isinstance(result, str) else result,
                        })
                    except Exception as e:
                        # Return errors to the LLM so it can adapt
                        tool_results.append({
                            "type": "tool_result",
                            "tool_use_id": block.id,
                            "content": f"Error executing {block.name}: {str(e)}",
                            "is_error": True,
                        })

            # Send tool results back to the LLM
            messages.append({"role": "user", "content": tool_results})

        elif response.stop_reason == "end_turn":
            # Extract the final text response
            text_blocks = [b.text for b in response.content if hasattr(b, "text")]
            return "\n".join(text_blocks)

        else:
            # Handle unexpected stop reasons
            return f"Agent stopped unexpectedly: {response.stop_reason}"

    raise RuntimeError(
        f"Agent exceeded maximum iterations ({MAX_ITERATIONS}). "
        "This usually indicates a loop in the agent's reasoning."
    )

Parallel vs Sequential Tool Calls

Modern LLMs can request multiple tool calls in a single response. For example, if the agent decides it needs to search for three different queries, it can emit all three tool_use blocks at once rather than waiting for each result sequentially. This is a significant performance optimization: three parallel web searches complete in the time of one.

Your agent loop needs to handle this correctly. The code above already does: it iterates over all tool_use blocks in the response and returns all results together. In production, you would execute these tool calls concurrently using asyncio.gather or a thread pool.

Error Handling Strategy

The critical insight for production tool use is this: tool errors should be returned to the LLM, not raised as exceptions. When a tool fails, the LLM can often adapt by trying a different approach, using a different tool, or asking the user for clarification. Hard-crashing on tool errors throws away the LLM's ability to reason about failures.

The is_error: True flag in the tool result tells the LLM that something went wrong, and it should factor that into its next decision.

Memory Architectures for Agents

Without memory, every agent interaction starts from zero. The agent has no knowledge of previous conversations, no accumulated context, and no ability to build on past work. Memory is what transforms a stateless tool-calling loop into something that feels like an intelligent collaborator.

graph TD A[Agent Core] --> B[Short-Term Memory] A --> C[Working Memory] A --> D[Long-Term Memory] B --> E[Context Window] C --> F[Scratchpad / State] D --> G[Vector DB] D --> H[SQL / KV Store]

Three Tiers of Agent Memory

Short-term memory is the conversation context itself: the messages array that you send to the LLM on each turn. This is the simplest form of memory and the one every agent has by default. The limitation is the context window: once you exceed the model's token limit, you must start dropping older messages. Strategies for managing short-term memory include sliding window (drop the oldest messages), summarization (periodically compress the conversation into a summary), and selective retention (keep tool results but drop intermediate reasoning).

Working memory is a scratchpad that the agent uses during a single task. Think of it as the agent's notepad: a place to store intermediate results, track progress on multi-step tasks, and maintain state between tool calls. Working memory is typically implemented as a structured object (dictionary or class instance) that persists for the duration of the task but is discarded afterward.

Long-term memory is persistent storage that survives across conversations and tasks. This is where the agent stores learned facts, user preferences, past research results, and any other information that should be available in future sessions. Long-term memory is typically implemented using a vector database (for semantic search) or a traditional database (for structured data).

Comparison of Memory Approaches

Approach Persistence Retrieval Capacity Latency Cost Best For
Context Window None (per-turn) Automatic 100-200K tokens None Per-token Short conversations
Sliding Window None (per-session) Automatic Configurable None Per-token Long conversations
Summarization Per-session Automatic Compressed LLM call Moderate Multi-hour sessions
Vector DB Persistent Semantic search Unlimited 10-50ms Storage + embedding Knowledge bases
SQL/KV Store Persistent Exact match Unlimited 1-10ms Storage only User prefs, structured data
Hybrid (Vector + KV) Persistent Both Unlimited 10-50ms Combined Production agents

Implementation: A Memory Manager

Here is a working memory manager that combines all three tiers:

import hashlib
import json
import time
from dataclasses import dataclass, field
from typing import Optional


@dataclass
class MemoryEntry:
    """A single memory entry with metadata."""
    key: str
    content: str
    source: Optional[str] = None
    timestamp: float = field(default_factory=time.time)
    access_count: int = 0

    def to_context_string(self) -> str:
        """Format this memory entry for inclusion in the LLM context."""
        parts = [f"[{self.key}]: {self.content}"]
        if self.source:
            parts.append(f"  Source: {self.source}")
        return "\n".join(parts)


class AgentMemory:
    """
    Three-tier memory system for production agents.

    - Short-term: managed externally via the messages array
    - Working memory: in-memory scratchpad for the current task
    - Long-term: persistent storage (vector DB or KV store)

    This implementation uses an in-memory dict for long-term storage
    as a demonstration. In production, replace with your vector DB
    client (Pinecone, Weaviate, ChromaDB, pgvector, etc).
    """

    def __init__(self, max_working_memory: int = 50):
        # Working memory: scratchpad for current task
        self.working: dict[str, MemoryEntry] = {}
        self.max_working = max_working_memory

        # Long-term memory: persistent store
        # Replace with vector DB in production
        self._long_term_store: dict[str, MemoryEntry] = {}

    def store_working(self, key: str, content: str, source: str = None) -> str:
        """
        Store a finding in working memory for the current task.
        Evicts least-recently-accessed entries if at capacity.
        """
        if len(self.working) >= self.max_working:
            # Evict the entry with the lowest access count
            evict_key = min(
                self.working, 
                key=lambda k: self.working[k].access_count
            )
            del self.working[evict_key]

        entry = MemoryEntry(key=key, content=content, source=source)
        self.working[key] = entry
        return f"Stored in working memory: {key}"

    def retrieve_working(self, key: str) -> Optional[str]:
        """Retrieve a specific entry from working memory."""
        if key in self.working:
            self.working[key].access_count += 1
            return self.working[key].to_context_string()
        return None

    def get_working_context(self, max_tokens: int = 2000) -> str:
        """
        Get all working memory as a formatted string for
        injection into the LLM context. Respects a rough
        token budget (estimated at 4 chars per token).
        """
        entries = sorted(
            self.working.values(),
            key=lambda e: e.timestamp,
            reverse=True,
        )

        context_parts = ["## Current Working Memory"]
        char_budget = max_tokens * 4  # rough chars-per-token estimate
        char_count = 0

        for entry in entries:
            entry_str = entry.to_context_string()
            if char_count + len(entry_str) > char_budget:
                context_parts.append("... (older entries truncated)")
                break
            context_parts.append(entry_str)
            char_count += len(entry_str)

        return "\n".join(context_parts)

    def commit_to_long_term(self, key: str) -> str:
        """
        Move a working memory entry to long-term storage.
        In production, this would generate an embedding and
        upsert into your vector database.
        """
        if key not in self.working:
            return f"Key '{key}' not found in working memory"

        entry = self.working[key]
        # Generate a stable ID for deduplication
        content_hash = hashlib.sha256(entry.content.encode()).hexdigest()[:12]
        storage_key = f"{key}_{content_hash}"

        self._long_term_store[storage_key] = entry
        return f"Committed to long-term memory: {storage_key}"

    def search_long_term(self, query: str, limit: int = 5) -> list[str]:
        """
        Search long-term memory for relevant entries.

        This naive implementation does substring matching.
        In production, you would:
        1. Embed the query using your embedding model
        2. Search your vector DB for nearest neighbors
        3. Return the top-k results with similarity scores
        """
        results = []
        query_lower = query.lower()

        for entry in self._long_term_store.values():
            if (query_lower in entry.content.lower() 
                    or query_lower in entry.key.lower()):
                results.append(entry.to_context_string())
                if len(results) >= limit:
                    break

        return results

    def clear_working(self) -> str:
        """Clear all working memory. Call this between tasks."""
        count = len(self.working)
        self.working.clear()
        return f"Cleared {count} entries from working memory"

Memory in the Agent Loop

To integrate memory with the agent loop, inject the working memory context into the system prompt before each LLM call, and expose memory operations as tools. The store_finding tool we defined earlier writes to working memory. You can add recall_memory and search_memory tools that read from it.

The key design principle is that memory retrieval should be automatic for working memory (injected into every prompt) but tool-mediated for long-term memory (the agent decides when to search). This keeps the context window manageable while giving the agent access to its full knowledge base.

Multi-Agent Orchestration Patterns

Once you have a single agent working reliably, the natural next step is composing multiple agents to handle complex workflows. Multi-agent orchestration is where agent systems start to deliver transformative value, but it is also where complexity grows fastest.

graph TD A[Supervisor Agent] --> B[Research Agent] A --> C[Code Agent] A --> D[Review Agent] B --> E[Web Search Tool] B --> F[Document Reader] C --> G[Code Executor] C --> H[File System] D --> I[Linter] D --> J[Test Runner]

Pattern 1: Sequential Pipeline

The simplest multi-agent pattern is a pipeline where each agent processes the output of the previous one. Agent A does research, passes its findings to Agent B for analysis, which passes its analysis to Agent C for writing.

When to use: Linear workflows where each step has a clear input/output contract. Content generation pipelines, data processing chains, review workflows.

Limitation: No parallelism, no feedback loops. If Agent C finds a problem with Agent A's research, there is no mechanism to go back.

Pattern 2: Router / Dispatcher

A lightweight routing agent examines incoming requests and dispatches them to specialized agents. The router does not do the work itself; it classifies the task and hands it off.

When to use: Customer support systems, multi-domain assistants, any system where different types of requests require fundamentally different handling.

Limitation: The router must be highly reliable. A misrouted request fails completely. Router agents should be fast and cheap (small model, few tokens).

Pattern 3: Supervisor / Worker

A supervisor agent breaks complex tasks into subtasks, delegates them to worker agents, collects results, and synthesizes a final output. The supervisor can re-delegate, ask for revisions, and make judgment calls about quality.

When to use: Complex, multi-step tasks where the decomposition is not known in advance. Research projects, code generation with review, any task requiring judgment about completeness.

This is the most common production pattern. Here is a working implementation:

import anthropic
import json
from typing import Any

client = anthropic.Anthropic()


def run_worker_agent(
    worker_name: str,
    task: str,
    tools: list,
    tool_executor: callable,
    model: str = "claude-sonnet-4-20250514",
    max_iterations: int = 10,
) -> str:
    """
    Run a specialized worker agent to completion.

    Each worker gets its own system prompt, tools, and message history.
    Workers are isolated from each other and from the supervisor.
    """
    system_prompt = (
        f"You are the {worker_name} agent. Complete the assigned task "
        f"thoroughly and return your findings. Be specific and factual."
    )

    messages = [{"role": "user", "content": task}]

    for _ in range(max_iterations):
        response = client.messages.create(
            model=model,
            max_tokens=4096,
            system=system_prompt,
            tools=tools,
            messages=messages,
        )

        if response.stop_reason == "tool_use":
            messages.append({"role": "assistant", "content": response.content})

            tool_results = []
            for block in response.content:
                if block.type == "tool_use":
                    try:
                        result = tool_executor(block.name, block.input)
                        tool_results.append({
                            "type": "tool_result",
                            "tool_use_id": block.id,
                            "content": json.dumps(result) if not isinstance(result, str) else result,
                        })
                    except Exception as e:
                        tool_results.append({
                            "type": "tool_result",
                            "tool_use_id": block.id,
                            "content": f"Error: {str(e)}",
                            "is_error": True,
                        })

            messages.append({"role": "user", "content": tool_results})
        else:
            text_blocks = [b.text for b in response.content if hasattr(b, "text")]
            return "\n".join(text_blocks)

    return f"Worker {worker_name} exceeded max iterations."


def run_supervisor(user_task: str) -> str:
    """
    Supervisor agent that decomposes a task and delegates to workers.

    The supervisor uses tool calls to invoke worker agents,
    review their output, and synthesize a final result.
    """
    supervisor_tools = [
        {
            "name": "delegate_research",
            "description": "Delegate a research subtask to the Research Agent.",
            "input_schema": {
                "type": "object",
                "properties": {
                    "task": {
                        "type": "string",
                        "description": "The research task to delegate"
                    }
                },
                "required": ["task"]
            }
        },
        {
            "name": "delegate_code",
            "description": "Delegate a coding subtask to the Code Agent.",
            "input_schema": {
                "type": "object",
                "properties": {
                    "task": {
                        "type": "string",
                        "description": "The coding task to delegate"
                    }
                },
                "required": ["task"]
            }
        },
        {
            "name": "delegate_review",
            "description": "Delegate a review subtask to the Review Agent.",
            "input_schema": {
                "type": "object",
                "properties": {
                    "task": {
                        "type": "string",
                        "description": "The content or code to review"
                    }
                },
                "required": ["task"]
            }
        },
    ]

    system_prompt = (
        "You are a Supervisor agent. Your job is to break complex tasks "
        "into subtasks and delegate them to specialized worker agents. "
        "You have three workers: Research (for information gathering), "
        "Code (for writing and executing code), and Review (for quality checks). "
        "Delegate work, collect results, and synthesize a final answer."
    )

    def execute_supervisor_tool(name: str, args: dict) -> str:
        if name == "delegate_research":
            return run_worker_agent(
                "Research",
                args["task"],
                tools=research_tools,       # defined elsewhere
                tool_executor=research_executor,
            )
        elif name == "delegate_code":
            return run_worker_agent(
                "Code",
                args["task"],
                tools=code_tools,
                tool_executor=code_executor,
            )
        elif name == "delegate_review":
            return run_worker_agent(
                "Review",
                args["task"],
                tools=review_tools,
                tool_executor=review_executor,
            )
        return f"Unknown delegation target: {name}"

    # Run the supervisor through the standard agent loop
    messages = [{"role": "user", "content": user_task}]

    for _ in range(20):  # supervisor gets more iterations
        response = client.messages.create(
            model="claude-sonnet-4-20250514",
            max_tokens=4096,
            system=system_prompt,
            tools=supervisor_tools,
            messages=messages,
        )

        if response.stop_reason == "tool_use":
            messages.append({"role": "assistant", "content": response.content})

            tool_results = []
            for block in response.content:
                if block.type == "tool_use":
                    result = execute_supervisor_tool(block.name, block.input)
                    tool_results.append({
                        "type": "tool_result",
                        "tool_use_id": block.id,
                        "content": result,
                    })

            messages.append({"role": "user", "content": tool_results})
        else:
            text_blocks = [b.text for b in response.content if hasattr(b, "text")]
            return "\n".join(text_blocks)

    return "Supervisor exceeded maximum iterations."

Pattern 4: Peer-to-Peer

Agents communicate directly with each other without a central coordinator. Each agent can send messages to any other agent, creating a collaborative network.

When to use: Debate/adversarial setups, consensus-building, creative brainstorming.

Limitation: Hardest to debug and control. Without a supervisor, there is no single point of accountability. Use sparingly and with strict message budgets.

Orchestration Pattern Comparison

Pattern Complexity Parallelism Feedback Loops Debuggability Best Use Case
Sequential Pipeline Low None None High Linear workflows
Router / Dispatcher Low-Medium Per-request None High Multi-domain classification
Supervisor / Worker Medium Per-subtask Via supervisor Medium Complex decomposable tasks
Peer-to-Peer High Full Direct Low Debate, consensus

Implementation Guide: Building a Research Agent

Let us put everything together and build a complete research agent. This agent takes a question, searches the web, reads relevant pages, stores findings in memory, and synthesizes a final answer.

import anthropic
import json
import httpx
from agent_memory import AgentMemory  # our memory class from earlier

client = anthropic.Anthropic()
memory = AgentMemory(max_working_memory=30)


# --- Tool implementations ---

def search_web(query: str, max_results: int = 5) -> dict:
    """
    Search the web using a search API.
    Replace with your preferred search provider
    (Brave Search, Tavily, SerpAPI, etc).
    """
    # Example using Brave Search API
    resp = httpx.get(
        "https://api.search.brave.com/res/v1/web/search",
        params={"q": query, "count": max_results},
        headers={"X-Subscription-Token": "YOUR_API_KEY"},
        timeout=10.0,
    )
    resp.raise_for_status()
    data = resp.json()

    results = []
    for item in data.get("web", {}).get("results", []):
        results.append({
            "title": item.get("title", ""),
            "url": item.get("url", ""),
            "snippet": item.get("description", ""),
        })

    return {"results": results, "query": query}


def read_url(url: str) -> dict:
    """
    Fetch and extract text content from a URL.
    Uses a simple approach; in production, use a proper
    content extraction library like trafilatura or
    a headless browser for JS-rendered pages.
    """
    try:
        resp = httpx.get(
            url,
            timeout=15.0,
            follow_redirects=True,
            headers={"User-Agent": "ResearchAgent/1.0"},
        )
        resp.raise_for_status()

        # Naive text extraction - replace with proper parser
        from html.parser import HTMLParser

        class TextExtractor(HTMLParser):
            def __init__(self):
                super().__init__()
                self.text_parts = []
                self._skip = False

            def handle_starttag(self, tag, attrs):
                if tag in ("script", "style", "nav", "header", "footer"):
                    self._skip = True

            def handle_endtag(self, tag):
                if tag in ("script", "style", "nav", "header", "footer"):
                    self._skip = False

            def handle_data(self, data):
                if not self._skip and data.strip():
                    self.text_parts.append(data.strip())

        extractor = TextExtractor()
        extractor.feed(resp.text)
        text = " ".join(extractor.text_parts)

        # Truncate to avoid blowing the context window
        max_chars = 8000
        if len(text) > max_chars:
            text = text[:max_chars] + "... [truncated]"

        return {"url": url, "content": text, "status": "success"}

    except Exception as e:
        return {"url": url, "content": "", "status": f"error: {str(e)}"}


def store_finding(key: str, content: str, source: str = None) -> dict:
    """Store a research finding in working memory."""
    result = memory.store_working(key, content, source)
    return {"status": "stored", "key": key, "message": result}


def recall_findings() -> dict:
    """Retrieve all current working memory as context."""
    context = memory.get_working_context(max_tokens=3000)
    return {"memory": context, "entry_count": len(memory.working)}


# --- Tool definitions for the API ---

RESEARCH_TOOLS = [
    {
        "name": "search_web",
        "description": (
            "Search the web for current information. Use this to find "
            "relevant articles, papers, and sources on a topic."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "query": {"type": "string", "description": "Search query"},
                "max_results": {"type": "integer", "description": "Max results (1-10)", "default": 5},
            },
            "required": ["query"],
        },
    },
    {
        "name": "read_url",
        "description": "Fetch and read the text content of a webpage.",
        "input_schema": {
            "type": "object",
            "properties": {
                "url": {"type": "string", "description": "URL to read"},
            },
            "required": ["url"],
        },
    },
    {
        "name": "store_finding",
        "description": (
            "Store an important finding in memory for later synthesis. "
            "Use this whenever you discover a key fact or data point."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "key": {"type": "string", "description": "Short label for this finding"},
                "content": {"type": "string", "description": "The finding to store"},
                "source": {"type": "string", "description": "Source URL"},
            },
            "required": ["key", "content"],
        },
    },
    {
        "name": "recall_findings",
        "description": (
            "Retrieve all stored findings from memory. Use this before "
            "writing your final synthesis to review what you have learned."
        ),
        "input_schema": {
            "type": "object",
            "properties": {},
        },
    },
]


def execute_research_tool(name: str, args: dict):
    """Route tool calls to implementations."""
    dispatch = {
        "search_web": lambda a: search_web(a["query"], a.get("max_results", 5)),
        "read_url": lambda a: read_url(a["url"]),
        "store_finding": lambda a: store_finding(a["key"], a["content"], a.get("source")),
        "recall_findings": lambda a: recall_findings(),
    }
    handler = dispatch.get(name)
    if handler:
        return handler(args)
    return {"error": f"Unknown tool: {name}"}


def research(question: str) -> str:
    """
    Run the full research agent on a question.

    The agent will:
    1. Search the web for relevant information
    2. Read promising sources
    3. Store key findings in memory
    4. Recall all findings
    5. Synthesize a comprehensive answer
    """
    memory.clear_working()  # fresh scratchpad for each research task

    system_prompt = (
        "You are a thorough research agent. Given a question, you must:\n"
        "1. Search the web for relevant, recent information\n"
        "2. Read at least 2-3 sources to cross-reference facts\n"
        "3. Store each important finding using store_finding\n"
        "4. Before writing your final answer, use recall_findings to review\n"
        "5. Synthesize a comprehensive, well-sourced answer\n\n"
        "Be thorough but efficient. Do not read more than 5 sources. "
        "Always cite your sources in the final answer."
    )

    messages = [{"role": "user", "content": question}]
    max_iterations = 15

    for iteration in range(max_iterations):
        response = client.messages.create(
            model="claude-sonnet-4-20250514",
            max_tokens=4096,
            system=system_prompt,
            tools=RESEARCH_TOOLS,
            messages=messages,
        )

        if response.stop_reason == "tool_use":
            messages.append({"role": "assistant", "content": response.content})

            tool_results = []
            for block in response.content:
                if block.type == "tool_use":
                    print(f"  [{iteration}] {block.name}: {json.dumps(block.input)[:80]}")
                    try:
                        result = execute_research_tool(block.name, block.input)
                        tool_results.append({
                            "type": "tool_result",
                            "tool_use_id": block.id,
                            "content": json.dumps(result),
                        })
                    except Exception as e:
                        tool_results.append({
                            "type": "tool_result",
                            "tool_use_id": block.id,
                            "content": f"Error: {str(e)}",
                            "is_error": True,
                        })

            messages.append({"role": "user", "content": tool_results})
        else:
            text_blocks = [b.text for b in response.content if hasattr(b, "text")]
            final_answer = "\n".join(text_blocks)
            print(f"\n  Research complete after {iteration + 1} iterations")
            print(f"  Findings stored: {len(memory.working)}")
            return final_answer

    return "Research agent exceeded maximum iterations."


# --- Entry point ---

if __name__ == "__main__":
    question = "What are the latest developments in AI agent frameworks in 2026?"
    print(f"Researching: {question}\n")
    answer = research(question)
    print(f"\n{'='*60}\n{answer}")

This implementation demonstrates all three pillars working together. Tool use handles the web search and page reading. Memory stores and retrieves findings across multiple tool-use iterations. And the agent loop itself is the simplest form of orchestration: a single agent with a clear task decomposition strategy encoded in its system prompt.

Comparison: Agent Frameworks in 2026

The framework landscape has matured significantly. Here is a head-to-head comparison of the major options as of early 2026:

Framework Language Tool Use Multi-Agent Memory Observability Production-Ready Learning Curve
Claude Agent SDK Python, TS Native Handoffs, delegation Manual Built-in tracing High Low
OpenAI Agents SDK Python Native Handoffs, guardrails Manual Built-in tracing High Low
LangGraph Python, JS Via LangChain Graph-based orchestration Checkpointing LangSmith High Medium-High
CrewAI Python Built-in Role-based crews Shared memory Basic logging Medium Low
AutoGen (v3) Python Built-in Conversation-based Teachability Basic Medium Medium
Google ADK Python Native (Vertex) Agent-to-agent Session-based Cloud Trace High (on GCP) Medium

Claude Agent SDK and OpenAI Agents SDK are the most straightforward choices if you are already committed to one provider's models. Both offer clean APIs for tool use, built-in tracing, and simple multi-agent patterns via handoffs. The main trade-off is provider lock-in: switching models later means rewriting your agent code.

LangGraph is the most flexible option for complex orchestration. Its graph-based approach lets you model arbitrary agent workflows with cycles, conditional branching, and persistent state via checkpointing. The trade-off is complexity: LangGraph has a steep learning curve and adds significant abstraction overhead.

CrewAI occupies a unique niche with its role-based approach. You define agents as "roles" (Researcher, Writer, Reviewer) and CrewAI handles the orchestration. It is the fastest path from zero to a working multi-agent system, but the abstraction can be limiting for custom workflows.

AutoGen from Microsoft focuses on conversation-based multi-agent patterns. Agents communicate via structured messages, which makes it natural for debate and review workflows. Version 3 improved production-readiness significantly, but it still lags behind the provider SDKs in observability.

Google ADK is the clear choice if you are building on Google Cloud. Tight integration with Vertex AI, Cloud Trace, and other GCP services makes it powerful in that ecosystem, but it is less portable than the alternatives.

The right choice depends on your constraints. For most teams starting out, the provider SDKs (Claude Agent SDK or OpenAI Agents SDK) offer the best balance of simplicity and capability. Graduate to LangGraph when you need complex orchestration that the simpler frameworks cannot express.

Production Considerations

Building a working agent is the easy part. Keeping it running reliably at scale is where the real engineering happens.

Cost management is the number one operational concern. Every agent interaction involves multiple LLM calls, and costs compound with context length. Implement token budgets per task (hard-fail if exceeded), use prompt caching aggressively (the Anthropic API supports automatic caching of repeated prefixes), and monitor cost per interaction in real time. Consider using smaller, cheaper models for simple subtasks and reserving frontier models for complex reasoning. A supervisor on Claude Sonnet delegating to workers on Haiku can cut costs by 80% with minimal quality impact.

Observability and tracing are non-negotiable. Every agent run should produce a trace that shows the full sequence of LLM calls, tool invocations, and decision points. Both the Claude and OpenAI SDKs ship with built-in tracing. If you are building your own, emit structured logs for each turn: the messages sent, the response received, which tools were called, and the results. Store these traces and build dashboards that show success rates, latency distributions, cost per interaction, and common failure modes.

Error handling and circuit breakers protect your system from cascading failures. When a tool consistently fails (API down, rate limited), a circuit breaker stops calling it and returns a cached or default response. Implement retries with exponential backoff for transient failures, but set a maximum retry count. Distinguish between recoverable errors (tool timeout, rate limit) and unrecoverable errors (invalid schema, permission denied).

Rate limiting applies at multiple levels. Your LLM provider has rate limits on tokens per minute and requests per minute. Your tool endpoints (web search APIs, databases) have their own limits. And you should impose your own limits on agent iterations and concurrent tasks. Build a queuing system that respects all three layers of rate limiting.

Testing agents is fundamentally different from testing deterministic code. You cannot write unit tests that assert exact outputs. Instead, build an evaluation framework that runs your agent against a curated set of tasks and scores the results on criteria like accuracy, completeness, tool efficiency, and cost. Track these eval scores over time and block deployments that regress beyond a threshold. Several open-source eval frameworks have matured in this space, including Braintrust, Promptfoo, and the built-in eval tooling in the provider SDKs.

Security is the dimension most teams underinvest in. Tool sandboxing ensures that a code execution tool cannot access the file system outside its designated directory. Prompt injection defense prevents malicious user inputs from hijacking the agent's tool calls. Input validation on tool arguments catches hallucinated or malicious parameters before they reach your backend. The Model Context Protocol (MCP) is emerging as a standard for secure tool integration, and adopting it early pays dividends as your tool ecosystem grows.

Conclusion

The three pillars of production AI agents — tool use, memory, and multi-agent orchestration — are no longer cutting-edge research topics. They are engineering problems with known solutions, mature tooling, and growing community expertise.

Tool use is the mechanism that gives agents the ability to act. The key to reliability is clear tool definitions, robust error handling, and loop detection. Memory is what gives agents continuity and context. A three-tier architecture (short-term, working, long-term) covers the full spectrum of memory needs. Multi-agent orchestration is what gives agents the ability to handle complex tasks. The supervisor/worker pattern handles most production use cases; reach for more complex patterns only when you need them.

The frameworks are ready. The Claude Agent SDK, OpenAI Agents SDK, and LangGraph each provide solid foundations for building production agent systems. The choice between them is primarily about your existing ecosystem and the complexity of your orchestration needs.

Where is this heading? The industry is converging on a few key trends. MCP is becoming the standard protocol for tool integration, much like REST became the standard for web APIs. Agent-to-agent communication protocols are emerging to enable agents built on different frameworks to collaborate. And evaluation frameworks are getting sophisticated enough to enable continuous deployment of agent systems with confidence.

The gap between demo and production has not disappeared, but it has narrowed dramatically. The patterns in this post represent the current state of the art for building agents that work reliably at scale. The best time to start building was six months ago. The second best time is now.


What agent architecture are you building? Share your patterns and pain points in the comments below, or find me on LinkedIn and X/Twitter.


Tools mentioned in this post

Disclosure: the links below are affiliate links. If you sign up via them, we earn a small commission at no extra cost to you. This helps fund the writing of more posts like this one.

  • Pinecone — production vector database. Sign up
  • Anthropic Claude API — production LLM access. Sign up
  • OpenAI Platform — GPT-4 and embedding APIs. Sign up
  • LangChain — LangSmith observability tier. Sign up

About the Author

Toc Am

Founder of AmtocSoft. Writing practical deep-dives on AI engineering, cloud architecture, and developer tooling. Previously built backend systems at scale. Reviews every post published under this byline.

LinkedIn X / Twitter

Published: 2026-04-09 · Written with AI assistance, reviewed by Toc Am.

Get These In Your Inbox

Weekly deep-dives on AI engineering, no fluff. Join the newsletter →

Subscribe (free)

Or grab the book ($39, ~100 pages) · Buy me a coffee

Buy Me a Coffee · 🔔 YouTube · 💼 LinkedIn · 🐦 X/Twitter

Attention Is All You Need, Explained Simply

We published a plain-language walkthrough of the 2017 transformer paper — queries, keys, values, multi-head attention, and why no-recurrence...