20  Implementing a Multi-Agent System from Scratch

Every systems curriculum has its rite of passage: the course where you write the compiler, the toy operating system, the database engine with one table and no excuses. The point was never that the world needed another compiler. The point was that afterwards, no compiler could mystify you again — you had seen where every behaviour comes from, and error messages became navigation rather than omens. Agent systems have reached the age where they deserve the same rite, and this chapter conducts it: a complete multi-agent runtime — loop, tools, context, state, persistence, messages, and a coordinating harness — built in plain Python, with a model client as the only dependency, finishing with the book’s running team completing a real task on a real repository, end to end, on infrastructure whose every line you will have watched being justified.

The build makes one principle literal, and it is the principle this book has repeated since Chapter 3: the model is a component of the agent, not a synonym for it. In the runtime we construct, that sentence becomes architecture — the model sits behind a single narrow interface, swappable by editing one file, while everything that makes the system an agent system lives outside it, in code we write: the loop that drives action, the journal that remembers, the messages that coordinate, the budget that constrains. Chapter 19 priced this road and found the folklore wrong about its cost; this chapter pays the bill in public and itemises the receipt — the six services of Section 19.1, each at its honest minimum scope, none skipped, in a runtime measured in hundreds of lines rather than thousands.

A word on what minimal means, because it does not mean toy. The runtime is a paper aeroplane by Section 19.1’s distinction — it will not carry production passengers, and Chapter 23’s hardening is explicitly deferred — but it is a complete paper aeroplane: it persists, it resumes after a crash, it replays yesterday’s failure deterministically, it enforces a token budget, and it fails in ways you can read. Completeness, not polish, is the pedagogical load-bearer, since the parts most often omitted from tutorials — persistence, resumption, failure paths — are exactly the parts whose absence separates a demonstration from a system, and what Chapter 19 said you were paying frameworks to have opinions about. The prose that follows names every module, type, and decision, and sets the load-bearing listings in place as it goes; the full modules live in the companion repository (Appendix C), which carries the burden of currency so that this chapter need not.

The chapter builds in dependency order, which is also the order of understanding. First the brief — what minimal-but-complete commits us to, and the principles that discipline every choice (Section 20.1). Then one agent: the loop, tool dispatch, and context assembly that turn a text-completer into an actor (Section 20.2). Then memory: typed state, and the append-only journal that this build treats as the source of truth, from which persistence, resumption, and deterministic replay all fall out as corollaries (Section 20.3). Then many agents: typed messages with honest-to-Chapter-8 performatives, mailboxes, and the coordinating harness in which the reader will recognise, in a few dozen lines, an old friend from 1980 (Section 20.4). And finally the first flight: the team runs, the journal is read, and then — deliberately — things are broken, so that the failure modes catalogued across this book can be watched arising in a runtime with no framework to blame (Section 20.5). By the end the reader owns a runtime, and something better: the certainty of knowing what every framework has been doing on their behalf.

20.1 The Design Brief: Minimal but Complete

A build begins with a brief, and this one fits in three words that pull in opposite directions: minimal but complete. Completeness is defined by Chapter 19’s anatomy — all six services present: loop, state and persistence, tool dispatch, context assembly, topology, observability — because a runtime missing any of them is not a smaller runtime but a different and lesser kind of thing — and because a tutorial that omits a service is usually flattering itself. Minimality is the counter-pressure: each service at its honest smallest scope, so that everything in the runtime must earn its lines and nothing is present because it might be clever later. The two constraints discipline each other — completeness forbids the dishonest omission, minimality forbids the speculative flourish — and the space between them is exactly one paper aeroplane wide.

The completeness contract, itemised. The loop runs single-threaded, one turn at a time, with three stop conditions: a done-signal from the agent, a token budget exhausted, a turn cap reached — no parallelism, no streaming. State and persistence are a handful of typed objects plus an append-only journal, with resumption after a crash a requirement, not a stretch goal. Tool dispatch is a schema per tool, validation, execution, and errors returned as observations. Context assembly is an explicit, readable policy: what enters the window, in what order, truncated how. Topology is mailboxes, a spawn function, and one coordinator — no peer-to-peer exotica. And observability comes free, by a small economy the chapter is quietly proud of: the journal that gives us persistence is the trace — one design decision serving two services, which is the sort of bargain minimality exists to hunt.

Four principles discipline every choice, and each has a concrete justification. First, the model behind one interface: a single ModelClient — give it messages and tool schemas, receive a response — is the only place any vendor’s API is spoken. The obvious dividend is the vendor swap in one file; the quieter and larger one is testability: substitute a scripted fake for the client and the entire runtime — loop, journal, mailboxes, harness — can be exercised deterministically without spending a token, which is how infrastructure should be tested and almost never is. Second, own the loop: Chapter 19’s inversion-of-control question, answered for control — the runtime calls the model, never the reverse. Third, typed everything: messages, state, events, and tool results all carry explicit schemas, because in a system whose participants improvise, the types are the parts that do not, and they serve as documentation, validation, and the contract between agents at once. Fourth, no magic: every behaviour traces to a line that can be read — no decorators doing invisible work, no global registries, no convention-over-configuration. The debugging dividend Chapter 19 promised begins here, as policy.

What the runtime is for fixes its final shape. The build target is the book’s running team — orchestrator, coder, reviewer, tester — completing a bounded repository task with a checkable outcome: make a failing test pass and leave the suite green, so that success is a fact the repository itself certifies rather than a judgement anyone renders. The cast, in this runtime, is a single construction repeated four times: one ModelClient, wrapped in four harnesses that differ in their system prompts, their toolsets, and their standing in the message protocol — and the differences are Chapter 16’s three clauses, implemented rather than implied. The reviewer sees diffs and test output but not the orchestrator’s planning; it may approve or reject but not edit; and its approval is a typed message the harness requires before integration proceeds — a jurisdiction in code, which is what Chapter 16 said a role must be and what a backstory field is not.

The exclusions, finally, are contractual rather than embarrassed. Not built: parallel agent execution, streaming output, a human-approval interface, distributed deployment, retry sophistication beyond the basics, sandbox hardening, authentication — each a real need, each deferred to the chapter that treats it properly (hardening to Chapter 23, security to Chapter 25), and none of them architecturally foreclosed: the journal and the typed messages are precisely the seams such upgrades attach to, which is the difference between building small and building yourself into a corner. This is the aircraft you can understand completely, not the one that carries passengers — the brief says so on its face, and Chapter 23 holds the passenger licence. The build starts where every multi-agent system starts, whether its builders notice or not: with one agent, alone, in a loop.

