Coding agent architecture is mostly hidden from you. You use Claude Code or Cursor or Codex every day, you have opinions about which one is better, and you have almost no idea what is happening inside. The interesting parts are closed. The open parts are usually so large that reading them is a project, not an afternoon.
Coding agent architecture is the structure connecting a model provider, an agent loop, and a frontend through a shared event vocabulary. It matters because the boundary placement determines whether swapping a model or adding a new interface requires rewriting the core. Unlike a direct API integration, the contract is a typed event stream rather than function calls, which isolates the model-facing logic from every surface that renders output.
So I went looking for a small one.
Tau is a terminal coding agent. 225 files, roughly 110 of them Python, where a production agent would have thousands. I pointed a knowledge graph at it expecting to skim the entry points, get the gist, and move on. I ended up reading the whole thing in an afternoon, and then reading it again because the second pass showed me something the first one missed.
The system turns on a single idea, and once you see it, you cannot unsee it in any other agent you look at.
The event stream is the contract. Everything else just plugs into it.
Table of Contents#
- The three packages, and the seam the README glosses over
- The narrow waist
- The loop fits in one function
- Providers plug in structurally
- Tools are an isolation boundary
- Two frontends, and the proof
- What the graph found that I was not looking for
- What to steal
- FAQ
How are the three packages structured, and where does the boundary actually break?#
Tau splits into three packages, and the split is the first thing worth understanding.
tau_coding is the application. CLI, slash commands, tools, session handling. This is the part a user touches.
tau_agent is the portable brain. Events, the loop, the harness, messages, tool definitions, session state. In theory you could lift this package out and build something that is not a coding agent at all with it.
tau_ai is the model adapter layer. Anthropic, OpenAI-compatible, OpenAI Codex, Google, Mistral, and a fake provider for tests.
The README sells you a clean one-directional chain. tau_coding into tau_agent into tau_ai. Layers stacked neatly, arrows pointing one way, the diagram every architecture doc draws.
Two thirds of that is true. I want to be precise about the other third, because it is the part I got wrong on my first read.
What genuinely holds#
The core is uncontaminated. tau_agent contains zero references to tau_coding. Zero references to Textual, the TUI framework. Zero to Rich. Zero to Typer. I went looking for all four, fully expecting a leak, because that leak is in almost every codebase that advertises a portable core.
There is nothing.
That is rarer than it sounds. Most projects that claim a reusable core have quietly let the UI bleed into it by version three. Somebody needed a spinner during a long tool call. Somebody wanted a progress bar. And now the "framework-agnostic" package imports a terminal rendering library, and the portability claim is a lie that nobody has gotten around to deleting from the README.
Tau has not done that. If you steal one thing from this repository, steal the discipline.
What does not hold#
The tau_agent and tau_ai boundary runs both ways.
Three tau_agent modules import from tau_ai, because loop.py and harness.py need ModelProvider and the provider event types. Ten tau_ai modules import straight back, because every single adapter needs AgentMessage and AgentTool. That is 3 arrows in one direction and 24 individual imports coming back the other way.
The two packages are co-dependent. They are not stacked.
This is not a bug, and I want to be fair about why. The message and tool vocabulary has to live somewhere both sides can see it. Tau puts it in tau_agent instead of inventing a fourth shared-types package, which is a defensible trade, and arguably the right one for a codebase this size. The module graph stays acyclic, so Python never chokes on the import. tau_ai.provider only reaches for the leaves, and only tau_agent.loop reaches back.
But "each arrow points one way" is a simplification. If you are reading this repository to learn from it, that seam is the one I would stare at longest, because it is the exact place where a growing codebase starts to hurt. The day somebody needs a provider-specific type inside the agent core is the day this trade goes bad.
This is also the argument for reading a codebase as a knowledge graph rather than as a directory tree. The directory structure said one thing. The import graph said another. I trust the import graph.
What is the narrow waist in a coding agent, and why does it matter?#
Here is the entire thesis in one shape.
Six model providers funnel into a single fourteen-member event union. That union fans back out to four unrelated frontends. Nothing else crosses the boundary.
It is an hourglass. All of the variety lives at the two ends, and the middle stays deliberately, almost aggressively skinny.
The fourteen events break into four groups:
Lifecycle. agent_start, agent_end, turn_start, turn_end.
Message. message_start, message_delta, message_end, thinking_delta.
Tool. tool_execution_start, tool_execution_update, tool_execution_end.
Control. retry, queue_update, error.
That is it. That is the entire vocabulary the system speaks. Every one of them is a Pydantic model with extra="forbid" set, which means a provider cannot smuggle an undeclared field through the waist. If Anthropic ships a new streaming field tomorrow, the adapter has to decide what it means and map it into this vocabulary, or drop it. The core never learns a new word by accident.
The file that holds all of this is 134 lines.
Add a seventh provider and nothing in the middle moves. Add a fifth frontend and nothing in the middle moves. That is what a narrow waist buys you, and it is the same property that made TCP/IP survive forty years of hardware and application churn while everything above and below it was replaced.
The fake provider is the tell#
Look at the provider list again: Anthropic, OpenAI-compatible, OpenAI Codex, Google, Mistral, and fake.py.
The fake provider is not a test helper hiding in a tests/ directory. It sits in the provider package like any other adapter and satisfies the same interface. Which means the test suite drives the entire agent loop, end to end, with no network and no API key.
That is not a testing convenience somebody bolted on in month six when the CI bill got embarrassing. It falls out of the design for free. You only get it if you drew the boundary correctly the first time, and it is the single strongest signal in the repository that the author knew what they were doing. Most teams discover they need this after their eval suite has already been written against live API calls, and by then retrofitting it is a rewrite.
How does the entire agent loop fit into a single function?#
run_agent_loop() is a single async generator, and the whole cycle fits on one screen.
Stream from the provider. Translate each provider event into its corresponding agent event. Then ask the assistant message exactly one question.
Did you ask for tools?
Yes means execute them, append the results to the message list, go around again. No means drain any queued steering messages, and if there are none, we are done.
That is the entire control flow of a coding agent. Not a simplification of it. The actual thing, in 276 lines.
Two decisions inside it are worth slowing down for.
The loop is stateless#
The messages list belongs to the caller. The loop just appends to it.
This sounds like a footnote. It is not. It is the small, unglamorous decision that lets the harness own transcript state, and session persistence, and branching, without the loop ever learning that a harness exists. Tau ships append-only JSONL sessions with branching support, and the loop knows nothing about any of it.
Every agent framework I have worked with that made the loop own its own state ended up with a second, competing state system bolted on the outside once somebody needed to resume a session or fork a conversation. Then you have two sources of truth about what the model said, and they disagree at exactly the moment you need them not to. This is one of the quieter causes of the observability gap in production agents. The trace and the transcript drift apart because they were never the same object.
while ... else is doing real work#
There is a Python detail in the loop that is easy to skim past. The turn loop uses while ... else, and the else branch fires only if the loop exhausted its iterations, never when it breaks.
Which means the max-turns error is structurally impossible to trigger by accident. If the agent finished cleanly, it broke out, and the error path is unreachable. If the agent ran out of turns, the loop exhausted, and the error fires. There is no counter to check, no flag to forget to set.
It is a small thing. But small things like this are what separate a codebase you can trust from one you have to defend against.
How do providers plug in without a registry or base class?#
provider.py is thirty-four lines. It defines ModelProvider as a Protocol with exactly one method:
class ModelProvider(Protocol):
def stream_response(
self, *, model, system, messages, tools, signal
) -> AsyncIterator[ProviderEvent]:
...
Six adapters satisfy it. Not one of them inherits from anything.
No base class. No registry. No decorator. No @register_provider("anthropic") line that you will forget to add, and no plugin discovery mechanism that fails silently when your entry point is misspelled. Python's structural typing means the adapter satisfies the protocol by having the right shape, and the type checker verifies it at build time.
Adding a seventh provider means writing one class with one method. You never touch the loop. You never register anything. You never read a wiki page about the plugin lifecycle.
Compare this to the registration ceremony in most agent frameworks, where adding a model means touching a factory, a config schema, an enum, and a settings file, and then discovering at runtime that you missed one. The ceremony exists to give the framework a place to hang features. Tau just does not have those features, and is better for it.
Why are tools an isolation boundary rather than just a plugin system?#
Tools follow the same pattern. An AgentTool is a frozen dataclass holding a name, a description, a JSON schema, and an async executor. ToolExecutor is a Protocol. That is the whole extension model.
But the interesting part is not the shape. It is what happens when a tool fails.
The loop wraps every tool execution in a bare except Exception, catches anything the tool throws, and converts it into a failed ToolResult that gets appended to the message list like any other result.
A broken tool cannot take the agent down. It produces a turn where the model gets told the tool failed, and the model deals with it. Retries with different arguments. Tries a different approach. Reports the failure to the user in plain language.
That is exactly what you want, and it is not what most people build. The instinct is to let the exception propagate, because swallowing exceptions feels like bad engineering. And in most code, it is. But an agent loop is not most code. The model is a participant in error handling, not a bystander to it, and the only way it can participate is if the failure arrives as a message it can read rather than a stack trace that kills the process.
Get this wrong and every flaky tool call becomes a crashed session. I have seen production agents with a 20 percent session failure rate that traced back entirely to one tool that threw on malformed input, and the fix was six lines of exception handling in the loop.
How do two different frontends prove the architecture holds?#
This is where the architecture stops being a claim and becomes a demonstration.
Tau has two completely different frontends. The interactive one is a Textual TUI, and its adapter is 100 lines:
class TuiEventAdapter:
def apply(self, event: AgentEvent):
if isinstance(event, MessageDeltaEvent):
self.state.assistant_buffer += event.delta
return
if isinstance(event, ToolExecutionStartEvent):
self._flush_assistant_buffer()
self.state.add_tool_call(event.tool_call)
return
if isinstance(event, ErrorEvent):
self.state.error = event.message
...
The non-interactive one is a 27-line Protocol with three implementations:
class EventRenderer(Protocol):
def render(self, event: AgentEvent) -> None:
"""Render one event."""
def finish(self) -> bool:
"""Return whether the run succeeded."""
# plain.py -> text
# json.py -> machine-readable
# transcript.py -> durable log
Both consume the identical AgentEvent stream. Neither one knows the other exists. And the loop knows about neither.
That is what turns "the TUI is one possible frontend" from a slogan in a README into a fact you can verify by reading two files.
It is also why the knowledge graph pulled tui/ and rendering/ out into their own architectural layer, even though they physically live inside the tau_coding package. The imports run tui into tau_coding nineteen times, and tau_coding into tui exactly once. They are consumers. They are not core. The directory structure said one thing and the import graph said another, and the import graph was right.
If you want the same thing in your own system, the requirement is not "build a TUI." The requirement is that your agent emits typed events rather than printing, and that every surface, terminal, web, log, eval harness, is just another consumer of that stream. Once that holds, adding a new surface costs you a file, not a refactor.
What did the knowledge graph find that I was not looking for?#
Two gaps fell out of the analysis that I had not gone looking for, and I want to include them because a teardown that only finds good things is not a teardown.
src/tau_ai/mistral.py has no test coverage at all. Not thin coverage. None. No test file so much as mentions Mistral. Every other provider is exercised through the loop; this one is not.
And branch_summary.py, diagnostics.py, and reload.py have no name-matched test module either.
I have not dug into why. It might be deliberate, a provider added late that nobody uses in anger yet. It might just be the tail end of a busy release. But it is the kind of thing you find in ten seconds with an import and coverage graph, and never find by reading files in the order the README suggests. Which is, in a way, the entire argument for reading code this way.
What should you steal from this architecture?#
Four files. 544 lines between them. That is the whole architecture, and everything else in the repository is scaffolding hanging off this spine: the slash commands, a 6,172-line provider catalog, OAuth, skills, session branching.
| File | Lines | Role |
|---|---|---|
tau_agent/events.py | 134 | The contract: 14 Pydantic event models, each with extra="forbid" |
tau_agent/loop.py | 276 | The cycle: single async generator driving the full agent turn |
tau_ai/provider.py | 34 | The seam: one Protocol, one method, six structural implementers |
tau_coding/tui/adapter.py | 100 | The proof: a frontend consuming the stream the core has never seen |
tau_agent/events.py, 134 lines. The contract. Fourteen Pydantic models, every one extra="forbid", in a single union.
tau_agent/loop.py, 276 lines. The cycle. One async generator. The clearest agent loop I have read.
tau_ai/provider.py, 34 lines. The seam. One Protocol, one method, six structural implementers.
tau_coding/tui/adapter.py, 100 lines. The proof. An event stream consumed by something the core has never heard of.
The transferable lessons are not Python-specific.
Define the event vocabulary before you write the loop. If you find yourself adding an event type to make one frontend happy, the vocabulary was wrong.
Make the loop stateless and let the caller own the transcript. This is what buys you sessions, branching, and replay for free.
Make the fake provider a first-class citizen, not a mock. If your test suite cannot drive the full loop without a network call, your boundary is in the wrong place.
Let tools fail into the conversation instead of into the process. The model is better at handling a failed tool than your exception handler is.
And keep the UI out of the core, ruthlessly, including the day it would be genuinely convenient not to. That day is the whole test.
None of this is exotic. It is the same discipline that shows up in harness engineering and in every well-built multi-agent system I have worked on. The difference is that Tau is small enough that you can see the discipline instead of taking it on faith.
Go read the four files. It will take you an afternoon.
FAQ#
What is a coding agent architecture, in plain terms?#
It is the structure that connects three things: a model provider that streams tokens, a loop that decides when to call tools and when to stop, and a frontend that shows a human what is happening. The architecture is mostly about where you draw the lines between those three, and what is allowed to cross them.
What does "the event stream is the contract" actually mean?#
It means the agent core emits typed events, and that event vocabulary is the only thing any other part of the system is allowed to depend on. Providers translate their own streaming formats into it. Frontends consume it. Nothing reaches around it. Change a provider or add a UI and the middle of the system stays untouched.
Why is a narrow waist better than a rich interface?#
Because variety at the edges is free and variety in the middle is expensive. Six providers and four frontends means twenty-four possible pairings. If every frontend had to know about every provider, you would maintain all twenty-four. With a fourteen-event waist, you maintain ten adapters and the pairings take care of themselves.
Should the agent loop own conversation state?#
No. Let the caller own the message list and have the loop append to it. The moment the loop owns state, anything that needs session persistence, branching, or replay has to build a second state system on the outside, and the two will eventually disagree.
What happens when a tool throws an exception?#
In a well-built loop, the exception is caught and converted into a failed tool result that goes back to the model as a message. The model then decides what to do: retry with different arguments, try a different tool, or tell the user. If the exception propagates instead, one flaky tool takes down the whole session.
Why does a fake provider matter so much?#
Because it is the proof that your provider boundary is real. If a fake adapter can satisfy the same interface as Anthropic and drive the entire agent loop, then your loop genuinely does not care which model is behind it. And as a side effect, your test suite runs with no network and no API key, which changes what you can afford to test.
How do I read a large codebase this way myself?#
Build the import graph before you read any files. Count the edges between packages and compare that count to what the README claims. Where the two disagree, you have found the seam that matters. I wrote about the tooling side of this in Graphify, but the technique works with any dependency analyzer.