I've been heads-down building for a while now, and I finally have the time to write down what I've learned about agents over this period.
From Manus blowing up last year, to Claude Code being decompiled and published by the community this year (CC ships as a minified npm package — what the community did was decompilation/reverse engineering, so strictly speaking it isn't a "source leak," but everyone calls it a leak so I'll go along with it), to the recent debate over "which language is best for writing agents" — you can feel the field's understanding of agents deepening. This CC episode passively pushed the world's core agent engineers forward by at least 20% :). I went through the same process myself, gradually moving from a "framework-first" mindset to my own agent methodology.
The path I took: I started building on agno but could never fully solve the streaming problems; later openclaw blew up and put pi-mono on my radar; recently I studied the Claude Code source. After reading several top-tier architectures, I can finally have a serious discussion about the current paradigm for agent development.
I also built a Python microkernel along these lines, called Mauri, and used it to migrate three products in completely different directions. This article covers three things:
- The L1–L7 seven-layer model I distilled — a clean decomposition of agent systems
- A cross-comparison of three independent implementations (Claude Code / pi-mono / Mauri) — why L1–L3 converge and L4–L6 diverge
- Measured data from migrating products in different directions, plus five pieces of counter-evidence where convergence failed
But before getting into those three, I want to state the core judgment that runs through all of it: an agent's capability comes from structural constraints, not from the model's good behavior. Governing an agent ≈ designing the harness (the governance layer).
1. How I Arrived at This
agno has two levels of streaming switches: stream=True gives you the basic token-by-token stream (enough for a typewriter UI on the frontend), while stream_intermediate_steps=True (renamed stream_events in newer versions) is what exposes the agentic intermediate events — ToolCallStarted / ReasoningStep and friends, the events you need for a "the model is thinking" indicator and a tool-call progress bar. The former is easy; the latter is a minefield — there's a string of related issues on GitHub. I patched internal timing, I threaded callbacks through — but the root cause isn't in the stream_intermediate_steps implementation. It's that agno's core loop is semantically "batch the tool calls, then return the whole batch"; stream_intermediate_steps is just after-the-fact annotation, not a genuine incremental event source. This was the first time I realized that "a framework's internal abstraction determines its ceiling" — no amount of patching on top of a wrong low-level abstraction will help.
Later, as openclaw took off, pi-mono started getting discussed too. Reading pi-mono's packages/ai is where it clicked: streaming isn't an interface problem, it's an architecture problem. pi's event stream is a set of type-tagged unified event types (text_start / text_delta / toolcall_start / toolcall_delta / toolcall_end / ..., each with its own type field as the discriminant), and every model adapter (provider adapter) is required to emit an event stream with the same structure. agno's pain point simply cannot exist in pi.
Then, once CC's source had been decompiled and published by the community, I read query.ts — and saw the same structure: an async-generator main loop, plus a full set of hooks covering before/after tool calls, before/after compaction, and lifecycle events, plus sub-agents wrapped as ordinary tool calls (sub-agent via tool). Two top-tier architectures from independent origins had converged on nearly the same shape. That made me revisit my earlier assumption: the core of agent architecture may be far smaller than I thought.
More precisely: I used to think the value of an agent framework was in "how many features it has and how flexible the orchestration is," which is why, when I first picked a framework, I agonized over whether LangGraph's graph DSL was more elegant or CrewAI's multi-agent orchestration was smarter. After reading the CC source, though: CC's actual core loop is on the order of a few thousand lines of TypeScript — the overwhelming majority of the remaining code is coding-domain tools, UI, session management, and file editing. pi's packages/agent core is also only about 2K lines (the other ~45K lines are the upper-layer coding-agent harness). The part that actually makes an agent an agent is far smaller than you'd expect; everything else is product responsibility — domain, form factor, business model.
With that judgment in hand, I went and tested it in practice: I migrated three products in completely different directions (one conversational, one image-generation + canvas, one video production pipeline) onto a single Python microkernel I wrote myself (Mauri). There was exactly one thing I was validating: can this "small core" actually work across directions?
The conclusion: yes. Layers L1–L3: all three products share 100% of the same kernel. Layers L4–L6: each is independent.
2. The Core Judgment: Agent Capability Comes from Structural Constraints, Not the Model's Good Behavior
I'm pulling this out as its own section because it's the underlying logic behind every layering decision that follows.
One of the mainstream approaches to governing agents has been prompt governance — writing "please don't do X" / "please prefer tool Y" / "please preserve the key information before summarizing" into the system prompt. The ceiling on this approach is the model's good behavior: the LLM has to "understand" the instruction, "remember" the constraint, and "proactively" follow it. If any one of those slips, it breaks. This is exactly why the same prompt runs great on Opus 4.7 but, switched to GLM, starts hallucinating, using the wrong tools, and losing the task's identity — it isn't that GLM is bad; it's that this style of governance is inherently dependent on the model behaving well, and a weaker model can't carry it.
The most important thing CC taught me: real agent governance is structural constraint, not model self-discipline. Look at a few of CC's key designs:
isConcurrencySafe(input)— whether something is concurrency-safe is a runtime-level structural decision, not a matter of telling the model to "be careful about side effects"- Tool filtering for sub-agents (
filterToolsForAgent+ALL_AGENT_DISALLOWED_TOOLS) — when CC launches a sub-agent, tools the sub-agent shouldn't have are physically removed from the tool table; the model literally cannot see them. This is the canonical example of a structural constraint. sub_agent_toolprevents recursion by default — same idea: the sub-loop's tool table physically does not contain a sub_agent tool (CC'sALL_AGENT_DISALLOWED_TOOLSincludes the Agent tool). It doesn't rely on the model "knowing not to nest."- Permission
denydecisions (CC's internal permission enum isallow/deny/ask) — the tool call is blocked outright, rather than the model being "talked out of it" - Marker-preserving context compaction — the kernel physically preserves the decision trail; it doesn't ask the model to "remember the key decisions itself"
- Strongly typed event protocol (strict Event schema) — non-conforming protocol shapes blow up at the parsing layer, rather than relying on the model to "understand the protocol"
All six of these are structural constraints. The model doesn't need to "know" anything and doesn't need self-discipline — structurally, the only thing it can do is the right thing. Where's the core difference from prompt governance? Prompt governance depends on the model's comprehension; harness governance depends on system structure. The ceiling of the former tracks the model; the ceiling of the latter tracks the architecture.
That's what harness = governance means. An agent's governance layer doesn't live in the prompt, it lives in the harness — the structural shell wrapped around the LLM (kernel + tool contracts + modes + permissions + context compaction). When we say "building an agent," what we're actually building is the harness, not the LLM. So governing an agent is something you should think about by optimizing architecture, not by optimizing prompts.
Once you internalize that, the L1–L7 model becomes clear: L1–L3 are all concrete forms of structural constraint. L1 uses a type protocol to lock down the model↔agent event contract; L2 uses a set of hooks plus modes to lock down execution flow; L3 uses tool contracts, concurrency classification, and sub-agents to lock down the capability boundary. Those three layers together are the hard skeleton of the governance layer. L4–L6 are product-form divergence, and L7 is the meta layer that drives changes to L1–L6. The seven-layer model is essentially an anatomy of the governance layer — it splits "governing an agent" into seven sub-problems you can reason about independently.
Let's go layer by layer.
3. The L1–L7 Model — An Anatomy of the Governance Layer
This layering isn't abstraction for its own sake; it was reverse-engineered from three implementations. Each layer answers one concrete question about what the governance layer should constrain:
Layer What the harness constrains here Converged? L1 Protocol The event contract between LLM ↔ agent Structurally identical across 3 implementations L2 Kernel The agent's execution-flow skeleton Structurally identical across 3 implementations L3 Tool The agent's capability boundary Structurally identical across 3 implementations L4 Knowledge The agent's long-horizon memory schema Independent per domain L5 Surface How users interact with the agent Independent per form factor L6 Ops Running the agent as a product/SaaS Independent per business model L7 Eval How the agent evolves Meta layer; drives changes to L1–L6
L1–L3 are the hard skeleton of the governance layer — the underlying problem has essentially one family of solutions, and there's only one reasonable shape for the structural constraint. L4–L6 are the soft wrapping — form-factor questions that differ in every domain. L7 is the meta layer, and its output feeds back to drive the evolution of L1–L6.
4. L1 Protocol Layer — Structural Constraints on the Event Contract
L1 defines two things:
- The model adapter contract: every provider implements the same
adapter.stream(...)interface, returning an async iterator that yields Events - A discriminated union of Event types: every event streaming out of any provider is normalized into a single type-tagged unified type — variants like
TextStart/TextDelta/ToolCallStart/ToolCallInputDelta/ToolCallEnd/ToolResult/Thinking/Usage/Stop
Everything above L1 consumes this event stream and never touches the provider's native SDK directly. That's L1's structural-constraint function — downstream code cannot possibly depend on provider-private fields, because the unified type doesn't permit them.
CC, pi, and Mauri all converge on this shape (the variant names differ somewhat across implementations — for instance, pi treats tool results as a separate Message and uses done/error for termination rather than dedicated ToolResult/Stop events; what's being discussed here is L1's abstract shape, not a one-to-one mapping of variant names). Why is there "only one" shape? Because provider APIs themselves only have one structure: streamed token output + tool-call deltas + usage accounting + stop reason. In practice, cross-provider work splits into two compatibility families — OpenAI Chat Completions and the Anthropic Messages API — and most other providers are covered by swapping the base_url on one of those two. The abstract skeleton is the same in both families. I verified this in a cross-provider test matrix: if any adapter cuts corners, the matrix immediately goes red. The constraining force of this protocol shape is stronger than you'd expect — it pins down L1's shape almost completely, leaving the designer freedom only in "what to name the event variants" and "which container to stuff provider-private extensions into."
One Easy Anti-Pattern at L1
The most common mistake is stuffing provider-private extension fields directly into the unified Event type — Anthropic's cache_control, OpenAI's function_call compatibility, Zhipu's reasoning_content, and so on. The moment Event becomes "the union of every provider's fields," the cross-provider abstraction is dead.
The right answer is for Event to retain only semantics that every provider has. Mauri simply doesn't give Event any free-form container at all: private fields are digested inside the adapter and never surface (Anthropic's cache_control is handled inside adapters/anthropic.py; OpenAI's function_call compatibility is translated by the adapter), and the Event union stays absolutely pure cross-provider common semantics. What genuinely needs a "free-form container" is cross-layer plumbing like the hook context dict, and for that Mauri wrote an explicit "payload contract discipline": every fire-site (where values get written in) must lock the key-name contract with a typed dict, so that later read-sites (where values get read out) can't drift on key names and fail silently.
Key Invariants
stream()returns an async iterator, not an awaitable coroutine (this is something pi emphasized early; if you write it asasync def stream()returning a coroutine, the cross-provider matrix catches it immediately)- At least one Usage event precedes the Stop event
- Stop reasons are normalized to a fixed literal set
5. L2 Kernel Layer — Structural Constraints on the Execution Skeleton
L2 is the actual microkernel: main loop + hooks + modes + context compaction. This is the layer with the tightest convergence.
5.1 A ReAct Main Loop as an Async Generator
The skeleton is just ReAct: call the model → parse tool calls → dispatch execution → write results back into history → next round. All three implementations have this shape. But there's one easy trap: the main loop must be an async generator, emitting events immediately without buffering.
async def loop(...) -> AsyncIterator[Event]:
while not should_stop:
async for event in adapter.stream(...):
yield event # emit immediately; the outer layer sees it right away
if isinstance(event, ToolCallEnd):
pending_calls.append(...)
for call in pending_calls:
result = await dispatch(call)
yield ToolResult(...)
The anti-pattern: collecting the stream into a list first and then sending it in bulk. This is the same pit agno falls into by default (agno does have a stream=True switch for token-by-token streaming; it's just that the default is buffered). CC's query.ts is an async generator, and pi's L1 stream() does return an async iterator rather than an awaitable coroutine. But at L2, pi's main loop takes a different route: callback emit + an EventStream wrapper — runAgentLoop itself is an ordinary async function returning a Promise and yields nothing; the incremental stream exposed to the outside is produced by the outer agentLoop() wrapper. So the precise statement of the convergence point is "an incremental, interruptible, typed event stream," not "an async generator" as a specific mechanism — pi proves that in TS, callback emit plus a clean EventSink abstraction achieves the same semantics.
Why does Mauri have to use an async generator in Python? Because the ReAct main loop has several nested layers inside it (context compaction → on_pre_llm hook → call adapter.stream → on_event hook → tool dispatch → on_post_tool hook → next round), and if any one of those layers wants to "pass events out immediately" under a callback model, you have to turn the whole call stack into continuation-passing style. Async generators let every layer yield events and let the outer layers propagate them naturally — this is the most natural way to write it in Python. pi-mono took the other route: L1's stream() is an async iterator, but L2's runAgentLoop is a plain async function that emits events via callbacks (the caller passes in onEvent to get the stream), with an outer EventStream wrapper providing iteration. Both styles solve the same nested-propagation problem.
5.2 A Three-Injection-Point Hook Architecture — Hooks as Structured Extension Points
CC enumerates twenty-odd event names in HOOK_EVENTS (PreToolUse / PostToolUse / UserPromptSubmit / PreCompact / Stop / SubagentStop / SessionStart / SessionEnd / ...) — but semantically it's still 3 phases + lifecycle: Assemble / Model / Execute, plus pause/stop/before-and-after-compaction. pi has 4 hooks in the core layer (packages/agent): onPayload / onResponse (request-response level) plus beforeToolCall / afterToolCall (tool level, actually invoked in agent-loop.ts); richer events like compaction and pre-LLM context live in the upper-layer coding-agent harness. Mauri's hooks are cut along the same three phases:
Phase When Mauri CC pi Assemble building the request on_pre_llm UserPromptSubmit onPayload (core layer) Model receiving the response on_event adapter observer onResponse / stream observer Execute calling tools on_pre_tool / on_post_tool PreToolUse / PostToolUse beforeToolCall / afterToolCall (core layer)
Why three phases and not four or twelve? The ReAct main loop has exactly three interfaces between "the LLM ↔ the outside world": Assemble is feeding the model, Model is the model speaking for itself, Execute is the model making the outside world do work. Any hook either lands in one of those three or belongs to lifecycle. I tried adding a fourth phase ("the last moment before rendering," "model introspection," and similar), and every time it turned out to be expressible with the existing three phases plus on_event. Three phases isn't a designer's choice; it's determined by the topology of the ReAct loop itself. CC's HOOK_EVENTS count is high because it subdivides lifecycle (session start/end, sub-agent stop, notifications, etc.), not because it invented new phases.
More importantly: hooks are the governance layer's extension interface. When a product wants to add a new structural constraint (as opposed to a new prompt instruction), it hangs a hook. For example, the judgment of "when should the agent stop" shouldn't be written into the system prompt for the model to decide — it should hang off on_pre_compact / on_stop so the kernel decides structurally. Hooks are the product-side extension of structural constraint.
5.3 ModeProfile — Structural Constraints on Modes
CC has plan mode (the actual permission-mode enum is default / plan / acceptEdits / bypassPermissions / dontAsk, so strictly speaking there's no "edit mode," though colloquially people map it that way). CC's plan mode is a system-prompt overlay plus a permission-layer deny as backstop — messages.ts says in plain text that plan mode is active, that no edits may be made, and that this supersedes other instructions, and the permission layer denies all non-read-only tools. In plan mode the model can still see every tool; the calls are just rejected. So, to be honest, CC's plan mode is partly dependent on the system prompt (exactly the "prompt governance" I criticized above) — it just has a permission-layer backstop, so it isn't relying purely on the model's discipline.
Mauri abstracts "mode" into a ModeProfile with six fields: name as the identifier plus five behavioral fields (system_overlay / visible_tool_names / permission_default / compaction_keep_extra / stop_predicate). Of these, visible_tool_names is Mauri's own addition — it physically removes any tool not on the whitelist from the model-visible tool table, achieving "you don't need to write it in the prompt; the model won't call what it can't see." The inspiration came from CC's sub-agent tool filtering (filterToolsForAgent); I extended the same idea from sub-agents to modes. pi hand-rolls this at the harness layer with no explicit ModeProfile data structure.
Empirical load across products: currently 4 fields shipped, 2 reserved. Two of the three products (conversational + video) actually use ModeProfile, and both use the first four fields (name + system_overlay + visible_tool_names + permission_default); the last two (compaction_keep_extra / stop_predicate) are reserved kernel interfaces (their docstrings say "ignored today"), waiting for 2+ products to need the same semantics. CC's plan mode is closer to 2 fields (system-prompt overlay + default permission); the two fields Mauri actually uses beyond CC are visible_tool_names (physical tool removal) and permission_default (mode-level default permission).
5.4 Marker-Preserving Context Compaction — Structural Constraints on Identity Preservation
CC's microCompact finds older tool-result messages and overwrites their content body in place with a placeholder string, preserving the shell (message position, ID, relationships). Compaction makes no model call. Note that microCompact itself is only overwriting — it does no archiving; CC's toolResultStorage is a separate mechanism (storing raw results to disk / an index keyed by tool_use_id) so the UI or later steps can retrieve them by ID. These are two distinct layers in CC; don't conflate them into a single "compact-and-archive." What Mauri borrowed is the microCompact idea — preserve the shell, placeholder the body, zero LLM calls during compaction — and archiving is not done in the Mauri kernel (that's L4–L5, product responsibility).
This principle didn't come from thinking through a single bug; it was forced out by three products each hitting a different compaction anti-pattern.
Conversational product (built on agno): used agno's built-in num_history_runs=3/5 (feed only the last N rounds) plus use_session_summary (auto LLM summarization of older conversation). The first makes early decisions physically vanish; the second summarizes away decision details like tool inputs/outputs and which options the user accepted or rejected. The naive-compaction anti-pattern — each looks reasonable on its own, but stacked together the agent has lost its decision skeleton long before a long session ends.
Visual product: crossing 100k tokens triggered a secondary-model summary — it read fine, the prose flowed, but the decision trail was gone. The main model could only see "Y was done" without the actual results, so it re-invoked tools and re-litigated plans that had already been settled. The summarization-compaction anti-pattern — the root cause of the repetitive behavior isn't a dumb model, it's a summary that tore up the decision evidence.
Video product: mechanically transplanted CC's marker-preserve (replacing old tool results with [content cleared — reference by revision_id]), but copied the letter without understanding why CC does it that way. The result was a quadratic-growth bug: repeated compaction would re-archive messages that had already been marked cleared — O(N²) growth, and silently overwriting the original content. It was only fixed once idempotency for the preserved marker was added at the kernel layer.
Three independent failure modes made it clear: what compaction must protect isn't "information density," it's session identity (original user intent + decision log + tool-call sequence + current mode + accepted artifacts). That's where Mauri's SessionIdentity + MicroCompactPolicy came from.
The principle, stated tightly: compaction is identity preservation, not summarization. "Have the model read the history again and rewrite it" — whether that's the main model or a secondary one — depends on the model voluntarily preserving the decision trail (it won't). Marker-preserving compaction has the kernel physically preserve the decision trail (structurally impossible to lose).
6. L3 Tool Layer — Structural Constraints on the Capability Boundary
Two designs originally invented by CC have spread through the ecosystem.
6.1 Sub-Agent via Tool
CC's Task tool (now internally named Agent): the main loop is unchanged, the sub-agent is an ordinary tool, and the tool's handler starts a sub-loop internally, returning the final text to the parent loop when it finishes. Mauri borrowed this design principle — sub-agent as a tool, not as a separate orchestration entity. pi hand-rolls it at the harness layer.
Why this is a structural constraint: recursion prevention doesn't rely on the model's discipline. The sub-loop's tool registry physically has no sub_agent tool (unless you explicitly turn on nesting with allow_nested=True). The model doesn't need to "know" not to nest — it can't see sub_agent in the tool list.
LangGraph takes the explicit-graph route, but in the real projects I've observed, the graph eventually degenerates into "a main agent plus a few fixed subtasks" — the graph DSL just expresses it once and then it degenerates back. CC's designers probably saw through this early.
6.2 is_concurrency_safe(input) — CC's Original Invention Spreading
CC's Tool.ts:402 has isConcurrencySafe(input): boolean — a callback that takes the input parameters. At runtime, within a single batch of tool calls, the "safe = true" ones are scheduled in parallel and the "safe = false" ones serially.
# The real concurrency predicate for the visual product's generate_image (3 cases):
def _concurrency_safe(input):
if input.get("source_image"): return True # explicit anchor, already resolved
if input.get("__pending_chain"): return False # before the agent layer's late-inject, must be serial
return True # independent variant generation (e.g. "explore 4 styles"), parallelizable
@tool(is_concurrency_safe=_concurrency_safe)
async def generate_image(input): ...
I wrote the simplified version early on — lambda input: input.get("chain_to_previous") is None — and the result was that "4 independent variant calls" got forced serial, which triggered the agent layer's auto-chain to inject image #1's result as the source_image for #2/#3/#4, contaminating the entire cohort. That single bug is what forced the 3-case classification.
pi also has concurrency classification, but in a different form: pi's tools carry a static executionMode?: "sequential" | "parallel" annotation (types.ts:330), defaulting to parallel, and if any tool in a batch is sequential the whole batch falls back to serial (agent-loop.ts goes through executeToolCallsParallel, which uses Promise.all internally). So pi independently converged on "partly parallel, partly serial" tool concurrency classification as well — which actually strengthens the L3 convergence claim. CC's real refinement is making the classification a per-call, input-dependent dynamic decision: the same generate_image tool is parallel-safe for an ordinary call, but a call carrying chain_to_previous that references the previous image must be serial. pi's per-tool static annotation cannot express "same tool, different input, different safety." In a coding-agent setting, per-tool annotation is enough; but in visual generation (same generate_image, where the input determines parallelizability) you need CC's input-aware dynamic predicate.
Why this is a structural constraint: concurrency safety doesn't rely on the model "being careful about side effects" — it's decided structurally at the scheduling layer. The model emits 8 tool calls, and the runtime works out for itself which can run in parallel and which must run serially. The model doesn't know and doesn't need to.
6.3 Tool Repair — Structural Constraints on Input Repair
CC's backfillObservableInput is embedded per-tool: when the model's input is missing context-dependent fields, the handler fills them in before the call. Mauri abstracts this into a unified RepairContext + repair: Callable[[input, ctx], input] framework. pi doesn't have this layer. The structural constraint: the model doesn't need to "remember to always fill in the size field" — the repair function fills it automatically. The model can forget; the structure backstops it.
7. L4 / L5 / L6 — Deliberately Non-Convergent
L1–L3 are the governance layer's hard skeleton. These three layers diverge in every project.
L4 Knowledge (content follows the domain): CC's stack is thickest here — CLAUDE.md + skills + /memory + Auto memory (automatically writing learning notes across sessions), the whole set. pi-mono does this too — automatic AGENTS.md/CLAUDE.md discovery plus the full Agent Skills standard, just without auto-memory. Mauri does no L4 at all — it only exposes a system_overlay slot. The content schema follows the domain: coding needs project structure and coding style; image generation needs a design system and brand guidelines; video production needs character setups and shot grammar. One schema cannot serve those directions.
L5 Surface (form factor follows the product): CC spans the CLI (claude in the terminal), VS Code / JetBrains IDE extensions, Claude Desktop, and cloud-hosted Managed Agents on the web. pi has pi-tui (terminal UI) and pi-web-ui (web chat components). My three products are React + SSE, React + canvas + SSE, and HTTP polling. Mauri does no L5 at all — the kernel only emits events; the product decides how to render.
L6 Ops (determined by the business model): billing mechanisms differ enormously across agent products — subscriptions, credit consumption, wallet-grade ACID (reserve / settle / reverse), passive cost tracking, Stripe / WeChat Pay pre-authorization gates, and so on. The level of abstraction, the concurrency model, and the enforcement strength are all different. Forcing any one of them into the kernel would contort the other products — so the Mauri kernel doesn't bind a business model; hooks expose the event stream and the product wires up its own.
8. L7 Eval Layer — The Evolution Engine
L7 is observability plus evaluation. Its output determines how L1–L6 should change.
CC's internal eval framework isn't public. What pi emphasizes is a faux provider plus a cross-provider test matrix — the discipline most worth borrowing from pi.
pi's packages/ai/test/* has 20+ cross-provider matrix tests that every provider adapter is forced to pass. Nine representative ones:
tokens.test— at least one usage event appears before the stop eventabort.test— mid-stream cancellation produces a stop event within ~1 secondempty.test— an empty user message still produces a proper stop eventcontext-overflow.test— context overflow normalizes to a unified typed error codetotal-tokens.test— total token accounting closes correctly in the stop eventunicode-surrogate.test— a broken Unicode surrogate pair doesn't crash the streamtool-call-without-result.test— a tool call with no result following it still terminates cleanlyimage-tool-result.test— tool results containing images round-trip correctlycross-provider-handoff.test— a conversation started on Provider A can be continued on Provider B
These items are the concrete form of L1's invariants — a correctness proof for the provider abstraction layer. Mauri borrowed the design discipline of pi's matrix and built its own 11-item core matrix (pi's 9 plus three Mauri kernel constraints: image_limits / stop_reason_normalization / usage_accumulation). A provider abstraction without this test matrix is running naked.
Add the faux provider plus main-loop-level evaluation, and L7 makes changes to L1–L6 falsifiable. Without L7 you do architecture by feel; with L7 you do architecture by data.
9. Cross-Comparison of the Three Implementations
Layer Claude Code pi-mono Mauri L1 Protocol Events shaped like the Anthropic Messages API; multiple providers via separate adapter layers such as the Bedrock / Vertex AI SDKs Typed discriminated union + generic StreamFunction<TApi, TOptions> Discriminated union (dataclass + Literal) L2 Main loop query.ts async generator runAgentLoop (async function + callbacks + EventStream wrapper) mauri.kernel.loop async generator L2 Hooks HOOK_EVENTS ~20+ (semantically 3 phases + lifecycle) 4 in the core layer: onPayload/onResponse + beforeToolCall/afterToolCall; compaction/context events at the harness layer 9 (3 phases + lifecycle) L2 Modes plan mode = system-prompt overlay + permission deny No explicit modes (hand-rolled in the harness) ModeProfile, 6 fields: name + 5 behavioral fields (system_overlay / visible_tool_names / permission_default / compaction_keep_extra / stop_predicate) L2 Compaction microCompact (placeholder-string overwrite of tool results) + a separate archival storage mechanism Harness layer MicroCompactPolicy + marker skip L3 Tool contract Tool interface + isConcurrencySafe(input) per-call (unique to CC) Tool type + static executionMode: "sequential" | "parallel" annotation (defaults to parallel) @tool decorator + RepairContext + concurrency classification L3 Sub-agents Task tool (now internally Agent) + filterToolsForAgent tool filtering Hand-rolled in the harness sub_agent_tool factory L4 Knowledge CLAUDE.md + skills + /memory + Auto memory + Managed Agents memory + Dreaming AGENTS.md/CLAUDE.md discovery + full Agent Skills standard (no auto-memory) Not done at all (system_overlay slot) L5 Surface CLI + IDE extensions (VS Code / JetBrains) + Desktop + cloud web pi-tui + pi-web-ui Not done at all (emits events only) L6 Ops No billing layer None Not done at all (event stream exposed via hooks) L7 Eval Internal eval not public Faux provider + 20+ cross-provider matrix items (pi's strongest area) 11-item core matrix + FauxAdapter
All three implementations are structurally identical at L1–L3 — different names, same skeleton. At L4–L6, Mauri chooses to do nothing at all — the microkernel principle. At L7, Mauri learns from pi.
A few observations:
- Mauri's 5 behavioral ModeProfile fields vs. CC's 2-field plan mode — this isn't over-abstraction; it was forced out by testing across directions. Under load from three products, removing any one of the five breaks some product. CC operates in a single domain (coding), so two fields suffice.
- pi exposes 4 hooks at the L2 core layer (
onPayload/onResponse+beforeToolCall/afterToolCall) vs. Mauri's 9 — pi leaves before/after-compaction and token-levelon_eventhooks for the harness layer to attach itself rather than hard-coding them in the core. Mauri fixedon_event(the Model phase) andon_pre_compactinto the kernel: the former because products need token-by-token streaming UI rendering, the latter because long-session compaction needs a hook into the before/after compaction state. This is a core-layer vs. harness-layer tradeoff, not pi "doing less." - L4–L6 are all "not done at all" in Mauri — this is the most important discipline. Putting any of them in the kernel would bind it to one product form, one business model, or one direction, and it would fail across three products.
10. Measured Data from Migrating Three Products
The three products started out with almost no overlap in direction:
Product Direction How it integrates with the Mauri kernel Conversational agent General conversation + rich media output agno shell + Mauri kernel wired in (project metadata is still agno-demo-app; the runner layer calls mauri.kernel.loop) Visual agent Image generation + frontend canvas editing + wallet-grade ACID billing Custom core/ + progressive merge with the Mauri kernel (compaction_bridge bridging + a use_mauri opt-out escape hatch) Video agent Video production pipeline + passive cost tracking Mauri kernel as the primary (already pinned to mauri>=0.3.1; the deepest integration)
Almost no tool overlap. But after the migrations:
- L1 Protocol: all three products consume the same Event union type
- L2 Kernel: the 9 hooks and the stop decision are shared from one source; the
ModeProfilemode stack is used by 2 of the 3 products (conversational + video; the visual product doesn't use it) - L3 Tool: Mauri's
RepairContextis shared; for concurrency-classified scheduling, the video product uses@tool(is_concurrency_safe=...)directly, and the visual product implements a form-equivalentis_concurrency_safe(input)callable in its owncore/tool.py - L4 / L5 / L6: independent in each
Three independent directions sharing one kernel skeleton — all three run on the same Mauri kernel at L1–L3 (even if the depth of integration differs). That fact is itself empirical support for the correctness of the L1–L3 convergence claim.
Here's one concrete story — the quadratic-growth bug in long-session compaction archiving:
The conversational product caught it first, in 50-round long-session telemetry: we observed 579 store_call_count / 480 archive entries where the naive expectation was 44 (11 triggers × 4 archived entries each) — roughly 10× expected. Extrapolating the amplification curve in a companion project: about 64× overhead at 200 rounds, about 227× at 1000. Later the video product re-ran the same class of compaction on a clean version of the wrapper and triggered 1075 store_calls — 2.5× worse than the conversational product's 579. Both blew through the "stable up to 50 rounds" ceiling, forcing a mid-cycle kernel patch. Debugging into it revealed the root cause: on each compaction, the policy would re-archive messages that were already cleared markers, writing to the same revision-number key and overwriting the original content, then emitting a fresh marker. That's O(N²) growth — and it was silently overwriting the originals.
The fix was a one-line idempotency check: if m.content.startswith(_CLEARED_MARKER_PREFIX): continue. After the fix, measurements landed at 46 store_calls = the theoretical lower bound (50 rounds × 1 tool per round − 4 keep_recent = 46 ToolResults to archive, each archived exactly once). But this bug is completely invisible below 50 rounds — none of the three products got that far in early development, until the conversational product ran the first long-session telemetry and exposed it. Being correct by design in short sessions doesn't mean being robust in long ones.
Here's another — hook payload key drift causing silent failure: the conversational product had a set of built-in hooks reading the payload fired by PRE_STOP. A PR introduced a new PRE_STOP fire-site, and the developer wrote the payload with keys like stop_reason / request_user_message / tool_calls_completed_total. But the built-in hooks read last_stop_reason / user_message / tool_calls_completed — the key names didn't match, so the hook fired but the built-ins read nothing at all: a completely silent failure. Type checking can't catch it (a dict accepts any key), the streaming protocol layer can't catch it (the hook does fire), and it only surfaced when users reported "the task never finishes." After fixing it, I wrote up an explicit payload-contract discipline: every hook fire-site must lock the key-name contract with a typed dict. That lesson fed directly back into the L2 kernel design.
There are a dozen or so similar surfaces. Each one is a micro-decision about "does this boundary belong in the kernel or in the product?" The credibility of a convergence architecture doesn't come from three projects looking similar; it comes from boundary stability probed across a dozen-plus surfaces.
11. Five Pieces of Counter-Evidence Where Convergence Failed
To be honest, L1–L3 convergence isn't universal. While investigating the visual product's migration, I found five wrapper responsibilities that are structurally incompatible with the 9-hook vocabulary:
- Same-batch sibling mutation — siblings within one batch of tool calls reading each other's results.
on_pre_toolandon_post_toolare single-call hooks with no same-batch view. - Hooks emitting additional events — a hook wants to emit extra events into the kernel event stream, but
on_post_toolonly returns a single tool result. - Semantic priority ordering — within one batch, a "preamble/explanation tool" must run before the "main generation tool." A parallel/serial binary can't express "priority within the serial set."
- Multi-source resolution at result time — a three-layer resolution of some ID field must happen after the state update but before the domain block is emitted.
on_post_toolis a single-call view. - Transport-error retry-and-continue — retrying on transient errors requires a hook that can intercept adapter exceptions.
on_eventis an observer with no interception semantics.
None of these five converged into the general microkernel. The trigger condition is 2+ products with the same semantics; all of these are currently single-product observations and haven't met the bar.
Why can't they fundamentally be unified into the 9 hooks? The common trait: these hooks want to emit, want sibling coordination, want to change control flow, want to intercept exceptions. Whereas the 9-hook vocabulary is designed as observers + transformers. The two are a full tier apart in capability dimension.
Satisfying these five would require upgrading hooks into "coroutines embedded in the scheduling loop" — but that would take the kernel API from ~50 lines of complexity to ~300, which is the start of the slide into becoming a framework. So my choice for now is to acknowledge that this 20% lives on the product side. CC's HOOK_EVENTS are essentially the same "observer + single-call transformer" shape — they can intercept and modify an individual tool call, but they don't expose emission, sibling coordination, control flow, or exception interception. The boundary of convergence is drawn exactly at 80/20.
12. My Conclusions — Do / Don't Do / Wait for a Trigger, Across 7 Layers
Layer Do Don't L1 Discriminated-union events + adapter contract + cross-provider matrix Provider-private fields in the unified type L2 ReAct async-generator main loop + 9 hooks across 3 phases + ModeProfile with 5 behavioral fields + marker-preserving compaction Letting the model summarize / multi-agent graph DSLs L3 @tool decorator + sub_agent_tool + per-call is_concurrency_safe(input) + tool repair Domain-specific tools in the kernel L4 Expose a system_overlay slot only A skills framework / a CLAUDE.md-style unified format / a long-term memory schema L5 Emit events only; the product renders A unified CLI / TUI / Web abstraction L6 Expose the event stream via hooks Billing / quotas / auditing in the kernel L7 Faux provider + cross-provider matrix + Checker extensibility A single scoring system
Wait for 2+ products with the same semantics before building: the five wrapper responsibilities in §11 (sibling mutation / hooks emitting extra events / semantic ordering / multi-source resolution / retry-and-continue), plus earlier recorded pending items (promoting session identity to a first-class concept / a lazy registration mechanism).
The "don't do" and "wait for a trigger" lists matter more than the "do" list — they're what prevents framework bloat, the failure mode every agent framework eventually falls into. LangGraph / CrewAI / AutoGen were all good frameworks early on; the problem was complexity that kept expanding — new concepts, new DSLs, new abstractions layered on top of each other, until the user's cognitive burden exceeded the benefit.
13. Closing Thoughts
Having run this whole loop — from agno's streaming pain, to the inspiration from openclaw, to reading pi's source, to reading CC's leaked source, to building Mauri and migrating three products onto it — we validated one judgment:
Mauri isn't "I built a framework of my own"; it's "I independently validated a third instance of the same convergence point."
The former is a personal project → "look, I made one too." The latter is a data point → "Python, and across directions, also lands on the same L1–L7 structure = the architecture itself is structural, not TypeScript-specific or coding-agent-specific."
A third independent data point carries far more information than a first personal project.
More importantly, this round crystallized my view on how to govern an agent. Governing an agent isn't prompt optimization, it's harness optimization. The harness is the hard skeleton of the seven layers. Get the L1–L3 structural constraints right and even a weak model runs stably; get them wrong and even a strong model will hallucinate, use the wrong tools, and lose the task's identity.
That's why the community's decompilation of the CC source could advance global agent capability by 20% — what people learned wasn't "how CC writes prompts," it was "how to govern this thing called an agent using structural constraints." pi-mono has been doing the same thing all along (the cross-provider matrix in packages/ai is the purest expression of structural constraint), it's just less well known than CC.
Mauri isn't open source right now — but the L1–L7 model in this article, the cross-comparison of three implementations, the measured data from my three product migrations, and the five pieces of counter-evidence are the entirety of my takeaways from this period. If you're building an agent system, stop and think hard about how to design your harness — it's far more useful than any advice about picking a framework.
If you run a similar exercise and reach a different convergence conclusion — some layer doesn't hold in your direction, or you find an eighth layer that should converge — I want to see your data. The value of a convergence hypothesis isn't that it's "right," it's that it's falsifiable.
One last thing: this thinking was accumulated slowly in the gaps between intense development work. It's not complete, and it's not entirely correct. But writing it down is more valuable than continuing to build with my head down — if a few dozen teams each published their own practice data and counter-evidence, the whole ecosystem might advance another round.
Appendix: Project Links
- I won't link the CC code; it should be easy enough to find.
- github.com/earendil-works/pi — pi (by Mario Zechner / badlogic, creator of libGDX). The original repo was
badlogic/pi-monowith the npm scope@mariozechner/*; the project later moved to the earendil-works organization.
— Mauri (the Python microkernel I wrote) isn't open source for now, but you can absolutely write your own microkernel based on the above. If it's ever open-sourced I'll update this footnote.
If you've read this far and found the L1–L7 model useful, or want to share your own counter-evidence, I'd love to talk — I'm continuously exploring this and would welcome more perspectives from people doing the same work.
Note: this article was written jointly by me and CC. I drafted the content and outline structure; CC filled in details from my development records; I reviewed and revised the final text.
