A coding agent that works through my backlog while I sleep — and never merges a thing.

Omarchy widget for the always-on machine running the loop

Omarchy widget for the always-on machine running the loop so I can control the loop outside a terminal.

Last Friday evening I switched on a loop that picks up GitHub issues and turns them into pull requests while I'm away from the keyboard. By Sunday night it had opened 61 PRs across five repositories. I merged 52, closed one, and eight are still waiting for me. The loop merged none: it isn't allowed to.

This is the first post in a series where I build quizmill in public: an open-source engine that turns a question bank into a practice app, which I've given six months to find paying users on 5–10 hours a week. More on that bet next time. I'm starting with the loop because it's what makes those few hours stretch.

Ralph, adapted

Ralph is Geoffrey Huntley's name for the simplest possible agent loop: a while loop feeding the same prompt to a coding agent, with all continuity in files and git rather than in the model's context. I kept the idea — fresh context every run, state on disk — and changed the rest:

It runs headless Claude Code (claude -p) on an always-on Linux laptop, under my Claude subscription: a bash script of about 340 lines, two prompts, a permissions file and four systemd units.

Timer, labels, worktrees

A systemd timer starts the night at 18:00; a second one polls every 30 minutes and starts a run whenever the loop is idle and there's work, so a comment I leave over breakfast is picked up within the half hour. On weekdays it works 18:00–06:00; at the weekend whenever there's work, since the usage gate is the real brake.

I steer it with labels, which means from my phone: ready (approved, oldest first), needs-decision (it stopped and asked), needs-attention (out of time or budget, no PR), backlog (not yet released). An After #N line in an issue body holds it back until #N is closed — added after a batch of refactor PRs collided on CLAUDE.md and README.md.

Each issue gets a fresh git worktree and a one-shot claude -p session. The repo's CLAUDE.md is the law; the loop's prompt only adds what's specific to being unattended. That took a few iterations: a -p session has nothing to wake it up, so an agent that backgrounds a build and waits for a notification just… ends, with nothing pushed. One PR's conflict-fixing pass did that twice in a row. The prompt now says never to background anything and to commit as it goes; if a run ends without a PR, partial work is pushed and the next attempt resumes from it.

Before new work, the loop handles feedback on open ralph/* PRs: my comments, Copilot reviews (capped at two rounds per PR), or a conflict with main, which it resolves by merging main in — never a rebase. Everything it writes starts with 🤖 ralph (nightly loop), since it posts as me.

The usage gate

The budget is a share of a weekly subscription limit, not a bill. Before every issue a 23-line usage.py reads the same numbers /usage shows and ends the night when any of three trips: 10 points of the week used tonight, the week past 70%, or the 5-hour window past 80%. If it can't read the numbers it reports 100% and stops rather than runs blind. Add a 90-minute timeout and a --max-budget-usd 25 ceiling per issue, and extra usage switched off on the account. On the first weekend I raised the night allowance to 60 points to see what it would do with room: the week went from 20% to 48%.

Never merges

It runs with --permission-mode acceptEdits and an allow/deny list — not --dangerously-skip-permissions. The deny list is the interesting half (abridged):

"deny": [
  "Bash(git push --force:*)", "Bash(git push origin main:*)",
  "Bash(gh pr merge:*)", "Bash(gh secret:*)", "Bash(wrangler:*)", "Bash(npm publish:*)",
  "Read(~/.ssh/**)", "Read(~/.aws/**)"
]

The prompt does the rest. When an issue needs a decision only I can make — a price, a name, two designs with different trade-offs — the agent writes one comment with the question and a recommended answer I can accept in a word, labels it needs-decision, pushes what's clean, and stops. My favourite line in the prompt:

A clear question is a good night's work. A guess that costs a morning is not.

The morning after

Every run ends with a digest comment on a pinned "Nightly log" issue (trimmed):

- ✅ quizmill/quizmill#99 — CLI: `quizmill bundle <dir>` exports a pack as one bundle .json → PR #104
- ⚠️ quizmill/website#19 — Content policy page → no PR (exit 0), labelled needs-attention
- 🔁 quizmill/website PR#27 — Gallery: link every /packs card to its hosted app → revised
- ✅ quizmill/website#19 — Content policy page → PR #34

Usage: week 27% → 31% (allowance 60 points).

Morning routine: read it, review and merge, answer any ❓, promote the next issues to ready.

Three days in

Early numbers; a proper report after a month.

The cost of writing code has moved to reviewing it, which was the point. What makes the loop safe to leave running isn't a cleverer model but boring mechanisms: a queue I control, permissions that make merging impossible, a gate that fails closed, a prompt that rewards asking over guessing. The number I'll watch next is review minutes per merged PR — and what the loop gets wrong that CI doesn't catch.


Quizmill's engine is open source — code here, apps at quizmill.dev. Thanks to Geoffrey Huntley for Ralph.