How I built an agentic fixer loop for TheBest.Ink with GitHub Actions
2026-07-21
I created a side project to grow as a developer. It became something I am proud of
2026-07-13
2026-07-21
2026-07-13
I delegate a lot of implementation work to Claude. The best change I made to that workflow was to give Claude its own fully provisioned machine, in addition to the setup I run on my laptop. In my case that machine is a Mac mini that runs the exact same environment my project needs. I still run Claude locally. I also run it on the mini, and sometimes on both at the same time. When Claude works on the mini, it runs against its own database, and I control it remotely from whatever device I have with me.
As a side effect, that same machine became my CI runner. But the agent was the point, so I will start there.
Nothing here needs a Mac mini specifically. I happened to have one that was not being used, and I decided to give it a job. Any spare machine works, and so does a VPS. What matters is that the machine is always on and already provisioned, not what it is or where it lives. If you have a cheap Linux machine that stays on, or a rented VPS, that is your setup. Read "Mac mini" everywhere in this post as "the always-on machine I chose."
My project is geospatial. That means the native GIS stack, GDAL, GEOS, PROJ, and related libraries, plus PostGIS running on Postgres just to run the test suite. None of this is installed by default in common Linux distributions. The setup is not large, but it is specific. The dependencies are tied to particular C library versions and behave differently across distributions, so I cannot assume a fresh machine has what the tests need. The real environment is the only environment.
An agent is only as useful as the environment you give it. If Claude cannot run the migrations, cannot reach a real PostGIS database, and cannot run the tests, then it is guessing. For a stack like this, guessing is worthless. So the requirement was simple. Claude needs a real copy of the environment, fully provisioned and always running. Not a mock, and not a reduced container. The same specific setup I run for myself.
The Mac mini is exactly that. It is a second, complete copy of the environment, and its only job is to be the place where Claude works.
The obvious question is why I did not just point an agent at a git worktree on my laptop. I tried that. Worktrees isolate source code and nothing else. Every worktree shares the same Postgres instance and the same migration history. The moment Claude runs a migration on one branch, it has changed the state for everything else on the machine, including whatever I am doing.
A separate machine isolates the part that worktrees cannot: the stateful part. Claude runs many migrations and test runs against the mini's database, and my laptop, on a different branch, never notices. That is real parallel work on branches that change state, which you cannot get by adding more worktrees on one machine.
Because the environment lives on the mini and not on the machine in front of me, it stops mattering where I sit. I connect to the mini and control Claude remotely with Remote Control. I give it a task from my laptop, check its progress from my phone, or review a diff on an iPad from the sofa. The valuable part, the provisioned environment, stays in one place. The client is only a way to view it, and I can use any device.
This is the nice part of the setup. The work does not live on a laptop that sleeps when I close it, or that travels when I travel. It lives on a machine that is always on, always provisioned, and reachable from anywhere on the network.
Once a fully provisioned machine with the whole GIS and PostGIS stack is there and always on, making it a self-hosted CI runner is almost automatic, and it is a good result.
GitHub-hosted runners are a poor fit for a stack with specific, non-default dependencies. Every run starts from an empty machine, so every run pays to install the whole setup again, billed per minute, on every push. On the mini the dependencies are already installed, Postgres and PostGIS are already running, and the cost is fixed, because it is hardware I already owned. I did not set up the mini for CI. CI was simply a result of having the environment there.
I would not be honest if I stopped there.
Self-hosted runner security. Self-hosted runners on a public repository are genuinely dangerous. A malicious pull request can run any code on your machine. This is only safe for me because I run it against a private repository. If yours is public, do not do this without first reading GitHub's warnings about self-hosted runners.
macOS versus Linux in production. I deploy to Linux, so running the agent and CI on macOS is a real difference in environment, and I will not pretend otherwise. What makes me comfortable with it is that the parts most likely to cause me problems are the same on both sides. It is the same GDAL, GEOS, and PROJ libraries, and the same PostGIS, only built for a different operating system. That is where the geospatial correctness I actually worry about lives. The difference between macOS and Linux sits mostly below that layer, in areas my tests do not check. This is a difference I accept on purpose, not one I have removed. In my experience these libraries behaving differently across operating systems is rarely the source of a bug. If something only appears in production, I would check this last, not first.
Single point of failure. It is one machine on my network. If it is down, or if I am on a bad wifi connection, both the delegated workflow and CI stop working.
Because short-lived containers make me pay the setup cost again on every run. You get slow builds from an empty state, fragile layer caching, and image versions that change unexpectedly. The setup is not large, but it is specific and it is stateful, and neither of those fits a container you throw away after each run. The whole strategy is to set this up once and keep it running. That is the opposite of what a fresh container gives you, and the opposite of what an agent working for an hour needs.
If your project is light, ignore all of this. An agent on a worktree is fine, and a spare machine is too much. But if your environment needs specific dependencies that are not installed by default, or a stateful database, the change that helped me most was to give my AI agent its own provisioned machine, not only run it beside me:
One Mac mini, one provisioned environment, and everything else follows from that.
For a while I kept reading that loop engineering is the next big change in how we work with coding agents. The idea is that you stop prompting the agent yourself. Instead you build a small system that prompts it for you. Several prominent voices in the industry have made the case for it, among them Addy Osmani in his blog post Loop Engineering. I found the idea convincing, but it was not obvious to me how to start on my own projects.
The part that mattered most to me was the warning at the end. A loop only works if you stay the engineer. You still read the code. You still decide what is worth doing. The human check is not a small detail. It is the thing that keeps the loop safe.
This post is my first small step in that direction. It is not a full loop yet. It is one agent that takes one issue, writes the code, runs the checks, and opens a pull request. I read the pull request and I merge it myself. Here is how I built it.
TheBest.Ink is a side project of mine, and an ambitious one. For a long time it had no real backlog. I kept a Notes file with things I wanted to build, and that was it.
The Notes file was full of small items. A note that should show in the dashboard, some text missing in one language, moving some section to anohter position, etc. Mostly those issues feel too small and with so low priory that I never touch them and the list never shrinks.
I wanted a way to get that work done without turning each item into a task for myself. What I wanted was a system that finishes the small changes that never feel worth the time on their own, while I stay the one who decides what is worth doing and what gets merged.
The idea is simple. I take one small issue and instead of doing it by hand, an agent checks out the code, implements it, runs the checks, and opens a pull request. I read it and merge it. The agent only does the part in the middle, the part I keep putting off.
The work lives in GitHub Issues. They are easy to query, they link to the pull request that closes them, and their labels act as a small state machine: candidate, ready, in progress, in review, done, plus blocked. One rule keeps it safe. The fixer, the agent that writes code, only acts on issues I have marked ready, and only I can mark an issue ready. It never approves its own work, and I merge every pull request myself.
The issue template just requires one thing: what needs to be done. While I am working on something else in Claude, I can ask it to create an issue for a thing I just noticed and would otherwise forget. The queue fills from my normal work, without me stopping to write a formal ticket. Before this, those thoughts usually stayed in the Notes file and were forgotten. Now they become issues I can approve later.
Claude can run scheduled tasks that start a fresh session each time. This was my first choice, because it needs almost no setup, and in theory it is exactly what a nightly job wants.
It did not work for my case, for a concrete reason. The fresh session started empty. It had no checkout of the repository, no GitHub tooling, and no working way to attach the repository to itself. It could not read the code, so it could not do anything with it. There is another mode that runs inside an existing session instead of a fresh one, and that session can see my code, but the environment behind it is temporary and goes away after a while. For a job that has to run every day on its own, neither option was reliable enough.
So I moved the whole thing to a place that already has my code and my tools: GitHub Actions.
The agent runs as a GitHub Actions workflow on a self-hosted runner, a Mac mini I already own. It uses the official Claude Code GitHub Action to run Claude. Here is the core of it.
name: Agent Fixer
on:
schedule:
- cron: "0 23,0-6 * * *" # every hour from 23:00 to 06:00 UTC, my quiet hours
workflow_dispatch: {}
concurrency:
group: agent-fixer
cancel-in-progress: false
jobs:
fixer:
runs-on: self-hosted
timeout-minutes: 45
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
token: ${{ secrets.FIXER_GH_TOKEN }}
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v6
with:
node-version: 24
- run: pnpm install --frozen-lockfile
- uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
github_token: ${{ secrets.FIXER_GH_TOKEN }}
claude_args: "--permission-mode bypassPermissions"
prompt: |
Read docs/agent-loop.md and follow it. Take one issue
labelled ready, implement it, run the checks, and open a
pull request. Never merge. If it is unclear or unsafe,
leave a comment and stop.
The runner is a Mac mini that already runs my CI, so it has the full toolchain. That matters because the agent verifies its own work, and some of those checks need a database and other services my CI already sets up. If the agent cannot run the same checks I run, I cannot trust its pull requests.
claude_code_oauth_token is my Claude subscription, not an API key that bills per use, so a run draws on my plan instead of my card. Running at night is a side benefit here: the subscription costs the same whether I use it or not, so the fixer works through my quiet hours on capacity I would otherwise leave idle while I sleep. FIXER_GH_TOKEN is a personal access token rather than the built-in one, because pull requests opened with the built-in token do not trigger CI, and I want the agent's pull requests checked like any other. bypassPermissions is there because a scheduled job has no human to approve tool prompts, so without it every action the agent tries is denied. That is a real trade, safe here only because I write every issue and review every pull request.
The workflow also lives in the repository as a normal file, and every run shows up next to my CI, so I can see what ran and when. Getting here took a few small fixes that are not worth listing, the kind of thing I only found by turning the full output on and reading it.
The change that made the agent trustworthy was not a better prompt. It was making myself the gate on every pull request.
The first real run showed the shape of it. The tasks was to put a warning text in the artist dashboard. The agent found the right component, reused an element that already existed instead of inventing new UI, wrote the copy in English, German, and Spanish and opened a pull request with the changes. That is the behaviour I wanted.
Today the agent handles small, well scoped tasks, and I fill the queue myself, either by writing issues or by asking Claude to file them while I work. That is the current limit. The queue only fills as fast as I fill it.
The fixer agent works. The next step I am building is a finder agent, one that reads the code, the running app, Sentry and proposes issues on its own.
With both in place, the full chain looks like this:
finder (proposes the work) -> me (approve what should be done) -> fixer (implements and opens a pull request) -> me (accept or reject, then merge to main)
I stay at both gates. The agents do the work between them, and I keep the two decisions that matter: what is worth doing, and what is good enough to ship. More about that soon.
A year ago I started a side project. The public reason was a real problem. Finding a tattoo artist who cares about your tattoo is harder than it should be. The good ones are overbooked, and when you search, you get a list of studios with no way to compare them.
The private reason mattered more to me. I wanted to grow as a developer, and for that I needed a real problem, not a tutorial. This is what a year of that looked like, and what it taught me.
When I started, the whole thing looked simple. A Django API and some kind of frontend. Let people search for studios by place and by style. That was it. I did not even plan to let users book appointments.
The simple idea was not simple. That gap is where most of the learning happened.
A search platform with an empty database is useless. Nobody visits, so no artist signs up, so there is nothing to search, so nobody visits. I could not wait for artists to fill it.
So I built the supply side first, before a single artist signed up. I wrote a pipeline that gets studios from a map provider. The first step gets the address, coordinates, and website. The second step reads each studio website as plain text and passes it to an LLM, which returns structured information: the artists, the styles, and so on. That data gets saved as a studio profile.
I designed the map provider to be interchangeable. I started with Google Maps, but the pipeline does not depend on it. I also made the region size configurable and kept a history of every scan, so I could grow the coverage area step by step instead of all at once.
The result is that when an artist arrives, their profile already exists. They claim it instead of building it. This is the part I am most happy with, because it solved a real problem and not a toy one.
Once search worked, a new problem appeared. Search for studios in Salzburg with the style Blackout and you might get six results. Which one do you pick? A plain list does not help.
I added a score from 0 to 100 based on reviews. The naive version, a simple star average, is unfair. A studio with one five-star review would beat a studio with fifty reviews at 4.8 stars. That is wrong. So I used a Bayesian ranking, which moves scores with few reviews toward the average until there are enough reviews to trust them. I called it the TheBest.Ink Index.
I kept telling myself this is only a side project. But if the goal was to grow as a developer, then building it carelessly would defeat the point. So I used the same practices I use at my job:
Here is what a year of that looked like.
This is my GitHub activity for the last year. Around 97 percent of it is one private repository, TheBest.Ink. The gaps are real. This was spare-time work, at night, early in the morning, and on some holidays, next to the job that pays the bills.
As the project grew, the number of cases became hard to keep track of. Discovery generates profiles, artists claim them, users send requests, artists accept, users leave reviews, artists reply. Each of those has edge cases.
I wrote most of the code and docs AI-assisted, as if I were working in a team with other developers. That changed how I think about two things.
The first is documentation. The same document has to serve a human who is new to the code and an agent that needs context to do the work. Keeping two separate sets of docs means they drift apart. The second is project layout. The project started as four separate repositories, and both I and the agents kept losing context when moving between them. Merging everything into one monorepo fixed that. The agent could see the whole codebase, and so could I.
I wrote two posts about what I learned there: "Customer Journeys Belong Next to Your Code" and "The Problem With Splitting Human and Agent Docs".
The full flow works end to end and is covered by tests. Discovery creates a profile, an artist claims it and verifies they own it, the artist manages their data, a user sends an appointment request, the artist accepts, the user leaves a review, and the artist can reply. The design is becoming consistent across the apps, and the whole thing is starting to feel like a solid product.
Here is a short video of the product in use.
Finding a studio and its artists, then an artist profile with the artist's own data, reviews, and portfolio images.
The goal was to grow as a developer, and it worked. Along the way it became something I did not expect: a product I am proud of. A year ago it was an idea and a rough plan. Today it runs end to end, and I built every part of it myself. That is what I am happy about. You can try it at TheBest.Ink.