Handing My GitHub Issues to an Agent Loop
Tags:#ai#agents#githubI stopped running tower after October 18. The repo stays up, and this is how it worked while it ran.
tower ran coding agents on my repos through the same GitHub flow an outside contributor would use. I wrote a small issue with a list of conditions for done and labeled it agent. A worker wrote the change and opened a pull request. CI decided whether it worked, and a second model that never saw the worker’s session decided whether it did what the issue asked. When both passed, it merged.
It’s mostly Bash around Claude Code, the GitHub CLI, bubblewrap and a systemd timer. I started it on the afternoon of October 17 from gaidin’s agent loop, and over that day and the next it worked on four of my repos, gaidin and moiraine among them.

The loop
Each repo had a settings file in repos/, and a systemd template timer ran bin/novice NAME every two minutes for each repo whose timer was enabled. A run with nothing to do was short. Before any work, novice compared a stamp of the repo’s GitHub state (when its issues and PRs last changed, and the heads of open PRs) with the stamp from its last full run, and exited after a few API calls if nothing had moved. That made a two-minute timer cheap, and it meant a merge or a review from me got picked up within about two minutes.
When something had changed, novice stepped until it was my turn. With one agent PR open, it worked on that PR. With none, it took the lowest-numbered open issue labeled agent, as long as:
- I had applied the label myself, not the agents’ account or an automation.
- No “Blocked by #N” line in the issue named an open issue.
- It hadn’t been tried already. An edit to the issue, or a blocker closing as completed, made it eligible again.
- It didn’t name code I’d marked as safety-critical, unless I’d added
safety-ok.
With more than one agent PR open, it stopped and waited for me. Each repo got 25 worker runs a day, up from 10 earlier on the 17th.
The worker
The worker was Claude Code in headless mode, running Sonnet 5.5 at medium effort, with a $3 budget and a 30-minute timeout per run:
timeout 1800 "$bin/sandbox" "$repo_dir" "${sandbox_args[@]}" "${sibling_args[@]}" -- \
claude -p --model "$model" --effort medium --max-budget-usd "$budget" --output-format json \
--strict-mcp-config --setting-sources project --no-session-persistence \
--allowedTools "${allowed[@]}" --disallowedTools "${denied[@]}" < "$1" > "$work/worker.json"
bin/sandbox is bubblewrap. The system, Node and Claude Code are mounted read-only and the one checkout is writable. Home is an empty tmpfs, so ~/.ssh, ~/.config and every other project aren’t there. The checkout’s .git/config and .git/hooks are read-only too, since git runs them outside the sandbox later. Only a handful of environment variables reach the process.
The tool lists allowed editing, pnpm, Node, and git’s status, diff, add, commit, log and show, and denied git push, gh, git remote, git config and curl. With no GitHub token in the sandbox, the worker had no way to push. novice pushed the branch afterward, as a separate GitHub account for the agents, and a pre-push hook in the agents’ clones plus a ruleset on GitHub kept that account off main.
Before pushing, novice counted the lines the change added, leaving out lockfiles. A change adding more than 300 got no PR. The branch stayed local, and the issue got a comment asking me to split it or add big-ok. That happened twice on October 18, at 404 and 313 lines. The second issue ran again later the same day and auto-merged as its own PR.
The reviewer
bin/review-pr ran Opus 5.5 at high effort in an empty directory, with no tools, no MCP servers and no saved session:
timeout 600 claude -p --model "$model" --effort high --max-budget-usd "$budget" --output-format json \
--system-prompt "$system" --tools "" --strict-mcp-config --setting-sources project --no-session-persistence \
< "$work/prompt.md" > "$work/out.json"
Everything it knew was in the prompt: the issue, the PR description, the worker’s notes on any revisions, CI’s result on that exact commit with the test counts pulled from its log, and the diff with 20 lines of context. It couldn’t read the repo or the agent instructions, and it never saw the worker’s reasoning. The PR description and revision notes went in under a heading that calls them the author’s claims, to check against the diff.
The system prompt set the stance in its first lines: “No human reads it before it merges: a pass from you merges it. Assume it is wrong until the diff shows otherwise.” It checked each “Done when” condition, correctness, whether new tests would fail without the change, scope, and claims the diff doesn’t support. On a public repo it also looked for real host names, LAN or tailnet addresses, people, home paths and secrets. Every finding had to name a file and line from the diff and say what goes wrong. Its rules end with: “If nothing should block, say so. Do not invent findings to look thorough, and do not praise.”
The answer started with Verdict: block or Verdict: no blocking findings, and the script turned that into a commit status called review: pending while it runs, success, failure on a block, and error if the review didn’t run or broke the format. A crash left it pending, which still blocked. The review itself went on the PR as a comment.
CI decides, with one retry
main on each repo had a ruleset requiring a PR, CI’s check job and the review status. novice waited up to 10 minutes for the CI run to appear and 25 for it to finish. A failed run got rerun once before it counted, and if the rerun passed, the summary and the log named the test that had flaked.
If CI failed or the review blocked, the worker got one automatic revision on the same branch, with the failed log and the review as its feedback. After that, or when CI and the review both passed on a repo without auto-merge, the PR got a one-line summary and the needs-brad label. From there I could merge it, close it, or request changes, which gave the worker up to two more revisions.
On repos with auto-merge on, a PR that passed both got the automerge label and merged right away, pinned to the head commit that had been checked. Then novice waited for CI on main. If that failed, it reverted the merge and stopped merging on that repo until I said otherwise:
echo "CI on main failed after auto-merging PR #$pr ($(date +%F)); remove this file to auto-merge again" > "$state/automerge-paused"
git checkout -q -B "$branch" origin/main
From there it ran git revert -m 1 on the merge commit. The revert went up as its own PR labeled needs-brad, and the paused file stayed until I removed it.
PRs also got labels for size (size:S under 30 changed lines up to size:XL over 300) and trust. Every agent PR started as vouch:unvouched, and novice noted in its journal when a repo reached 10 clean merges, which was my cue to consider vouching for it. Vouching changed only the label.
The log
Every worker run that produced a PR, or tried to, added a line to log.md in the tower repo. These are four of gaidin’s from October 17:
| Date | Repo | Issue | PR | Model | Turns | Est. cost | CI | Review | Brad stepped in | After merge |
| 2026-10-17 | gaidin | #3 Declare the supported Node version in package.json | #8 | sonnet-5.5 medium | 6 | $0.06 | success | pass | merged as-is | |
| 2026-10-17 | gaidin | #5 `gaidin --version` | #11 | sonnet-5.5 medium | 10 | $0.13 | success | pass | merged as-is | |
| 2026-10-17 | gaidin | #12 An app-wide event stream, starting with approvals | #14 | sonnet-5.5 medium | 12 | $0.17 | success | block | | |
| 2026-10-17 | gaidin | #12 revision (auto) | #14 | sonnet-5.5 medium | 13 | $0.18 | success | pass | merged as-is | |
novice wrote the first nine columns as each run finished. On later runs it filled in “Brad stepped in” for PRs that had closed since: auto-merged, merged as-is, merged after a fix with my commit subjects, or closed with my last comment. A last column, “After merge,” recorded a revert on main or an issue saying “Broken by #N,” and “none” after seven quiet days. It never overwrote anything I wrote there myself. silviana, a small web console, showed each repo’s timer and its current or last run.
October 17 and 18
Across the two days, the log has 44 worker runs and 31 PRs. All 31 merged: I merged 22 as they were, 8 merged on their own once auto-merge was on, and 1 I merged before the review finished. Nine PRs got the automatic revision. The worker runs ranged from 2 to 34 turns and $0.05 to $0.61 each, and came to $8.12 by tower’s own estimate, about 26 cents per merged PR. The reviewer’s cost isn’t in that figure.
The whole thing was 64 commits over those two days, and the repo is still on GitHub with the scripts, the docs and the log.