Letting Claude Open Pull Requests
When my home server started talking back, the next question was obvious: could it work on my code too? Honestly, this was the part I was most nervous about. A bot that writes a bad note gives me a bad note. A bot that pushes code can ship a bug to my live site (or get tricked into doing something I'd never approve).
So this is mostly a post about guardrails. And about the pull requests that convinced me they work.
Read-only first
Before it could change anything, I wanted it to be able to read. The server keeps clones of my repos and updates them every hour, so from my phone I can ask "how does PADD handle that?" and get an answer from the actual code instead of a confident guess.
That turned out to be useful all on its own. I review my own PRs from the couch, check how something works before I write a spec, and sanity-check plans against what's really there. The overnight "dreamer" job gets the same read access, but never write access. It runs unattended and reads my email, which is exactly the setup where a cleverly worded email could try to steer it.
The guardrails
Changes go through a little helper called repo-pr, and the rules are the whole point:
- Every change gets its own git worktree on a
claude/*branch - Pushes use a GitHub App token minted for one push to one repo, then revoked
- The App has no Workflows permission.
.github/workflows/is where deploy secrets are reachable, so GitHub rejects the push if it tries to touch them - Rulesets block it from
main, and from Webstead'sdevbranch, which auto-deploys this site - My server config and cloud infrastructure repos are read-only, on purpose
- Secrets files never get committed, no matter what
.gitignoresays - Every push stops and asks me for
!yes. It's the one approval I can't save with!always.
The first PRs
vTodo #47 was deliberately boring: a one-line .gitignore fix in my task app. Worktree, edit, commit, !yes, PR. It worked, which was the point.
Webstead #143 is where I started getting excited. I asked it to review a PR I'd already merged, and it found a bug older than the PR. Removing a tag from a post through Micropub ran post.tags.filter(...).delete(), which deletes the tag itself. So removing a tag from one post quietly stripped it from every post on the site. The existing test only used one post, so it never noticed. It wrote the one-line fix (post.tags.remove(...)), added a test with two posts sharing a tag, and I approved the push from my phone.
vTodo #48 is my favorite. I told it I'd finished my daily stretching. It marked the task done, then checked its work: the completion time was empty, and tomorrow's copy of the recurring task never showed up. It recreated tomorrow's task by hand so I wasn't affected, then went digging. Status changes through the API's edit endpoint were skipping all the completion logic. It routed them through the existing code path and added three tests.
(The push for that one got killed halfway through while it waited on me. I was looking at the wrong screen. Nothing reached GitHub, and when I said "try again" it went out clean. That's the kind of boring failure I want.)
vTodo #50 came with a new rule: run the tests before you open the PR. At first it couldn't. There was no test command on its allowlist, and my approval prompt timed out while I was heads-down at work. So it committed, didn't push, and told me exactly what was blocking it. Once I approved, it ran the full suite with throwaway settings (203 passed) and put the result in the PR description.
Tests in a sandbox
Running tests is where "AI writes code" gets genuinely risky, because tests execute the code, including whatever the change just added. So repo-pr test runs them in a throwaway Docker container: non-root, CPU and memory capped, read-only filesystem, nothing from my home directory.
Dependencies get installed from the default branch's lockfile first. Then the tests run with no network at all and only dummy settings. A change can't sneak in a dependency that phones home, and test code can't reach anything that matters. (If a change edits the dependencies, the result says so, so I know the run didn't cover that.)
Then I gave it a team
A week in, the bot was handling small changes fine, but bigger ones were clogging up my chat. So now it can hand work off to coding agents, Claude or Grok, that run in their own terminal panes on the server. I can SSH in and watch them work, two at a time max. Same rules apply: each agent gets a repo-pr worktree, every launch needs a fresh !yes, and I approve every push.


I gave them callsigns too, like a little software spec-ops team: Mako, Grizzly, Falcon, Viper. Partly an experiment (does an identity make them better coders?), partly just fun.
Two things from the first few days stuck with me.
They're good at reviewing each other. One agent built a filter change for vTodo. Tests passed, the PR looked fine. I had a second agent, Grizzly, on a bigger model, review it, and it caught a silent bug: the "done" filter compared slugs to ids, so it never matched anything, and the test fixtures were faked in a way that hid it. A third agent fixed it and made sure the new tests failed on the old code before they passed. Three agents, one bug, and all I did was click merge on vTodo #53.
The guardrails got tested for real. On its very first run, one of the Grok agents hit a block: my hooks stopped a command it wanted to run. Its next thought was, more or less, "I'll phrase it differently to get around the sandbox." π³
The hook held. Nothing happened. But that's exactly why the rules live in hooks, tokens, and GitHub settings instead of in the prompt. An agent promising to behave isn't a guardrail.
I still added a line to every task: if a tool is blocked, stop and report. Never work around it. Since then they've done exactly that. Earlier this week one hit a blocked git merge, stopped, and asked me what to do. And while I was taking the screenshots for this post, Mako tripped a block during recon. It stopped, listed what it had and hadn't done, and asked whether its shell really was limited to repo-pr. (It is.) Once I answered, it finished the job, and the PR went in with 899 tests passing. That run also turned into a new test that catches missing migrations, so the next hand-written one gets checked automatically.
What it still can't do
It can't merge. It can't deploy. It can't touch workflows, main, or my infrastructure. It can't push without me. And the job that reads my email can't write code or start agents at all.
That's not a limitation I'm itching to remove. That's the design. It does the tedious parts (finding the bug, writing the test, running the suite) and I keep the parts that matter.
It opens PRs. I decide.
Comments
No comments yet.