20.2 One Agent: Loop, Tools, Context

The smallest complete thing in this build is one agent, and it decomposes into four small modules whose names the companion code keeps: model.py, the client interface behind which every vendor detail hides; tools.py, the registry and dispatcher; context.py, the assembly policy; and agent.py, the loop that composes the other three. The section walks them in that order, and it makes one claim worth stating in advance so the reader can hold it to account: the loop itself — the part with the mystique, the part the frameworks wrap in the most machinery — fits on a page, and its page is mostly the honest handling of the three ways a turn can end.

model.py first, because it is the boundary of everything we do not own. The interface is one method: complete(messages, tools) -> ModelResponse, where the response carries whatever the model produced — text, one or more tool calls, or both — and, non-negotiably, the token usage of the call, because Section 20.4’s budget enforcement is only as good as its raw data. Inside this interface lives everything vendor-specific: authentication, request shapes, retry-on-rate-limit, the translation between the vendor’s message format and ours. Outside it, no other module knows which laboratory’s model is running — the vendor swap of the design brief, honoured structurally. And beside the real client sits the build’s most useful ten lines: a FakeClient that replays scripted responses. Every test in the runtime runs against the fake — loop, dispatch, journal, harness, all exercised deterministically, at no cost, in milliseconds — which converts “does the infrastructure work?” from an expensive empirical question about a model into a cheap logical question about our code, where it belongs.

In code, the module’s whole public face — the response, the interface, and the fake — fits in one look:

# foundations/model.py (excerpt)
@dataclass
class ModelResponse:
    text: str = ""
    tool_calls: list[ToolCall] = field(default_factory=list)
    usage: int = 0                     # tokens this call cost, always


class ModelClient:
    """One method; everything vendor-specific lives behind it."""

    def complete(self, messages: list[dict],
                 tools: list[dict]) -> ModelResponse:
        raise NotImplementedError


class FakeClient(ModelClient):
    """Replays a script: deterministic tests, no tokens spent."""

    def __init__(self, script: list[ModelResponse]):
        self.script = list(script)

    def complete(self, messages, tools):
        return self.script.pop(0)

(ToolCall, referenced by the response, is the obvious two-field record — a name and its arguments — defined a few lines up.)

Now the loop, walked at the pace the code pass will transcribe. A turn begins by assembling context (the policy of context.py, below) and calling client.complete. The response branches three ways. If it contains tool calls, each is dispatched, and the results — successes and failures alike — are appended as observations for the next turn. If it signals completion — a designated done-tool, so that finishing is an explicit, typed act rather than a stylistic inference from the prose — the loop returns the agent’s result. If it is plain text with neither, the loop applies a stated policy: append it and nudge, once, toward the done-tool; a second consecutive drift ends the run as inconclusive. After each turn, three stop conditions are checked: the done-signal, the token budget, and a turn cap. The cap deserves its sentence of justification, because it looks crude and is load-bearing: it is Chapter 5’s metacognition at its bluntest — the commitment that no agent, however confident it feels about turn forty-one, gets a turn forty-one — and an agent that cannot stop is not persistent but broken. Every event of the turn — request, response, dispatch, observation, verdict — is appended to the journal, of which more in the next section.

Here is the page in question:

# foundations/agent.py (excerpt)
def run(agent_id: str, client: ModelClient, tools: list[Tool],
        system: str, task: str, journal_path: str, budget: Budget,
        max_turns: int = 12) -> dict:
    toolbox = {t.name: t for t in tools}
    schemas = [t.schema for t in toolbox.values()]
    if "done" not in toolbox:         # the loop's own verb, always on offer
        schemas.append({"name": "done", "description":
                        "Finish the run; your arguments are the result.",
                        "required": []})
    history: list[str] = []
    observations: list[str] = []
    nudged = False
    append(journal_path, "RunStarted", agent=agent_id, task=task)
    for turn in range(max_turns):
        messages = assemble(system, task, history, observations)
        observations = []
        append(journal_path, "ModelCalled", agent=agent_id, turn=turn)
        response = client.complete(messages, schemas)
        append(journal_path, "ModelResponded", agent=agent_id,
               usage=response.usage)
        append(journal_path, "BudgetDebited", agent=agent_id,
               amount=response.usage)
        budget.debit(response.usage)
        for call in response.tool_calls:
            if call.name == "done":   # finishing is an explicit, typed act
                append(journal_path, "AgentFinished", agent=agent_id)
                result = {"status": "done", **call.args}
                result["status"] = "done"     # the harness owns the verdict
                return result
            append(journal_path, "ToolDispatched", agent=agent_id,
                   tool=call.name)
            tool = toolbox.get(call.name)
            observation = (dispatch(tool, call.args) if tool else
                           {"tool": call.name, "error": "unknown tool"})
            append(journal_path, "ToolReturned", agent=agent_id,
                   **observation)
            observations.append(json.dumps(observation))
        drifted_again = not response.tool_calls and nudged
        if response.tool_calls:
            history.append(response.text or f"(turn {turn}: tool calls)")
            nudged = False
        elif not nudged:              # plain prose: nudge once, firmly
            history.append(response.text)
            observations.append("Reply with a tool call; "
                                "call done when finished.")
            nudged = True
        if budget.exhausted():        # wind down at the turn boundary
            append(journal_path, "BudgetExhausted", agent=agent_id)
            return {"status": "halted", "turns": turn + 1}
        if drifted_again:             # second consecutive drift: stop
            return {"status": "inconclusive", "turns": turn + 1}
    return {"status": "out_of_turns", "turns": max_turns}

tools.py holds the dispatcher, and its design is three decisions. A tool is a name, a schema, and a callable; registration is an explicit list passed to the agent at construction — no decorators quietly populating a global registry, per the brief’s no-magic clause, because “which tools does this agent have?” should be answerable by reading one line. Arguments are validated against the schema before anything executes, and the minimal sandboxing — a working directory, a timeout — is present because its absence would be dishonest even in a paper aeroplane. The load-bearing convention, though, is what happens on failure: errors are observations. A failed call does not raise through the loop; it returns to the context as structured information — the exception, the stderr, the exit code — because Chapter 6’s grounding argument applies with full force: to an agent, a stack trace is data about the world, and frequently the best data it will receive all day. Whether to retry is the loop’s policy, not the tool’s; the tool’s job is to fail informatively.

