Eight Rules for Delegating Real Work to a Local Coding Agent
Twenty tickets shipped through a local AI agent on my home server. The protocol around the model matters more than the model.
A few weeks ago I sent a bundle of four "mechanical" tickets to my local agent. Two hours later it returned with zero bugs closed, four green checkmarks, and acceptance tests that read like this:
grep -v "abstenciones" output.txt
That command returns success whether the bug is fixed or not. The agent ran the test, saw the exit code, and committed.
The bundle cost me an evening of cleanup. It also cost me the assumption that I knew how to brief a coding agent.
I run a local agent on Titan, a hand-me-down PC with an RTX 3090 under my desk. Stack: Hermes on top of Ollama, Qwen 2.5 Coder 32B doing the typing. Frontier model (Claude Opus) writes the spec. Local model executes. Free per inference, ~30 seconds to 5 minutes per ticket. Twenty-something tickets shipped: SubCat refactors, civic-tech back-end work, one-shot ports.
What follows is the protocol that made it work, written as eight rules. Each one came from a specific failure.
1. Triage before you bundle
Not every ticket is delegable. Before anything goes out, every ticket gets one of three labels:
- mechanical: ≤1 file, ≤20 lines, regex-shaped. Edit a prompt string. Replace one import. One commit, no behavioural ambiguity.
- structural: cross-file, depends on the router, schema, or pipeline. Requires testing the call path end to end.
- unclear: don't know yet.
Only mechanical goes to the agent. structural stays with me. unclear gets fifteen minutes of reconnaissance before it's reclassified.
The bundle that opened this article had two structural tickets disguised as mechanical ones. They were "mechanical" only at the level of the file diff. The bug lived in the pipeline three layers up.
2. Reconnaissance happens upstream
Hermes implements. It does not investigate.
Before any delegated ticket I spend 5–15 minutes with the frontier model proving the bug exists where I think it does:
grep -nthe exact symptom string across the repo. Often the bug isn't where the spec assumes.- Run the failing command with
--debugor--verboseand capture intermediate state. PRAGMA table_infoon every database the ticket mentions.- Confirm the root cause, then put
Root cause confirmed:at the top of the spec.
This is the boring half of the work. It's also the half the local agent can't reliably do, because investigation rewards judgment and Hermes is paid to type.
3. Positive assertions, not negative ones
The grep at the top of this post is the cardinal sin: an assertion that passes when nothing happens. The whole bundle was full of them.
# Bad: passes when fix is missing
grep -v "abstenciones" output.txt && echo "ok"
# Good: fails when fix is missing
python query.py "Q5" | grep -E "Serafim|menções: [1-9]"
Acceptance criteria has to bind the correct behaviour, not the absence of the wrong one. Same for exit codes:
- Bad: "script runs with exit 0".
- Good: "output contains the expected JSON shape AND row count > 5".
A negative assertion is a green light wearing a green light's costume. The agent will hand it back, you'll merge, and the bug will resurface in production.
4. Test-or-bust contract
Every spec ends with an explicit clause:
If the positive test does not pass, do not commit. Report and stop.
Working tree dirty is fine. I'd rather review a half-finished branch than chase a green CI on a broken main. One failing ticket inside a bundle does not abort the others. Each ticket is independent.
This sounds obvious. It is not the default behaviour. The default is "the agent finishes". That's what kicked off rule 6.
5. Authz drops are the failure mode you'll repeat
In two consecutive tickets on a web app, Hermes dropped the user_id filter from a SELECT and a DELETE. The spec mentioned the filter. The spec had a flagged section ("IMPORTANT: IDOR risk"). The agent dropped them anyway.
The model optimises for "query returns row". A predicate that doesn't change the test fixture's outcome looks redundant.
The mitigation is structural. In any ticket that touches authorisation I now write the WHERE clause literally:
where(and(eq(t.id, id), eq(t.user_id, session.user.id)))
And the acceptance test greps for eq(conversations.user_id in the route file. Count must be ≥ 1. If the agent removes the filter the grep fails. If the agent leaves it the grep passes.
Authz checks never go to the local agent without three things: the literal WHERE, a grep assert, and a manual diff review before push. The third one is non-negotiable.
6. STOP rules must be machine-enforced
A spec said: "If the script reports any split, STOP and investigate manually."
The agent found 11 splits, wrote its own investigation script, convinced itself the splits were fine, and committed.
The outcome was actually safe. The protocol was violated.
"STOP" to a coding agent reads as "STOP unless I can convince myself otherwise." It has a terminal. It has files. It can reason. Of course it'll explore until it's confident enough to proceed.
Take the decision out of the agent's hands:
assert split_count == 0, "splits found, return to orchestrator"
Or exit 1 with a documented code, which the orchestrator interprets. Conditions that should halt the agent get encoded as checks, not prose. The model can re-interpret a sentence. It cannot re-interpret a non-zero exit.
7. Sync the workspace before dispatch
Last week's failure was the cleanest case yet. I delegated Phase 2 of a worker. The spec assumed worker/db.py and worker/jobs.py already existed (Phase 1 had merged them on GitHub). On Titan, the local main was one commit behind origin/main. Those files weren't on disk.
Hermes branched off the stale main, didn't see the files, and filled the gap. It invented a schema. New table name. New status enum. The five tests it wrote passed because they mocked the cursor. In production, the worker would never have claimed a job.
Pre-dispatch checklist, no exceptions:
ssh titan "cd <workspace> && \
git fetch origin && \
git checkout main && \
git pull --ff-only && \
git status --short"
If the workspace is ahead, behind, or on a branch that isn't main: stop. Resolve before dispatch.
The spec also lists, explicitly, which files already exist and which are new. Not derived from git log, derived from ls on the working tree. The local agent's checked-out tree is the only ground truth that matters.
8. The division of labour is the actual product
The pattern across all eight rules is the same. The frontier model is paid for judgment: classification, root cause, acceptance criteria, review. The local model is paid for typing: edits, tests, commits.
FRONTIER (Claude Opus) LOCAL (Hermes on Titan)
┌─────────────┐
│ Triage │ ──► structural / unclear stays here
└──────┬──────┘
▼
┌─────────────┐
│ Recon │
└──────┬──────┘
▼
┌─────────────┐ contract ┌─────────────┐
│ Spec │ ─── (asserts, ──► │ Implement │
└─────────────┘ STOP exits) └──────┬──────┘
▼
┌─────────────┐
│ Test │
└──────┬──────┘
▼
┌─────────────┐ exit code ┌─────────────┐
│ Review │ ◄── ──────────── │ Report │
└──────┬──────┘ └─────────────┘
▼
┌─────────────┐
│ Merge │
└─────────────┘
When the line blurs (when Hermes investigates, or when Opus writes 200 lines of TSX) the economics break. Frontier tokens go into bulk typing. Local cycles go into reasoning the model isn't reliable enough for. Both pools leak.
The line is not a feature of the models. It's a feature of the protocol.
What this is and isn't
This is a workflow for a specific shape of work: solo, well-bounded, self-hosted. It would not survive a team setting without serious adaptation. It does not replace frontier models for design or architecture. It does not run unsupervised: every commit gets a human review before push.
For the slice of work it covers (boilerplate, ports, refactors with clear acceptance criteria, schema-aware migrations behind asserts) the cost per ticket dropped to roughly the electricity bill, and the throughput went up.
The model is not the moat. The protocol is.