Projects — michaelslop.org
MoeCode — a local-only coding agent in Go
← back to projects
2026-08-09 · go, llm, agents, testing

Built with Claude Code. See how these projects were built for what that means in practice.

MoeCode is a terminal coding agent I wrote in Go. It does what you would expect — reads files, edits them, runs commands, holds a conversation about a codebase — except that every model it talks to runs on hardware I own. It speaks to local model servers over their OpenAI-compatible APIs. There is no cloud call, no API key, and no telemetry anywhere in it.

It is not a fork. There is a separate configuration layer for another tool that unfortunately also ended up named MoeCode; that is a different thing that happens to share the name. This is the Go program.

Why build this at all

The honest answer is that I wanted to understand agents by building one rather than by reading about one. Everything interesting about a coding agent lives in the parts nobody demos: what happens when the model claims it edited a file it never opened, what happens when the conversation outgrows the context window, what happens when a tool call comes back malformed. You do not learn any of that from using someone else's agent.

The local-only constraint was the second half of the point. If it runs on my own GPU, then latency, context limits and model choice are all mine to tune, and the cost of a long experimental run is electricity.

Shape of it

Eleven packages, all green under -race:

package coverage what it does
perms 100% permission checks
termtext 93.5% terminal sanitisation
agent 89.9% the tool loop
subagent 88.9% the investigate sub-agent
session 86.9% JSONL persistence, pruning
config 86.6% config and the model roster
tools 77.9% the seven tools and the lazy-load gate
llm 76.3% streaming, XML recovery
tui 75.3% the Bubble Tea frontend
mcpbridge 55.3% MCP client — the weakest, and I know why
cmd/moecode 0% structurally untestable — see below

The test suite runs in about fifteen seconds with -short, which skips everything needing a live model server. A separate live suite is the only part that talks to a real model, so nothing in normal development depends on a GPU being free.

Beyond the unit tests there is a reliability benchmark: fifteen runs of a two-file reading task, scored on correctness, recoveries, repeat flags and fabrication flags. Baseline is 15/15 correct with about four recoveries and zero fabrications. Anything below 15/15 is a regression worth chasing. That benchmark has caught more real problems than the unit tests have.

Two model hosts, and why the tags differ

MoeCode can point at either of two machines: this workstation's GPU, or a second box with its own card. A small script rewrites the config to switch between them.

The interesting wrinkle is that the model tags are not the same on both hosts, and that difference is a real trap rather than a cosmetic one. The Windows Ollama app manages its own server environment and ignores the context-length environment variable — it auto-picked a 256k context for the available VRAM, which promptly spilled onto the CPU and dropped generation to single-digit tokens per second. The fix was Modelfile-derived model tags with the context length baked in, which share the same underlying blobs and so cost no extra disk. The Linux host honours the environment variable properly and runs stock tags.

The consequence: sending a locally-derived tag to the other host is a 404, per model. At one point the config loader inherited the wrong roster when the model list was omitted, which produced exactly that failure in a way that looked like a network problem. It is fixed and tested now, and any change to config handling has to keep four specific keys stable, because the host-switching script writes all four.

Measured on the workstation card: the derived-tag coding model runs at about 239 tokens/sec at 64k context, fully on the GPU. A higher-quality quantisation served through llama.cpp managed about 214 tok/s — near parity at better quality, which was interesting enough to wire up as a third option but not a clear enough win to promote.

Things I got wrong, and what they taught me

This is the part worth reading.

A cooperative fake tests the plumbing, never the instruction. Conversation compaction had unit tests from early on. It still dropped standing instructions in three out of three live runs — because the fake model in the tests never read the prompt, it just returned what the test wanted. Any code whose correctness depends on how a model interprets something needs a live test. The prompt is program logic, and a fake that agrees with you tests nothing.

Assert on claims, not vocabulary. I twice tried to test whether the model was being honest by substring-matching topic words, and twice built something that flagged honesty as dishonesty. Forbidding the phrase "tests pass" matched the model innocently restating my request. Forbidding a filename matched the model correctly saying that file does not exist. Match the assertion you care about, not the subject matter it happens to mention.

"Does not panic" and "is correct" are different assertions. The TUI had a test for hostile content that only proved rendering did not crash. The moment I asked instead whether the output was sane, it immediately found a panic on small windows and raw escape sequences reaching the terminal. A test that only checks for absence of crashes will happily certify garbage.

A sandbox not rebuilt from scratch each run is not a sandbox. A baffling "0 of 8 edits applied" failure turned out to be an earlier run having rewritten the seed file. Related discovery: Python and Bash resolve the temp directory to two different real locations on this machine, which is its own quiet source of confusion when two tools are supposed to be looking at the same file.

Measure before building. Two roadmap items died to measurement instead of implementation — parallel tool execution and near-duplicate loop detection. Both times, measuring cost a fraction of what building would have. Being willing to throw away working instrumentation once it has answered its question is what makes that real rather than a slogan.

Prompt rules alone are usually not enough. Every behavioural fix that actually stuck needed a structural backstop behind it: a three-state verdict behind the verification rules, a repeat notice behind "don't loop", a write guard behind "don't invent files". Telling a model not to do something is a weaker mechanism than making it awkward to do.

Known weak spots

I would rather list these than pretend they aren't there.

cmd/moecode sits at 0% coverage and is structurally unreachable from tests. Two real bugs have hidden in there — a data race between a background goroutine and the save path, and model-cycling desyncing after a config reload. The pattern that works is extracting logic out into a package rather than trying to test main, and that extraction is unfinished.

mcpbridge at 55% is the weakest package, and the untested part is live connection handling, which needs a real MCP server to exercise. That is the obvious next coverage target.

There is a package-level mutable global holding the context limit, written by a reload closure. Single-threaded in practice, but it is exactly the kind of thing that bites eighteen months later.

The shell integration is Windows-only — it assumes PowerShell in a couple of places. Running MoeCode on the Linux box would need a shell abstraction first, though running its inference remotely already works fine.

What's next

Deliberately undecided. Everything on the roadmap is either done or dropped, so the honest answer is that the next move is discretionary. The most defensible candidates are MCP coverage, more live tasks in scratch repos — every round of those has found something fixtures could not — and continuing the extraction pattern out of cmd/.

There is a Rust rewrite of the core listed as roadmap item eight. It is a learning exercise under consideration, not a plan, and no Rust exists. I am listing it here mostly so that future-me does not mistake it for a commitment.

CS student · developer · streamer

localhost:8000