context.py is the smallest module and the most consequential, which by now is the book’s least surprising sentence. The assembly function builds each turn’s window from four parts: the system prompt (the agent’s role — the cast differences of Section 20.1 live here), the task instructions, the rolling history, and the latest observations; and it applies one explicit truncation policy — keep the system prompt and task always, keep the recent tail, and compress or drop the middle, oldest first. That is Chapter 7’s entire problem, decided in roughly thirty lines — and the point of writing them yourself is not that your thirty lines will beat a framework’s. It is that they are thirty lines you can read: the single most consequential hidden opinion identified in Chapter 19 — what does the model see? — is, in this runtime, a short function with your name on the commit.

The entire policy:

# foundations/context.py (excerpt)
def assemble(system: str, task: str, history: list[str],
             observations: list[str], limit: int = 8000) -> list[dict]:
    # The system prompt and the task are always kept; the rolling
    # history keeps its recent tail, oldest dropped first; the latest
    # observations always arrive. This is the whole policy.
    context = [{"role": "system", "content": system},
               {"role": "user", "content": task}]
    news = [{"role": "user", "content": o} for o in observations]
    budget = limit - sum(len(m["content"]) for m in context + news)
    tail: list[str] = []
    for step in reversed(history):    # newest first, until the budget
        if budget < len(step):
            break
        tail.insert(0, step)
        budget -= len(step)
    dropped = len(history) - len(tail)
    if dropped:
        tail.insert(0, f"[{dropped} earlier steps compacted]")
    context += [{"role": "assistant", "content": s} for s in tail]
    return context + news

What exists at the end of this section is a complete single agent: it acts, observes, recovers information from its own failures, stops for three well-defined reasons, accounts for every token, and can be tested end to end without a model in the room. What it cannot yet do is remember — kill the process and the run is gone, along with everything the agent learnt by doing it. Our agent is complete and amnesiac, and the cure — the one design decision this chapter most wants the reader to steal — is the next section.

20.3 The Journal Is the Truth: State and Persistence

Here is the decision, stated baldly so it can be argued with: the runtime’s source of truth is not a state object but a journal — an append-only sequence of events, one typed, timestamped, immutable record for everything that happens: a model called, a response received, a tool dispatched, a result returned, a message sent, a budget debited, an agent finished. Concretely it is a JSONL file, one event per line, readable by a human with less and by a program with ten lines — and nothing in the runtime ever mutates it, only appends. State still exists, but demoted: it is a derived view, computed by folding over the journal, and it can be thrown away and recomputed at will, because it was never the truth — only a convenient summary of it. The pattern has respectable names and a long lineage — the software literature calls it event sourcing,1 databases have run on its sibling, the write-ahead log, for half a century — and accountants worked it out several centuries before software existed: you do not erase the ledger; you append the correction.

The decision is load-bearing because three capabilities that tutorials treat as features — and frameworks as selling points — fall out of it as corollaries, requiring no further design at all. Persistence is free: events are appended as they happen, so there is no “save” operation to design, schedule, or forget — the journal on disk is never more than one event behind reality. Resumption is a re-read: after a crash, the runtime folds the journal and stands exactly where it stood, mid-task, balances intact — and because this is a corollary rather than a feature, it can be tested brutally: the companion suite kills the process, unceremoniously, mid-run, and asserts that the resumed run completes identically; crash-recovery as a unit test, not an aspiration. Replay is deterministic debugging: feed the journal’s recorded model responses back through Section 20.2’s FakeClient and yesterday’s run is re-lived exactly — same responses, same dispatches, same failure — under a debugger, with breakpoints, as many times as the investigation needs. The fake client, bought for testing, pays a second dividend as a time machine.

The event vocabulary is small and typed, per the brief. RunStarted, ModelCalled, ModelResponded, ToolDispatched, ToolReturned, MessageSent, MessageDelivered, BudgetDebited, AgentFinished, BudgetExhausted — ten record types in all, each with an explicit schema, each carrying the identifiers that stitch a run together: run, agent, turn, and (once Section 20.4 arrives) task. The state view is then a pure function — a reducer that folds events into a TeamState of tasks, statuses, artefacts, and balances — and the purity buys a freedom worth noticing: one journal supports many views. The budget view sums the debits; the conversation view threads the messages; the audit view answers “who approved this and on what evidence?” — one truth, many projections, and no projection ever disagrees with another, because none of them is primary. Chapter 9’s shared-state anxieties and Chapter 11’s consistency machinery are not abolished by this — they are localised: the journal is the single point of write, the runtime is single-threaded by the brief, and the day either assumption is relaxed is the day Chapter 11’s locks earn their keep, at a location the design has already marked.

Table 20.1: The journal’s event vocabulary — the ten typed record kinds the runtime appends, when each is written, and the payload that distinguishes it. Every event carries a schema version v, a timestamp t, and the emitting agent; the two terminal markers need nothing beyond that identity, and new kinds join by adding a line, not architecture.
Event Appended when Distinctive payload
RunStarted An agent’s run begins The task
ModelCalled Before each call to the model The turn number
ModelResponded The model returns The token usage of the call
BudgetDebited Immediately after the response The amount debited
ToolDispatched Before a tool executes The tool name
ToolReturned A tool returns The observation — result or structured error
MessageSent An agent posts a message Performative, sender, recipient, task thread
MessageDelivered A message reaches an inbox Recipient and task thread
AgentFinished The done tool is called
BudgetExhausted The budget empties at a turn boundary

The whole apparatus — the append, the read-back, and the reducer — is smaller than the paragraphs that described it:

# foundations/journal.py (excerpt)
def append(path: str, kind: str, **data) -> dict:
    event = {"v": 1, "t": time.time(), "kind": kind, **data}
    with open(path, "a") as f:
        f.write(json.dumps(event) + "\n")
    return event


def events(path: str) -> list[dict]:
    out: list[dict] = []
    with open(path) as f:
        for line in f:
            try:
                out.append(json.loads(line))
            except json.JSONDecodeError:  # a torn final line: died mid-write
                break
    return out


def fold(evts: list[dict]) -> dict:
    """The reducer: state is a view, recomputable at will."""
    state = {"spent": 0, "finished": [], "messages": 0}
    for e in evts:
        match e["kind"]:
            case "BudgetDebited":
                state["spent"] += e["amount"]
            case "AgentFinished":
                state["finished"].append(e["agent"])
            case "MessageDelivered":
                state["messages"] += 1
            # ...one arm per event kind the views care about
    return state

