A large language model doesn’t perfom any action. It reads text and writes text. That is the whole contract.
So how does an “AI agent” edit your files, run your tests, and call your APIs? It runs a loop around the model. The model asks for an action, the program performs it, and the result goes back into the next prompt. Repeat until the model stops asking.
That loop is short. The code around it is not. This post walks through a real open-sourced one: CodeAlta, an agentic coding CLI written in .NET. We will look at the actual source, name the actual types, and separate the part that is simple from the part that is hard.
CodeAlta is opinionated, and it says so. Its manifesto is eight principles: efficient, transparent, keyboard-first, thread-oriented, provider-agnostic, native .NET, error-aware, pluggable. Two of them drive most of what follows. Provider-agnostic means no model SDK leaks into the core. Native .NET, with a deliberately narrow and auditable dependency graph, is treated as a constraint, not a tagline. That constraint is what makes the code worth reading.
Before diving into the code, here is a screenshot of CodeAlta running over the question:
“Explain how the method SendAsync() works and what it does It is located in file .\src\CodeAlta.Agent\LocalRuntime\LocalAgentSession.cs ln144”
The CodeAlta TUI is very impressive and is built on top of XenoAtom.Terminal.UI. Both CodeAlta and XenoAtom.Terminal.UI projects are primarily developed by Alexandre Mutel (xoofx).
An agent is just a loop
Here is the core of CodeAlta’s LocalAgentSession.SendAsync, cut down to its shape:
|
1 2 3 4 5 6 7 8 9 10 11 12 |
while (true) { var response = await CallModelAsync(conversation, tools); // the LLM call conversation.Add(response.AssistantMessage); var toolCalls = response.GetToolCalls(); if (toolCalls.Count == 0) return; // model is done foreach (var call in toolCalls) conversation.Add(await RunToolAsync(call)); // observe, append } |
Call the model. If it asked for tools, run them and append the results. Loop. If it didn’t, you’re done. This is the ReAct pattern, and it is the entire idea behind “agent.”
You could write this in an hour. Everything else in this post is about the gap between that snippet and an agent you would trust with your repository.
The loop, for real
The real SendAsync method source code is about a few hundred lines across the method and its helpers, not 15. It leans on a handful of collaborators, each with one job:
- Session (
LocalAgentSession) — owns one conversation and drives the loop. - Turn executor (
ILocalAgentTurnExecutor,LocalAgentChatClientTurnExecutor) — performs one round-trip to the model. - Conversation (
LocalAgentConversationMessageand its parts) — the working memory. - Store (
ILocalAgentSessionStore) — the durable, event-sourced history. - Tools (
AgentToolDefinition,LocalAgentToolBridge) — what the model is allowed to do. - Compaction (the
Compactionfolder) — keeps the context window from overflowing. - Steering (
SteerAsyncmethod) — lets you add input to a run that is already going, without stopping it. Like telling a driver “turn left up here” while the car keeps moving.
Several responsibilities orchestrated, the LLM call is one of them. Most of the engineering lives in the other ones.
Before each turn, the session estimates how many tokens the prompt will use. If it is over a threshold, it compacts. Then it calls the model.
If the provider rejects the request because the context is too long, the session catches that specific error, compacts, and retries the same turn once. A naive loop would crash here.
After the model replies, the session checks for tool calls. No tool calls usually means the run is over. But first it drains any steering input that arrived mid-run, and if there is some, it keeps going. With tool calls, it runs each one, records a diff of any files that changed, appends the result, and loops.
Summarizer / Compaction
Interacting with an LLM consumes tokens, and tokens come at a cost. As a result, a key responsibility of any agent is to manage and minimize this usage. Compaction plays a crucial role by preserving essential information in the context while reducing token consumption.
The compaction layer lives in CodeAlta.Agent.LocalRuntime.Compaction/ and shrinks a session’s conversation history when it nears the model context limit, replacing summarized turns with a structured Markdown checkpoint.
The system is split into focused components: LocalAgentTokenEstimator and LocalAgentTokenBudgetResolver decide when compaction is needed and the allowed summary size; LocalAgentCompactionPlanner selects which messages to summarize, keep, or treat as oversized anchors; canonicalization, media stripping, serialization, and chunking are handled by LocalAgentCompactionCanonicalizer, LocalAgentMediaCompaction, LocalAgentCompactionSerializer, and LocalAgentCompactionChunker.
LocalAgentCompactionSummarizer orchestrates recursive summarization and shrink-to-fit passes, delegating LLM calls to LocalAgentTurnExecutorCompactionSummaryExecutor, which wraps ILocalAgentTurnExecutor.ExecuteTurnAsync.
LLM Calls
The model is invoked through three paths, all converging on ILocalAgentTurnExecutor.ExecuteTurnAsync. First, the normal turn (LocalAgentSession.ExecuteTurnWithOverflowRecoveryAsync) streams user requests to the provider and relays responses.
Second, the summarizer (LocalAgentCompactionSummarizer) runs when context is near capacity and issues dedicated model calls to compress history into a checkpoint using a summarization prompt, not a continuation of the chat.
Third, if a context overflow still occurs, the catch handler (IsContextOverflow) triggers CompactCoreAsync and then replays the same turn on the compacted conversation. This can involve the retry path in LocalAgentSession (e.g., post-compact re-execution). A single user request may therefore trigger the main call, summarization calls, and a retry, treating overflow as a recoverable condition.
Composing the prompt
People picture a system prompt as a long string a developer typed. In a working agent it is built per run, from parts. LocalAgentInstructionComposer.Compose assembles three.
First, the system message. Second, a runtime context block it generates on the spot: the date, the OS, the default shell (pwsh on Windows), the working directory, the project roots. Third, the developer instructions.
That third part is where it gets interesting. The composer walks up the directory tree and pulls in AGENTS.md and CLAUDE.md files it finds along the way. So when the agent seems to “just know” your project’s conventions, this is why. It read the file you committed.
Loaded skills get appended too, inside an <active_skills> block. The whole bundle is hashed. If the hash hasn’t changed since last turn, the session doesn’t re-log it.
Prompt engineering at this scale is deterministic assembly, closer to a build step than to creative writing.
Here is what a request sent to the LLM looks like:
Invoking Tools
The prompt also includes a list of available tools and their descriptions, enabling the LLM to decide when to schedule a tool call.
When asking CodeAlta to explain the method SendAsync(), the LLM asks the agent to call the read_file tool, providing the file path along with the offset and limit parameters. The tool is then executed, and the source file content is sent back to the LLM in a new request within the next loop.
MCP Tools?
You can usually extend a coding agent with additional tools through the Model Context Protocol (MCP). For instance, at NDepend we have an open-source NDepend.MCP.Server built on top of the NDepend API. It exposes tools for workspace analysis, code inspection, and automated fixes for .NET projects. With pre-code scanning in place, such tools can help a coding agent reduce AI bias and avoid unnecessary token consumption.
From the LLM’s perspective, MCP tools works the same way as described earlier: the available tools and their descriptions are included in the prompt, and the model can decide which tool to call and with which parameters.
We also document the approach in this tutorial: Developing an MCP Server with C#: A Complete Guide.
Unlike Anthropic’s Claude Code, Copilot CLI and OpenAI Codex, CodeAlta does not currently include an MCP client. It’s worth noting that the tool is still in a pre-1.0 stage, so the architecture and integrations will evolve over time.
Memory of a Conversation
The obvious way to store a conversation is a List<Message> in memory. CodeAlta keeps that list, but it is not the source of truth.
Every user message, assistant reply, and tool result is appended to the store as an event. On load, ReplayConversation rebuilds the in-memory conversation from those events. If a compaction checkpoint exists, replay seeds from the summary and then replays everything after it.
This is event sourcing, a pattern most .NET developers already know from CQRS. The benefits come for free: sessions are durable, they survive a crash, and they resume exactly where they stopped.
CodeAlta Overall Architecture
Now that we have a solid understanding of the orchestration happening in the main loop, here is a diagram of the overall CodeAlta architecture.
The host only ever sees IAgentSession and a stream of events. Everything below the session is swappable.
One LLM call, any provider
Here is the overall CodeAlta architecture at the code level. Unsurprisingly, CodeAlta.Agent is the root project of the solution.
One notable aspect shown in the graph is that there is a separate project for each LLM provider: OpenAI, Anthropic, XAI, Copilot, and Google GenAI.
The session layer never interacts directly with a model SDK. Instead, it depends on the interface ILocalAgentTurnExecutor, whose responsibility is simply to execute one turn. This interface acts as the abstraction layer that makes the different providers interchangeable.
Not all providers implement ILocalAgentTurnExecutor. The Anthropic and Google GenAI providers rely on the interface IChatClient defined in Microsoft.Extensions.AI. They can therefore use the class LocalAgentChatClientTurnExecutor, which implements ILocalAgentTurnExecutor.
IChatClient is a powerful standard interface for interacting with LLMs. We explain its usage in details here: LLM Chat in .NET with IChatClient: The Complete Guide.
The Agent Client Protocol
In the overall graph from the previous section, you can see an important project: CodeAlta.Acp. This project has been removed — see the explanation in this GitHub issue.
ACP (Agent Client Protocol) is a stdio-based JSON-RPC contract for interacting with a fully autonomous coding agent running as its own separate process — such as Claude Code or Gemini CLI. In this model, CodeAlta acted as the host: it forwarded prompts, mediated permissions, and bridged filesystem access, while the remote agent process brought its own reasoning loop, toolset, model access, and authentication. In other words, the intelligence sat entirely on the other side of the socket — CodeAlta no longer called an LLM directly at all.
This was unified under the IAgentSession interface, implemented by both LocalAgentSession and AcpAgentSession, which abstracted the execution model completely — whether the agent was local or remote made no difference to the rest of CodeAlta.
The protocol had clear architectural appeal, but was ultimately removed. The reasons are detailed in the linked issue above.
From one agent to orchestrating many
Finally let’s mention the project CodeAlta.Orchestration. If a single agent turn is one heartbeat, orchestration is the nervous system that keeps a whole organism of them alive at once. CodeAlta lets you run several conversations — “work threads” — in parallel, each talking to its own agent, each streaming events back to the UI while you keep typing. The hard part isn’t talking to a model; it’s doing it many times concurrently without the state dissolving into race conditions.
CodeAlta’s answer is to give every thread a single owner. Instead of sharing mutable state behind locks, each thread processes a serialized stream of commands — send this, steer that, abort, compact — and emits a clean stream of events in return. One writer, one mailbox, no tug-of-war. The runtime turns the messy, asynchronous reality of live agents into something the rest of the app can treat as a predictable feed.
That design buys three things: concurrency that doesn’t corrupt, a frontend fully decoupled from the engine, and uniform handling whether the agent lives in-process or across an ACP socket. Orchestration, in short, is where CodeAlta stops being a chatbot and starts being a workspace.
Conclusion: What separates a good coding agent from a demo
Feedback from the Author
On a reddit post about CodeAlta first public release, its author Alexandre Mutel answers the question: What was the biggest challenge making this project spanning all of the quirks of LLMs / inference providers / tool calling differences?
Answer: Not so many actually, but quite a few challenges:
-
Anthropic API .NET had a few issues for some non-Anthropic standard models: Handle missing streaming thinking signatures or Fix several HasNext() that should rely on response.HasMore
-
I had to implement the more efficient websocket protocol for ChatGPT/Codex subscriptions which is not implemented by OpenAI .NET. I relied on Codex CLI codebase to replicate the behavior, but it was possible to nicely integrate with the HttpClient/System.ClientModel to configure/plug custom protocol.
-
Google.GenAI / Gemini is completely broken due to unmerged PR here from Google folks. This one is annoying to the point that I wonder if I should not fork/maintain my own API/endpoint wrappers instead on relying on 1st(!) party providers.
-
I still have an unmerged Copilot SDK PR here but I removed support for Copilot SDK and went straight to Copilot API endpoints. The middle-man API is not worth the trouble, and it blocks many scenarios of CodeAlta own harness. Same for Codex app-server, I’m connecting directly to Codex endpoints instead.
Some Chinese provider/models have slight differences in their behavior, so they require a few knobs, but hopefully, it is possible to configure these knobs via the config file. (e.g. developer role not supported by some models)
The biggest challenge for developing this assistant was to go through every single details, and fix/improve them one after the other. I had already something working 2.5 months ago, but it took a lot more iteration to polish, improve the performance, fix provider issues…etc.
As I’m also heavily relying for the TUI on XenoAtom.Terminal.UI, I had to push several improvements/fixes there, and the separate repository/package model complicates a bit the development process, but I think in the end it is for the better, as I have clear dependencies and having to slow down between these dependencies helps to avoid taking bad shortcuts.
In the same post Alexandre also writes: “I have spent 3 months working on this, mornings, evenings, and during my weekends, it is worth > 200 hours of my time“
So what matters?
Highlighted in bold what I consider being an answer to the initial question: What separates a good agent from a toy
At its core, it is not just model choice or clever prompting — it is the amount of real-world failure exposure and the engineering effort spent turning those failures into predictable behavior. Unlike traditional software, agents compose uncertainty across multiple steps, so failures don’t just stack — they compound. The gap between demo and production is earned in logs, not notebooks. One can only imagine the volume of iteration behind systems like Claude Code, Codex, Copilot CLI or Cursor, where every edge case becomes a design constraint.
A demo agent is optimized for the happy path: clean tool outputs, short sessions, ideal model responses. A good agent is designed to survive entropy. It handles long-running sessions, degraded or partial model outputs, tool failures, and context saturation without collapsing — treating the environment as unreliable and building recovery as a first-class concern.
In practice, this means pushing work out of the model and into deterministic tooling — using structured operations like grep instead of asking the model to re-scan large contexts in natural language. A significant portion of CodeAlta’s codebase lives in exactly this layer: the machinery that keeps the system stable when everything around it is noisy, incomplete, or wrong.
Going Further
The CodeAlta github repo provides some interesting documentation.
Also it appears that the source code of Claude Code leaked at the end of March 2026. You can refer to the GitHub project Claude Code Source Analysis to learn more about its internal architecture and implementation details.











Comments: