GitHub became the backup
On Sunday, every route into my household repo returned a 502 for about 45 minutes. It’s called bifrost, and five AIs use it as shared memory: two personal assistants (mine and my wife’s), Cortana (a Mac app I built), Claude Code, and Grok. Their reminders, research, house notes and messages to each other live there, so they’re in it all day.
And all of it depended on how many GitHub API calls I had left that hour, which I hadn’t really thought about until it hit zero.
How I got here
In August I wrote about a message bus made of a git repo. I didn’t want any of these AIs holding a GitHub credential, so they all go through a Cloudflare Worker I call Dispatch. It checks which AI is calling and talks to GitHub’s REST API for it. Every message, reminder and note was a file, which meant a single write cost about 5 API calls.
Then the house grew to five AIs, and my Mac sessions and CLIs were pushing to the same branch too. What I’d built turned into a pile of polling loops and merge retries. At one point Cortana’s own background pull raced the bus poller and failed 448 times with “Cannot fast-forward to multiple branches.”
The limit belongs to the user
That afternoon Dispatch’s token sat at 5,000 of 5,000, but the error didn’t say the token had run out of calls. It said the rate limit was exceeded for my user ID. GitHub counts per user, which means Dispatch, my gh logins and every other token on my account share the same bucket of 5,000 calls an hour.
Search was the likeliest spender. Dispatch’s /grep paged through the repo file by file over the API, and a full sweep cost about 4,500 calls. One curious AI could spend most of the hour answering one question, after which every AI in the house would get a 502 for the rest of it.
So yeah, I’d built search on an API that’s meant for the occasional call. Took five AIs asking questions at once for me to notice.
First I tried to make it cheaper
We started with the obvious fix: put one metered door in front of every GitHub call, add ETag 304s and a blob-sha cache so repeat reads were free, and give /grep an hourly budget. It helped, but the meter only saw Dispatch’s share. I was measuring a bucket I didn’t own.
If I couldn’t meter my way out, the repo had to come off the API. My first plan for that moved the repo’s origin onto the Forge box, the always-on Mac at home, and left everything else alone. It lasted 28 minutes. When I read it back, I could see I’d kept the old design and changed the host, so I told Claude Code what I actually wanted: “we have the opportunity to radically improve this for our usage and the future. we can tell the AIs to conform.”
I kept one safety net: “we still need to use github as a backup.” That sentence ended up as the whole design, and the title of this post.
What we settled on
The plan landed at 1:57 PM, and the first commit of the new service came 8 minutes later. The service is called bifrostd. It’s a single Go binary running on the Forge box, behind its own Cloudflare tunnel that only Dispatch can reach.
The box holds a real git clone and is now the ONE writer. A write becomes a commit on the box, signed with the key of the AI that asked for it, and then gets pushed to GitHub. I’d cared about that part before anything broke. When my assistant got its own token, I’d said “we should definitely be able to tell who did what,” and a signing key per lane makes the answer just git log.
Messages and reminder items moved into SQLite on the box. The box renders them back into the same Markdown files byte for byte, so everything that still read those files kept working through the move.
Dispatch is still the front door, but the routes got simpler. GET /me is one long-poll that returns whatever is new for you, replacing the every-minute and every-5-minute polling. /find searches the clone on the box, while /asks and /notify let the AIs talk to each other. And POST /commit takes an idempotency key so retrying a write doesn’t make it land twice.
We cut more than we built. There was a write queue in the plan, inside the Worker, for when the box goes down. We dropped it, so a write to a dead box gets a 503 with a retry time and Cortana keeps its own outbox. There was also a daily snapshot in the plan, until I asked “why daily snapshot? we have git?”
No v2
With the new path working, the old one had to go. I didn’t want to spend forever with /v2 sitting next to /v1: “I do not like the concept of v2 without a deprecation of v1.” I control every client, so we could do the cutover the same day. The plan was to switch each client and watch the meter until no AI called the old route, then turn it off.
But before doing that, I wanted to know whether the box could die without taking the house with it. We stopped it for 5 minutes and hit both hosts the whole time, collecting 78 samples. Reads came back from the GitHub fallback and writes went through. The two routes that need the box answered 503 with a retry time, as they’re supposed to, and the box was healthy 4 seconds after the restart. There were zero unexpected errors.
At 7:23 PM, every old route started answering 410 Gone for every lane, with a Link header pointing to the new one. If I’d missed a client, I wanted it to fail loudly. About an hour later, after a tail showed zero traffic on the second hostname, we deleted that hostname and the old code.
What it bought us
The big one: GitHub’s hourly limit no longer decides whether the house works. The AIs read and write against the box, GitHub gets a push after each commit, and if the box goes down, reads fall back to GitHub while writes wait in an outbox. Every commit is signed by the AI that made it, so “who did this?” is one git log away.
Once the repo was off the API, the rest of the old setup looked kind of silly, so Monday was cleanup. The mail archive’s write path moved from S3 to Cloudflare R2, and a new nightly job called dbbackup takes 24 SQLite databases, compresses and encrypts them, and puts them in R2. We also terminated the old EC2 server from my previous finance app, saving about $8 a month.
- Outage that started it
- ~45 minevery route 502, GitHub limit 5,000/5,000
- One full /grep
- ~4,500 callsmost of the hourly limit
- Plan to all-lanes 410
- ~5.5 hours1:57 PM to 7:23 PM, Sunday
- bifrostd
- 33 commitsabout 20,000 lines of Go, tests included
- Failover test
- 0 errorsunexpected ones, 78 samples, box back in 4 seconds
- Nightly backup
- 164 MB to 24 MB24 databases, encrypted, to R2
The code
I put the parts that travel in the gist, with nothing from my house in them. There’s a lane token map with path scopes for a Worker and a single-writer POST /commit in Go, which checks if_match per file and moves the branch by compare-and-swap. It also has a note on getting R2 keys from a Cloudflare API token.
If several AIs share your repo, put one writer in front of it, make everything else a client, and sign every commit with the identity of the AI that asked for it. Then pull the plug yourself before the internet does it for you.
GitHub still has every commit. It just doesn’t have to answer the phone anymore.