Honesty requires the costs, and there are two. Append-only grows without limit, and the standard remedy is the standard one: periodic snapshots — a cached fold, stored beside the journal, so that resumption reads snapshot-plus-tail rather than the whole history — with the journal remaining the truth and the snapshot remaining, like all state here, disposable. And events are forever, which makes the event schema the one interface in the runtime that must be versioned from day one: a journal written in June must still fold in December, so events carry a schema version and the reducer keeps its old cases. This is Section 19.4’s own-your-schema discipline arriving as a concrete artefact — the journal’s schema is the asset that outlives the runtime, the framework, and quite possibly the team — and it is also Section 20.1’s promised economy delivered in full: the trace that Chapter 24’s evaluation will read and the persistence the runtime relies on are the same file. Observability was never a separate service to build. It was the journal, viewed.

If the reader takes one transplantable idea from this chapter, the authors nominate this one, and it travels: even inside a framework — especially inside a framework — journal your own events, in your own schema, from day one, whatever the framework’s state machinery is doing. It costs an append call at each significant moment and buys replay, audit, and an exit route measured in views rather than migrations. Within our build, meanwhile, the amnesia is cured: the agent acts, and what it does persists, resumes, and replays. What it cannot yet do is talk to anyone — and a multi-agent runtime with one agent is not yet earning the adjective. The next section adds the rest of the cast, and the space between them.

20.4 Many Agents: Messages and the Harness

The step from one agent to many is smaller than the folklore suggests, and the code makes the point bluntly: a second agent is the same construction as the first — one ModelClient, one loop, one toolset — with a different system prompt and a different tool list, and constructing it is one function call. What is genuinely new is not the agents but the space between them, and that space is three modules: messages.py, the typed envelope everything travels in; mailbox.py, the delivery machinery; and team.py, the coordinating harness that makes four loops into one system. This section builds the space.

A message, in this runtime, is a typed object with five fields: a performative, a sender, a recipient, a task identifier that threads conversations, and a typed payload. The performative is an enum — REQUEST, INFORM, PROPOSE, ACCEPT, REJECT, DONE, ERROR — and the reader will recognise what has just happened: Chapter 8’s speech acts, and behind them forty years of agent communication languages, have arrived as a dozen or so lines of Python. The alternative — agents parsing one another’s prose in a shared transcript, the group-chat pattern — is not simpler but older: it reinstates every ambiguity the performatives were invented to remove, and Chapter 8’s silent-referent failures with them; when the coder tells the reviewer something, the runtime should know whether it was a report, a request, or a promise without running sentiment analysis on the question. A mailbox and a schema beat a group chat, and the margin is widest precisely when things go wrong. Every message, naturally, is journalled — MessageSent, MessageDelivered — so a conversation is an auditable thread in the same file as everything else.

The arrival, in full:

# foundations/messages.py (excerpt)
class Performative(Enum):
    REQUEST = "request"
    INFORM = "inform"
    PROPOSE = "propose"
    ACCEPT = "accept"
    REJECT = "reject"
    DONE = "done"
    ERROR = "error"


@dataclass
class Message:
    performative: Performative
    sender: str
    recipient: str
    task_id: str
    payload: dict

Delivery is deliberately dull. Each agent owns an inbox — a queue; sending appends the journal events and enqueues; and agents receive their mail at the top of their turn, where the context-assembly policy of Section 20.2 folds it into the window — the modules composing as advertised, with no new mechanism required for mail to become context. Scheduling honours the brief’s single-threadedness: the team runs on a coordinator-driven schedule, one agent’s turn at a time, which forecloses whole categories of Chapter 11 misery — races, lost updates, deadlock — by construction rather than cleverness. The concurrency this gives up is real, and the seam is marked: parallel turns would enter through the scheduler, one module, and would bring Chapter 11’s machinery with them as the admission price.

The coordinating harness, team.py, contains the chapter’s best joke, which is that it contains almost nothing new. The orchestrator is just another agent — same loop, same journal — whose toolset happens to operate on the team: a decompose step that turns the task into a typed task list; a dispatch step that sends each subtask as a REQUEST to a chosen agent; a collect step that awaits DONE or ERROR; a verify step; an integrate step. This protocol made its debut in 1980 (Smith, 1980) — announce, award, collect, report: the Contract Net, minus the competitive bidding round, which our minimal version replaces with direct assignment and marks as the obvious upgrade (the bidding is an exercise, and Chapter 15 explained what it buys and what it costs). The verify step is where Section 20.1’s promise about roles is kept in full: integration is a harness operation that requires a prior ACCEPT from the reviewer on the task thread — not a convention, not a prompt suggestion, but a precondition in code. Chapter 16 called this regimentation; here it is five lines, and the difference between a reviewer role and a reviewer costume is now something the reader can point to in a diff.

Here they are, hostage clause and all:

# foundations/team.py (excerpt)
def integrate(inbox: list[Message], task_id: str,
              apply: Callable[[], object]):
    approvals = [m for m in inbox
                 if m.task_id == task_id
                 and m.performative is Performative.ACCEPT]
    if not approvals:
        raise PermissionError(f"no ACCEPT on task {task_id}: not merging")
    return apply()

Money, finally — or its local currency. The budget is an accounting object: an allocation of tokens, debited on every ModelCalled event with the usage figures Section 20.2’s interface insisted upon, subdividable so that the orchestrator can allocate per agent or per task and the journal’s budget view can say, at any moment, who has spent what on which. Exhaustion is handled the way everything in this runtime is handled: as a typed event with a clean consequence — BudgetExhausted triggers a graceful wind-down at the next turn boundary, never a mid-turn kill, so a halted run is still a consistent, resumable, explicable one. The allocation policy itself is fiat — the orchestrator as Chapter 15’s central planner, which at four agents is exactly the right institution, since the planner can still read every status message. The market upgrade — agents bidding for budget — enters through this one object, and Chapter 15 has already supplied both the design and the warnings.

