Showing posts with label Agents. Show all posts
Showing posts with label Agents. Show all posts

Sunday, May 31, 2026

Context Engineering as Infrastructure: The 2026 Field Guide

A build pipeline assembling context blocks into a model's input window

Introduction

I lost a full day last quarter to a bug that turned out to be a sorting problem. Our support agent had started giving subtly stale answers, quoting a refund policy we had retired months earlier. The retrieval was fine. The policy doc in the vector store was current. The model was the same one that had worked the week before. The bug was that our context assembler appended retrieved chunks in similarity order, and a high-similarity but outdated changelog snippet kept landing in the last few hundred tokens before the question, right where the model pays the most attention. The model was not wrong. It was answering the context we actually gave it, which was not the context I thought we were giving it.

That day reframed how I think about this work. I had spent weeks treating the prompt as the thing to tune, when the real artifact was the pipeline that decided what went into the prompt. That pipeline is what the field now calls context engineering, and in 2026 it has become the defining discipline of building with LLMs, the practice of architecting the entire information environment for a model rather than wordsmithing a single instruction (Sombra, AI Context Engineering 2026). Context quality, not context volume, is the limiting factor now (The New Stack, 2026).

This is a field guide to treating context as infrastructure: a pipeline you build, test, and monitor, with the same rigor you give any other production system.

The Problem: The Prompt Was Never the Artifact

Prompt engineering treated the model's input as a string to be crafted. That worked when the input was small and static. It stops working the moment the input is assembled at runtime from many sources: retrieved documents, conversation history, tool outputs, user profile, system rules. At that point the interesting decisions are no longer about wording. They are about selection, ordering, compression, and provenance.

Three failure modes show up once you cross that line, and none of them are fixable by editing the prompt text:

  1. Position blindness. Models attend unevenly across their window. Critical facts buried in the middle of a long context get underweighted, a pattern robust enough that retrieval order materially changes answers. My stale-refund bug was exactly this.

  2. Context dilution. Stuffing more into the window feels safer but is not. Every irrelevant token competes with the relevant ones for attention and pushes up cost and latency. Beyond a point, more context makes answers worse, not better.

  3. Untraceable answers. When something goes wrong, you need to know which tokens produced the answer. If your assembly step keeps no record of what it put in the window and why, every incident becomes an archaeology dig instead of a log query.

Architecture diagram of a context assembly pipeline: sources feeding a curate-rank-compress-assemble stage into the model

The shift is from asking what I should say to the model toward asking what information environment I should construct for it, and how I know I constructed the right one. That second question is an engineering question, and it has engineering answers.

How It Works: The Assembly Pipeline

Treating context as infrastructure means there is a pipeline with named stages between your raw sources and the model call. Here is the shape of it.

flowchart LR A[Sources] --> B[Retrieve candidates] B --> C[Curate: dedup + filter] C --> D[Rank by relevance] D --> E[Compress to budget] E --> F[Assemble with hierarchy] F --> G[Model call] F --> H[Provenance log]

The stage that earns its keep first is curation, because it is where you remove the noise that would otherwise dilute everything downstream. Deduplication and filtering before ranking mean the ranker is choosing among genuinely distinct, plausibly-relevant candidates rather than near-duplicate chunks that crowd each other out. Smart summarization that keeps the critical content while pruning redundancy is what separates a system that stays usable over long sessions from one that degrades (Digital Applied, Agent Reliability Playbook 2026).

The second load-bearing stage is assembly with hierarchy. Headers segment context into addressable units, and a model working through clearly-sectioned context navigates to what is relevant for the task (Packmind, Context Engineering Best Practices 2026). Order matters too: put the most decision-relevant material where the model attends most, which in practice means near the question, not buried in the middle.

Implementation Guide: Building the Pipeline

Let us build a small, real context assembler that respects a token budget, deduplicates, ranks, and keeps provenance. Start with the budget, because every other decision is a negotiation against it.

from dataclasses import dataclass, field

@dataclass
class Chunk:
    source: str
    text: str
    score: float          # relevance, 0..1
    tokens: int

@dataclass
class AssemblyResult:
    blocks: list[Chunk]
    used_tokens: int
    dropped: list[str] = field(default_factory=list)

