One brain, two agents: an OS for autonomous coding agents.
I ask something simple of my AI coding agents: take a task, work on it end to end, ship the result to my taste, and do it autonomously. I want to hand them a list of 100 tasks and have them finish every single one without losing important context, stopping early, or skipping work. But that’s exactly what they don’t do.
I initially tried to fix this with a better prompt. A stricter CLAUDE.md. A sterner “do not stop until you are finished.” A queue of /goals. I tried all of it, and none of it worked reliably enough - because CLAUDE.md, AGENTS.md, and memory are just part of the context and do not enforce the behaviour.
There are three ways it goes wrong:
- Losing context is really a compaction problem. When the window fills up, the conversation gets summarized away - everything except the project-root instruction file (which gets re-injected from disk) and memory. So your durable rules have to live in that root file, and your working state has to live on the filesystem, not in the chat.
- Stopping early and skipping work isn’t something you can fix by writing “don’t stop” in a prompt. That’s advice. Every model will interrupt itself to wait on a human decision instead of picking up the next task, every one of them has a limit on how long it can run before it stops and the Claude API going down 20 times a day is also worth mentioning here. It needs a hook that mechanically rejects premature stop attempts. Ideally, it should also retry the turn.
- Learning your taste can’t lean on the model “remembering.” A rule no machine enforces just gets violated again. It has to graduate into a check.
The severity of these problems depends on the model, its version, and its effort level. For example, Sol can take on long-horizon tasks or a list of them, but compactions and context rot can steer it into heavy over-engineering. Opus will stop early when I give it a long list of things to do, no matter how I prompt it. Fable is simply too limited on most subscriptions to be practical for routing coding tasks. Things also get better or worse over time as harnesses and models change. The system around them has to remain stable regardless of all of that.
So I built an “operating system”. It’s boring, pragmatic, file-based, and almost entirely made of markdown and three shell hooks. It refuses to stop while there’s work left, it learns my style and turns my corrections into checks the model can’t slip past, and, since I use multiple subscriptions, it runs on both Claude Code and Codex from a single source of truth.
This assumes you can already run an agent without it wrecking your laptop. If you can’t yet, start with the sandbox: running an AI coding agent you can’t trust. This is the other half - what makes that safe box actually finish the job.
Everything below is built on one line: the stuff that should happen goes in markdown, and the stuff that must happen goes in hooks.
Beating amnesia: a boot protocol and a memory on disk
Context loss comes first because it’s the failure everybody misreads as the model being dumb. It isn’t. When the context window fills up, the harness summarizes the conversation and carries on. State is lost during this process, and harnesses try to work around that by injecting the root instruction file - only the root one - and memory back into the context.
So treat the root file as a survival kernel - the core that has to outlive any compaction. The first thing my AGENTS.md defines is a BOOT protocol: the exact order to re-read whenever the context is fresh or freshly compacted.
# BOOT - read these, in order, before touching code:
1. portal/AGENTS.md, .. (project specific instructions for monorepos)
2. .agent/kb/rules/ (the taste knowledge base)
3. .agent/tasks/README.md (the queue's own instructions)
3. ls .agent/tasks/todo (the work queue, in order of creation)
4. .agent/tasks/in_progress/*.md (the task in flight, if any)
In my setup, every project gets an .agent/ folder for durable working memory that agents share on disk and humans can read:
| Path | What it is |
|---|---|
tasks/ |
the work queue - one folder per task, and its state is which directory it sits in |
tasks/<status>/<id>/state.md |
the resume snapshot: the plan, current position, next concrete action, and traps |
tasks/<status>/<id>/log.md |
the agent’s working journal: what it did and why, so reasoning survives a compaction |
tasks/<status>/<id>/decision.md |
the human call this task is parked on, with options and a recommendation |
kb/rules/ |
the taste knowledge base |
The <status> directories are 00_todo/, 10_in_progress/, 50_blocked/, 99_done/ and xx_backlog/. Model moves the task folders between those directories, making the queue and current work state durable.
For all those task files log.md is the one people skip, and it’s the one that pays off the most. The task queue tells a recovering agent what’s left but the log tells it why the last three tasks were done the way they were - the decision it would otherwise re-litigate or quietly contradict. Reasoning is the first thing compaction throws away and the most expensive to rebuild, so the agent is instructed to write important bits of it down as it goes. The queue’s own README speaks directly to the agent, and BOOT reads it into every fresh or freshly compacted context:
# .agent/tasks/README.md
## log.md - the working journal
Append-only history of what you did and **why** - the decisions, dead ends, and
surprises a reviewer (or the next agent) needs. Add to the bottom; never rewrite
it. This is the durable "why"; state.md is just "where am I now".
## state.md - the resume snapshot
The single most useful file for an unattended loop: each iteration is a fresh
agent with no memory of the last, so this is how a task survives an interruption.
**Overwrite the whole file** at every checkpoint (before a commit, before you
pause) - it's a snapshot, not a log. Keep it to a few lines, and on resume read
it first.
There’s a second lever for the same problem: keep exploration out of the main context entirely by instructing your agent to use subagents for exploratory and mechanical work. Subagents read files in their own window and hand back only the conclusion - one of them burns six thousand tokens reading code, and your main thread gets a four-hundred-token answer. The main context doesn’t grow so quickly and compactions happen less often.
That’s the whole antidote to amnesia. The rules and intent survive because they’re in the re-injected root file. The filesystem becomes the memory. Once that clicks, the rest of the OS kind of designs itself.
The contract: a queue with nowhere to hide
The task queue solves another problem: the model’s natural tendency to skip work. If you want hours of unattended work, the task list can’t live in the model’s head - or in the chat scrollback, which is the same thing with extra steps. It lives in .agent/tasks/, where a task is a folder and its state is which directory it sits in.
Every entry point gives the agent the same instruction:
Take the next folder in
00_todo/, claim it by moving into10_in_progress/→ do it → run the gate → commit → write the state snapshot → close it by moving to99_done/. Blocked? Move to50_blocked/plus add adecision.mddescribing the decisions you need to get from a human, provide options for the decision, argue about their tradeoffs and give your recommendation. Spot unrelated work? Small and ready goes straight on the queue, big or half-formed goes to the backlog - either way, stay on task. Never end a turn with a task still in10_in_progress/, and never stop while00_todo/still has tasks.
That backlog line matters: it prevents an agent from wandering off and never coming back when it spots something three files over, because a compaction wiped out what it set out to do initially. “Capture it, don’t do it” gives the distraction a home, so the agent keeps the idea and stays on the task. It also saves you the worst kind of review: an unrelated change buried deep in the diff.
Hooks: the rules that can’t be argued with
Everything so far is still advice - context the model reads, weighs, and sometimes loses. I use only three hooks to enforce the parts that cannot be optional. Hooks are shell scripts the harness runs at fixed points in the agent’s life; the harness controls them, not the model, so they can’t be silently ignored.
The most practical one is the Stop hook. It fires the moment the agent decides it’s finished and lets the harness force it to continue:
#!/bin/bash
# .agent/skills/sweep/queue-guard.sh - refuse to stop while work remains
if [ -z "${CLAUDE_PROJECT_DIR:-}" ] || [ -z "${CLAUDE_CODE_SESSION_ID:-}" ]; then
echo "Claude Code project or session ID is unavailable; cannot inspect the queue." >&2
exit 2
fi
queue="$CLAUDE_PROJECT_DIR/.agent/tasks"
armed="$queue/.sweep-armed/$CLAUDE_CODE_SESSION_ID"
# The hook is always installed, but only an armed /sweep session is gated.
[ -d "$armed" ] || exit 0
left=0
for state in 00_todo 10_in_progress; do
[ -d "$queue/$state" ] || continue
left=$((left + $(find "$queue/$state" -mindepth 1 -maxdepth 1 -type d | wc -l)))
done
if [ "$left" -gt 0 ]; then
echo "$left actionable task(s) remain in 00_todo or 10_in_progress." \
"Finish them, or move genuinely blocked tasks to 50_blocked." >&2
exit 2
fi
# An empty actionable queue completes the sweep and disarms this session.
if ! rmdir "$armed"; then
echo "Queue is empty, but the sweep guard could not disarm: $armed" >&2
exit 2
fi
Exit code 2 blocks the stop, and that stderr message goes straight back to the agent. When it says, “I’ve completed the most important items, wrapping up,” the hook answers: No. Forty-one folders are still sitting in 00_todo; back to work.
I do not leave that guard armed for every interaction. Otherwise a quick one-off question could force the agent through the entire queue. Instead, the Stop hook is installed once in .claude/settings.json and returns immediately unless the current session has been armed:
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "bash",
"args": ["${CLAUDE_PROJECT_DIR}/.agent/skills/sweep/queue-guard.sh"]
}
]
}
]
}
}
The /sweep skill arms the guard as it loads:
# .agent/skills/sweep/SKILL.md
---
name: sweep
description: Drain this repo's .agent/tasks/ queue autonomously, one commit per task.
disable-model-invocation: true
allowed-tools: Read, Grep, Glob, Bash, Write, Edit
---
!`mkdir -p "${CLAUDE_PROJECT_DIR}/.agent/tasks/.sweep-armed/${CLAUDE_SESSION_ID}" && printf 'Sweep guard armed for this session.\n'`
Drain `.agent/tasks/` according to the root queue contract. Move genuinely
blocked work to `50_blocked/`; do not leave it in an actionable state.
If the human explicitly cancels the sweep, remove this session's marker before
stopping. Otherwise the Stop guard removes it when the actionable queue is empty.
The marker includes Claude Code’s session ID. A normal interactive session has no marker and the permanent hook is a no-op. Resuming the same sweep session keeps the guard armed; opening a different session does not inherit it. The script removes its own marker as soon as 00_todo/ and 10_in_progress/ are empty. 50_blocked/ is parked on a human, and 99_done/ has shipped. A human interrupt still wins.
Claude Code marks a retry forced by a Stop hook with stop_hook_active. This guard uses the queue itself as the terminating condition and keeps blocking while actionable work remains. Claude Code still ends the turn after eight consecutive Stop blocks, so this hook applies pressure rather than creating an infinite process; the foreman later in this article handles restarts across sessions.
The second hook is a commit gate on PreToolUse: intercept git commit, run the fast formatters over the staged files, and refuse the commit if they come back dirty.
#!/bin/bash
# .claude/hooks/commit-gate.sh - format staged files, block the commit if they're dirty
input=$(cat) # the tool call arrives on stdin
echo "$input" | grep -q '"command"[^}]*git commit' || exit 0 # ignore every other command
staged=$(git diff --cached --name-only --diff-filter=ACM) || exit 0
go_files=$(echo "$staged" | grep '\.go$' || true)
if [ -n "$go_files" ] && command -v gofmt >/dev/null 2>&1; then # no gofmt? fail open
bad=$(gofmt -l $go_files 2>/dev/null || true)
if [ -n "$bad" ]; then
echo "commit blocked - these need gofmt:" >&2; echo "$bad" >&2
exit 2
fi
fi
exit 0
Scope it to staged files so an unrelated mess somewhere else can’t block you, and make it fail open - that command -v gofmt check is the whole trick, so a missing toolchain never wedges the whole run.
One honest caveat: harness hooks are the single genuinely tool-specific layer here. Claude Code has them; Codex doesn’t have an equivalent yet. That’s what the third hook is for - a plain git pre-commit gate, tracked in the repo with core.hooksPath pointed at it, so the format check runs for every committer (Codex, Gemini, a bare git commit) and rides along on a fresh clone. Only the Stop guard stays Claude-side; everything else - rules, queue, log, skills - is shared. More on how that sharing works in a second.
Teaching it your taste
Here’s how to make the agent write code like you instead of like the average of all of GitHub. Treat every correction you give it as more than a one-off fix - record it as a rule in the same change:
- Fix the flagged instance.
- Record it - a one-line entry in the project’s rule index, plus a
.agent/kb/rules/<slug>.mdwith a worked ✅-good / ❌-bad example when the rule needs one. - Sweep the codebase for other instances of the old shape - one correction usually has siblings.
- Graduate it - if it’s mechanically checkable, add a real check: a custom lint, an AST rule, a hook. Now it can’t regress.
The Sweep step is the one most people skip, and skipping it bites you later: the model wanders back into the old, off-taste code still in the repo, decides that style must be fine, and copies it again. I learned this the hard way: while the agent is deep in a big file, it does not keep every rule in context.
The Graduate step is the endpoint: the goal of teaching your taste was never to make the model remember - it was to make the violation impossible to commit. In my setup, a dozen rules have graduated into real static-analysis checks (Credo AST rules on the Elixir side); the rest live as worked examples the agent reads on demand from .agent/kb/rules/. Memory drifts. A static-analysis rule doesn’t.
One brain, two agents
This is the part I’ll happily bore you about at parties.
The whole design follows one rule: all durable knowledge and state live in tool-neutral files at fixed paths. Each tool’s own config is thin wiring that points at them, never a copy.
The spine is one file - AGENTS.md - the canonical, tool-neutral manual, at the repo root and in each project. CLAUDE.md is just a symlink to it. Claude reads through the symlink; Codex reads AGENTS.md natively. One source, zero duplication.
myapp/
AGENTS.md # the creed, the BOOT protocol, the contract
CLAUDE.md -> AGENTS.md # symlink
GEMINI.md -> AGENTS.md # symlink
.agent/ # shared working memory (every tool reads/writes it)
.agent/skills/ # the skills, in one tool-neutral place
.claude/settings.json # Claude-only hook wiring
.claude/skills -> ../.agent/skills # symlink
.codex/skills -> ../.agent/skills # symlink
.gemini/skills -> ../.agent/skills # symlink
control_plane/AGENTS.md # Elixir specifics
control_plane/CLAUDE.md -> AGENTS.md # symlink
runner/AGENTS.md # Go specifics
runner/CLAUDE.md -> AGENTS.md # symlink
Because everything lives in those shared files, nothing is duplicated per tool - one rulebook, one queue, one set of skills. While I was building this, a Codex agent in the same repo even picked the whole thing up on its own and started working the queue right alongside Claude.
The only thing you have to get right is what gets committed. Keep the knowledge - kb/, the skills, the presets - in git, and git-ignore the working state. The queue and its logs are local, and two agents committing the same queue is a merge-conflict machine. The one exception is the queue’s own README: it is a BOOT entry point, so it has to survive a fresh clone.
**/.agent/*
!**/.agent/kb/ # the taste KB is shared
!**/.agent/skills/
!.agent/project.yaml
!**/.agent/tasks/ # the queue is local state...
**/.agent/tasks/*
!**/.agent/tasks/README.md # ...but its layout doc is a BOOT entry point
The foreman is a while loop
The Stop hook keeps a single session honest. It does nothing for the session that dies - the laptop sleeps, the API hiccups, the context degrades into mush after the fourth compaction. The industry’s answer to this is an orchestration framework. The actual answer is twenty lines of bash:
#!/bin/bash
# bin/foreman - run disposable agents until the queue is provably done
set -euo pipefail
work_remains() {
find .agent/tasks/00_todo .agent/tasks/10_in_progress \
-mindepth 1 -maxdepth 1 -type d 2>/dev/null | grep -q .
}
while true; do
while work_remains; do
bin/agent -p "Boot, then claim and work the next task per the protocol." </dev/null || sleep 10
done
bin/agent -p "Signoff: for every folder in .agent/tasks/99_done/, verify its acceptance
criteria are met, its gate passes, and a commit implementing it exists in the git log.
Reopen any that fail - move it back to 00_todo/ and say what's missing in its log.md.
Do not fix anything yourself." </dev/null || continue
work_remains || break
done
echo "queue verified done"
bin/agent is the sandboxed launcher from the first article, called headless. The trick is what the loop doesn’t do: it never resumes a session. Every pass is a brand-new agent that boots, reads the queue, works a few items, commits, and dies. Don’t fight context rot - make sessions disposable. A crash costs you the one item in flight; the next pass finds it still sitting in 10_in_progress/ and picks it up from its state.md. (For that to stay fast, the environment has to be durable even though the container isn’t - toolchain in the image, deps cached in a volume, exactly as the first article set up.)
The signoff pass at the end is where the quality comes from: a fresh agent that never saw the work get done re-checks every finished task against the real git log and reopens anything that doesn’t hold up. There’s a round cap on that, and it scales with the batch - when the worker and the reviewer keep ping-ponging on the same item past it, the task gets blocked for a human instead of being reported as done. That’s your cue to re-read it: it’s underspecified, and that one’s on you.
What a day looks like
In the morning I write the queue - I point Claude at the open issues, throw ideas at it, and have it draft everything down into .agent/tasks/. Deciding what to build and what “done” means is the real work; once that’s settled I start the foreman and close the tab. Around lunch I run coop tasks decisions and unblock a couple of items with a sentence each. In the evening I review the branch like a contractor’s PR. The agents worked eight hours and interrupted me twice, both times for calls that were genuinely mine.
Final thoughts
None of this makes the model smarter - it just stops depending on the model being smart, which is the only thing that’s held up for me. There’s no framework here either: an instruction file, a folder of markdown, three shell hooks, and a while loop. I packaged it all as a single binary, coop. You can run coop loop instead of copying scripts around, but it’s the same boring markdown underneath.
It’s simple - that’s exactly why it works.