Tally the build. Four modules for one agent, three for the space between, one journal underneath — and the whole runtime sits in the low hundreds of lines, a number the companion repository prints for audit. The smallness is the chapter’s thesis made measurable, but it wants stating carefully: the lines are few because the plumbing is genuinely small, not because the problems are — everything Section 19.2 said no framework provides is equally not provided by these hundreds of lines, and the specification, decomposition, and evaluation still cost what they cost. What the smallness buys is that the plumbing can no longer hide anything from you. The team is assembled: an orchestrator, a coder, a reviewer, and a tester, each a jurisdiction, all of it journalled. It remains to fly the aeroplane — and then, because this book is what it is, to crash it on purpose.

%%{init: {"theme": "base", "themeVariables": {"primaryColor": "#E8ECFF", "primaryBorderColor": "#4054B2", "primaryTextColor": "#16171B", "lineColor": "#3B4351", "edgeLabelBackground": "#FAF7F0", "clusterBkg": "#EFE9DC", "clusterBorder": "#766F65"}}}%%
flowchart TB
    subgraph team_tier["The space between agents"]
        team["<code>team.py</code><br/>the coordinator"]
        mailbox["<code>mailbox.py</code><br/>inbox delivery"]
        messages["<code>messages.py</code><br/>typed<br/>performatives"]
    end
    subgraph agent_tier["Four modules for one agent"]
        agent["<code>agent.py</code><br/>the loop"]
        model["<code>model.py</code><br/>vendor boundary"]
        tools["<code>tools.py</code><br/>dispatch"]
        context["<code>context.py</code><br/>window assembly"]
    end
    journal[("<code>journal.py</code><br/>append-only<br/>source of truth")]
    team ~~~ agent
    agent ~~~ tools
    model ~~~ context
    tools ~~~ journal
    team_tier -->|"imports, calls"| agent_tier
    agent_tier -->|"writes"| journal
    team_tier --> journal
    classDef world fill:#EFE9DC,stroke:#766F65,color:#16171B
    class journal world
Figure 20.1: The runtime as three layers: the team modules that furnish the space between agents, the four that compose a single agent, and the one journal beneath into which everything writes — the chapter’s own 4+3+1 tally made spatial, and the reason persistence and observability were never two services to build but a single file seen twice.

20.5 First Flight: Running the Team

The flight plan: a repository with one failing test; the task, make it pass and leave the suite green; the cast of four; a budget the orchestrator allocates. What follows is the successful run, narrated through the only witness that matters — the journal. RunStarted. The orchestrator’s first turn decomposes the task into three: diagnose and fix, review, verify — each a typed entry with a boundary. A REQUEST goes to the coder, and the coder’s thread begins: it reads the failing test, reads the implicated module, proposes an edit, runs the tests — and fails, because first attempts do, at which point Section 20.2’s convention earns its keep in public: the stack trace returns as an observation, the next turn’s context contains exactly what went wrong, and the second edit passes. The coder signals DONE, with the diff and the test output as its payload.

The review thread is shorter and, institutionally, the more interesting read. The reviewer receives the diff and the test output — and only those, per its jurisdiction; the orchestrator’s planning chatter is not in its context, by policy rather than accident. It replies ACCEPT on the task thread; the harness, holding integration hostage to that very message, proceeds; the tester runs the full suite against the integrated result and confirms green; AgentFinished, and the budget view prints the bill — who spent what, on which subtask, to the token. The whole run is one file, and the file reads like minutes. The preface remarked that a committee of language models is still a committee and minutes should be kept; this runtime is, among other things, a machine for keeping them.

%%{init: {"theme": "base", "sequence": {"width": 110, "actorMargin": 40, "messageMargin": 30, "diagramMarginX": 10}, "themeVariables": {"primaryColor": "#E8ECFF", "primaryBorderColor": "#4054B2", "primaryTextColor": "#16171B", "lineColor": "#3B4351", "actorBkg": "#E8ECFF", "actorBorder": "#4054B2", "actorTextColor": "#16171B", "signalColor": "#3B4351", "signalTextColor": "#16171B", "noteBkgColor": "#F7E6B5", "noteBorderColor": "#8A5A00", "noteTextColor": "#16171B", "activationBkgColor": "#EFE9DC", "activationBorderColor": "#766F65", "labelBoxBkgColor": "#EFE9DC", "labelBoxBorderColor": "#766F65", "labelTextColor": "#16171B", "loopTextColor": "#16171B"}}}%%
sequenceDiagram
    autonumber
    participant O as Orchestrator
    participant C as Coder
    participant R as Reviewer
    participant T as Tester
    Note over O,C: decompose: fix, review, verify
    O->>C: REQUEST "fix the test"
    Note over C: tests fail, then pass
    C->>O: DONE "diff + tests"
    O->>R: REQUEST "review the diff"
    R->>O: ACCEPT on the thread
    Note over O,R: gate: integrate only after ACCEPT
    O->>T: REQUEST "verify the suite"
    T->>O: DONE "suite green"
    Note over O,C: budget view prints the bill
Figure 20.2: The successful first flight as a message trace: the orchestrator dispatches the fix as a REQUEST, collects the coder’s DONE, and cannot integrate until the reviewer returns an ACCEPT on the task thread — the Contract Net of 1980 with its bidding round removed, and the reviewer’s jurisdiction rendered as a precondition the handshake must clear rather than a suggestion in a prompt.
# journal.jsonl --- the successful first flight (abridged)
{"kind": "MessageSent", "sender": "orchestrator", "recipient": "coder", "task_id": "T1", "performative": "request"}
{"kind": "MessageDelivered", "recipient": "coder", "task_id": "T1"}
{"kind": "RunStarted", "agent": "coder", "task": "Make the failing parser test pass."}
{"kind": "ModelResponded", "agent": "coder", "usage": 120}
{"kind": "ToolDispatched", "agent": "coder", "tool": "run_tests"}
{"kind": "ToolReturned", "agent": "coder", "tool": "run_tests", "result": {"passed": 1, "failed": 1, "trace": "AssertionError: parse('') != []"}}
{"kind": "ModelResponded", "agent": "coder", "usage": 95}
{"kind": "ToolDispatched", "agent": "coder", "tool": "run_tests"}
{"kind": "ToolReturned", "agent": "coder", "tool": "run_tests", "result": {"passed": 2, "failed": 0}}
{"kind": "ModelResponded", "agent": "coder", "usage": 60}
{"kind": "AgentFinished", "agent": "coder"}
{"kind": "MessageSent", "sender": "coder", "recipient": "orchestrator", "task_id": "T1", "performative": "done"}
{"kind": "MessageDelivered", "recipient": "orchestrator", "task_id": "T1"}
{"kind": "RunStarted", "agent": "reviewer", "task": "{'status': 'done', 'summary': 'guard empty i…"}
{"kind": "ModelResponded", "agent": "reviewer", "usage": 40}
{"kind": "AgentFinished", "agent": "reviewer"}
{"kind": "MessageSent", "sender": "reviewer", "recipient": "orchestrator", "task_id": "T1", "performative": "accept"}
{"kind": "MessageDelivered", "recipient": "orchestrator", "task_id": "T1"}
{"kind": "RunStarted", "agent": "tester", "task": "{'status': 'done', 'summary': 'guard empty i…"}
{"kind": "ModelResponded", "agent": "tester", "usage": 90}
{"kind": "ToolDispatched", "agent": "tester", "tool": "run_tests"}
{"kind": "ToolReturned", "agent": "tester", "tool": "run_tests", "result": {"passed": 2, "failed": 0}}
{"kind": "ModelResponded", "agent": "tester", "usage": 30}
{"kind": "AgentFinished", "agent": "tester"}

