How I built an agentic fixer loop for TheBest.Ink with GitHub Actions
2026-07-21
2026-07-21
The booking form on thebest.ink asks a visitor for the usual things, a description of the tattoo they want, where on the body it goes, how to reach them, and then it asks for photos. Up to five reference images of the style they like, and up to five photos of the body part where it goes. Ten images at most, and any one of them can be up to five megabytes.
For a long time this was one POST. The browser packed everything into a multipart form, sent it to the API, and the API validated the images, wrote them to object storage, and created the row. One request, one response, nothing clever. It worked, which is why it survived as long as it did.
It does not scale, and the reason is more interesting than "uploads are big".
Every byte crossed the server twice. Once on the way in from the visitor's phone, then again on the way out to the bucket. The server did nothing to those bytes in between. It was an expensive piece of wire.
The slow half of that trip is the phone. People fill this form in on mobile data, and a worst case submission is fifty megabytes going up a connection that was never built for it. For the entire length of that upload, one of my worker threads is occupied and doing nothing. It is not computing. It is holding a connection open while a phone trickles bytes through it.
So capacity was never really about requests per second. It was about how many people could be uploading at the same moment, and that number was small. The reverse proxy had to buffer request bodies of up to fifty megabytes, and to keep memory from running out I had to cap how many of those could be in flight at once. That cap was the ceiling on the whole feature. Timeouts had to be long enough to tolerate a bad connection, so one stalled phone held its slot for minutes.
A load test made the ceiling easy to see. The bottleneck was not the database, not the image handling, not anything I could rewrite. It was the decision to put the bytes on the request path at all, and there is no tuning my way out of that. The only fix is to move the bytes.
The browser uploads each image directly to object storage. The API never sees a single byte of image data.
The flow becomes three steps instead of one. The browser asks the API for permission to upload, describing what it wants to send. The API answers with a short lived, narrowly scoped credential per file. The browser uploads straight to the bucket. Then the form submits as it always did, except the request body is now a small JSON document naming the things that were uploaded, rather than carrying them.
A submission that could weigh fifty megabytes now weighs about two kilobytes. The proxy buffering limits and the in flight cap stopped being load bearing. The generous timeout became unnecessary. None of that was an optimisation in the usual sense, where the same work is made to run faster. The work simply stopped happening on that machine.
There is a second win that falls out for free. Ten images used to be one request that failed as a unit, so a single flaky upload at the end of a long form lost everything. Now each image is its own transfer with its own progress and its own retry, and the form submission is a small request that either works or does not. The failure modes got smaller and more honest.
The instinct that makes people nervous about this is correct: it hands an anonymous browser a credential that writes to my bucket. The answer is that the credential is not general. The server decides everything about what that upload is allowed to be, and signs those decisions in.
The server picks the destination, so the holder cannot write anywhere else. The server pins the access level, so nothing lands publicly readable in a bucket that also serves public files. The server pins the content type and the exact size, both of which the browser already knows before it asks. The credential expires in minutes.
The same principle governs the second phase. When the form is finally submitted, the browser sends back a reference to what it uploaded, and the temptation is to let it name a storage location directly. That is the mistake. The server hands out a signed token naming the location, and only accepts that token back. Without it, a visitor can submit a form pointing at somebody else's uploaded files, and the performance improvement has quietly become a data leak.
The rule I ended up with: the client can move bytes, but the client never gets to choose facts. Every fact stays server side, signed, and short lived.
This is not free, and the costs are the part that is missing from most write ups.
The server stops seeing the bytes, so it stops being able to prove anything about them. The old code knew an uploaded file was genuinely an image, because it had the file in hand. Pinning a content type is not the same thing: it constrains what a client claims, not what it sent. That check has to move to a background job after the fact, or be given up. The thing worth getting right is making that an explicit decision rather than something that silently stops happening.
One request became two phases, and phases leak. A visitor picks four images, they upload, and then the visitor closes the tab. Those files are now in the bucket, referenced by nothing, and no code path will ever look at them again. This is not an edge case, it is ordinary behaviour on a form that people abandon. Uploads that are never claimed need an expiry policy, and that policy lives in the storage provider rather than in the application.
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"
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.