A human-in-the-loop AI coding agent for your terminal, written in Go.
zerrcode turns a natural-language request into verified changes in your workspace.
It runs an LLM through OpenRouter and lets it call a small set of
workspace tools — read, search, patch, write, delete — inside a sandboxed project directory.
In TUI mode it goes further: every change the model proposes is computed as a real before/after diff from the files on disk, and shown to you for approval before anything is written. Nothing reaches your disk that you have not seen and approved.
- Why zerrcode
- Two Ways to Run It
- The TUI
- Features
- Built-in Tools
- Requirements
- Installation
- Configuration
- Usage
- Testing
- Code Quality
- Security Model
- Known Limitations
- Project Structure
- Architecture
- Documentation
- Roadmap
- Contributing
- License
Most "AI coding assistant" demos hand the model a shell and hope for the best. zerrcode
takes a narrower, more defensible position:
- No shell. Ever. The model can only act through seven explicit, schema-described
tools. It cannot run
go test,npm install, orcurl. The system prompt states this outright and requires the agent to admit what it did not run. - Humans approve every write. Workspace-changing tools are planners: they validate the request and compute the complete result — including a real diff — in memory. The registry then shows that plan to an approval manager. In TUI mode that is you. If no approver is configured, the registry fails closed.
- The diff you review is the truth. Approval diffs are computed from the file's actual current content versus the content that would be written — never from the model's own description of the change. If the model claims to have edited one line and actually truncated the file, the diff shows it immediately.
- Your edits are never clobbered. Each plan snapshots the files it touches. If a file changed while you were reviewing the diff, the write is refused rather than silently overwriting your work.
- Verified completion, enforced in code. After a mutating tool succeeds, the run is
marked unverified. The agent loop will not let the model finish until it has actually
called
git_diffand seen the result. - Workspace confinement by construction. Every path — from any tool — passes through a
single shared gate that rejects lexical
..escapes and symlink escapes, before any filesystem call.
The result is an assistant that is deliberately quieter and more honest than a shell-wielding agent, and that is safe to point at a real repository.
One binary, two modes.
| TUI mode | REPL mode | |
|---|---|---|
| Command | zerrcode -tui |
zerrcode |
| Interface | Full-screen Bubble Tea chat UI | Classic line-based prompt |
| Change approval | Interactive — real diff, y / n |
Automatic (AllowAll) |
| Tool activity | Rendered in the chat as live events | Not printed (assistant text only) |
| Cancel a running turn | Ctrl+C / Esc |
Not supported |
| Best for | Careful, review-driven work | Scripting, piping, quick edits |
Both modes require an OpenRouter API key.
zerrcodeexits immediately withOPENROUTER_API_KEY is not setif the key is missing. See Configuration.
-tui launches a full-screen terminal interface built on Bubble Tea v2.
zerrcode · myproject ● ready
──────────────────────────────────────────────────────────────────────────────
zerrcode
I'll rename the variable and update the
callers.
⚡ edit_file {"patch":"--- a/main.go..."}
↳ modified main.go
──────────────────────────────────────────────────────────────────────────────
❯ Send a message...▌
esc quit · enter send · ↑/↓ scroll · /help · /clear
When the agent wants to change a file, the UI switches to an approval card showing the actual diff:
⚠️ Proposed edit_file (2 file(s) changed):
➜ modified main.go
created internal/tools/new.go
--- Diff preview: main.go (File 1 of 2, [Tab] to switch) ---
@@ -12,7 +12,7 @@
func main() {
- x := 1
+ count := 1
⚠ Approve changes? [y] Yes · [n] No · [d] Toggle Diff · [Tab] Next File
| Key | Context | Action |
|---|---|---|
Enter |
Composer | Send the message |
Esc |
Composer | Clear the input |
Esc |
Working | Cancel the running turn |
Esc |
Idle | Quit |
Ctrl+C |
Working | Cancel the running turn |
Ctrl+C |
Idle | Quit |
y / Y |
Approval | Approve the change |
n / N |
Approval | Reject the change (nothing is written) |
d / D |
Approval | Toggle the diff preview |
Tab |
Approval | Cycle to the next affected file |
↑ / k |
Viewport | Scroll up (pauses auto-scroll) |
↓ / j |
Viewport | Scroll down |
PgUp / PgDn |
Viewport | Scroll by five lines |
| Command | Action |
|---|---|
/help |
Show the available commands |
/clear |
Clear the conversation view |
/exit, /quit |
Quit the application |
- Conversational agent — a REPL or TUI chat that keeps full conversation context across the session.
- Tool calling — seven workspace tools, each with its own JSON Schema, discovered dynamically from the registry.
- Streaming responses — model output is streamed to the terminal token by token.
- Bounded agent loop — hard cap of 25 model round-trips per turn (configurable), with
contextcancellation propagated through every layer. - Verified completion — the agent is forced to read
git diffbefore it can claim the work is done. - Honest reporting — the system prompt separates Done and observed from Not run / not verified, and forbids claiming any command was run without a matching tool result.
- Full-screen Bubble Tea interface — header bar, scrollable chat viewport, input composer, and a contextual footer.
- Interactive change approval — every workspace-changing call shows a real
before/after diff for you to approve (
y) or reject (n). - Multi-file diff review — cycle through every affected file with
Tab. - Real-time streaming — assistant responses stream token by token into the chat.
- Turn cancellation —
Ctrl+CorEsccancels the in-flight turn. - Live event feed — tool calls, results, rejections, and verification nudges render as they happen.
- Auto-scroll — follows new content; manual scrolling pauses it.
- Workspace sandbox — lexical and symlink-aware path confinement on every operation.
- Optimistic concurrency guards — if a file changes while you are reviewing an approval, the write is refused rather than silently overwriting your edits.
- Fail-closed approval — with no approver configured, the registry refuses to change the workspace at all.
- Strict input validation — unknown JSON fields, trailing values, control characters,
absolute paths,
..segments, and.gitcomponents are all rejected.
Tools describe themselves — name, description, and JSON Schema live next to the code that parses the arguments, so the two cannot drift apart.
| Tool | Mutates | Description |
|---|---|---|
read_file |
— | Read the contents of a file within the workspace. |
list_files |
— | List files and directories in a workspace directory (sorted, non-recursive). |
search_code |
— | Literal-text search across files (wraps grep -rnIF, flag-safe). |
git_diff |
— | Read-only: changed files plus the diff against HEAD. |
write_file |
✅ | Create a file, or overwrite an existing one. |
edit_file |
✅ | Apply a multi-file unified diff — all-or-nothing. |
delete_file |
✅ | Delete a single file within the workspace. |
The three mutating tools implement the Planner interface: Plan validates the request
and computes the complete result in memory, and Apply performs the write once — and only
once — the plan has been approved and re-verified against the filesystem.
See docs/tools.md for the exact input schema, validation rules, and
failure behaviour of each tool.
| Requirement | Needed for | Notes |
|---|---|---|
| Go 1.27+ | Building | See go.mod for the pinned toolchain. |
| OpenRouter API key | Both modes | Set OPENROUTER_API_KEY. The program exits without it. |
| git | git_diff |
Also required for the workspace to be verifiable. |
| grep | search_code |
Literal search only. |
| True-color terminal | TUI mode | 24-bit color. REPL mode works anywhere. |
git clone https://github.com/Aneeshie/zerrcode.git
cd zerrcode
go mod tidy # install dependencies
go build -o zerrcode . # build the binaryOr run it straight from source without building:
go run . -tuiConfiguration is environment-based. Create a .env file in the project root (it is
git-ignored):
OPENROUTER_API_KEY=your_openrouter_api_key_here| Variable | Required | Description |
|---|---|---|
OPENROUTER_API_KEY |
✅ | Authenticates every request to the OpenRouter API. Required in both TUI and REPL mode — zerrcode prints OPENROUTER_API_KEY is not set and exits if it is missing. |
The key may also be exported in your shell environment; .env is loaded first and the
process environment wins if both are set.
Never commit your API key.
.envis already listed in.gitignore.
The default model is minimax/minimax-m3.1-flash:free. It is set in
internal/client/openrouter_client.go (convertToChatRequest) and can be changed to any
model available on OpenRouter.
Run the binary from the root of the project you want to work on — the workspace root is the current working directory, and every tool is confined to it.
./zerrcode -tuiType a request and press Enter. When the agent proposes a change, review the diff and press y to approve or n to reject.
./zerrcodezerrcode — type 'exit' to quit.
zerrcode > list the files in the current directory
zerrcode > read main.go and explain what it does
zerrcode > create hello.go that prints Hello, World
zerrcode > rename the variable x to count in main.go
zerrcode > exit
Goodbye!Press Ctrl+D to quit as well. Note that REPL mode prints only the assistant's text —
tool activity is visible in TUI mode.
Testing is a first-class part of this project, not an afterthought. The suite is 190 test cases across 71 test functions, all of which pass.
| Package | Statement coverage | Test functions | Notes |
|---|---|---|---|
internal/diff |
100.0% | 5 | Line classification, binary handling, create/delete, no-newline-at-EOF, round-trip against gitdiff. |
internal/agent |
95.0% | 18 | Every branch of the loop, the verification gate, rejection handling, and the error paths. |
internal/tools |
78.3% | 40 | All tools, all path-confinement and argument-validation paths. |
internal/tui |
38.0% | 8 | Model interactions: typing, streaming, approval, rejection, slash commands, resize. |
internal/client |
0.0% | — | Thin SDK adapter; exercised end-to-end against the live API. |
internal/permissions |
0.0% | — | Contract only (Manager, AllowAll, DenyAll). |
main.go |
0.0% | — | Flag parsing and REPL/TUI wiring. |
| Total | 56.7% | 71 | Across the whole module. |
go test ./... # run everything
go test ./... -v # verbose, with subtest names
go test ./... -race # race detector
go test ./... -cover # per-package coverage
go test ./... -coverprofile=cov.out && go tool cover -html=cov.out # HTML reportThe suite is written against real behaviour, not mocks, wherever the behaviour under test is a filesystem or git behaviour.
Every tool is tested for the failure modes that matter when code runs on model output:
- path traversal (
.., deep traversal, absolute paths) and symlink escapes - missing files, directories passed where a file is expected, and vice versa
- empty and blank arguments
- malformed JSON, unknown fields, and multiple JSON values in one call
- assertions that a rejected call leaves the workspace untouched, including files outside it
Tools are exercised against real temporary directories and real git repositories, because symlink resolution and file permissions are exactly what is being verified.
The agent loop is tested with a scripted fake client and fake tools, covering:
- the normal loop, and multiple tool calls in a single turn
- tool errors being forwarded to the model instead of aborting the run
- unknown tool names being reported back to the model
- the iteration cap (
ErrMaxIterations) stopping a runaway tool-call loop - context cancellation stopping the run mid-flight
- the verification gate: that it fires after a change, that it does not fire for read-only runs or for failed changes, that a later change makes an earlier verification stale, that it can be disabled, and that it gives up after the nudge limit
- rejection handling — a user-rejected change is reported to the model with a distinct status and never marked as a workspace change
The TUI is tested as a state machine, not by screenshotting: typing and backspace, quit, agent-done, token streaming, the full approval flow, rejection, slash commands, and window resize.
The diff library is tested by round-trip property test. Over 300 randomized
before/after pairs, the diff it produces is parsed and applied back by the independent
go-gitdiff parser, and the result must reproduce the intended content byte for byte —
which catches classification bugs a few hand-written cases would miss.
The system prompt is tested as a contract. TestSystemPromptStatesKeyRules asserts
that the prompt still contains each rule the product depends on (no shell, never claim
unverified work, keep the two lists separate, the edit_file retry rule, and the
git_diff verification rule).
Tests degrade gracefully. Tests that require grep or git skip themselves with a
clear message when the binary is unavailable, so the suite stays green on minimal
environments.
See docs/decisions.md for the reasoning behind
this testing strategy.
The codebase is kept clean by the standard Go toolchain, and the checks are part of the normal workflow:
gofmt -l . # formatting: no output == all files formatted
go vet ./... # static analysis: no output == no findings
go test ./... # tests: all packages passCurrent status: gofmt clean, go vet clean, all tests passing.
zerrcode can modify your files, so filesystem access is constrained in layers.
Workspace confinement. Every tool resolves paths through resolveInWorkspace, which
rejects paths that escape the root lexically (../../etc/passwd) and after resolving
symlinks — a symlink inside the workspace pointing outside it is refused. Only the
existing prefix of a path is resolved, so creating new files still works.
Stricter validation for model-supplied paths. edit_file (every path in a patch) and
git_diff additionally reject absolute paths, any .. segment, any .git component,
control characters including NUL, and paths over 4096 bytes.
Approval gate. Registry.Execute is the only path from a model's tool call to a tool,
and it is where approval is enforced. A Planner tool is planned first, the resulting
changes (with real diffs) are handed to the permissions.Manager, and only an approved
plan is applied. With no approver configured, the registry fails closed with
ErrNoApprover — it never silently assumes consent.
Real diffs, computed from disk. Approval diffs come from the file's actual current content and the content that would be written. The model's own description of the change is never what you are shown.
Optimistic concurrency. Every plan snapshots the files it touches. Plan.Apply
re-checks those snapshots immediately before writing; if anything changed in the interim
the write is refused with a clear message.
All-or-nothing edits. edit_file validates and computes every file in memory before
writing anything, so a failed hunk changes nothing.
No clobbering. A create or rename may not overwrite an existing file, and a path may appear only once per patch.
Strict inputs. JSON arguments are decoded with DisallowUnknownFields and trailing
values are rejected.
Read-only git. git_diff passes only fixed arguments to git — never raw model text —
and never writes to the repository.
Bounded work. The agent loop has an iteration cap, tool output is capped
(search_code 32 KB, git_diff 64 KB), and edit_file is limited to 50 files per patch.
This project is honest about its edges. The full list with reasoning is in
docs/decisions.md.
- Not a hardened sandbox. The symlink checks are time-of-check / time-of-use: a symlink could be swapped between the check and the write. Concurrent filesystem changes and filesystem permissions need careful consideration before running against untrusted workspaces.
.gitprotection is partial. It applies toedit_fileandgit_diffonly —write_fileanddelete_filedo not apply it.- The approval gate protects writes, not reads. Read-only tools (
read_file,search_code) can read anything inside the workspace, including secrets you have not meant to expose to the model. - REPL mode has no approval gate. It runs with
permissions.AllowAll{}, so every change is written without review. Use-tuiwhen the workspace matters.
edit_filephase 2 is not transactional. Validation and computation happen all-or-nothing, but the write phase is sequential: a mid-write failure (permissions, disk full) leaves earlier files written. The error reports which ones.edit_fileformat detection is per patch. A patch mixing plain anddiff --gitsections is treated as git format throughout; in a plain diff with noa//b/prefixes, a real top-level directory namedaorbwould be mis-stripped.- Binary patches are unsupported by
edit_file, and binary files render as an opaque "binary files differ" marker in the TUI rather than a line diff. write_fileis not atomic (no temp-file-and-rename), andread_filehas no size limit — very large files are returned in full.git_diffdoes not show the contents of untracked files. They appear in the changed file list, butgit diffhas nothing to show for them.search_coderequiresgrepand is literal-only — it does not support regular expressions.
- Prompt rules are guidance. Only the iteration cap and the verification nudge are
enforced in code. The agent can be required to call
git_diff, but it cannot be forced to judge the diff correctly. - Verification is best-effort. After
MaxVerificationNudgesreminders (default 2) the agent returns anyway rather than looping forever. - The agent has no shell. It cannot run tests, builds, linters, or formatters. If you want those run, you must run them — and the agent is instructed to say so rather than guess.
- A model can still talk nonsense. The prompt asks for honest reporting; it cannot guarantee it.
- The TUI needs a true-color terminal. The theme uses 24-bit ANSI. On terminals without it, colours degrade but the interface still functions.
- An unanswered approval blocks the turn. The approval prompt waits on the agent's tool goroutine until you answer or the turn is cancelled. There is no timeout.
- REPL mode cannot interrupt a running request. The REPL uses a background context with no cancellation path; only the TUI can cancel a turn mid-flight.
- No session persistence. Conversation history lives in memory and is lost on exit. There is no resume, no transcript export, and no checkpoint.
- One workspace per process. The workspace root is the current working directory. There is no way to point the agent at a different directory at runtime.
- TUI coverage is the thinnest in the project (38%). The state-machine transitions are well covered; the rendering layer is not.
- The model is a compile-time constant. Changing it means editing
internal/client/openrouter_client.goand rebuilding — there is no env var or flag. internal/clienthas no unit tests. It is a thin adapter over the OpenRouter SDK and is covered end-to-end against the live API instead.
zerrcode/
├── main.go # CLI entry point: -tui flag, REPL/TUI wiring
├── go.mod / go.sum
├── LICENSE # MIT
├── README.md
├── docs/
│ ├── architecture.md # components, agent loop, approval flow, security model
│ ├── decisions.md # design decisions, trade-offs, limitations, change log
│ └── tools.md # per-tool reference: inputs, validation, failures
└── internal/
├── agent/
│ ├── agent.go # bounded agent loop, event system, verification gate
│ ├── prompt.go # SystemPrompt (honesty, editing, verification rules)
│ ├── agent_test.go # scripted fake client + fake tools
│ └── prompt_test.go
├── client/
│ ├── client.go # LLMClient interface, Message / ToolCall types
│ └── openrouter_client.go # OpenRouter SDK adapter (streaming + tool calls)
├── diff/
│ ├── diff.go # structured diff computation from real content
│ └── diff_test.go
├── permissions/
│ └── persmissions.go # approval contract (Manager, FileChange, AllowAll)
├── tools/
│ ├── tool.go # Tool, Mutator and Planner interfaces
│ ├── plan.go # Plan/Planner pattern + optimistic concurrency guards
│ ├── registry.go # registry + the approval gate
│ ├── path.go # resolveInWorkspace — shared symlink-aware path gate
│ ├── read_file.go
│ ├── list_files.go
│ ├── search_code.go
│ ├── git_diff.go
│ ├── write_file.go # Planner: approval-gated write
│ ├── edit_file.go # Planner + validatePath, multi-file patches
│ ├── delete_file.go # Planner: approval-gated delete
│ └── *_test.go
└── tui/
├── model.go # Bubble Tea Model (chat UI, approval card)
├── theme.go # ANSI true-color theme, diff rendering
├── approver.go # TUIApprover — permissions.Manager bridge
└── model_test.go
The agent loop, the tools, the approval contract, and the UI are separate packages behind
interfaces. Nothing in internal/agent knows about Bubble Tea; nothing in internal/tools
knows about the terminal.
| Layer | Responsibility | Knows about |
|---|---|---|
main.go |
Chooses a client, registers tools, wires approval, runs REPL or TUI. | Everything — the only place that wires things together. |
internal/tui |
Full-screen interface and the approval UI. | Agent events, permissions.Manager, diff rendering. |
internal/agent |
The loop: call the model, run tools, feed results back, enforce limits and verification, emit events. | The LLMClient interface and the Registry. Nothing provider-specific. |
internal/client |
Translate between internal types and the OpenRouter SDK. | The SDK, Message, ToolDefinition. |
internal/tools |
The capabilities, the Plan/Planner pattern, and the approval gate. | The filesystem, grep, git, permissions, diff. |
internal/permissions |
The approval contract. | diff.FileDiff. Nothing about tools, agent, or UI. |
internal/diff |
Structured diff computation. | go-udiff. Nothing else. |
- Build tool definitions from the registry and send the conversation to the model.
- If the model requested tools, execute each one through the registry — which plans,
diffs, requests approval, and applies. A tool error becomes
Tool call failed: <error>; it never aborts the run. - If the model returned a plain answer, the run may end — unless the workspace was
modified and
git_diffhas not been called since. In that case the agent appends a verification reminder and loops (up toMaxVerificationNudges, default 2). MaxIterations(default 25) caps model round-trips per run.
The agent emits Events (EventAssistant, EventToolStart, EventToolResult,
EventVerify) instead of printing, so the same loop drives the REPL, the TUI, or a
headless service.
Full details, including Mermaid diagrams of the request lifecycle, the agent loop, and
the approval sequence, are in docs/architecture.md.
| Document | Contents |
|---|---|
docs/architecture.md |
Components, the request lifecycle, the agent loop, the approval flow, the system prompt, and the security model. Includes Mermaid diagrams. |
docs/tools.md |
Exactly how each tool works: inputs, validation, output, failure behaviour, and limitations. |
docs/decisions.md |
Every significant design decision with its context, reasoning, and trade-offs; known limitations; a chronological change log. |
- OpenRouter client with streaming and tool calling
- Tool registry with self-describing JSON Schemas
- Symlink-aware workspace path confinement
- Multi-file, all-or-nothing
edit_file - Read-only
git_diffand the enforced verification gate - Honest-reporting system prompt
- Bounded, cancellable agent loop
- Full-screen Bubble Tea TUI with interactive diff approval
- Plan/Planner pattern with optimistic concurrency guards
- Fail-closed approval gate in the registry
- Structured diff computation from real file content
- Comprehensive test suite
- Session persistence — resume a conversation across runs
- Configurable model and temperature via environment or flags
- Additional LLM providers behind the
LLMClientinterface - Token / cost accounting per session
- Undo / rollback of an applied change without git
- Hardening the path gate against time-of-check / time-of-use races
Contributions, bug reports, and suggestions are welcome.
- Fork the repository.
- Create a branch for your change.
- Add tests that cover it — the suite is expected to stay green,
gofmt-clean, andgo vet-clean. - Update the documentation if you change behaviour.
- Open a pull request describing the change and the reasoning behind it.
Design decisions and their trade-offs are documented in
docs/decisions.md; if you are about to make a significant
architectural change, that file is the right place to add the rationale.
Released under the MIT License.
Copyright © 2026 Aneesh.