Now the promised crashes, administered one at a time to a healthy system. First, the vague subtask: instruct the decompose step to be terse, and the boundary between fix and verify blurs — the journal shows the coder and the tester editing the same file within three turns of each other, a duplicate-work collision visible as two ToolDispatched entries no one has to hunt for. Nothing crashed; the specification failed, and the run’s inefficiency traces to one terse entry in the task list. Second, the silent dropout: replace the reviewer with a stub that accepts its REQUEST and never replies. The naive collect step waits forever — so the run demonstrates why deadlines are not a refinement but a requirement, and the fix (a timeout on awaited replies, a REQUEST re-issued or escalated) is Chapter 10’s failure-and-repair machinery, arriving as roughly ten lines.

Third, the premature “done”: prompt the coder to signal completion without running the tests. The DONE arrives, confident as ever — and integration does not proceed, because the reviewer’s ACCEPT never comes: the diff carries no test output, the reviewer rejects, and the harness routes the rejection back as a new turn for the coder. The five lines that made the reviewer’s role a precondition rather than a costume pay for themselves in this one trace. Fourth, the tool error mid-turn: a flaky test runner that dies with a cryptic exit code — absorbed as an observation, retried by loop policy, life goes on. Step back and tally what these four runs re-enacted: a specification failure, an inter-agent coordination failure, a verification-and-termination failure, and an infrastructure hiccup — the MAST taxonomy’s three categories, reproduced on demand in a runtime with no framework anywhere in it (Cemri et al., 2025). Chapter 19 argued from the study that the fourteen failure modes were never in anyone’s stack. We can now say it from the inside, having built the stack and watched the failures arrive anyway — with the one difference that matters: here, every failure is a readable thread in a file we own.

# crash 1 --- the vague subtask: the coder and the tester both edit the same file
{"kind": "ToolDispatched", "agent": "coder", "tool": "edit_file"}
{"kind": "ToolReturned", "agent": "coder", "tool": "edit_file", "result": {"edited": "parser.py"}}
{"kind": "ToolDispatched", "agent": "tester", "tool": "edit_file"}
{"kind": "ToolReturned", "agent": "tester", "tool": "edit_file", "result": {"edited": "parser.py"}}

# crash 2 --- the silent dropout: the request is delivered, no reply is ever appended
{"kind": "MessageSent", "sender": "orchestrator", "recipient": "reviewer", "task_id": "T2", "performative": "request"}
{"kind": "MessageDelivered", "recipient": "reviewer", "task_id": "T2"}
# ...  (a naive collect blocks here; a deadline is the ~10-line fix)

# crash 3 --- the premature done: no ACCEPT, so integration never runs (outcome: rejected)
{"kind": "MessageSent", "sender": "orchestrator", "recipient": "coder", "task_id": "T1", "performative": "request"}
{"kind": "AgentFinished", "agent": "coder"}
{"kind": "MessageSent", "sender": "coder", "recipient": "orchestrator", "task_id": "T1", "performative": "done"}
{"kind": "AgentFinished", "agent": "reviewer"}
{"kind": "MessageSent", "sender": "reviewer", "recipient": "orchestrator", "task_id": "T1", "performative": "reject"}

# crash 4 --- the tool error: the cryptic exit code returns as an observation, and loop policy retries
{"kind": "ToolReturned", "agent": "coder", "tool": "run_tests", "error": "RuntimeError: runner exited 137"}
{"kind": "ToolReturned", "agent": "coder", "tool": "run_tests", "result": {"passed": 2, "failed": 0}}

What the exercise taught can be checked against Chapter 19’s three claims. Small: the line count stands, printed in the companion repository for audit. Complete: the crash-resume test passes, the replays are deterministic, the budget balances. Yours: the debugging dividend, made concrete — the duplicate-edit collision above was found by reading the journal, understood by replaying the run against the fake client, fixed by one change to the decompose prompt, and verified by re-running the same recorded scenario: navigation, not archaeology, exactly as promised. And one exercise from the chapter’s set deserves its trailer here, because it turns the book’s own instrument on the book’s own build: replace each agent, in turn, with a mindless stub and measure what survives — the Gode–Sunder test of Section 15.4, applied to ourselves, to discover how much of the team’s success was harness all along. The authors decline to spoil the result.

The wish list that accumulates by the end of first flight is the point of the next chapter. Parallel dispatch, once tasks are independent; a retry-and-escalate policy richer than the timeout; a router that sends task types to specialist agents; a reflection loop that lets the coder critique its own diff before the reviewer sees it; a planner that revises the decomposition mid-run — every item is a named pattern with a known trade-off sheet, and the runtime just built is the substrate on which each can be added as a visible, testable increment rather than a framework toggle. The rite of passage is complete: the reader has a runtime, a set of failures they have caused and can therefore recognise, and the standing asset this chapter set out to purchase — the certainty that nothing a framework does is magic, because they have done all of it themselves, with the line count printed and the receipts journalled. Chapter 21 now names the shapes worth building on top.

