The AI Doomsday Prepper's Guide to an Off-Grid Coding Agent
I am not a prepper and this is not about the apocalypse. It is about what happens when the coding agent is a 9 GB file on a graphics card in your spare room, the network cable is optional, and nobody is metering your tokens. The title is the joke. The hardware is real and so is everything measured below.
Last week I trained a 110 million parameter language model on my gaming PC and wrote about what it taught me. That model is bad on purpose. It produces fluent Wikipedia prose about countries that do not exist. The point was never to get a good model, it was to stop treating training as a thing other people understand.
Same exercise here, one layer up. I use Claude Code every day and I could describe what it does without being able to say how it does it. So: harness from scratch, the way the last post was model from scratch.
A model sitting behind an HTTP endpoint cannot read your files, cannot run your tests, and cannot tell you whether the change it suggested compiles. The thing that lets it do those is the harness, and if you have used Claude Code or Cursor or opencode, the harness is most of what you have actually been using. When one of them works well, a good part of the credit belongs to code someone wrote in TypeScript, not to the weights.
I spent a day trying to drive a local model with two existing harnesses. Both failed, in the same way, for a reason that took a long time to find. Then I wrote my own: 1,011 lines of TypeScript, six tools, aimed at exactly one model on exactly one graphics card. It is not better than the harnesses it replaced, and it was never going to be. It is small enough to read in an afternoon.
What a harness does
Strip away the product and a coding agent is a loop.
- Render the conversation so far into one long string of text.
- Send it to the model.
- Read the output. If it contains a tool call, execute it.
- Append the result to the conversation. Go to 1.
- Stop when the model answers instead of calling a tool, or when you hit a turn limit.
The model has no file access and no shell.
It emits text that looks like <tool_call>{"name": "read", ...}, and
the harness is the thing that notices, runs the actual fs.readFile,
and puts the bytes back into the conversation.
Everything interesting is in the details of those five steps: what the tools are, how the model is told about them, what happens when the conversation grows past the context window, and whether anyone checks that the model's claims match what is on disk.
What failed first
The starting point was Ollama on the same machine, serving models over HTTP, and two existing agents pointed at it.
opencode with deepseek-coder-v2:16b failed in the first second:
does not support tools. That model's template has no function calling
at all.
opencode with qwen2.5-coder:7b got further and then produced tool
calls as raw JSON inside the text field, never as the structured
tool_calls array the protocol expects. I confirmed that at the raw API
level rather than through opencode, so it was not opencode's bug. The
model's template tells it to wrap calls in <tool_call> tags, the model
does not reliably comply, and Ollama's parser does not recognise the
bare JSON it emits instead. A community variant built specifically to
fix this failed the same way, using markdown fences instead of tags.
opencode with llama3.1:8b and mistral-nemo:12b passed isolated
tool-calling tests cleanly. Correct structure, correct arguments, every
time. Inside opencode's actual multi-turn loop, both would describe what
a tool call would look like, in prose, instead of issuing one. Both
hallucinated results for code that was never written. One produced a
convincing curl response for an HTTP endpoint that did not exist. One
invented a tool that opencode does not have.
Claude Code with llama3.1:8b did the worst version of this. It
fabricated an entire execution trace, complete with Permission granted and File created successfully, that read exactly like the
real thing. No file was created.
At that point I concluded that 7B to 12B models cannot drive an agentic loop, and that conclusion was wrong.
Two hypotheses that were also wrong
Context truncation. Ollama's default context is 4,096 tokens and
these system prompts are large. If the tool instructions were being
silently cut, that would produce exactly this behaviour. I raised the
context to 32,768, re-ran the identical failing request, and read
Ollama's own server log: n_tokens = 3332, truncated = 0. The request
was not close to even the old limit.
The translation layer. Ollama had recently added an Anthropic-compatible endpoint, and a new compatibility shim mistranslating the request was plausible. Rather than install LiteLLM to test this, I wrote a 150 line zero-dependency translation proxy by hand. The reason for that detour is worth keeping: LiteLLM versions 1.82.7 and 1.82.8 on PyPI had been compromised earlier in the year, with a payload that harvests SSH keys, cloud credentials and kubeconfigs and attempts lateral movement across Kubernetes clusters. This machine sits on the same network as a cluster I care about. Writing the proxy cost less than auditing the package would have.
Same failure through my own proxy. The shim was not it.
The measurement
I added request logging to that proxy and captured what Claude Code actually sends.
request size: 101 KB
tool definitions: 25
largest tool: Workflow, 18,997 characters of description
Twenty-five tools, including things like CronCreate, EnterWorktree,
DesignSync and ScheduleWakeup. This was not a misconfiguration. It
is a genuine install of the published package, and that is what the
default toolset looks like, because the product is a general-purpose
autonomous agent and not a function-writing assistant.
A 14B model at 4-bit quantization reading 101KB of instructions about cron scheduling and worktree management, in order to fix a typo, is being asked to do a different job than the one I wanted.
Measured rather than estimated, against the running server's own tokenizer:
Claude Code's default request 101 KB, 25 tool definitions
its largest single tool 18,997 characters of description
brodecode's whole prompt 4,525 bytes, 970 tokens
its six tool definitions included in that number
context window on this card 8,192 tokens
The entire harness, system prompt and all six tool schemas, is an eighth of the window. One tool in the other list would not fit in the window at all.
Claude Code takes a --tools flag. Scoped to
Read,Write,Edit,Bash,Glob,Grep:
| full default toolset | six scoped tools | |
|---|---|---|
| single tool-call tasks | unreliable | 4 / 4 |
| write then read back | roughly 0% | 3 / 4 |
The model was capable the whole time. The prompt was the variable.
Why write one anyway
Three out of four is usable. It is not diagnosable. When a run fails through someone else's harness, the candidates are the model, the quantization, the tool scoping, the prompt template, the parser, and the HTTP layer, and you cannot isolate any of them from outside.
So the bet brodecode makes is the opposite one. Pick exactly one model. Talk to it through the rawest interface available. Write the prompt renderer, the parser and the tools by hand, so that when something fails there is nothing left in the middle to blame.
src/
types.ts shared types
config.ts runner URL, turn limit, context size
prompt.ts hand-built ChatML renderer and the system prompt
runner.ts raw HTTP client for llama-server's /completion
parse.ts splits raw output into <think> / <tool_call> / text
tools.ts read, write, edit, bash, glob, grep
agent.ts the loop
cli.ts REPL
logger.ts structured JSONL session log
1,011 lines. No agent framework, no LangChain, no OpenAI-compatible
chat endpoint, no tools parameter. It posts a string to
/completion and gets a string back. Everything above that is ours,
which means every bug is also ours.
Fitting it to the card
The hardware is one desktop: Ryzen 9 5950X, 128 GB of RAM, and a single RTX 3080 Ti with 12 GB of VRAM. It dual boots into Windows for games, which is the only reason it exists.
That 12 GB is the binding constraint on every decision in this project.
Model: Qwen3-14B, Q4_K_M GGUF, about 9 GB on disk
Loaded VRAM: 9,783 MiB of 12,288 MiB
Context: 8,192 tokens
Load time: 19.3 seconds
Idle: 35 C, 73 W
Q4_K_M means the weights are quantized to roughly four bits each. A 14B model at full 16-bit precision is 28 GB and does not fit on this card twice over. At four bits it is 9 GB and fits with about 2.5 GB to spare, which is what the KV cache spends.
--ctx-size 32768 does not fit. That is not a tuning preference, it is
an out-of-memory error at load. So the harness gets 8,192 tokens to hold
the system prompt, the task, every file it has read and every command
output it has seen, and that number is hard-coded into how history
gets trimmed.
The trimming is the part that took two attempts. The first version dropped whole completed user turns, which helps with a long chat and does nothing for the actual common case: one open-ended task, no second user message, and 15 to 20 tool-call rounds accumulating file contents. The rewrite works at exchange granularity, where an assistant reply plus its tool results is one droppable unit. It always keeps the first exchange, because that is the task statement, and the most recent ones, because that is the state. If dropping exchanges is not enough it shrinks individual oversized tool results.
Before that existed, a long session would simply exceed the context window and the turn would fail with a 400, losing the work, and the bloated history was not cleaned up either so the retry failed the same way.
The harness verifies, it does not trust
This is the one design decision I would keep if I threw away everything else.
Every write and every edit is immediately read back from disk and
diffed against what the model claimed, before that result enters the
conversation. edit refuses to run on a file that was not read first
in the same session, enforced in code rather than requested in the
prompt.
That came directly out of the Claude Code failure above, where a model narrated a complete fake tool execution and claimed a server was running when nothing had ever been written to disk. A harness that forwards the model's own account of what happened has no way to catch that. A harness that reads the file back has no way to miss it.
The cost is one extra syscall per write. The benefit is that the class of failure that wasted most of a day has nothing to attach to.
What "verifies" covers, and what it does not
Worth being exact here, because "the harness verifies" can be read as a much larger claim than it is.
The harness checks mechanical facts about the filesystem and the
shell. Did the bytes land. Does the file on disk match what was
claimed. Was this file read before it was edited. Is the background
process still alive 700 milliseconds later. Does something else hold
the port. All of that is fs.readFile and process.kill(pid, 0),
with no model involved and no judgement required.
It cannot check whether the code is correct, whether the page still
makes sense, or whether an edit broke something three files away. The
duplicate id="textInput" further down this post sailed through every
check, because each individual write was exactly what the model said
it was.
So once the harness is running, correctness is the local model's problem. A 14B model at 4-bit is the thing deciding what to write, and nothing downstream of it grades that. The verification layer only guarantees that what you are reading in the terminal is what actually happened, which is a smaller promise than it sounds and the one that was being broken.
Where Claude Code stops and the off-grid model starts
Two different models are involved in this post and they do not overlap
in time, so: Claude Code wrote the harness and drove it during
development, over eleven commits in one day. It ran brodecode against
real projects, read the JSONL logs, found the bugs listed below, and
fixed them. Every one of those findings was checked against ground
truth by hand, npm test and cat, never against brodecode's own
report of itself, since grading a thing on its self-report is how the
original failure happened.
That involvement ends at the point you clone the repository. Running
brodecode needs a llama-server and a GGUF file and nothing else. No
API key, no hosted model, no network beyond the LAN. The sessions in
the video were driven by a script, not by Claude, and the only model
answering in them is Qwen3-14B on the 3080 Ti.
Which leaves the honest division: a frontier model built and debugged the plumbing, and a 14B model lives in it. The plumbing does not get smarter when you unplug the network. Everything the agent decides once it is off grid, it decides on its own.
How it actually got built
The build loop was not me reading the code and reasoning about it. It was Claude driving the harness as a user, against real messy projects, checking the result against ground truth, and fixing what broke.
Ground truth means running npm test by hand and reading the file with
cat, never trusting the harness's own report of what it did. The whole
project is about not trusting a model's self-report, so grading it on
its self-report would have been circular.
Eleven commits, from 16:01 one afternoon to 08:29 the next morning. Here is what the driving actually turned up.
glob("**/*") never matched root-level files. The pattern compiled
to a regex requiring a literal slash, so it only matched nested files. A
model asked what is in this repository would see the subdirectories,
miss everything in the root, and draw a confident wrong conclusion about
what the project even was. A second glob bug sat next to it:
*.{test,spec}.js matched nothing, because brace expansion was never
implemented and the braces were escaped as literal characters. Both
are dead ends a model walks into without any way to know the ground is
missing.
Tool calls nested inside <think> were thrown away. The parser
matched the thinking block and discarded the entire matched span before
looking for tool calls, and Qwen3 sometimes emits a <tool_call> inside
its own reasoning, particularly after a couple of failed loops. So the
model would decide what to do, the harness would silently drop the
decision, and then re-prompt with no new information and get the same
decision again. From the terminal this looked exactly like a model stuck
in a loop repeating itself. It was not. The harness was dropping its
output on the floor. The fix is to extract tool calls from the full raw
text before touching <think> at all.
Piped input only ever processed the first line. Node's readline
fires close as soon as the stream hits EOF, which for a pipe is almost
immediately, and that landed during the long async model call for line
one. A closed flag check at the top of the loop then killed the
session before line two. Invisible when typing by hand, because a live
terminal never hits EOF mid-session, and silently fatal for every
scripted use including the testing itself.
Then a structured round: five scratch projects, each a different shape of task, driven through the harness and graded against ground truth.
| project | result | tool calls | |
|---|---|---|---|
| 1 | pure question answering, no README on purpose | clean | 2 |
| 2 | one real formula bug, one failing test | clean | 4 |
| 3 | add a feature plus a test for it | deadlocked at the 20-turn limit | 20 |
| 4 | extract duplicated normalization, behaviour must not change | clean | 15 |
| 5 | open-ended safety review of code using eval() | clean | 9 |
Number 3 is where the brace-expansion bug came from. Number 5 is the one
I did not expect: the task says only to review for safety issues, with
no bug pointed out, and the model found the eval() call itself,
replaced it with a maths expression library, hit an API mismatch, read
the actual test failure, and corrected itself.
Two minutes of it, unedited
Your prompt, typos intact, then "very nice, make ui pretty". Two turns, 108 seconds, recorded on the machine. Browser shots are cut in where the UI changed, so you can see what each turn actually produced rather than infer it from a tool call.
Turn one builds a working application in five tool calls: npm init,
npm install express, server.js, public/index.html, start it.
The hash it returns for "hello world" is
2aae6c35c94fcfb415dbe95f408b9ce91ee846ed, which is correct. It looks
like 1994, because nothing in the prompt said otherwise.
Turn two is where it gets interesting.
The card, which the video holds on at the end, is better. It also added that card without
removing the form it replaced, so the page now has two inputs, two
buttons, and two elements with id="textInput". getElementById
returns the first match, so the page works by luck of ordering. The
blue button has no click handler either; the hash in that screenshot
appeared because I pressed Enter.
Do not bring Claude Code expectations
That page is the single most useful thing in this post, so here is the same two prompts run three times:
| run | turn 1 | turn 2 |
|---|---|---|
| A | works, plain | writes the styled page to public/, which the server never serves |
| B | works, plain | styles it correctly, then spends 9.6 minutes on an unrelated detour |
| C | works, plain | styles it correctly and leaves the old form on the page |
Three runs, three different ways of being almost right. Every one got the CSS roughly correct. Not one of them kept the page coherent. The styling is the easy part.
None of this is the harness failing. Every write in all three runs was read back and verified, every tool result was accurate, and every claim the model made about what it had written was true. What none of them could tell you is whether the result makes sense, because nothing in a file-level check knows that two elements now share an id.
If you come to a 14B model expecting a hosted coding agent, this is the gap, and it is not one that a better harness closes. Hosted models hold a whole page in mind while editing part of it. This one edits the part you asked about, correctly, and lets the rest drift. Scoped, verifiable tasks land. "Make it nice" produces something that needs reading.
A fast, tireless junior who never pushes back and never notices collateral damage, for free, on hardware you already own, with no token meter running. A different product from the hosted ones, useful for different things.
What a design brief is worth
The video covers the first two prompts: one sentence with the typos left in, then "very nice, make ui pretty". Browser defaults, then the house style every model reaches for when told to make something nice.
The third prompt changes one thing. I copied a 4,440 byte frontend design brief into the project directory, the kind of document a team keeps in its repository: typography, palette commitment, spatial composition, motion, background treatment, and an explicit instruction to avoid generic system fonts and predictable layouts. Then: read that file and restyle the UI to follow it, changing nothing about behaviour.
Ten tool calls, 90 seconds, six of them spent reading before it wrote anything. Dark gradient ground, a committed accent colour instead of Bootstrap blue, glow on the button and on the result card, spacing that is deliberate rather than default. Same model, same harness, same six tools. The difference is 4,440 bytes of brief.
That is the part worth taking away for anyone pointing a small model at their own codebase. The leverage was not in the harness and not in the model. It was in whether the repository contained a document saying what good looks like here.
Was the brief too big for the window?
That was my first assumption when the earlier attempt applied only the surface of it, and it is wrong. Measured against the running server's own tokenizer rather than estimated:
| tokens | |
|---|---|
| the design brief | 858 |
| brodecode's system prompt and six tool schemas | 970 |
| context window | 8,192 |
The brief is a tenth of the window. The entire harness, prompt and tool definitions included, is another eighth. Roughly three quarters of the context is still free before the model reads a single file.
I checked that it arrived intact too, because the session log shows
only the first 109 tokens of it. That is the logger truncating tool
results to 500 characters for readability, not the tool. Calling
read() on that file returns all 4,440 characters.
Peak usage, summing every message across all ten rounds, was 2,006 tokens of conversation on top of the 970-token prompt. History trimming never fired.
So a 4,440 byte standards document is not an imposition on an 8,192 token window, which is useful to know before deciding your own repository's conventions are too long to hand to a small model. The constraint people expect to hit here is not the one that bites.
Two things the reruns turned up
The slow run of stage 1 built a working application in its first six
tool calls and then called bash("exit") 78 times in a row,
burning down to the 20-round limit without producing a final answer.
It was trying to end the session. exit in a subshell is a no-op
returning an empty string, so it had no way to learn that it had not
worked, and repeated itself. Two harness gaps, neither of them a
model failure: identical repeated tool calls are not detected, and
there is no way for the model to say it is finished other than
emitting prose. A model that decides to stop, and reaches for the
only stop-shaped thing in its toolset, deserves better than 78
no-ops.
The ceiling is sharper. In another session, against a test file already mangled by earlier failed edits and a genuine breaking change between two major versions of a test library, the model alternated between two incompatible module systems and two mutually exclusive assumptions about the library's API until it hit the turn limit. Every tool call was executed correctly and reported accurately, and nothing in the plumbing failed. That is a 14B model at 4-bit quantization running out of room to hold a compounding problem across twenty rounds, and no amount of harness work moves it.
Scoped tasks on this hardware work. Open-ended refactors across a broken codebase do not. Knowing which is which decides whether you reach for it on a Tuesday.
Running it yourself
Six tools, a short system prompt, MIT.
llama-server \
--model /path/to/Qwen3-14B-Q4_K_M.gguf \
--n-gpu-layers 999 \
--ctx-size 8192 \
--port 8090
npm install && npm run build
node dist/cli.js /path/to/your/project
Point BRODECODE_SERVER_URL elsewhere if the runner is on another
machine. Every session writes unabridged JSONL to ~/.brodecode/logs/,
with full untruncated tool calls, which is the only honest record of
what the model did rather than what it said it did.
A different model needs prompt.ts and parse.ts adjusted, because
both are written against Qwen3's actual output format rather than a
negotiated protocol. That is the trade this project made on purpose:
nothing general, nothing in the middle, nothing left to blame.
The repository is github.com/abhiapsunde/brodecode.