The Loop You Are Writing For
Before you write an extension, know the loop it attaches to: how a submitted message becomes a model request, how a tool call becomes a result, and when the run is over.
You are going to write an extension. Before that, you need to know what it is extending.
This is the step most people skip. They install Pi, run a task, get something nearly right, and start writing code against a mental model built from observation. The model is usually close enough to work and wrong in a few named places, and those places are where every interesting bug lives.
Why “wrong in a few places” is worse than “wrong everywhere” is worth one sentence of its own, because it explains the shape of the rest of the book. A model that is wrong everywhere fails loudly: you stop and read the documentation. A model that is nearly right fails as a symptom. The model ignores your instructions. Your tool never appears. Your injected content vanishes after a compaction. Your handler fires at the wrong moment. Each of those observations is compatible with several different causes — some in your code, some in Pi’s, some in the way you assembled the request — and the observation alone cannot tell them apart. What tells them apart is knowing the exact mechanism the symptom passed through. That mechanism is the loop.
So this chapter does one thing at the top level: it describes one turn of Pi exactly, names the four inputs a model request is assembled from, and names the boxes an extension can attach to. The second half answers a prior question the loop raises the moment you try to place your own code — is the agent a model call, or is it a layer of its own? — because the answer decides where your code is allowed to live.
Everything else in this book is a decision about where in the loop to attach. This chapter is the map those decisions are made against.
One turn
Pi’s documentation calls this the agent loop. It is worth being precise, because “the loop” is used loosely and the precise version answers most extension questions on its own.
A submitted message is added to the active branch. Pi builds a model request from four things: the system prompt, the active branch, the available tools, and the model settings. That request goes out through the selected provider.
The provider streams back an assistant response, which can contain text and tool calls. Pi records the response, executes any requested tool calls, and records the results. That completes one model-and-tool cycle. The wider run may continue through another request, a queued message, or application-level recovery.
Then the question:
Do the tool results, or queued messages, require another model request?
|
+-- yes -> start another turn
|
+-- no -> the run ends
That is the whole loop. Read it twice, because it is short enough to hold in full and almost every later chapter is a refinement of one of its boxes. Everything else in this book is a decision about where in it to attach.
Three properties of that description are load-bearing, and we will use all three later:
- A turn has a fixed shape. It is one request plus the tool execution that follows it. The tool execution is not a separate loop; it belongs to the turn that asked for it.
- Continuation is an explicit decision. Tool results and queued messages can cause another request. At the application layer, retries, recovery and settlement handlers can also affect when the run actually ends.
- The request is assembled, not written. It is built from parts, and those parts are the subject of the next section. This is the fact that makes the loop extensible without making it complicated.
Four inputs, one request
The model request is assembled from four inputs. This decomposition is worth holding in your head, because each one is separately addressable, and almost every extension you will write manipulates exactly one of them.
| Input | What it is | How you change it |
|---|---|---|
| System prompt | Pi’s base instructions plus discovered context files | SYSTEM.md, APPEND_SYSTEM.md, AGENTS.md, or an event handler |
| Active branch | The conversation history that leads to the current entry | The conversation itself, or context / context_with_system |
| Available tools | What the model may call | pi.registerTool(), pi.setActiveTools() |
| Model settings | Which model, and how hard it thinks | /model, thinking level, settings.json |
The “separately addressable” part is the whole point, so here it is as a worked example. Two extensions can look identical from the outside and be nothing alike inside. An extension that inserts a sentence into the conversation is working on the second input — the active branch. An extension that registers a tool is working on the third — the available tools. Both end up in the same request, both appear to “change what the model sees”, and changing one does nothing to the other. The debugging consequence is the rule this chapter keeps returning to: when a request is wrong, the first question is not what should I write? but which input did this land in, and is that the input I meant?
Chapter 18 follows this distinction into the mechanisms that change what the model receives.
What is the active branch?
The second input deserves its own section because it is the one people get wrong. Pi stores sessions as trees. Each entry has an ID and refers to its parent. The path from the root to the current entry is the active branch, and that path is what supplies the history for the next request.
Two consequences follow immediately, and both of them will save you later.
The branch is not the session. A session file can contain several branches. Going back and editing creates another one. Everything on the other branches still exists in the file. When something inspects “the conversation,” it has to decide whether it means the active branch or the whole tree — and these are different questions with different right answers. A tool that walks the tree and a tool that reads the branch will disagree about what “earlier” means, and both will be correct about their own question.
Compaction does not delete. When Pi compacts, it inserts a summary entry that replaces older messages in subsequent requests. The original entries stay in the tree. This is why a compacted session can still be inspected in full, and why a compaction bug is usually a bug about which entries you counted rather than a bug about missing entries. The entries are still there; the summary is a view laid over them for the next request, not an erasure.
Chapter 24 covers the tree properly and chapter 23 covers compaction. For now the only thing to hold is: history is a tree, and the next request reads one path through it. If a later bug is about “what the model remembered”, the question is always which path the request read, not whether the data survived.
The turn is not the run
One more distinction, and it is the one that catches most extension authors. It follows directly from the continuation test above: if a turn can be followed by another turn, then the unit you observe (a turn) is not the unit you care about (the whole piece of work).
A turn is one model request plus the tool execution that follows it. A run is the whole sequence — turn, tool results, another turn, tool results, until nothing more is needed.
Wrong: “
agent_endfired, so the run is over.”Correct: “
agent_endclosed one low-level agent run. Automatic retry, overflow recovery, compaction, steering or follow-up work can still follow.”
Two boundaries sit between them, and Pi’s documentation treats them with unusual care:
agent_before_settle— the final actionable boundary. A handler here can append entries and request one continuation.agent_settled— final and notification-only. Nothing can continue after it.
Read the pair as a sequence of permissions, because that is how they behave.
agent_end is a checkpoint you can act after. agent_before_settle is the last
point at which a handler can still change what happens — it can append entries
and ask for one more turn. agent_settled is past all action: it announces that
the run is done, and nothing can continue after it. If your extension needs to
change something, agent_before_settle is the latest it can fire. If your
extension only needs to know, agent_settled is where the “done” is trustworthy.
Both matter, and they matter most in Part III, where automatic retries, recovery,
compaction and queued work can all continue after agent_end. Chapter 16 is the
full treatment.
Queued messages
The continuation test mentions “queued messages” without explaining them. Two kinds of message can arrive while a run is in progress, and they land in different places — which is exactly the kind of detail whose absences produce “my handler fired at the wrong moment.”
Steering messages enter after the current assistant turn. They do not interrupt the tool calls already in flight; they land once that turn closes.
Follow-up messages enter after the agent has finished its pending work. That
is after the agent stops asking for turns, not after agent_settled: queued
follow-up work is resolved before the final settlement boundary, which is what makes
agent_settled a genuine end rather than a pause. Chapter 25 gives the queue
mechanics and chapter 16 the event order.
The distinction is subtler than it looks, so state it as two different “afters”.
Steering fires after the current assistant turn. Follow-up fires after the agent
has nothing more of its own to do — but still before agent_settled. That
ordering is what lets queued follow-up work be resolved by the agent rather than
piled on a finished run.
And aborting stops the current run and returns queued messages to the editor, rather than discarding them.
This is a small design with a large consequence: a steering message does not interrupt tool calls already in flight. To stop the current work, abort the run; queued messages can then return to the editor. Chapter 25 covers the mechanics. The important distinction here is between steering at a turn boundary and aborting work in progress.
Where the context comes from
Before the request goes out, Pi assembles the prompt. This part surprises people, so it is worth stating plainly.
The system prompt is built from Pi’s base instructions and discovered context files. The request also carries tool definitions and skill descriptions.
Full skill instructions are not included merely because a skill is available. Pi can advertise skills by their descriptions; the instructions enter the conversation when a skill is selected and its content is read. That is what keeps an unused directory of skill instructions out of the request.
Extensions can add instructions or transform context. Prompt templates expand in the editor before anything becomes a user message. Selected files, images, pasted text and shell output can all become message content.
So when the model “knows” something, it is because one of these put it there:
base instructions
context files AGENTS.md and friends, discovered by directory
APPEND_SYSTEM.md additive instructions
SYSTEM.md replaces the base prompt
skill descriptions always present
skill instructions loaded on demand, when matched
tool definitions always present, for active tools
extension additions whatever you add, by event
editor input prompt templates, @-selected files, pasted content
That list is a useful map of the principal context sources, not an exhaustive inventory of every possible contribution. When you are debugging why the model did or did not receive something, start by working down it. Two things about the list are worth noticing before you need it. First, the entry points are spread across the four inputs from the earlier table — some add to the system prompt, some to the request, some to the branch — so “where does knowledge come from” and “which box did I attach to” are the same question asked twice. Second, the difference between always present and loaded on demand is a cost decision, not a correctness one; it is why a skill you have not used does not cost you context, and why a skill the model failed to match onto does not appear at all.
The loop, as a drawing
flowchart TD
IN["INPUT<br/>message added to the active branch"] --> REQ["REQUEST<br/>system prompt, active branch,<br/>tools, model settings"]
REQ --> PROV["PROVIDER"]
PROV --> RESP["RESPONSE<br/>text and tool calls"]
RESP --> EXEC["EXECUTION<br/>run each tool call, record each result"]
EXEC --> MORE{"more needed?"}
MORE -->|yes| REQ
MORE -->|no| DONE["RUN ENDS"]
Every integration point in this book is a box on that diagram, and each one names where an extension attaches:
flowchart TD
X["EXTENSION"] --> T["registerTool"] --> E["EXECUTION"]
X --> C["context handler"] --> R["REQUEST"]
X --> V["pi.on, any event"] --> G["the edges between boxes"]
X --> P["registerProvider"] --> PR["PROVIDER"]
A prompt template changes what arrives at INPUT.
The value of the second diagram is that it converts every extension into a
location. registerTool lands on EXECUTION; a context handler lands on
REQUEST; a provider lands on PROVIDER; and pi.on for any event lands on the
edges — the moments between boxes, where one box hands off to the next. When you
can point at the box, the argument about whether your extension is “the right
place” becomes an argument about whether that box is where the requirement’s
decision actually belongs, which is a question with an answer.
Why this chapter is first
An extension author who does not know how context is assembled writes an extension that fights the system, and — this is the expensive part — cannot tell whether the bug is theirs or Pi’s.
The symptoms all look the same from the outside. The model ignores your instructions. Your tool never appears. Your injected content vanishes after a compaction. Your handler fires at the wrong moment. In every case the question you need answered is the same, and it is answered by the diagram above: which box did I attach to, and is that box the one I meant?
That question is worth writing down as a procedure, because it is the reusable move of this chapter and it is used constantly for the rest of the book:
- Name the decision the requirement is actually about. “The model should ignore X” is a decision about the request; “the tool must be blocked” is a decision about execution; “the run must not finish yet” is a decision about a boundary.
- Ask which box on the diagram makes that decision.
- Ask which box your extension attached to.
- If the two boxes differ, the bug is a misattachment, and no amount of rewriting the extension will fix it — you are standing in the wrong room. Move to the right box, or report that no box for this decision is reachable from where you are.
- Only when the boxes match and the symptom persists is the bug inside the box.
The reason this procedure is useful is subtle: an extension bug may be correct code attached to the wrong place, rather than incorrect code attached to the right one. The code does exactly what it says, and it says the wrong thing for this requirement. Knowing the boxes is how you tell those two failures apart.
What kind of thing the loop is
One question has to be answered before any of this is usable, because it decides where your own code is allowed to live: is the agent a model call, or is it a layer of its own?
This is not a philosophical question, and it is not answered by taste. If the agent is just a model call, then “adding behaviour” means calling the model differently, and your code lives at the edge. If the agent is a layer of its own, then there is a place for your code that is neither the model nor the application, and the loop you just learned is that place. The answer determines whether the boxes above are something you extend or something you merely call.
Pi is unambiguous here. The published package dependencies provide evidence independent of the explanatory prose in the documentation. At version 1.0.4:
pi-ai model access: provider SDKs, schemas, telemetry. No loop, no
tool execution, no session.
↑
pi-agent-core pi-ai + typebox. The loop, messages, state, tool execution.
↑ ↑
pi-coding-agent composes pi-agent-core and pi-ai directly, with pi-tui,
pi-mcp, pi-codemode and chord
pi-tui terminal rendering. Knows nothing about agents.
pi-agent-core depends on nothing from pi-tui, and pi-tui depends on nothing from the agent. The core runs without a terminal, without an application, and without a provider-specific model. You can embed it; the same agent then drives a terminal, a JSON stream, an RPC
process, or an application you wrote yourself.
It is worth being precise about what “core” means here, because it is more than a loop. Its own package description is a “general-purpose agent with transport abstraction, state management, and attachment support”, and the shipped module is about sixty kilobytes across an agent, a loop, a proxy and the type surface. It owns the loop, messages, agent state, tool execution and the event flow.
Notice the missing dependency: pi-agent-core does not import pi-tui.
A core that required terminal rendering would have to depend on it. This one
does not, so its loop can be run and reasoned about separately from the terminal.
The four-input model above describes how the coding-agent application assembles
a request around that core; it is not a claim that the core independently loads
Pi’s project configuration or session files.
So the useful decomposition has more rungs than “model” and “agent”. Here is the stack this book uses, with the dependency direction stated:
Interface drives and observes: terminal, print, JSON, RPC, an SDK host
│ drives
Application / host what the agent is for, and where authority lives
│ uses
Agent application sessions, compaction policy, context policy, extensions, MCP, workflow
│ uses
Agent core loop, messages, state, tool execution, event flow
│ uses
Model access one request to one model, streamed back
│ uses
Provider Anthropic, OpenAI, a local server
┊ not a layer: the effect boundary, the point past which
┊ a decision cannot be taken back. It cuts across the stack.
Read it as a dependency direction: each layer uses the one beneath it and knows
nothing about the one above. The agent sits above the model and below the
application that gives it a purpose. In Pi’s packages the middle three have names:
pi-ai is model access, pi-agent-core is the agent core, and pi-coding-agent is
an agent application. The application or host above it is yours: the program, the CI
job or the service in which a Pi session runs. Chapter 36 takes the lower rungs apart,
and chapter 42 is about the dotted line.
| Layer | Owns | Does not own |
|---|---|---|
| Provider | the model itself, its API, its limits | anything about your agent |
| Model access | model identity, auth, request and response conversion, streaming, capabilities | agent policy, workflow, context strategy |
| Agent core | the loop, messages, agent state, tool declaration and execution, event flow | rendering, credentials, the machine’s filesystem |
| Agent application | sessions, compaction and context policy, extensions, MCP, workflow | the loop itself |
| Application / host | the purpose, the user, the permissions the deployment grants | the agent’s internals |
| Interface | driving and observing the agent | what the agent is |
The “Does not own” column is the operational half of the table, and it is the harder half to write. Anyone can list what a layer does; the useful design test is what it should not be responsible for deciding, because that helps locate where a given piece of behaviour belongs. Keep the table open across the rest of this section: every claim below is either a claim about a row, or a claim about what the rows do not give you.
The package graph above is documented by the manifests. The program below provides an observed run against the pinned packages. Treating these as the right architectural boundaries is the book’s proposed design argument. The rest of this section says what the decomposition does and does not buy you.
Replaceability is not isolation
Two different properties get called “separation of layers”, and the book needs them kept apart. This is the first place a correct mental model of the stack goes wrong, so it gets its own heading.
Replaceability is substitution: swap the provider without rewriting agent policy, swap the terminal for an RPC client without rewriting the agent, swap the host without touching provider adapters. It is the test for whether you understood a layer. Part IV demonstrates it for the interface: chapter 31 drives one agent through print, JSON and RPC on the shipped binary and gets the same answer each time, and chapters 32 to 35 walk the same machinery through five interfaces. Where a change cannot be made without rewriting something else, that is a coupling.
Isolation is containment: whether a layer can stop the consequences of a decision made in a layer above it. A replaceable layer is not thereby an isolating one. The same agent behind any of the three interfaces does the same thing, which is the point of chapter 31, and also the reason the interface is no place to put a safety property: it can be swapped away without changing what the agent can do. Chapter 39 measures the same gap inside the loop, and chapter 42 turns it into a rule about where authority can live.
The reason these get confused is that both are produced by the same one-way dependency arrows, so a diagram that shows one appears to show the other. It does not. Replaceability says “you can put something else here.” Isolation says “what you put here can stop what happens above it.” Those are different promises, and the second does not follow from the first.
Replaceability is observed for the interface in chapters 31 to 35. Isolation is what the rest of the book keeps asking of each layer, and the answer is almost always “not this one”.
The agent core, run with no provider and no terminal
A layer you can only read about is a claim. This one you can run. The program below uses pi-agent-core and pi-ai and nothing else: no pi binary, no terminal, no credentials. The “model” is the faux provider that pi-ai ships for tests, which replays a script you write, so the loop is real and the model is not.
import { Agent, type AgentTool } from "@earendil-works/pi-agent-core";
import {
Type,
createModels,
fauxAssistantMessage,
fauxProvider,
fauxText,
fauxToolCall,
} from "@earendil-works/pi-ai";
// The provider layer: a scripted stand-in for a model.
const faux = fauxProvider();
const models = createModels();
models.setProvider(faux.provider);
// A tool is a name, a schema and an execute(). Nothing here knows about Pi.
const WordCountParams = Type.Object({ text: Type.String() });
const wordCount: AgentTool<typeof WordCountParams> = {
name: "word_count",
label: "Word Count",
description: "Count the words in a string.",
parameters: WordCountParams,
async execute(_id, params) {
const n = params.text.trim().split(/\s+/).filter(Boolean).length;
return { content: [{ type: "text", text: String(n) }], details: { words: n } };
},
};
// Turn 1 asks for the tool. Turn 2 answers from its result.
faux.setResponses([
fauxAssistantMessage([fauxToolCall("word_count", { text: "the quick brown fox" })], { stopReason: "toolUse" }),
fauxAssistantMessage([fauxText("Four words.")]),
]);
// The agent core: loop, messages, state, tool execution, events.
const agent = new Agent({
initialState: { systemPrompt: "You count words.", model: faux.getModel(), tools: [wordCount] },
streamFn: models.streamSimple.bind(models),
});
agent.subscribe((e) => {
if (e.type === "tool_execution_end") console.log("tool:", e.toolName, JSON.stringify(e.result.details));
if (e.type === "message_end" && e.message.role === "assistant") console.log("stop:", e.message.stopReason);
});
await agent.prompt("How many words in 'the quick brown fox'?");
Run it from the examples/ folder beside this book, on Node.js 22.19 or newer, which
executes TypeScript directly. The book’s Run this book’s examples
page has the full setup — the pinned install, the prerequisites, and the difference
between running one chapter and verifying all of them. This program prints:
stop: toolUse
tool: word_count {"words":4}
stop: stop
That is the loop introduced earlier in this chapter, with the labels filled in. Read the three lines against the diagram, because each one is a box announcing itself:
stop: toolUseis the RESPONSE box: the assistant response that asked for a tool, marked with the reason it stopped (toolUse, meaning a tool call is pending).tool: word_count {"words":4}is the EXECUTION box: the tool ran, and the result’sdetailscame back as{"words":4}.stop: stopis the second response — the “more needed?” test answered no, which is why the run ends there.
The printed output is evidence of the loop executing, not merely a description of its intended behaviour. It ran with a scripted provider, no terminal, and no coding-agent application. That is what “separable layer” means in practice: the core does not require Pi’s terminal or session host. Connecting a real provider would change the model-access configuration, but need not change the word-count tool or the loop that executes it.
Three things to take from it. The provider is one argument, streamFn. The tool is a plain object whose execute() returns content and details, the same shape chapter 15 uses inside an extension. And everything Pi adds on top (sessions, compaction, trust, the terminal) is absent, which is the difference between the agent core and the agent application in the table above.
Documented API; observed execution: the Agent class, agent.subscribe(),
fauxProvider() and the streamFn option are documented in the packages'
READMEs and declarations. The specific program,
examples/ch01-agent-core/agent-core.ts, is run by the book’s test suite
against @earendil-works/pi-agent-core and @earendil-works/pi-ai 1.0.4,
the pinned release. Part V (chapters 36 to 41) builds on this program.
Note what this demonstration does and does not settle. It shows that the loop is a runnable object with four inputs and a continuation test, and that providers are swappable by one argument. It does not show anything about a real model’s behaviour, because the model here is scripted. The loop is real; the model is not.
Representation crossing a boundary is not responsibility crossing it
One last clarification, because the provider layer looks like the obvious offender and is not.
Pi’s model abstraction necessarily knows the shape of a tool call. A model
protocol has tool declarations, tool-call deltas, tool-call responses and
tool-result messages, and any layer that speaks to a model has to be able to
represent all four. pi-ai containing types named for tool calls is that and
nothing more — it is the wire format, not the policy.
What the model-access layer must not own is the decisions:
which tools should exist at all
whether a given tool is authorised
when the agent should take another turn
what project policy applies
how a result should change the workflow
So the useful test is not does this layer mention the thing, which is
meaningless, but does this layer decide. A model-access layer that knows
what a tool call looks like is fine. One that knows this repository must run
tests after every edit is architectural leakage, and it is leakage whether or not
the type lives in a package called pi-ai.
The test generalises, and it is worth carrying: whenever you catch yourself arguing that a layer is “wrong” because a concept appears in it, stop and ask whether the layer decides anything about that concept or merely represents it. Representation is forced by the interfaces below and above; decision is a choice about where authority lives. This is the same discipline the rest of the book uses, one level down: what a mechanism represents is not what it is responsible for. Chapter 11 turns it into a placement rule, and chapter 42 turns it into the difference between a procedure and a boundary.
Where the layer boundary actually is
The agent core is a real, separable layer, and the package graph above shows the core is separable enough that whole categories of product — review workflows, memory systems, permission decision layers, sandboxing, protocol bridges — can be built above it without touching it. That much holds.
What does not hold is the tidy assumption that the core owns only a loop. It owns loop, messages, agent state, tool execution and event flow. And a number of decisions around it have repeatedly proved not to be expressible as extensions at all.
Three cases make the pattern concrete rather than rhetorical. Each is a need, a mechanism that almost worked, the specific point the fork had to change, and what that says about the seam. They are reported, not run. They come from one extension-first fork’s own change records, they are proposed evidence for the seam principle below, and none of the three was reproduced in this book’s test suite. They illustrate a pattern; they are not a survey of the ecosystem. Chapter 39 tests the principle itself, on the agent core’s hooks, where it can be run.
1. Compact when a model rejects a context that is too long. An extension
can observe session_before_compact and can act after one, and it can call
ctx.compact() itself. It cannot intercept the point at which a provider
rejects an over-long request, so it cannot guarantee that recovery happens
before the turn fails. The fork changed the compaction path in core. The
decision here is “recover before the turn fails”, and no reachable hook sits
before the failure.
2. Refuse to start a session on a model that cannot afford it. A budget
check that throws from createAgentSession runs in the constructor, which
happens before any extension hook is wired. There is no interception point at
all, not a narrow one. The fork modified the core constructor. The decision here
is “do not admit this session”, and by the time any hook could run, the decision
has already been made.
3. Build the system prompt from parts rather than by appending to it. A
before_agent_start handler may append to the prompt for one turn. It cannot
replace the builder that produced it, so a rule that must apply to the prompt’s
structure — not just its text — has no seam. The fork rewrote prompt
construction. The decision here is “the prompt must be composed this way”, and
appending can change the prompt’s text without changing its structure.
The shared shape is worth naming, because it is more useful than any of the three examples:
An extension point is a real architectural seam only for decisions it can intercept before those decisions become irreversible.
Two refinements make the sentence usable. First, a seam is a seam for a named
decision. beforeToolCall is a seam for “may this effect happen”, and, as chapter 39 shows, a hook that is no seam for that decision can still be one for another
(“does this run continue”). Second, “irreversible” is a property of the decision, not
of the hook: the question is always what the hook can still change by the time it
fires.
A third refinement, added after an independent review pushed on it: judge a seam by
the declared protocol before you judge it by what the language permits. Chapter 39
separates the two, and it is the separation that stops the argument being wrong in
both directions. A hook with no veto field is not thereby powerless — a
prepareRequest that throws stops the request, and a beforeToolCall that mutates
args changes what the tool receives. Both are observed on 1.0.4. Neither is
documented, and a policy built on either is built on an implementation detail. So the
question is not “can I make this happen?” but “can I make this happen in a way the
package promises?”, and the honest answer for a reader designing an extension is the
second one.
Observation is easy to expose. Observation after the fact is what most of the extension surface provides. Arbitration is what fails.
So the defensible version of the claim is narrower than the tidy one, and more interesting:
Pi has a separable agent core. It owns the loop and the core state and execution abstractions, and a great deal of product can be built above it unchanged. But several lifecycle decisions — when to compact, whether to admit a session, how the base prompt is constructed — are ones an extension can see too late to influence, and those are the decisions the reported fork changes were written to reach.
That is not a defect. It is the shape of the problem. It is the same distinction chapter 11 turns into a placement rule, and chapter 42 draws between a procedure and a boundary: in the words this book uses throughout, between a mechanism that observes a decision and one that owns it.
The same test on any assembly-to-execution pipeline
Everything above was stated about Pi, but the procedure does not depend on Pi’s names. The only structural requirement is a phase that assembles a request and a phase that executes it, with an irreversible effect at the far end. Almost every service has that shape — an outbound job that resolves its recipient and flags, then dispatches to a provider; a build that composes a manifest, then hands it to a registry — and the seam question transfers directly. It is stated here as the book’s proposed procedure, not as a measured result; it inherits the seam principle’s status, which is that the underlying cases are reported and the principle is tested later, in a runnable form, in chapter 39.
Given a behavioural requirement and no mechanism:
- Name the decision, not the symptom. “Opted-out recipients must not receive a message” is the surface; the decision is “may this effect happen”, and it may be made at assembly time (what the request carries), at dispatch time (whether the effect is attempted), or not at all until after the provider has accepted the message.
- Enumerate the candidate seams in the pipeline and ask which can intercept that decision.
- Test each candidate before the effect boundary, and only through the interface the package declares. A candidate that only works by relying on an undocumented side effect is not a seam.
- A failed candidate is not a conclusion. It means “not a seam for this named decision, at this layer.” The temptation is to escalate to “this cannot be done”; resist it. Report the misattachment and keep locating.
That is the transferable core of this chapter: decompose to the effect boundary, name the decision, test each seam against it, and treat a stubborn symptom as a misattachment until a seam that fires before irreversibility is confirmed through the declared protocol.
Next
You can now name the loop and its seams — the four request inputs, the active branch, the turn/run distinction, the settlement boundaries, the queues and the context ladder — and you have a diagnostic for localising a symptom to a box. That is enough to debug against the loop, but not yet enough to have watched one run end-to-end through the application, with sessions, compaction and a terminal around it.
The next chapter starts the process, runs a real task through it, and marks the decisions in that first hour that you will keep making for the rest of the book. If you already have Pi running, skip to chapter 11 and come back here when something behaves strangely.
Chapter 2: installation, a first session, and a first task.