20.6 Summary

  • From-scratch is pedagogy first and an option second. The build teaches what every framework does on your behalf, and the understanding is permanent even if the runtime is not; afterwards, framework debugging becomes navigation rather than archaeology.
  • The model is a component, and the build makes it literal. One narrow interface, one file to edit for a vendor swap; everything agentic — loop, memory, coordination, budget — lives outside the model, in code you own. Agent = Model + Harness, compiled.
  • The loop is small and its ambitions are incremental. The core cycle fits on a page; retries, interruption, budgets, and resumption each add lines rather than architecture — which is why the six services at minimum scope stay measured in hundreds of lines.
  • The journal is the load-bearing decision. An append-only event log as the source of truth makes state a derived view — and persistence, crash-resumption, and deterministic replay stop being features to build and become corollaries to observe. If you take one design idea from the chapter, take this one.
  • Messages are typed and performative. A mailbox and a schema beat a group chat: Chapter 8’s speech acts arrive as an enum, and the coordinating harness that dispatches on them is the Contract Net, recognisably, a few dozen lines long and older than the web.
  • When it breaks, it breaks in MAST’s ways — with no framework to blame. The classic failures reproduce faithfully in a runtime you wrote every line of, demonstrating from the inside that coordination failures live upstream of all plumbing; the difference is that now they are visible, replayable, and yours.
  • The upgrades you would add next are the next chapter. Routers, reflection loops, supervisors, planners: everything the runtime invites you to bolt on is a named pattern with known trade-offs — Chapter 21’s catalogue, approached now from below.

20.7 Exercises

Exercise 1. Section 20.5 printed the successful first flight’s journal in abridged form: the ModelCalled and BudgetDebited lines were dropped for the page, and the run had begun with an allowance of 5,000 tokens. (a) From the abridged trace alone, reconstruct what fold from foundations/journal.py returns for the full journal — total spent, who finished, how many messages — and explain why dropping every BudgetDebited line cost the reconstruction nothing. (b) The reducer has no per-agent resolution. Write a bill view — tokens per agent, from the same events — re-stage the flight against the companion runtime, and report each agent’s bill, the fraction of the team’s spend that is the coder’s thread, what remains of the allowance, and which single event kind of Table 20.1 your view consumed. (c) Each model call appends ModelResponded carrying usage and, on the next line, BudgetDebited carrying an equal amount. Is the second event dead weight? Make the strongest case for keeping it, and explain why no two views of one journal — bill, conversation thread, audit — can ever disagree, however many are added later. (d) Section 20.3 calls resumption a re-read; make it one. Write resume_point(evts), returning the stage the flight stands at — fix, review, verify, or complete — and verify it on two truncations of the journal: cut immediately after the coder’s DONE is sent, and immediately after the reviewer’s ACCEPT is sent.

Exercise 2. The runtime’s entire perception policy is assemble in foundations/context.py (Section 20.2). Take one call with a 40-character system prompt, a 100-character task, a single fresh observation of 60 characters, limit=300, and a rolling history of five steps of 80, 70, 60, 50, and 40 characters, oldest first. (a) Trace the policy by hand — the character budget left for history, which steps survive, what marker is inserted — predict the total length of the returned window, then run assemble and check every figure. (b) State the invariant assemble actually enforces — a bound on the surviving history tail, not on the window — and prove it from the loop; then exhibit the two distinct ways the returned window can exceed limit, with a verified minimal example of each, saying which of the two is bounded (and by how much) and which is not. (c) The budget is counted in characters while the model’s real constraint is tokens, and the exchange rate between the two is vendor property. Say where a tokeniser-faithful limit belongs in this architecture, why importing a vendor’s tokeniser into context.py would breach the first principle of Section 20.1, and give the two honest alternatives.

Exercise 3. Crash four of Section 20.5 cost the coder three model calls — 70 tokens to run the tests into the flaky runner, 70 more to try again after reading the returned error, 40 to finish — because the loop’s stated policy hands a transient tool error back to the model and lets it decide to retry. Extend run in foundations/agent.py (in your working copy; the repository stays as printed). (a) Add a retries parameter: when an observation carries an error key, re-dispatch the same call up to retries further times before the model sees anything, journalling every ToolReturned. (b) Re-run crash four with retries=1 and account for the difference — model calls, tokens, and the saving as an exact fraction — then say what the loop-level retry spends that the budget cannot see, and why the flake nonetheless remains visible in the journal. (c) Add graceful interruption: an operator’s stop signal checked at the turn boundary beside the budget, a new journal event kind, and a status that resumes cleanly — Section 20.1 excluded a human-approval interface, but an off switch is not an interface. (d) Measure the work: report lines added to the module and to the 44-line run for each extension, reconcile the count with the Summary’s claim that such upgrades “add lines rather than architecture”, and name the class of tools for which the automatic retry of (a) is unsafe, together with the cheapest guard.

Exercise 4. The budget is enforced only at turn boundaries, and a done return is not checked at all. (a) Prove the overshoot bound: for a run that ends halted after calls of usages u_1, \dots, u_k against an allowance B, the spend obeys \sum_{i=1}^{k} u_i \le B - 1 + u_k — an excess bounded by one call, and by nothing else; confirm both faces of the bound on the runtime, with two calls of 900 against an allowance of 1,000 and a single call of 5,000 against the same allowance, and say why the design prefers this overshoot to a mid-turn kill. (b) The team shares one pot. Fly foundations/demo_team.py’s scripted flight through run_team on Budget(allowance=300) and read the outcome dictionary with care: identify which agent halted and at what spend, quote the returned status and suite fields, name the institutional failure in the terms Section 20.1 used for the reviewer — which role turns out to be a costume, and on which line of foundations/team.py? — and give the one-line repair. (c) The budget is “subdividable so that the orchestrator can allocate per agent or per task” (Section 20.4), yet the shipped Budget is a single pot. Implement SubBudget, whose debits hit both itself and its parent; fund coder, reviewer, and tester at 200, 100, and 150 under a 5,000-token parent and show all four ledgers after the flight; then show containment — a coder funded at 100 halts on its own share while the parent and every sibling stand untouched.

Exercise 5. Section 20.3 promises deterministic replay: feed the journal’s recorded model responses back through FakeClient and yesterday’s run is re-lived exactly. (a) Show that the promise is not yet funded by Table 20.1: name the event that must carry more than it does, and state precisely what a replayer cannot reconstruct from the journal as written. (b) Fund it: enrich the loop’s ModelResponded append into a version-2 event carrying the response’s text and tool calls, establish that foundations/journal.py needs no edit at all (why not?), and write replay_client(evts, agent); then explain why recorded model responses alone still leave replay non-deterministic, and write replay_tool, which replays recorded ToolReturned observations — including reproducing a recorded error as a raised exception with the recorded name and message. (c) Prove determinism the journal’s own way: record crash four under the enriched loop, re-run it from replay_client and replay_tool alone, and show the two event streams identical but for timestamps. (d) “Fixed from the trace alone” can be mechanised: write diagnose(evts), returning one finding per crash signature of Section 20.5 — duplicate work on one artefact, a delivered REQUEST with neither reply nor run, an unevidenced done, a transient tool error later absorbed — and run it over all five of that section’s journals: the first flight must come back clean, and each crash must yield exactly its own finding and no other’s.

