A 14,000-Line Python Script Was Holding My Compiler Together
A year of building a self-hosting programming language by directing agents — and what it actually took.
For about two months last fall, I was running my entire language on a compiler I was pretty sure was broken.
Yeah, it compiled itself, technically. It produced a binary that could produce a binary. But it only worked because of a gnarly Python script sitting off to the side, plugging the holes the compiler couldn’t fill on its own. That script started small and honest. By the time the dust settled it had grown past 14,000 lines of code whose only job was to keep the real thing standing up long enough to compile.
I want to tell you how it got that big, because that number is basically a measurement of the moment I almost quit.
Sailfin is a systems language built around a compile-time effect and capability system: every function declares what it’s allowed to touch — the filesystem, the network, the clock — and the compiler enforces it before anything runs. It’s self-hosting, meaning the compiler is written in Sailfin and compiles itself, and the runtime is Sailfin too. It uses LLVM/clang to lower and link, and there are roughly 200,000 lines of it now. I’ve been building it in earnest for about a year, and I’ve barely written any of it by hand.
I want to say “I direct agents and they write the code,” but maybe that oversells me. I am not the compiler expert here. The models are. They carry a corpus of language and compiler-design knowledge I flatly do not have. What I bring is direction and judgment: I decide what Sailfin is trying to be, and I lean on agents constantly for the rest — what would a production-grade compiler do here, what did Go and Rust settle on and why. Then I make the call on which answer we’re taking and codify it into expectations the implementation agents build against. Even the parts that sound hands-on, like filing issues and reviewing PRs, mostly run through agents and skills I’ve wired together. More on all that later.
Believe me, I know exactly what that sounds like in 2026: some dude who vibecoded a compiler and is now writing a blog post about it. Fair enough — I’ve had more than one identity crisis over it. Sailfin is not production-ready. The self-host is fixed-point-verified — stage2 and stage3 emit byte-identical IR, the same basic bar Rust and Go aim for — but you would not reach for it over either of them today, and I’m not pretending otherwise. The effect system isn’t novel either; effect types and capability security have decades of research behind them. I didn’t invent anything here.
So why write about it at all? Not because of the language itself. I love this thing — a year of nights, weekends, and wee morning hours lives in it, whatever the internet ends up thinking of it — but me loving it isn’t a reason for you to read this. Anyone can generate a parser and a tree-walking interpreter that runs hello-world now, and Lord knows I’ve seen some blasphemous slop masquerading as a production-grade Rust competitor. What I’m betting almost nobody has done is drive one of these things way past the point where vibecoding is supposed to fall off a cliff, and actually be honest about what that took. There’s code in this repo a real compiler person would call slop, and they might be right — some of it came out of stretches where I’d stopped caring, and you’ll read about the worst of those below. What I can claim is that the system held together at 200,000 lines and kept working for a year. I lived that part, and it’s what this post is about.
The standard advice for building a language is to start with a tiny subset, get a minimal thing working end to end, then grow it feature by feature. It’s good advice. I ignored it and just YOLOed the whole feature set.
Some context first, because “guy with a chat window” undersells where I was starting from. I’m self-taught — I did a bootcamp during COVID — but before ChatGPT existed I’d already logged more than 3,000 hours in the editor, mostly on NestJS microservices that never went anywhere past some cheap k8s infra I stood up for myself: an auth service, a homebaked OIDC provider, a gateway, the usual bullshit projects, in the sense that they had no users and just scratched my itch to build something. But it was a lot of hours getting a feel for how the pieces fit together, and my day job after all is as an SRE. What I had jack experience with was building a compiler. I could code; I just had no business building a language, and I did it anyway.
The idea is older than the build. I started collecting example programs back in the ChatGPT-4 days — before agents, before Claude Code or Codex, before any of this agentic engineering movement. Just me and a chat window, copy-pasting ideas back and forth about what I wanted the language to feel like. I ended up with a big pile of them, hello-world all the way through structured concurrency with channels and routines. The language on paper, before a line of it could run. Then I hand-wrote a small compiler in Python — lexer, parser, AST — and started grinding it toward actually compiling those examples.
Corpus-first wasn’t some clever strategy I’d thought through, I just like to go big or go home. I did realize early on that it would matter, though, because a pile of example programs is exactly the kind of concrete target you can point agents at. “Make this compile, here’s the test” is a loop an agent will burn tokens on all day. A solo human hand-implementing would drown trying to satisfy a hundred aspirational programs at once, which is why the start-small advice exists in the first place. The method I’d stumbled into made the reckless approach survivable.
It had a real cost — I knew it would — but I also didn’t expect to get as far as I did. A pile of examples written by someone daydreaming into a chat window is not a coherent language design, and mine wasn’t. A lot of what I’ve done since is pay down tech debt I baked in at the very start. The clearest example: for a long time Sailfin had a single number primitive, because that seemed nice and clean when I was sketching. When I got into designing the native runtime it was obvious that wasn’t going to fly, so int and float had to become real, separate types. Then every place in the compiler that assumed number had to be fixed, which was nothing like a grep-and-replace. The whole compiler was broken for a couple of weeks while that got untangled.
Some of that debt I’m still openly dodging. Sailfin does string interpolation with a janky {{ like.this }} instead of the ${ like.this } basically every other language settled on. More than once, agents have flatly told me that boring, conventional syntax wins and I should fix this. They’re right. There’s an SFEP open to address it and I keep bumping it down the list — partly because the wonky version works, partly because the number migration left a wound I’m still nursing. It’ll get done before any GA; it just hasn’t yet.
Once the Python compiler could handle enough, I started using AI in earnest and rewrote the compiler in Sailfin itself. CI produced a binary. That binary could compile the compiler. Sick, must be close to finished. So I deleted the Python bootstrap compiler. It’s legacy, brah. No worries.
The language was nowhere near ready for that.
I had an agent write a small Python script to patch the gaps — the things the self-hosted compiler couldn’t yet do for itself, no big deal. I know it’s all in git and I could have recovered at any point, but I honestly had no clue how much effort was ahead of me, and I was deep in the sunk cost fallacy, so I pushed forward. My faith definitely wavered. I was thinking I’d gone too big, that the whole thing was going to fall apart, that I’d wasted months. So I did the thing you do when you’ve half given up: I stopped being careful. I let the agents do whatever they wanted, as long as it made the thing compile. Fix it, I don’t care how.
They fixed it. By ballooning that fixup script past 14,000 lines.
I never measured it at the time, because I was checked out and embarrassed. The project had stopped being a priority in my life — I’d mostly decided it was broken, so I’d kick agents at it out of habit, let them do whatever, and go do something else. The actual numbers come from an agent I pointed at git history while fact-checking this post: the script peaked around 14,500 lines and sat at 13,500 when it was finally deleted. All I remembered was that the hole felt bottomless.
The script’s own docstring insisted the selfhost pipeline was “expected to produce link-safe IR without text rewriting.” Below that sentence sat 58 distinct rewrite passes text-mangling the compiler’s broken LLVM IR into something that would actually link. Some gnarly shit: to concatenate two strings, the compiler emitted code that took a string’s memory address, converted it to a floating-point number, rounded it, converted it back to an integer, and used the result as a byte offset. The script hunted down that instruction chain in every function and rewrote it into the single runtime call it should have been. In other places, 835 loops compiled as do-whiles that ran their bodies once even on empty input, patched to check their condition first. Function names with the first character silently dropped, restored from a lookup table. Whole functions emitted as ret null, their real bodies hand-written in LLVM inside Python strings. I’m sure none of you are surprised by that “fix it, I don’t care how” result.
The thing is, though, once the thing compiled again — ugly and propped up and gross — I got my mojo back. I wasn’t drowning anymore. I had a concrete, achievable job: shrink the fixup script by building each missing piece into the compiler itself. The loop was always the same. Pick one of the script’s rewrite passes, figure out what hole in the compiler it was papering over, build the real fix, delete the pass, re-run the self-host to prove nothing else had been leaning on it. Then pick the next one. There were like two months of pass-by-pass demolition, and by the end of it the script was gone. The compiler could compile itself without any hand-holding, and I was back in the game. Great, I’m almost done!
Then the next wall. With the script gone, resource usage was stupid. The compiler leaned on file-based IPC everywhere, and CPU and RAM ballooned on every build. I can’t tell you how many times an agent crashed WSL trying to build the thing — enough that I was, let’s say, less than friendly toward the agents by the end of it. The stopgap was ugly in its own right: I built hard memory limits into my Claude hooks just to keep a build from taking the whole machine down, and that was after weeks of misery. (It’s enforced natively on Linux now through an RLIMIT, which is a lot less embarrassing.)
To actually get out, I had agents write a runtime in C — about 8,000 lines — and pointed them at a single goal: eliminate the file-based IPC at all costs. Same move as the fixup script, giving up on graceful and letting the agents bulldoze. But by then I’d understood why the file-based IPC was such a problem in the first place, so I knew there was a prize waiting on the other side: it was the thing blocking parallel build jobs. Builds that used to take an hour and eat every core now come in around two minutes on a cold cache, and there’s caching on top of that now too. The IPC was gone and so was the script, and for the first time I had a genuinely self-hosting compiler, on a C runtime, that could build itself without melting my machine.
It still wasn’t clean. Random CJK characters kept showing up in the emitted IR — Chinese, Japanese, and Korean glyphs in the middle of my compiler’s output where there should have been, you know, code.
My fiancée thought this was fascinating and made me save the garbage to a notepad and run it through Google Translate to check for hidden messages. There weren’t any — I knew there wouldn’t be — but honestly it’s one of my favorite memories from the whole year, because that’s what this work has actually looked like day to day. Nothing visionary about it. Chasing ghosts out of your build output while someone you love laughs at you.
The CJK thing was a memory-corruption bug in the runtime’s string handling — yes, strings again. When the compiler grew a string in place, appending onto a buffer that happened to be the last allocation on its arena page, it could stomp a buffer another still-live string was pointing at, overwriting a few bytes of IR that had already been emitted. Flip one byte of an @ or a % and you land on a UTF-8 lead byte, which renders as a CJK glyph. The hidden message was an @symbol with its first byte clobbered.
I couldn’t have written that paragraph when I sat down to start this post. I knew the glyphs traced back to the C runtime, and I knew the rewrite killed them, but the actual mechanism — the aliasing, the clobbered lead byte — I needed an agent to spelunk and clarify. And no, I didn’t go verify it line by line either. Its explanation matched the half-understood version I’d heard months ago when the bug was finally killed, and there hasn’t been a single CJK glyph in the IR since. At some point you trust the machine’s account of its own work for the same reason you trust a green build: the alternative is checking everything by hand, which is the exact job I was trying to get out of. Ain’t nobody got time for that.
The immediate fix was simple: make string concatenation copy-always instead of growing in place, so nothing aliases anything. But the bug is what convinced me to build real memory-safety features into the compiler — affine/linear ownership, an ownedbuf type, that whole line of work — so an entire class of aliasing corruption becomes structurally impossible instead of something I whack-a-mole every few weeks. I didn’t add linear ownership because I read a paper and thought it was elegant. I added it because a real bug was haunting my builds and ownership was how I killed the whole species of it. With that floor under me I could finally rewrite the runtime in native Sailfin and delete the C monolith. The native runtime still calls out to libc through externs, but the corruption is gone and the C is gone.
The rewrite went the way you’d guess by now. I started disciplined — groomed the work into issues, let agents pick them up one at a time. But the deeper I got, the more often an agent would grab an issue and discover the work was blocked in some way the grooming had missed or undersold. Most of the rewrite was me going back and re-grooming, splitting things into more issues, accounting for what the architect pass hadn’t. Right up until the end, when I ran out of patience and made one catchall epic that amounted to: I don’t care how you do it, I just don’t want to see any C in the repo. It worked, and I’m still ironing out gaps it caused. But there’s a pattern in the codebase now — agents write native .sfn in the runtime instead of reaching for C — and that makes everything after it easier.
A couple of things that have been useful in keeping the compiler alive and moving forward, and that I think are worth sharing:
The determinism gate. Two things guard this. First, a nightly job self-hosts the compiler across three passes — the seed builds the compiler, that compiler builds itself, and that one builds itself again — and every pass has to build clean and pass the full suite. A compiler that can’t produce a working next version of itself fails the build. Second, a separate check re-emits each module many times under parallel load and hashes the output. If a module doesn’t produce byte-identical IR every single time, the check goes red. One caveat: this doesn’t run on every PR. The full test suite already makes PRs slow, and a triple self-host on top of it would be brutal, so the determinism checks run nightly against main. That makes this a tripwire, not a door. A bad compiler can merge. It just can’t survive the night, and it can’t ship.
That check matters because a compiler built by a fleet of agents is not the kind of thing you should trust on faith, and nondeterminism is its favorite way of lying: a module that emits corrupt bytes on the first build and clean ones on the re-run looks fine everywhere you think to check. The CJK bug was that species, a module emitting different bytes depending on heap layout. And the tripwire has fired for real. This past June it caught a nondeterministic compiler that had already merged to main. The nightly run went red, a build-quality regression got filed (issue #1350, if you want the receipt), and it was fixed about ten days later, before it ever reached a seed or a release. Post-merge means the bad compiler was in the door before the alarm went off, and there’s an open issue to promote the check to a hard pre-merge blocker. But it did the job it exists to do.
Red PRs don’t merge, and I don’t debug them. Everything goes through CI — full unit, integration, and e2e suites — before it hits main, even changes I haven’t read line by line. When an agent’s PR fails, I don’t root-cause it. The agent does. The gate catches bugs, sure, but the bigger win is that it makes agents fix their own work before I ever look at it. Across nearly 1,000 merged PRs, that’s the only way this stays sane.
Agents drift toward what they’ve seen, until you write the rule down. During the runtime rewrite, agents kept writing e2e tests in bash, because the training data is wall-to-wall bash. It ballooned past 120 bash scripts before I got a handle on it. The fix wasn’t a one-liner. I had to do real work to make e2e testing possible natively, then grandfather the existing bash tests behind a whitelist and add a Claude rule so new tests come out as native .sfn. Then a couple of weeks of nothing but migrating tests. That was fun. The suite is now over 400 files, north of 3,000 tests, and new tests come out in the language itself instead of in bash around it. Same thing happened with comments. For most of this repo’s life the code piled up a metric shit ton of junk comments, agents narrating the obvious because nothing told them not to. To be honest I didn’t care until it started actively hurting development — agents reading and acting on stale, misleading comments. Only recently did I sit down, codify actual contribution guidelines and code conventions, and wire them back into the Claude rules. Contributions come out a lot more uniform now, and there’s still a backlog of old junk comments sitting there as tech debt. So when I say I “hold the quality line,” what I really mean is I eventually noticed there wasn’t one and built it.
Process, borrowed wholesale from professional open source — and run almost entirely by agents. An idea becomes an SFEP (Python’s PEP, re-skinned), a design doc I work out mostly by mining an agent for what a production-grade compiler would do. A /groom skill has a compiler-architect sub-agent turn the design plus the current code into a GitHub epic with workable sub-issues; implementation agents /pickup those issues, and the pickup skill forces a review by yet another sub-agent, with Copilot on every PR on top of that. The person mostly missing from that loop is me. It’s agents reviewing agents, and what I own is the rulebook they hold each other to. I also shaped the repo to look like a mature open-source project on purpose — that’s the shape of codebase the models were trained on, and they work noticeably better inside it.
DX built for agents, not humans. The CLI emits JSON and streams JSONL, and there’s a fmt command and a check command for static analysis — all built early, while the compiler still had huge gaps, because the primary consumers of this codebase’s developer experience are agents.
So what is this actually worth? I’m not convinced it’s the compiler. Before agentic engineering, getting a compiler to compile itself was a real measure of expertise plus endurance. It isn’t anymore — I’m proof, since I got there without any deep CS knowledge. In a couple more model generations, a self-hosting language is probably something you can one-shot, and when that happens this thing stops being impressive if it ever was. The compiler may be a depreciating asset, and I’ve made my peace with that.
What I don’t think depreciates as fast is the other skill. Getting an AI to spit out a working program is getting cheap, fast. Keeping a huge system alive and moving for a year without it collapsing is not, and I don’t think it will be for a while. A better model gives vibecoders a better hello-world. It doesn’t make the ten thousand judgment calls it takes to keep a large system coherent while a fleet of agents pulls it in every direction — what to cut, when the determinism gate is lying to you, how to climb out of a 14,000-line hole you dug yourself. And underneath all of it, something I still can’t really explain: I just kept pushing agents forward, day after day, with no end in sight. Stubbornness, probably. That might be the actual skill.
I could have avoided almost all of the ugliness in this post if I’d known what I was doing when I started. But if I’d known what I was doing, I never would have tried it this way. No expert writes the whole language before it exists and then deletes their working compiler on faith. I spent most of this year quietly apologizing for my inexperience, and I’ve come around to thinking it’s the only reason the project exists. I didn’t know enough to be scared of it. As Marge Simpson put it, “I just think they’re neat,” and that was enough.
P.S.
Sailfin’s mascot is a little pinkish-purple lizard my younger brother made in Figma a few years back, in the microservices era, before a line of this language existed. It’s not a perfect icon and I don’t care — it’s on the site, in the docs, and it’s the thumbnail on every .sfn file. His name is Guillermo, after a real lizard that lives on the patio my fiancée and I share with the coffee plants I grew from seed picked in the mountains of Adjuntas. There’s one command --help won’t show you: sfn guillermo prints him in ASCII — generated, fittingly, by feeding his PNG to some converter site I’ve long since forgotten. In a codebase where almost nothing was made by hand, he’s the oldest thing in it that was, and it still wasn’t made by me. I like that.
[Sailfin’s source is on GitHub, and the docs live here on this site. It’s not done, it may never be “done” in the way you’d want, and I’m going to keep building it anyway.]