AI Coding Assistant Five-Layer Architecture: Why the Code Is Readable
This article explains why AI coding assistants, despite introducing model-driven uncertainty, often have unusually readable codebases. It breaks a widely used AI coding CLI tool into five layers: CLI entry, agent loop, tool execution, permissions, and system prompts. It also traces how a single request moves through these layers and provides a practical checklist for applying the same structure to other AI applications.
1. The Problem: Why Systems With Uncertainty Can Still Be Readable
For decades, the core logic of command-line tools has usually followed a predictable path: parse arguments, execute a task, and output a result. Inputs are known, execution paths are defined, and outputs can be predicted.
AI coding assistants break this pattern. They run inside the command line and can read and write files, execute commands, and modify code. However, what happens next is no longer hard-coded. Instead, a large language model decides the next step based on the current context. A single request may turn into a sequence like this:
- The model says, “Let me first look at the project structure,” and triggers a directory read.
- After receiving the result, the model decides that three files need to be changed and initiates three file writes.
- The model then runs tests, finds that two test cases fail, reads the source code, locates the problem, and modifies the code again.
- The loop ends only when the model decides that the task is complete.
There may be several tool calls, a dozen, or even dozens. The code itself does not know when the loop will stop.
Intuitively, such a system should be harder to read. Control flow is determined probabilistically, side effects occur outside the model, and safety boundaries run through the entire process. Yet mature AI coding tools often have unusually well-organized codebases. The secret is not clever code. It is layering: each type of change is constrained to a fixed location, while the rest remains stable.
This article uses a widely used AI coding CLI tool as a reference. The tool is referred to as “the tool” in this article. It can be examined through five layers:
| Layer | Illustrative directory or file | Core responsibility | Problem addressed |
|---|---|---|---|
| CLI entry layer | entrypoints/, main.tsx |
Parse commands, select execution mode, optimize startup | Startup efficiency |
| Agent loop layer | query.ts, QueryEngine.ts |
Use a while loop to drive model calls and tool execution |
Control flow |
| Tool execution layer | Tool.ts, tools/, toolExecution.ts |
Turn model intentions into concrete operations | Side effects |
| Permission control layer | utils/permissions/, permissions/ |
Determine whether each operation is allowed | Safety boundary |
| System prompt layer | constants/prompts.ts |
Dynamically assemble instructions and context for the model | Model input |
These five problems correspond to the main complexity added by AI agents compared with conventional programs. From the outside in, the five layers connect a complete chain: user input → model decision → tool execution → result return. The following sections analyze them one by one.
2. Entry Layer: Never Do Work During Startup That Can Be Skipped
The CLI entry layer is the first interface users see. After the user runs claude, the first code executed is entrypoints/cli.tsx. Its core logic can be simplified as follows:
// entrypoints/cli.tsx (simplified)async function main(): Promise<void> {const args = process.argv.slice(2);// Fast path: --version requires zero imports and exits immediatelyif (args.length === 1 && args[0] === '--version') {console.log(`${MACRO.VERSION} (Claude Code)`);return; // can finish in about ten milliseconds}// Subcommand dispatch: daemon, remote-control, and other modules load only what they needif (args[0] === 'daemon') {const { daemonMain } = await import('../daemon/main.js');await daemonMain(args.slice(1));return;}// Default path: load the full REPLconst { main: cliMain } = await import('../main.js');await cliMain();}
This code answers only one question: what does the user want to do this time? From this question, three design principles emerge.
First, skip anything that can be skipped. Printing a version number should not require loading hundreds of thousands of lines of code and a large dependency tree. The version number is obtained from MACRO.VERSION, injected at compile time, rather than being read from package.json at runtime. As a result, this command does not import any business modules, and cold startup usually takes only around ten milliseconds.
Second, load on demand. Subcommands such as daemon and remote-control have their own modules and are loaded through dynamic import() only when needed. This also brings a readability benefit: the entry file remains only a few dozen lines long. Readers can immediately see what entry points exist and where each one leads, without searching through a huge import list.
Third, start slow operations in parallel. The interactive main program main.tsx often has more than 180 import lines. These declarations may look like simple module imports, but during module evaluation they may trigger side effects such as configuration loading or dependency initialization. main.tsx uses the fact that dynamic import() returns a Promise to split startup into three phases:
// main.tsx (simplified illustration)// 1. Start slow operations first: MDM subprocess and Keychain readconst mdmPromise = startMdmCheck(); // launch subprocess without waitingconst keychainPromise = readKeychain(); // start Keychain read without waiting// 2. While executing 180+ imports, the CPU resolves dependencies and slow operations run in the backgroundimport('./ui.js');import('./tools/index.js');// ... remaining imports omitted// 3. After imports complete, wait for slow operations to finishconst [mdm, keychain] = await Promise.all([mdmPromise, keychainPromise]);
Much startup time is not spent on imports. It is spent on system calls that run alongside imports, such as launching external processes, accessing system credential stores, or querying enterprise device management policies. This code changes those operations from sequential waits into operations that run in parallel with imports. The total time is approximately equal to the slower of the two paths.
All three principles are based on the same judgment: pay the loading cost only when necessary.
3. Loop Layer: One while Loop Supports the Entire Agent Runtime
Once the interactive interface is entered, each user input enters the agent loop layer. The representative files in this layer are query.ts and QueryEngine.ts. The structure can be summarized by the following simplified model:
// query.ts (simplified model)async function* query(input: string, ctx: Context): AsyncGenerator<Message> {const messages = [...ctx.messages, { role: 'user', content: input }];while (true) {// 1. Call the model and stream incremental contentconst response = ctx.callModel(messages);for await (const chunk of response) yield chunk;// 2. When the stream ends, obtain the complete responseconst message = await response.finalMessage();messages.push(message);// 3. If the model did not initiate tool calls, end this conversation turnif (message.stopReason !== 'toolUse') return;// 4. Find all tool calls and pass them to the tool execution layerconst requests = message.content.filter(isToolUse);const results = await runTools(requests, ctx);// 5. Append results to message history and enter the next roundmessages.push({ role: 'user', content: results });}}
This loop reflects the core idea of separation of concerns. The loop layer does only three things: ask the model, feed tool results back, and stream content. It does not care how tools are implemented, how permissions are determined, or how prompts are generated. Those responsibilities belong to other layers. What the loop layer really cares about is the rule of the loop itself:
- Termination condition. When the model returns ordinary text instead of tool calls, the loop naturally exits. This is also the most common ending.
- Exception handling. If the model reaches its maximum output length, the streaming connection breaks, or the user presses Esc to abort, the loop must have a clear and predictable exit path. This avoids infinite retries in abnormal states.
- Round protection. In real products, there is usually an upper limit on tool calls per conversation round, and message history is compressed or trimmed when the context approaches the model window. This controls cost and latency and prevents the model from falling into a loop of repeated modification and repeated verification.
- Streaming output. The function is declared as an async generator. Each incremental text fragment from the model, as well as the start and end of each tool call, can be consumed one by one by the interface. Users can see what is happening in real time instead of waiting for the final result.
Two details are worth noting:
- Message history exists only in the loop layer. The
messagesarray is the agent’s only working memory. Reading this code is enough to reconstruct the conversation state. - Execution behavior is decided by the tool layer. The model can request multiple independent tool calls in one round, such as reading multiple files at the same time. The loop layer only collects all results. It is the tool layer that decides which calls can run concurrently.
A while loop plus a message array forms the entire runtime center of the agent. This is the foundation of this architecture’s readability: to understand how the agent runs, you only need to read one function.
4. Tool Layer: Model Decisions Become Real Actions Here
The loop layer decides which tool to call and what parameters to pass. The tool execution layer performs the actual work. The core files in this layer include the tool interface definition Tool.ts, the tool collection under tools/, and the scheduler toolExecution.ts.
The tool interface is essentially a “contract readable by the model”:
// Tool.ts (simplified)export interface Tool<TInput> {name: string; // tool name used by the modeldescription: string; // usage description written for the modelinputSchema: JSONSchema<TInput>; // parameter structure used by the modelisConcurrencySafe?: (input: TInput) => boolean; // whether it can run concurrently with other callsisReadOnly?: boolean; // whether it is read-only and does not modify any stateasync *call(input: TInput, ctx: ToolContext): AsyncGenerator<...>;}
Five design points support the extensibility and readability of this layer:
- Unified interface and plugin-style integration. Whether a tool reads files, writes files, executes commands, searches code, or fetches web pages, all tools follow the same interface. Adding a new tool usually means adding a module that implements the interface and registering it in the tool set. There is no need to modify the loop layer, permission layer, or prompt layer.
- Organize by capability, and let names act as documentation. Under
tools/, directories are usually divided by capability, such as files, commands, and search. The directory name itself acts as a tool list. When reading any file, you can immediately tell what type of operation it belongs to and whether it produces side effects. - One JSON Schema definition, used in two places. The schema is included in the system prompt along with the tool description, helping the model understand how to fill in parameters. At runtime, it also validates the model’s input. If validation fails, the error information is returned to the model as a tool result, allowing the model to correct the parameters itself instead of interrupting the entire session.
- Tools declare whether they are concurrency-safe. The loop layer does not guess which calls can run at the same time. Instead, tools declare this through
isConcurrencySafe. For example, read-only searches can run in parallel, while writes that depend on previous results should run sequentially. Placing this judgment where tool semantics are best understood keeps the loop layer simple. - Errors are also results. Failed commands, missing files, and invalid parameters are returned to the model as structured error content. The model then decides whether to retry, choose another approach, or explain the situation to the user. The boundary between “failure” and “crash” is clear: any problem from which the model can adjust its strategy is treated as a result. Only process-level exceptions truly interrupt the flow.
toolExecution.ts sits at the choke point of this layer. It receives tool call requests submitted by the loop layer and handles deduplication, concurrency orchestration, and result aggregation. For this reason, it is also a natural place to enforce permission checks. The permission layer described in the next section is integrated here.
5. Permission Layer: Every Operation Passes Through a Gate Before Execution
Because tools can modify files and run commands, a single mistake from the model can cause real damage. The permission control layer, located in utils/permissions/ and permissions/, addresses this problem: before executing any operation with side effects, determine whether it is allowed.
Its relationship to the tool layer is similar to the relationship between system calls and kernel permission checks in an operating system. Programs make requests, and the kernel decides. The permission layer usually outputs one of three decisions:
- Allow: execute directly.
- Ask: pause, show operation details to the user, and wait for approval or rejection.
- Deny: do not execute, and feed the reason back to the model.
It is important to note that feeding the reason back to the model does not mean that anything the model says will be allowed. For operations that repeatedly cross red lines, persistent deny rules can permanently block them, no matter how the model rewrites the request.
The decision logic used by such tools can be abstracted as follows: given a tool name, parameters, a set of rules, and a default mode, output one of three decisions.
// Permission decision (simplified illustration)type Mode = 'default' | 'acceptEdits' | 'bypassPermissions';type Decision = { behavior: 'allow' } | { behavior: 'ask' } | { behavior: 'deny'; reason: string };function decide(tool: string,input: unknown,rules: PermissionRules,mode: Mode,): Decision {// Read-only tools are inherently safe and are allowed directlyif (isReadOnly(tool)) return { behavior: 'allow' };// Deny rules take priority: explicitly forbidden operations cannot be bypassedconst denied = matchDenyRule(tool, input, rules);if (denied) return { behavior: 'deny', reason: denied.reason };// Allow rules come next: the user has explicitly approved similar operations beforeif (matchAllowRule(tool, input, rules)) return { behavior: 'allow' };// In default mode, all write operations require human confirmationif (mode === 'default') return { behavior: 'ask' };// Permissive mode: skip confirmation, but deny rules still applyreturn { behavior: 'allow' };}
This pseudocode reflects several principles of the permission layer:
- Secure by default. Read-only operations are allowed directly to maintain fluidity. Operations with side effects, such as writes and command execution, require confirmation in default mode.
- Deny takes priority. The decision order is: read-only allow → deny rules → allow rules → default mode. Stricter rules take effect earlier, preventing looser rules from accidentally overriding safety boundaries.
- Rules are data. Allow and deny rules exist as data. They can be configured by the user, declared in project-level configuration files, or added dynamically during interaction when the user chooses “always allow for this session.” Adding rules does not require modifying the decision code.
- Rules should be interpreted semantically, not through simple string prefix matching. For example, determining whether a command is read-only cannot rely only on the command name. A command may contain structures such as pipes or command substitutions, so its structure must be parsed before making a decision.
- Loose entry, strict exit. For modes that loosen confirmation, such as “full automatic execution,” a safer approach is to require explicit user activation, while deny rules still remain in effect.
- Decisions are testable. The decision logic does not depend on the interface or the model. Given a tool, parameters, rules, and mode, it produces a deterministic output, making it suitable for unit tests covering edge cases.
To reduce the risk of missed checks, practice often combines two safeguards: centralized decision-making plus defensive validation inside tools. Decision-making is concentrated at the choke point of the execution layer, ensuring that no call can bypass it. Critical tools also perform basic checks internally, such as requiring file writes to remain inside the working directory.
6. Prompt Layer: The “Instruction Manual” for the Model, Assembled at Runtime
The previous five layers determine what the system can do and what it is allowed to do. Whether the model uses these capabilities correctly depends on the instructions it receives. The system prompt layer, located in constants/prompts.ts, assembles these instructions.
Some tools write system prompts as static text spanning thousands of lines. The tool takes a different approach: prompts are assembled dynamically at runtime based on context. Common components include:
- Identity and behavior guidelines. A fixed role definition and explanation of how to work, such as understanding before acting and verifying after making changes.
- Tool usage guidance. Dynamically generated from the tool registry. It describes available tools, parameter meanings, and selection suggestions. When tools are added or removed, this guidance updates automatically.
- Environment and directory structure. Information such as the operating system, current working directory, whether the project is a Git repository, and the project directory tree is filled in based on the actual runtime environment.
- Project conventions. Content from user-level and project-level instruction files, such as code style, testing requirements, and directories that must not be modified. In team collaboration, this part often provides the most valuable context.
- Dynamic state. Runtime information such as the current time and a summary of code changes.
There is an easily overlooked optimization here: the more stable the prompt prefix, the better for cache hits. Major large-model services generally support prompt prefix caching. When the same prefix is requested repeatedly, it does not need to be recalculated, which reduces latency and cost. Therefore, content that changes frequently, such as time or change summaries, should be placed toward the end of the prompt. If such content is inserted earlier, the cache for the entire prefix may be invalidated.
This layer also brings two hidden benefits:
- The prompt becomes testable code. Given a set of environment information, you can assert which sections the generated prompt contains and whether it contains sensitive information.
- Changing behavior does not require changing logic. Adjusting how the model works often only requires editing text in
prompts.ts, without touching the loop or tool code.
7. Putting It Together: One Request Through Five Layers
Each layer is not difficult to understand on its own. What is more important is how they work together. A request such as “help me fix the failing tests” usually moves through the five layers like this:
- Entry layer: The user enters the request and presses Enter. The entry layer has already completed command parsing, mode selection, and module loading. It passes the request and current configuration to the loop layer.
- Prompt layer → loop layer: The loop layer obtains the system prompt and tool descriptions from the prompt layer and sends them to the model together with the user input.
- Model decision: The model returns text, such as “Let me first run the tests,” and several tool call requests. The text is streamed to the interface in real time.
- Tool layer validation: The tool call requests are passed to the tool execution layer, which validates their legality according to the parameter schema.
- Permission layer decision: Running tests is an operation that may produce side effects. The permission layer evaluates the rules. If allowed, execution continues. If confirmation is needed, it waits for the user. If denied, it returns the reason to the model.
- Tool layer execution: The tests finish. The output, including information about failed test cases, is returned as a tool result.
- Result feedback: The loop layer appends the result to the message history and enters the next round. The model then reads relevant source code, modifies code, and requests another test run until all tests pass.
- Loop ends: The model no longer requests tools, outputs a final summary, and the loop exits.
Abstracting this chain produces a one-way dependency graph:
entrypoints/ ──→ QueryEngine.ts ──→ tools/ ──→ utils/permissions/│ │└──→ constants/prompts.ts
The arrows indicate dependency, not direct invocation. The entry layer assembles configuration and passes it to the loop layer. The loop layer calls the tool layer to execute operations and obtains the system prompt from the prompt layer. The tool layer requests a permission decision before execution. The permission layer does not depend on other business layers, and the prompt layer only provides data without producing side effects.
If these capabilities are extended to the cloud, this dependency graph can be implemented further:
- The tool execution layer can run in isolated sandboxes on container platforms.
- Session and execution records can be archived in object storage.
- Loop behavior can be observed through logging services and monitoring alerts.
For example, general cloud capabilities such as containers, object storage, logging, and monitoring/alerting from Baidu AI Cloud can serve as infrastructure for this architecture.
8. Why This Layering Is Readable: Four Benefits
1. Problem diagnosis has a clear entry point. If the user says, “The command did not run,” check the tool layer. If the user says, “It asked for confirmation even though it was allowed,” check the permission layer. If the user says, “The model does not follow the project conventions,” check the prompt layer. If the user says, “It has been running for two hours without finishing,” check the termination conditions in the loop layer. Symptoms map directly to layers, making diagnosis short and clear.
2. Each layer can be tested independently. The permission layer can be tested with pure functions covering rule combinations. The tool layer can be tested with simulated model outputs to verify parameter validation. The prompt layer can be tested with snapshot tests. The loop layer can be tested by injecting a controlled fake model client to verify behavior in each round.
3. Change is isolated locally. Adding a tool only changes the tool layer. Adjusting confirmation policy only changes the permission layer. Replacing the prompt only changes the prompt layer. The impact scope of a change can be predicted in advance. This is one of the most important readability properties of a large codebase.
4. Constraints come before action, not after failure. Termination conditions in the loop layer, parameter validation in the tool layer, and pre-execution decisions in the permission layer all occur before something happens. When readers understand the rules of each layer, they can infer the behavioral boundaries of the system without reading a large number of runtime logs to guess what the system is doing.
9. Applying This to Your Own Project: A Five-Step Checklist
The value of this layering is not in how directories are arranged. It is in the design trade-offs behind it. If you want to apply this pattern in your own AI application or agent project, the following steps may help:
- Draw the dependency graph first, then create directories. Clarify which modules depend on the model, which modules produce side effects, and which modules make decisions. Ensure that dependencies flow in one direction and avoid circular references between the loop layer and the tool layer.
- Define interfaces first, then implement logic. Define the tool interface, permission decision interface, and model call interface first. Specific implementations can be replaced gradually, including switching to a different model.
- Converge safety boundaries into a single entry point. All operations with side effects should pass through the same execution channel and undergo unified permission checks before execution. Do not let each tool implement its own validation.
- Make rules data. Allow rules, deny rules, tool descriptions, and prompt templates should be configurable and testable. They should not be scattered across code branches.
- Pay attention to startup and caching from day one. Load modules on demand at the entry point and keep prompt prefixes stable. The later these optimizations are made, the higher the refactoring cost.
10. Conclusion
The five-layer structure ultimately answers a seemingly simple question: What kind of change is each piece of code preparing for?
- The entry layer prepares for changes in startup scenarios, so it skips work whenever possible.
- The loop layer prepares for the fact that the model may make any decision, so it needs only one stable loop.
- The tool layer prepares for capability expansion, so it uses unified interfaces and organizes tools by capability.
- The permission layer prepares for the possibility that the model may make mistakes, so every operation passes through a gate.
- The prompt layer prepares for context differences, so it is assembled dynamically at runtime.
Layering cannot make the model more deterministic, but it can give uncertainty a place to live. When readers open any file, they can immediately know where it sits in the overall chain and which type of change it is designed to handle.
This is the essence of readability: not short code, but code where each part is concerned with only one thing. AI coding tools take this practice to an extreme, and any team building AI applications can benefit from it.