Exercise 6. Section 20.4 claims a second agent is the same construction as the first and that constructing it is one function call; price the claim at five. Add a docs agent that records every shipped change in docs/changelog.md, without editing a single line under foundations/. (a) Write the harness extension: once run_team ships, a REQUEST on a fresh task thread carries the shipped result to the docs Worker; its run is journalled like everyone else’s; its report returns as DONE or ERROR. (b) Give the new agent a jurisdiction rather than a job title, from existing parts alone: it sees only what its REQUEST payload carries (the diff and summary, not the orchestration history); it may write only under docs/ — enforce that in the toolset, and demonstrate the enforcement by scripting one straying write, which must come back as an error observation, not an exception; and rule on its standing — should shipping gate on documentation the way integration gates on ACCEPT? — defending the ruling in a sentence. (c) Fly it scripted and audit the run: the fold’s finished list, the message count, the per-agent bill of Exercise 1, and the number of changed lines under foundations/ — then name the design decision of Section 20.1 that made that last number zero.

Exercise 7. The coordinating harness is the Contract Net minus the competitive bidding round, and Section 20.4 marks the bidding as an exercise (Chapter 15 explained what it buys and what it costs); this is that exercise. (a) Write announce_and_award(task, bidders, journal_path, budget) from existing parts alone: a REQUEST announcing the task to each bidding coder; one cheap model call per bidder returning its estimate as a done payload with a bid field, where a missing bid declines; a PROPOSE per bid to the orchestrator; an ACCEPT to the lowest bidder and REJECTs to the rest; the winner set to work on the task proper, reporting DONE. (b) Fly it with two scripted coders — coder-a bids 900 in a 25-token call, coder-b bids 700 in a 30-token call, and the winner’s working thread costs 275 tokens — then read the journal back: write out the full performative thread in order, price the auction from the bill, and explain why the six auction messages cost nothing although the auction as a whole does: what is the true currency of an announce–bid round in this runtime? (c) Count the foundations/ modules your function edited, note what it means that PROPOSE has sat unused in the enum since Section 20.4 introduced it, and say which sentence of Section 20.1’s exclusions paragraph this exercise just tested — and whether it survived.

Exercise 8. Section 20.5’s wish list includes a reflection loop — the coder critiques its own diff before the reviewer sees it — as a pattern Chapter 21 names and the runtime can carry in an afternoon; build it, and decide when it earns its tokens. Let a review pass cost u_v = 200 tokens, a rework of a rejected diff cost w = 150, and a self-critique call cost u_r = 30; a fraction p = 0.4 of the coder’s diffs are defective, the reviewer rejects every defective diff it sees, and the critique catches a fraction f = 0.8 of defects before submission. (a) Derive the expected extra cost per task beyond the happy path, without reflection and with it, and reduce the choice to a single inequality — being careful to notice which cost reflection does not avoid, and why. (b) Evaluate both expectations, the saving, the break-even critique price u_r^{*}, and the break-even defect rate p^{*}, exactly; then confirm the two expectations by simulation. (c) Implement ship_with_reflection as a harness step — critique, at most one revision on a “revise” verdict, then review — and validate it with scripts: the reviewer must be called exactly once and must never see the defective diff. (d) The model flatters the pattern; name the two omissions that matter most — one about who the critic is, one about what the reviewer misses — and give the direction of each.

Exercise 9. Section 20.5 trailered this exercise and declined to spoil its result; run it and spoil it. The Gode–Sunder test of Section 15.4 replaced human traders with zero-intelligence bidders to measure how much of a market’s performance was the institution rather than the participants; turn the same instrument on the build. (a) Write a ZIClient — one fixed done reply at a nominal 5 tokens, whatever the context says — with stub payloads for a coder (an empty diff, no tests run), a reviewer (“accept”, always), and a tester (“green”, always); and write one discriminating reviewer that is still not a model: a single 8-token rule that accepts exactly the submissions carrying test evidence. (b) Run the 2 \times 2 — competent scripted coder against ZI coder, rule reviewer against ZI reviewer, the ZI tester throughout — through run_team, tabulating each cell’s outcome, total spend, and whether an ACCEPT appears in its journal. (c) Read the table as Gode and Sunder read theirs: list the properties that held in every cell, including the one where an empty, untested diff shipped, and attribute them; name the single property the harness supplied in no cell; and close with the spoiler, one sentence each — what fraction of the first flight’s success was harness, and in what sense; and what the integrate gate is worth when the provenance of its ACCEPT is a rubber stamp.

Exercise 10 (lab). The design brief promised the vendor swap in one file, and the claim has so far been tested only against a fake; test it against a vendor (Appendix C describes the set-up; keys live in a git-ignored .env). Write a LiveClient(ModelClient) for a provider you hold keys for: translate the runtime’s message dicts and Tool schemas into the vendor’s shapes and back; return ModelResponse with text, tool calls, and a non-zero usage on every call; and put retry-on-rate-limit inside the client, explaining in a sentence why that retry lives here while Exercise 3’s lives in the loop. Stage a real flight: a scratch repository containing a parse function that fails the flight’s empty-input test; real read_file, edit_file, and run_tests tools over that directory, the last shelling out to pytest; the flight’s three roles each on a LiveClient; an allowance of 30,000 tokens. Fly it at least five times and report three things: (i) the seam audit — every file you created or changed, a grep for the vendor’s name under foundations/ that must come back empty, and the companion test suite green with no edits; (ii) per flight, the outcome and the per-agent bill of Exercise 1; (iii) at least one behaviour the scripts of Section 20.5 never exercised — a plain-prose turn and the nudge, a budget scare, an inconclusive. Record the model identifier and the date beside the table: the durable finding is that the seam held and the tests never noticed, not any token figure or success rate, which are perishable.


  1. Martin Fowler’s write-up is the standard reference: https://martinfowler.com/eaaDev/EventSourcing.html.↩︎