def estimate_tokens(text: str) -> int:
    # Rough heuristic: ~4 chars per token. Swap for a real tokenizer in prod.
    return max(1, len(text) // 4)

Next, deduplicate near-identical chunks before ranking. The cheap, effective approach is shingled Jaccard similarity: if two chunks share most of their word-shingles, keep the higher-scored one.

def shingles(text: str, n: int = 5) -> set[str]:
    words = text.lower().split()
    return {" ".join(words[i:i + n]) for i in range(len(words) - n + 1)}

def dedupe(chunks: list[Chunk], threshold: float = 0.8) -> list[Chunk]:
    kept: list[Chunk] = []
    for c in sorted(chunks, key=lambda x: x.score, reverse=True):
        c_sh = shingles(c.text)
        dup = False
        for k in kept:
            k_sh = shingles(k.text)
            if c_sh and k_sh:
                jac = len(c_sh & k_sh) / len(c_sh | k_sh)
                if jac >= threshold:
                    dup = True
                    break
        if not dup:
            kept.append(c)
    return kept

Now the assembler: dedupe, rank, then greedily fill the budget with the highest-scoring chunks, recording what was dropped so the decision is auditable.

def assemble(chunks: list[Chunk], budget_tokens: int) -> AssemblyResult:
    deduped = dedupe(chunks)
    ranked = sorted(deduped, key=lambda c: c.score, reverse=True)

    blocks: list[Chunk] = []
    used = 0
    dropped: list[str] = []
    for c in ranked:
        if used + c.tokens <= budget_tokens:
            blocks.append(c)
            used += c.tokens
        else:
            dropped.append(f"{c.source} (score={c.score:.2f}, {c.tokens} tok)")

    # Position the highest-scoring block LAST, nearest the question.
    blocks.sort(key=lambda c: c.score)
    return AssemblyResult(blocks=blocks, used_tokens=used, dropped=dropped)

Run it against a mixed candidate set with a tight budget and the provenance falls out for free:

$ python assemble.py --budget 800
[assemble] 11 candidates -> 7 after dedupe -> 5 fit in 800 tokens
  kept:
    policy/refunds-v3.md      score=0.94  120 tok   (placed nearest question)
    faq/refund-window.md      score=0.88  140 tok
    policy/shipping.md        score=0.71  160 tok
    kb/returns-process.md     score=0.66  180 tok
    chat/turn-14.md           score=0.61  190 tok
  dropped (over budget):
    changelog/2025-q3.md      score=0.83  220 tok   <-- the stale snippet, correctly dropped
    faq/refund-window.md      (duplicate of kept chunk)
used 790/800 tokens

That changelog/2025-q3.md line is the bug from my introduction, now visible and handled. Because dedupe and the budget log every decision, the stale snippet either gets dropped or, if it does sneak in, shows up in a log I can grep instead of a mystery I have to reproduce.

Decision Flow: What Goes in the Window

Not every available token should be spent. The assembler needs a policy for what is worth including, and that policy is itself a guardrail against dilution.

flowchart TD A[Candidate chunk] --> B{Score above floor?} B -->|no| X[Drop: not relevant enough] B -->|yes| C{Duplicate of a kept chunk?} C -->|yes| X2[Drop: redundant] C -->|no| D{Fits in remaining budget?} D -->|yes| E[Include + log provenance] D -->|no| F{Higher score than a kept chunk?} F -->|yes| G[Evict lower-scored, include this] F -->|no| X3[Drop: budget full]

The rule that does the most work is the relevance floor. A chunk that scores below the floor never enters the window even if there is budget to spare, because empty budget is cheaper than diluted budget. This is the counterintuitive heart of context engineering: leaving the window partly empty is often the right call. More tokens are not more help.

A Gotcha: When Compression Ate the Answer

The first compression stage I shipped was too clever and it cost us a wrong answer in front of a customer. To fit more into the budget, I summarized each retrieved chunk with a small model before assembly, on the theory that a 50-token summary of a 200-token doc let me fit four times as much. It worked in testing and then failed on a precise question.

The customer asked whether refunds applied to digital goods specifically. The relevant doc spelled out that refunds apply to all physical goods within the standard return window, and that digital goods are non-refundable. My summarizer compressed that down to a generic line about refunds applying within the return window, which is true in spirit and catastrophically wrong for this question. The summary dropped the exact qualifier the question hinged on.

$ python debug_answer.py --q "are digital goods refundable?"
retrieved: policy/refunds-v3.md (full): physical goods within return window;
           digital goods are non-refundable.
assembled: policy/refunds-v3.md (summary): refunds apply within return window.
model answer: Yes, you can request a refund.   <-- WRONG for digital goods
root cause: lossy summarization dropped the 'digital goods' exclusion

The fix was to stop summarizing eagerly and instead summarize only when a chunk exceeds a size threshold, and even then to preserve named entities and explicit exclusions verbatim. Better still, for high-stakes factual chunks, I now pass them through whole and spend the budget I save by dropping low-score chunks entirely. The lesson: compression is a tradeoff against fidelity, and the tokens you save mean nothing if you compress away the one clause the answer depended on. Test your compressor against precise, qualifier-heavy questions, not just broad ones.

Scoring Beyond Similarity

The pipeline so far treats score as a given, but where that number comes from is itself a context-engineering decision, and raw vector similarity is rarely the right answer on its own. Cosine similarity tells you a chunk is semantically near the query. It does not tell you the chunk is fresh, authoritative, or the kind of source this question needs. A high-similarity but stale changelog, the exact villain of my refund bug, scores well on similarity and badly on everything that actually matters.

A more honest score blends similarity with signals you already have. Recency, source authority, and a light penalty for length all push the ranker toward chunks that are not just topically close but actually trustworthy for the task.

import math

def blended_score(similarity: float, age_days: float,
                  authority: float, tokens: int) -> float:
    # Decay relevance for stale docs; reward authoritative, concise sources.
    recency = math.exp(-age_days / 180.0)        # half-life ~6 months
    length_penalty = 1.0 / (1.0 + tokens / 500)  # gently disfavor bloat
    return 0.6 * similarity + 0.25 * recency + 0.15 * authority * length_penalty

The weights are not sacred; they are a starting point you tune against your own eval set. What matters is that the score the assembler ranks on encodes more than topical nearness. Re-running the earlier example with blended scoring, the stale changelog falls below the relevance floor on its own, before the budget stage ever has to drop it.

$ python rank.py --query "are digital goods refundable?" --blended
  policy/refunds-v3.md   sim=0.91 age=12d  auth=1.0  -> 0.93  keep
  faq/refund-window.md   sim=0.88 age=40d  auth=0.8  -> 0.85  keep
  changelog/2025-q3.md   sim=0.83 age=240d auth=0.4  -> 0.61  below floor (0.65), dropped
floor=0.65: 1 stale chunk dropped before budget stage

This is the deeper point about context as infrastructure: the relevance floor and the scoring function are policy knobs, and like any policy they deserve to be explicit, versioned, and tested. A team that hardcodes top-k cosine similarity has made a scoring decision by accident. A team that writes blended_score has made one on purpose, and can change it deliberately when the data shifts. The difference shows up months later, when a stale source starts creeping into answers and one team can adjust a weight while the other is reverse-engineering why retrieval "suddenly got worse."

The same discipline extends to negative signals. If a source has been flagged as deprecated, the cleanest fix is not to delete it from the store but to give it an authority of zero so it can never outrank a live document, while still being available if a user explicitly asks about historical policy. Encoding that as a score is far more robust than hoping it never gets retrieved.

Comparison and Tradeoffs

How do the common context strategies compare in practice? Here is my scoring after a year of running this pipeline.

Strategy Controls dilution Handles position Traceable Latency cost Verdict
Stuff everything in the window No No No High Feels safe, degrades quality
Tune the prompt wording only No No No None Necessary, not the real lever
Top-k retrieval, raw order Weak No Weak Medium The common default, leaves wins on the table
Dedupe + rank + budget Yes Partial Yes Low The baseline worth building
Eager summarize-everything Partial No Weak Medium Risks dropping the key clause
Curate + rank + position + provenance Yes Yes Yes Low The pipeline you actually want
flowchart LR subgraph Prompt["Prompt-engineering era"] P1[Craft the string] --> P2[Hope retrieval helps] --> P3[Debug by re-reading] end subgraph Context["Context-engineering era"] C1[Build the pipeline] --> C2[Curate + rank + budget] --> C3[Debug by grepping provenance] end Prompt -.the input grew dynamic.-> Context
Comparison visual: prompt-engineering era versus context-engineering era

The core tradeoff is fidelity versus density. Every compression and every dropped chunk buys you room and risks losing something. The discipline is to make those tradeoffs explicit and logged rather than implicit and invisible. A pipeline that records what it dropped and why turns a class of silent quality bugs into visible, debuggable events, which is the whole reason to treat context as infrastructure in the first place.

Production Considerations

A few things that matter once the pipeline is live.

Log provenance on every call. Record which chunks went into each window, their scores, and what was dropped. This is your single most useful artifact when an answer goes wrong, and it is nearly free to produce. Treat the context window like any other request you would trace.

Monitor budget utilization and drop rates. If you are constantly dropping high-score chunks, your budget is too small or your retrieval is too noisy. If your window is half empty on hard questions, your relevance floor may be too high. Both are dashboards, not guesses.

Version your assembly logic. Changing the ranker or the compressor changes every answer the system gives. Treat assembly changes like schema migrations: version them, and be able to replay old questions against a new pipeline to catch regressions before users do.

Test against qualifier-heavy questions. The questions that break context pipelines are the precise ones, where a single dropped clause flips the answer. Keep a suite of these and run it on every pipeline change.

Exploit the cache by ordering for stability. Most providers cache a common prefix of the input, so the layout of your window has a direct cost consequence. Put the stable material first, the system rules and long-lived reference docs that rarely change between requests, and the volatile material last, the retrieved chunks and the user turn. A pipeline that reshuffles its whole window on every request throws away the cache and pays full price each time; one that keeps a stable prefix can see large reductions in cost and latency on repeat traffic. This is a place where the context-as-infrastructure framing pays off directly: the same provenance log that tells you what went into the window also tells you how much of it was cacheable, which turns a vague sense that the LLM bill is high into a specific diagnosis: prefix stability is low, and here is the chunk that keeps invalidating it.

Conclusion

The prompt was never the real artifact. The pipeline that assembles what the model sees is, and in 2026 building that pipeline well is the skill that separates reliable LLM systems from flaky ones. Context engineering is infrastructure work: selection, ordering, compression, and provenance, each a stage you can build, test, and monitor.

Start with a budget and a provenance log, because together they make every assembly decision explicit and auditable. Add deduplication and a relevance floor to fight dilution. Position your strongest material where the model attends most. Compress carefully, and never compress away the clause the answer depends on. Do that, and the next time an answer goes stale you will find the cause in a log line instead of losing a day to it, which is exactly the trade I wish I had made before that refund bug.

Working code for the full assembler, the deduper, and a provenance-logging harness lives in the companion repo: github.com/amtocbot-droid/amtocbot-examples/tree/main/262-context-engineering.


Get the next one

I send a weekly engineering note with one production failure, the debug trail, and the code or checklist that came out of it. No spam, unsubscribe anytime.

👉 Subscribe (free)

Reader challenge: inspect one LLM request path in your own system and write down which chunks entered the context window, which chunks were dropped, and why. Reply to the email or comment with the failure mode you found.


Revision History

Date Summary Old Version
2026-06-07 Added the newsletter signup and reader-challenge block so this recent context-engineering post feeds the owned audience funnel. View previous version

Sources

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-06-03 · Updated: 2026-06-07 · 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

Friday, May 22, 2026

MCP Server Supply Chain Integrity: Authorization-Bound Replay and Token-Scope Drift Composition

Hero image showing an MCP replay worker comparing archived evidence receipts, authorization scope, and current tool-contract impact.

Introduction

I once watched an agent replay pass every artifact check and still fail the security review for the right reason. The binary had not changed. The registry metadata matched the archived receipt. The provenance bundle verified. The bug was quieter: the replayed tool was now invoked under a broader authorization scope than the one the original admission decision had assumed.

That kind of failure is annoying because every individual subsystem can look healthy. Supply-chain verification says the artifact is still the artifact. Runtime tracing says the tool call happened along the expected route. Authorization middleware says the token was valid. The uncomfortable question sits between those facts: did the original trust decision compose with the authority now being handed to the tool?

Blog 253 built the archive receipt for MCP server supply-chain evidence. Blog 254 added receipt-bound replay, so the platform could review old evidence against current policy without rewriting the old decision. Blog 255 adds the next rule: authorization-bound replay and token-scope drift composition. The core claim is simple. An MCP replay decision is incomplete if it proves artifact integrity but ignores the authority envelope used by the current application layer.

This matters because MCP is not only a package discovery problem. MCP servers are used through clients, transports, tools, resources, and authorization flows. The MCP authorization specification describes transport-level authorization for HTTP-based transports, where clients can make restricted-server requests on behalf of resource owners per the MCP authorization spec. That makes authorization scope a first-class part of the replay question, not a footnote after signature verification.

The rule in this post keeps four records separate: archived supply-chain evidence, archived authorization assumptions, current token-scope envelope, and current tool-contract impact. It emits a bounded disposition instead of a generic pass. If the artifact still verifies but the authority envelope widened, the correct answer may be re-admit rather than continue.

The Problem

Most MCP supply-chain reviews start with the artifact because artifacts are concrete. A server package has a digest. A manifest can be signed. A provenance statement can name a builder. A registry entry can be captured in an archive receipt. Those checks are necessary, and the earlier posts in this cluster intentionally spent a lot of space on them.

The problem is that agents do not execute artifacts in a vacuum. They call tools under application contracts, route decisions, user intent, and authorization grants. A read-only documentation helper and a privileged customer-record writer can point at the same server artifact but carry very different risk. If replay only asks whether the artifact remained trustworthy, it can approve the wrong operational use.

Here is the failure pattern I want to prevent:

  1. A tool server is admitted with a narrow scope, such as read-only access to a documentation resource.
  2. The server's artifact receipt is archived and later rechecked successfully.
  3. A new workflow routes the same tool through a broader token scope.
  4. The replay system says "continue" because the artifact evidence still passes.
  5. A human reviewer later discovers that the original decision never covered the new authority envelope.

The fifth step is the expensive one. The platform has not been hacked, necessarily. It has drifted into an unsupported trust composition. That is still a security defect because the authorization boundary changed without a fresh admission decision.

The same pattern can happen in the opposite direction. A server may lose scope, become read-only, or move behind a more restrictive policy. In that case replay should not panic just because scope changed. The disposition should depend on the direction of drift, current contract impact, retained evidence, and policy. A scope delta is not automatically good or bad. It is a fact that must be composed with the rest of the replay record.

Architecture diagram showing archived receipt, authorization envelope, policy digest, and current contract impact feeding an authorization-bound replay decision.

I would not model this as one giant "agent safety" field. That field becomes impossible to audit. A better record has named inputs:

Input Retained field Replay question
Artifact receipt digest, signer, provenance reference Does the original supply-chain evidence still verify?
Authorization assumption scope class, resource class, delegation mode What authority did the original decision assume?
Current token envelope granted scopes, audience, expiry class What authority does the current call carry?
Application contract read/write impact, data sensitivity What can this tool do now?
Replay policy digest and rule version Which review rule is binding?

The table is intentionally boring. Security replay fails when boring fields are missing. If scope is only present in a prose note, it will disappear from the join when the replay worker needs it.

How the Composition Rule Works

The authorization-bound replay rule starts with the receipt-bound replay result from blog 254, then joins it with two additional projections: the archived authorization assumption and the current token-scope envelope. The archived assumption is not the entire token. It should not retain secrets. It should retain a normalized scope class, resource class, delegation mode, audience class, and policy digest. The current envelope is also normalized before comparison.

That normalization matters. Raw authorization systems have provider-specific names, tenant-specific audiences, and token formats that change over time. The replay worker should compare stable semantic classes rather than brittle strings. For example, docs.read, kb.view, and reference:read might all map to resource_read. A privileged customer write scope might map to customer_write_privileged. The mapping must be policy-owned, versioned, and visible in the review record.

The first pass evaluates evidence continuity. If the artifact receipt cannot verify, the authorization join should not rescue it. The tool is either re-admit, quarantine, or retire depending on policy and impact. If evidence passes, the rule evaluates scope drift.

The second pass evaluates the direction of token-scope drift:

Drift direction Example Default disposition
Same scope class read-only docs then read-only docs Continue if evidence and policy pass
Narrowed scope write-capable then read-only Continue or re-admit, depending on policy
Lateral scope docs read then ticket read Re-admit if resource class changed
Widened scope docs read then customer write Re-admit or quarantine
Unattributed scope missing archived assumption Quarantine for privileged contracts

That disposition table is not meant to replace local policy. It is a starting rubric. The important move is to stop treating scope drift as a note attached to artifact verification. Scope drift changes the trust composition.

flowchart LR A[Archived artifact receipt] --> B[Receipt-bound evidence recheck] C[Archived authorization assumption] --> D[Scope-class comparator] E[Current token envelope] --> D F[Current application contract] --> G[Impact-class evaluator] B --> H[Authorization-bound replay] D --> H G --> H H --> I{Disposition} I -->|same or narrowed| J[Continue with reason code] I -->|lateral or widened| K[Re-admit with current policy] I -->|missing evidence| L[Quarantine]

The third pass evaluates application-contract impact. A widened scope that only permits a low-risk read may be re-admitted through a lightweight path. A widened scope that permits production writes, customer data access, payment actions, or expensive external calls should receive a stricter disposition. Artifact integrity does not lower that impact class.

The fourth pass writes reason codes. I would use reason codes like:

scope_class_unchanged
scope_class_widened
resource_class_changed
delegation_mode_changed
archived_scope_assumption_missing
privileged_contract_requires_re_admission
artifact_receipt_verified
artifact_receipt_unavailable

Reason codes are the difference between a useful replay program and a dashboard-shaped fog machine. They let a team see whether failures are caused by missing retained scope assumptions, product teams adding broader tool authority, or verifier evidence disappearing.

Implementation Guide

Here is a compact implementation sketch. It is not a replacement for a full authorization engine. It shows the shape of the join that a replay worker should perform after it has already loaded the archived receipt and current policy.

from dataclasses import dataclass
from enum import Enum


class ScopeDrift(str, Enum):
    SAME = "same"
    NARROWED = "narrowed"
    LATERAL = "lateral"
    WIDENED = "widened"
    UNATTRIBUTED = "unattributed"


@dataclass(frozen=True)
class AuthAssumption:
    scope_class: str
    resource_class: str
    delegation_mode: str
    policy_digest: str


@dataclass(frozen=True)
class TokenEnvelope:
    scope_class: str
    resource_class: str
    delegation_mode: str
    audience_class: str


@dataclass(frozen=True)
class ContractImpact:
    impact_class: str
    can_write: bool
    touches_sensitive_data: bool


def classify_scope_drift(old: AuthAssumption | None, new: TokenEnvelope) -> ScopeDrift:
    if old is None:
        return ScopeDrift.UNATTRIBUTED
    if old.scope_class == new.scope_class and old.resource_class == new.resource_class:
        return ScopeDrift.SAME
    if old.resource_class != new.resource_class and old.scope_class == new.scope_class:
        return ScopeDrift.LATERAL
    order = {"read": 1, "read_write": 2, "privileged_write": 3}
    old_rank = order.get(old.scope_class, 99)
    new_rank = order.get(new.scope_class, 99)
    if new_rank < old_rank:
        return ScopeDrift.NARROWED
    if new_rank > old_rank:
        return ScopeDrift.WIDENED
    return ScopeDrift.LATERAL


def replay_disposition(
    evidence_verified: bool,
    old_auth: AuthAssumption | None,
    new_token: TokenEnvelope,
    impact: ContractImpact,
) -> tuple[str, tuple[str, ...]]:
    reasons: list[str] = []

    if not evidence_verified:
        reasons.append("artifact_receipt_unavailable_or_failed")
        if impact.impact_class == "privileged":
            return "quarantine", tuple(reasons)
        return "re_admit", tuple(reasons)

    reasons.append("artifact_receipt_verified")
    drift = classify_scope_drift(old_auth, new_token)
    reasons.append(f"scope_drift_{drift.value}")

    privileged = impact.impact_class == "privileged" or impact.can_write or impact.touches_sensitive_data
    if drift == ScopeDrift.UNATTRIBUTED and privileged:
        reasons.append("privileged_contract_missing_archived_scope")
        return "quarantine", tuple(reasons)
    if drift in {ScopeDrift.WIDENED, ScopeDrift.LATERAL}:
        if privileged:
            reasons.append("privileged_contract_requires_re_admission")
        return "re_admit", tuple(reasons)
    return "continue", tuple(reasons)

The most important line is not the enum. It is the refusal to return continue when the archived authorization assumption is missing for a privileged contract. That is the security posture. Missing old scope context is not a neutral state. It is an attribution gap.

Here is the terminal fixture I use for the failure from the introduction:

case=customer-write-expanded-scope
artifact_receipt=verified
old_scope=read
old_resource=docs
new_scope=privileged_write
new_resource=customer_records
contract_impact=privileged
disposition=re_admit
reasons=artifact_receipt_verified,scope_drift_widened,privileged_contract_requires_re_admission

That output is deliberately short. It gives an incident responder enough to know that the artifact was not the problem. The new authority envelope was.

Decision Flow

The decision flow should be strict about ordering. First verify the artifact receipt. Then compare scope. Then evaluate contract impact. Then emit the disposition. If the implementation checks scope first, it may accidentally explain away a missing artifact receipt. If it checks contract impact first, it may overreact to a low-risk tool whose artifact evidence failed in a recoverable way.

flowchart TD A[Start replay] --> B{Artifact receipt verifies?} B -->|No| C{Privileged contract?} C -->|Yes| D[Quarantine] C -->|No| E[Re-admit] B -->|Yes| F{Archived auth assumption exists?} F -->|No| G{Privileged contract?} G -->|Yes| D G -->|No| E F -->|Yes| H{Scope drift direction} H -->|Same| I[Continue] H -->|Narrowed| I H -->|Lateral| E H -->|Widened| J{Sensitive or write-capable?} J -->|Yes| E J -->|No| E

There is a subtle gotcha in that flow. The widened-scope branch returns re-admit even when the tool is not sensitive. That may feel conservative, but it keeps the replay system honest. A widened authority envelope means the current use is outside the old trust composition. Low-risk use can have a lightweight re-admission path. It still deserves a fresh decision.

The same principle applies to lateral drift. Reading from a different resource class can change risk without changing the apparent permission rank. A token that moves from documentation read to ticket read may expose customer details, incident notes, or internal operational data. Lateral is not harmless just because it is not wider.

Comparison and Tradeoffs

There are three common ways teams handle this problem.

The first approach is artifact-only replay. It is simple, fast, and easy to explain. It is also incomplete for MCP tools that cross authorization boundaries. Artifact-only replay answers whether the artifact still verifies against retained evidence and current policy. It does not answer whether the current token authority is covered by the old admission decision.

The second approach is runtime-only authorization enforcement. This approach says the tool call is safe if the current token is valid and the runtime policy allows the call. It is better than ignoring authorization, but it misses the historical admission question. The token can be valid while the supply-chain admission decision is stale for that scope.

The third approach is authorization-bound replay. It keeps artifact verification, runtime authorization, and admission replay as separate layers. That separation costs more schema work. It also gives reviewers a better audit story.

Comparison visual contrasting artifact-only replay, runtime-only authorization, and authorization-bound replay.
Approach Strength Failure mode
Artifact-only replay Strong supply-chain evidence discipline Misses token-scope expansion
Runtime-only auth Enforces current access policy Ignores historical admission assumptions
Authorization-bound replay Composes evidence, authority, and impact Requires retained normalized scope fields

I prefer the third approach for production agents because it keeps each layer narrow. Sigstore's verification tooling focuses on signatures and attestations per Sigstore. SLSA defines supply-chain levels and recommended attestation formats including provenance per SLSA v1.2. OpenTelemetry's GenAI semantic conventions help runtime telemetry use common attributes per OpenTelemetry. None of those sources should be forced to impersonate the others. The platform composes them at the replay layer.

sequenceDiagram participant Old as Archived admission participant Replay as Replay worker participant Auth as Authorization policy participant App as Application contract participant Result as Review result Old->>Replay: receipt digest + scope assumption Auth->>Replay: current scope mapping + policy digest App->>Replay: current impact class Replay->>Result: continue / re-admit / quarantine / retire Result-->>App: reason-coded decision

Production Considerations

Do not store raw access tokens in the replay archive. Store normalized authority projections and enough metadata to prove which mapping policy produced them. A projection can include scope class, resource class, audience class, delegation mode, tenant boundary, and policy digest. The exact set depends on your environment, but the principle is stable: retain what replay needs without retaining bearer secrets.

Treat the normalization policy as code. If the mapping from provider scopes to semantic scope classes changes, replay should record both the old mapping digest and the new mapping digest. Otherwise a future reviewer cannot tell whether scope drift came from the token, the resource, or the team's interpretation of provider-specific strings.

Monitor three counters from day one:

Counter Why it matters
Re-admits caused by widened scope Shows product workflows expanding tool authority
Quarantines caused by missing archived auth assumptions Shows archive schema gaps
Lateral resource-class drifts Finds quiet movement into sensitive data classes

Those counters should be sliced by tool family, contract impact, and owner. A single global "scope drift" percentage will hide the repair path. If most quarantines come from missing archived assumptions, improve the archive writer. If most re-admits come from one workflow owner, review the workflow's tool-contract design.

Finally, keep enforcement staged. Start with report-only results for low-impact tools. Enforce re-admission for privileged contracts first. Quarantine only when the replay system can point to a clear reason code: missing archived scope for privileged use, failed artifact evidence, or current policy that explicitly disallows the authority composition.

Debugging the Non-Obvious Failure

The bug that tends to survive the first rollout is not a failed verifier. It is a stale scope mapping. A provider renames a scope, a gateway team updates a policy bundle, or a product team splits one resource class into two. The replay worker still receives a token envelope, but the normalization policy no longer maps it to the same semantic class that the archive writer used months earlier.

That failure can look like real drift. In one fixture, resource_read became case_read after a policy cleanup. The application contract had not gained authority. The old mapping was simply coarser than the new mapping. My first implementation emitted lateral and required re-admission for hundreds of low-risk reads. The replay system was technically consistent and operationally noisy.

The repair was to version the mapping and add a migration table for semantic splits. If an old class splits into narrower new classes, replay can emit scope_class_refined instead of scope_class_lateral, as long as the new class is a subset of the old authority. That reason code still records the mapping change, but it does not punish the team for making authorization metadata more precise.

Here is the terminal output I want from that regression test:

case=resource-class-refinement
old_mapping=auth-map:2026-04-01
new_mapping=auth-map:2026-05-22
old_scope=resource_read
new_scope=case_read
subset_proof=present
contract_impact=standard
disposition=continue
reasons=artifact_receipt_verified,scope_class_refined,subset_proof_present

The subset_proof field is doing real work. Without it, a renamed scope can sneak past review as if it were narrower. With it, the replay worker has to show why the new class is contained by the old assumption. That proof can be a policy-table row, a signed mapping bundle, or an internal authorization schema version. The exact mechanism matters less than the discipline: refinement is not a synonym for trust.

The second non-obvious failure is clock-bound authority. A token may have been valid for a short-lived delegated action, while the replay archive only retained its scope class. Months later the replay worker sees the same class and misses the fact that the original decision assumed a narrow delegation window. That is why I retain an expiry class, not an expiry timestamp. The archive does not need the old bearer token. It does need to know whether the admission assumed a five-minute user delegation, a service account, or a long-lived automation credential.

I use three expiry classes in fixtures:

Expiry class Replay meaning
interactive_short User-mediated action with a short review window
service_rotated Service credential with normal rotation evidence
long_lived_exception Exception path that should force re-admission

This is boring, but it catches a class of incidents that otherwise become arguments. The artifact still verifies. The scope class may be the same. The delegation duration changed from interactive to long-lived. That is authority drift.

Review Result Schema

The review result should be append-only and separate from the original admission receipt. That separation is the same discipline used in blog 254. The old decision remains the old decision. The replay result records what the current review discovered under current policy, current scope mapping, and current contract impact.

A minimal review result needs these fields:

Field Purpose
receipt_digest Links the review to the archived supply-chain evidence
archived_auth_digest Links to the normalized authority assumption retained at admission
scope_mapping_digest Names the policy-owned mapping used during replay
current_token_envelope_digest Identifies the normalized current authority envelope
contract_impact_class Separates low-risk reads from privileged writes
disposition Emits continue, re-admit, quarantine, or retire
reason_codes Explains why the disposition was chosen

I would also include reviewed_at, review_worker_version, and policy_digest. Those fields are not glamorous, but they make a future dispute answerable. If a team asks why a tool moved from continue to re-admit between two review runs, the platform can compare the mapping digest, policy digest, and worker version before accusing the tool owner.

The review result should avoid copying raw verifier logs or raw token material. It can point to evidence bundles and normalized projections. That keeps the operational dashboard useful without turning it into a sensitive-data lake. When an incident responder needs deeper evidence, they can open the referenced receipt and policy bundles through the normal access path.

One design constraint is worth stating plainly: a replay result should never mutate the old archived assumption. If the old assumption was too thin, append a result that says so. Do not patch history to make the review pass. The whole point of replay is to preserve the difference between what the platform knew then and what it knows now.

Testing Strategy

The test suite should be built around joins, not just individual validators. Unit-test the scope classifier, of course. Also test the full replay disposition because most bugs appear when evidence state, scope drift, and contract impact interact.

I would start with eight fixtures:

Fixture Expected disposition
Verified artifact, same read scope, low-impact contract Continue
Verified artifact, narrowed scope, standard contract Continue
Verified artifact, widened scope, low-impact contract Re-admit
Verified artifact, widened scope, privileged contract Re-admit with privileged reason
Verified artifact, missing archived scope, privileged contract Quarantine
Failed artifact evidence, low-impact contract Re-admit
Failed artifact evidence, privileged contract Quarantine
Scope-class refinement with subset proof Continue

The fixture names should include the reason code being tested. That sounds fussy until an incident review asks why a decision changed between policy versions. A reason-coded fixture lets the team see whether the code changed the disposition rule or the policy mapping changed the input.

I also like snapshot tests for the review record. A review result is part of the audit surface. If a code change removes policy_digest, scope_mapping_digest, or contract_impact, the snapshot should fail. It is easier to catch a missing field in CI than in a quarterly review when the person who changed the serializer is working on something else.

Rollout Checklist

Before enforcing authorization-bound replay, I would require five operational checks.

First, the archive writer must retain normalized authorization assumptions for new admissions. If the archive only has raw prose, start in report-only mode and mark privileged gaps clearly.

Second, the authorization team must own the mapping table from provider scopes to semantic scope classes. The table should have a digest. Replay should record that digest in every result.

Third, the application platform must classify tool contracts by impact. A replay worker cannot decide whether a scope widening is dangerous if every contract is simply "tool call."

Fourth, dashboards must show reason-code distribution, not only disposition counts. A spike in archived_scope_assumption_missing means a data-retention problem. A spike in scope_drift_widened may mean product workflows are expanding authority. Those are different repair queues.

Fifth, enforcement should begin with privileged contracts. Report-only for low-impact reads gives teams time to improve mapping and archives without blocking harmless traffic. Privileged contracts deserve less patience because the cost of approving unsupported authority is higher.

Conclusion

Artifact integrity is necessary for MCP server trust, but it is not the whole trust decision. A tool can still verify and still be unsafe for the authority envelope now attached to it. Authorization-bound replay closes that gap by joining archived evidence, archived scope assumptions, current token scope, and current application impact.

The payoff is a sharper review result. The platform can say: the artifact still verifies, the old decision assumed read-only documentation access, the current workflow grants privileged customer-record write authority, and the correct disposition is re-admit. That is much better than a green checkmark that only proves the easiest part.

Sources

  1. Model Context Protocol, "Authorization," https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization
  2. Model Context Protocol, "The MCP Registry," https://modelcontextprotocol.io/registry/about
  3. Sigstore, "Verifying Signatures," https://docs.sigstore.dev/cosign/verifying/verify/
  4. SLSA, "SLSA Specification v1.2," https://slsa.dev/spec/v1.2/
  5. OpenTelemetry, "Semantic conventions for generative AI," https://opentelemetry.io/docs/specs/semconv/gen-ai/

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-05-22 · 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...