Blog

LLMs Are Tools, Not Replacements

I’ve been meaning to write this post for a bit, but never found the right time. I guess this is it. Until sometime last year, I was more or less an AI-skeptic. I say more or less because I was always very interested in the technology. I built my own LLM to learn about it and I thought then, as I do now, that the technology is incredible.

And yet, I had tried using LLMs to help with coding and my experiences were not great. I used LLMs to write one-off scripts for me, they were very good at that. But whenever I tried to use them to help me write “production code”, they would hallucinate or get stuck in “bug loop”. I felt like I was spending more time dealing with the aftermath than I’d do writing it all by hand. I even disabled Copilot autocomplete because I felt like it was distracting.

Fast forward to today and most of my code is written by LLMs. How this change happened is a combination of how much the tooling improved but also the recognition that I was holding it wrong.

Now, don’t get me wrong. This post is not meant to convince anyone of anything. I’m not selling anything here. This post is for engineers who are curious about how others work with LLMs and trying to find their own workflow. I’ll show you exactly how I work now and how it works for me.

The bug that changed my mind

As mentioned, I was a bit of a skeptic. I knew LLMs were good at writing one-off scripts and I was using them a lot for that, but not more than that. Then one day someone asked for help with a bug.

We had this multicell architecture and we had a proxy/multiplexer that would decide where any given request should be routed to. Once that decision was made, the request would be proxied to an ALB using a custom transport. The ALB had resource mappings to know where inside a given cell things were hosted, so the custom transport requested a URL from the ALB, the ALB responded with a redirect to the actual destination inside the cell it belonged to. The custom transport would require the request and make it to the correct destination.

The bug: seemingly at random, some requests would succeed and some would not and no one could figure out why. So I started looking and quickly found that it wasn’t random at all: requests with bodies would fail. When I saw that, I immediately thought it was the custom transport eating the body, except I remembered writing that transport and found it hard to believe the issue was there. And upon looking at the code, it seemed fine. I added logging and went about trying to reproduce the issue. The code seemed correct, but the issue was still there.

After a while, I decided to try Claude Code. I launched it on the repo and explained the problem. I’ll admit I did not have high expectations, but hoped that maybe it could give me some insight that would help. To my surprise, in about 40s it came back saying it had found the issue: the transport was eating the request bodies. My first reaction was being frustrated because I knew I had already looked at it and the issue was not there. I thought Claude was being dumb. Except I noticed it was showing code that didn’t look like what I was looking at. Long story short: at some point, someone had copied and pasted some code and added a second custom transport somewhere where it shouldn’t, and that transport had a bug.

I didn’t fully convert then, but I started paying more attention. I began using LLMs for debugging and code reviews, things where being wrong was mostly harmless and I could verify the output easily. Over time, that expanded. Now we’re here.

The mistake I made early on

When I first tried AI coding tools, I treated them like code generators. Describe what you want, get code back, paste it in, repeat. This was the intuitive way to use them, and it’s wrong as far as I am concerned.

For those one-off scripts I mentioned before, I recognize now that I was “vibe coding” them. But that was fine because they were only going to be used by me. But I don’t let LLMs write unsupervised code that I need to ship for others. So the problem is that generated code requires review. Review requires understanding. If you didn’t think through the implementation yourself, you’re now reading code you don’t fully understand, looking for bugs you can’t anticipate, in an approach you didn’t choose. You’re doing more cognitive work than if you’d just written it yourself, and the code is probably worse.

The mental shift that made everything click for me was that LLMs are tools, just like LSPs were tools, and pre-LLM autocomplete was a tool. They’re not a replacement, but a complement. A junior engineer who has read everything but never built anything. Lots of talent but absolutely not trusted unsupervised.

My workflow

This is how I work with LLMs. I found that this works very well for me. I am aware that it is a much more involved workflow than a lot of people’s.

Phase 0: Reverse Rubber-ducking

I don’t start in an agent. I start in Claude, just chatting.

Before I write any code, I want to understand the domain. If I’m implementing auto-updates for a macOS app, I am asking Claude about how Sparkle works. Not “implement auto-updates for me”, but “how does Sparkle choose when to prompt the user?” or whatever. I want to know the concepts, gotchas, tradeoffs, etc. I often talk about some other app and ask “how does X do this?”

This is basically rubber-ducking in reverse. I’m building my own mental model through conversation. By the time I’m ready to touch the code, I actually understand what I’m about to do. This matters because it means I now can review what the LLM produces. I develop an intuition for what to expect, which in turn lets me quickly spot when something is wrong.

This phase gives me confidence, and that matters. And of course, this is mostly for areas I am not already familiar with. But even when am familiar, I find that these conversations give me insights or what I need to ask when doing the plan.

Phase 1: Plan

Now I move to an agent. Lately I’ve been using Amp, but the specific tool matters less than the process. This could be Claude Code, Codex CLI, etc. My process is tool-agnostic.

I don’t say “build me X.” Instead, I start another conversation, mostly a Q&A. “How would you approach this?”, “What are the steps?”, etc. I challenge it when something sounds off. I often ask the LLM to pushback to my ideas if it thinks they’re not good. I may still insist but it’s good to have some pushback here and there. We go back and forth until I’m satisfied with the approach.

Then I ask it to split the plan into the smallest self-contained, testable phases. This is critical. I want each phase to be something I can review, run, and validate before moving on. Those codebase-wide big changes re where things go off the rails.

Finally, I have it write everything to a spec.md file. This serves two purposes: (1) it’s a reference I can point the LLM to if context gets lost, and (2) it’s documentation of what we decided and why. For longer projects, this is how I resume after a break. I also make manual adjustments to this plan when needed, though this is getting more and more rare.

Phase 2: Implement each phase of the plan, one by one

Now the agent starts writing code, one phase at a time.

I watch the diffs as they flow in and because I was part of the planning and did my homework in Phase 0, I know what to expect. A quick glance usually is enough to tell me if it’s writing what we discussed or going off-script. That’s why the prep work matters: review is fast when you understand what you’re looking at.

I also give it context to save time. The agents nowadays are very smart and can find their way, but I can shortcut that by giving it hints “in internal/foo/foo.go there’s a function called DoFoo() and it does this and that and I want it to do that other thing before that” or whatever. Less tokens, faster iteration. This is probably astrology for nerds, pure superstition at this point, but I still do it. (Hi, it’s me, from the future: maybe it’s not astrology?)

Here’s a little trick I’ve started using: cross-agent reviews. Once Amp finishes a phase, I’ll ask Claude Code or Codex to review the diff. Different models and harnesses catch different things. It’s not foolproof, but it’s cheap and occasionally catches something I missed.

Phase 3: Validation, commit, and handoff

Once a phase looks good, I test it. I run and do what I can to validate it. I’ve mostly reviewed the code both by myself and using an LLM.

If something is wrong, I iterate with the agent. I point out the problem and let it fix it. This usually works and only very occasionally I have to take over and fix it myself.

When I’m happy, I commit. This is an easy rollback point if something goes wrong afterwards. At this point I use Amp’s /handoff command to start a fresh context for the next phase. This is a forced boundary: the agent will start clean (though it can reference the previous phase in Amp), it will re-read the spec and we continue. This helps prevent context rot, which is where long sessions start to drift.

Trust Boundaries

I rely on LLMs heavily but I don’t trust them.

These are the lines I don’t let them cross:

  • Nothing ships without my review. I read every line before it goes in. I am too anxious to ship something I don’t understand. That prep work from Phase 0 is not just about understanding, but about making review fast enough that this is sustainable
  • Don’t let the LLM write tests unsupervised. I learned this one the hard way. When a test fail, LLMs often “fix” the test to make it pass. I’ve heard this is less likely nowadays but I’ve been burned and trust isn’t easily restored. So there. Now I’m extremely careful about letting them modify test code. Only thing I do like to use LLMs for in testing is asking them “do the tests cover the case where this, this, and this happen?” Helps finding holes in the coverage.
  • Debugging is still mostly me. This is ironic, given that debugging a bug was my entry point into using LLMs more and more, but I’ve found that for my day-to-day debugging, I’m usually faster on my own. I reach for an LLM if I’m stuck, not as a first resort. Maybe this is muscle memory or maybe the tooling is weaker here. Either way, I don’t force it.

What still doesn’t work well

I want to be honest about the limitations, because the hype around these tools is exhausting.

I don’t think they’re good at complex refactoring across many files. The agent loses the thread. It will make changes that are locally correct but globally inconsistent. For big refactors, I still do a lot of manual work. I feel like the quality of code after an LLM-assisted refactor is not great quality.

Also, anything requiring deep context about the codebase’s history. Why is this weird workaround here? What’s the implicit contract this function has with its callers? The agent doesn’t know, heck most people don’t either, but whereas a human might be reluctant, LLMs will happily remove that code that seemed inconsequential but that now breaks some contract with a client.

And the final one can be controversial, but I think they’re bad at novel architecture decisions. Don’t get me wrong, ask an LLM to design something and it will, but then you ask it “oh but what if…” and it will immediately “yes good point” and redesign it all. It just goes along with whatever you last said. It doesn’t know how to make decisions. It shouldn’t be surprising given how LLMs work, but our brains tend to anthropomorphize everything and then these things become counterintuitive. So I still have to think about architecture myself.

The Real Lesson

These tools have changed a lot — GPT 5.2 and Opus 4.5 are watershed moments IMO — but not as much as my own approach did. I stopped trying to skip the thinking part and started using LLMs to enhance it. The agent participates in discovery, planning, obviously implementation, and also reviews, but I am still driving.

If you bounced off these tools, it might be worth trying again with a different approach, it’s all I’m saying.

I’ve found that my workflow is more work upfront, but dramatically less work overall. More importantly, it lets me focus on the interesting parts and helps me with the drudgery.

Trying out Codex CLI

A while ago I was a little skeptical of AI-assisted coding. Mostly because my experience had been with CoPilot autocomplete and it was really not good. I still avoid AI autocomplete to this day, even if I can see it got better because I still find it distracting and often still not great.

That said, Claude Code shook my world view and I’ve been daily driving it ever since. I need to write a post about how I use this agent, but tl;dr I use it for the boring parts of coding and to help me read and review code (especially my own) instead of using it to write feature code.

I have been happy with Claude Code, but I also heard very good things about the new GPT-5 model for coding and wanted to check it out. Enter the Codex CLI. It’s OpenAI’s answer to Claude Code.

I am approaching this with a very open mind. I completely understand that it is early times in Codex CLI land and thus I did not expect it to have feature parity with Claude. I’m ok with that, just to get that out of the way.

The onboarding was rough

My first experience with it was that it wouldn’t install due to an issue in the post-install of a dependency (ripgrep, which, I must say, I already had installed.) I went to file a ticket and say that someone else had already done so.

No matter! I thought. I figured out how to get around it and then decided to try it. I opened a local repo and typed /init.

Codex decided it wanted to run tests to check the status of the repo. Fair enough, go ahead. It then failed to compile my Go code, claiming the Go toolchain wasn’t available. I was confused by that, so I closed Codex CLI and ran go version, all good. I ran my tests, all passed. Wut?

I tried again and this time I told it that I checked and I had the toolchain installed. It tried again, no dice. It kept trying until eventually I stopped and did some digging. That’s when I learned that Codex CLI runs inside a sandbox and doesn’t share my shell’s environment. Ok, that was a little upsetting. So I asked Codex CLI how we could provision the sandbox with Go. It proceeded to look for Go 1.13, which was release over six years ago. It asked me to download the tarball and leave it in a certain directory and it would take from there.

Ok, time for some more digging. It’s a this point that I must point out that the Codex CLI documentation is basically non-existent, and being a relatively newcomer, there’s not a lot of resources out there. Again, I get it, let’s just get through this initial steps.

I keep at it until I figure the issue: though my shell’s PATH includes Go 1.25, the sandbox’s did not. I couldn’t quite figure out why but I did manage to get it working by telling GPT where to find the Go binaries.

Once it got working

Now, once I got it working, things went a lot smoother. I quickly got used to the differences from Claude Code (and they are many) and got somewhat comfortable with it. I got GPT to analyze my code and look for bugs and it found a minor one that had escaped Claude for a long time. That was cool.

I found that it tends to be a little noisier than Claude Code, because CC tends to hide somethings behind its quirky verbs (“lampoonig…”, etc) This isn’t necessarily a negative, just different and something to get used to.

I miss the TODO lists that Claude Code creates and follows. Again, not a huge deal. The part that it needs to improve is tool calling. More than once I saw it calling some Go tool with bad parameters. And also, it doesn’t seem to quite grasp the error messages.

Case in point, I asked it to run a linter, so it started running golangci-lint, but it ran it at the root of the repo, where there are no Go files, and without parameters, which resulted in an error “No Go files”. It didn’t seem to understand this error and concluded golangci-lint wasn’t installed.

It then entered a loop trying the same command over and over again until I interrupted it and told it to pass ./... to include subdirectories. It then tried again with the parameters, but bizarrily this time it decided that the golangci-lint would be in ./bin, which is not true at all. So I had to tell it where to find it. And then it worked fine.

Conclusion

It’s early days and it’s clear there’s some ground to make up if they want to catch up, but I also remember the early days of Claude Code. The CC team iterated quickly and we got to where we are today, and I’m hoping the Codex team will do the same. They seem very active in answering questions on X, so I have hope.

I’m hopefuly and interested. I’ll keep an eye on it.

Cedilla in Fedora 2025

Years ago I posted about getting the c-cedilla (ç) working in Fedora when using the US International keyboard with deadkeys. This has been a struggle for decades now and every time I set up a new Linux installation, I need to look it up.

That wouldn’t be such a problem, weren’t for the fact that the way to accomplish this seemingly simple task keeps changing over the years, so most of the information you find online is awfully out of date.

So for 2025, based on my experience with Fedora 42 (the current version at time of writing) – I suspect it would work similarly with other distros, but I cannot confirm it – this is how I did it.

First add these two lines to /etc/environment –

export GTK_IM_MODULE=cedilla
export QT_IM_MODULE=cedillaCode language: Bash (bash)

And then in your home dir, add a file named .XCompose with this –

<dead_acute> <C>			: "Ç"	U0106 # LATIN CAPITAL LETTER C WITH CEDILLA
<dead_acute> <c>			: "ç"	U0107 # LATIN SMALL LETTER C WITH CEDILLACode language: HTML, XML (xml)

Then reboot and it should just work. To be fair, this is the easiest it’s been for years to get this done.

As a bonus, there are a few other changes that I’ve made in my .XCompose file to solve some annoyances I have with the US-intl keyboard in Linux. When I type fast, I tend to accidentally end up with a lot of mistakenly accented consonants that I don’t need in any of the languages I write in.

These are, of course, entirely up to you if you want them and have nothing to do with the cedilla. You can find them on Github.

Hopefully the search engine gods will help someone out there find this when they need it. Might just be me in the not-too-distant future.

How to Make :x Just Save Instead of Save and Quit

I’m going to leave this here in case someone out there is looking for the same thing, because this one was hard to figure out for me.

I’ve been using vim for a long time. Decades. Although it has never been my main editor (I used to be a proud emacs guy), vim is the editor I always go to when I need to edit something quickly.

My main editor these days is Visual Studio Code, with the VsCodeVim extension. I still use vim often from the vscode integrated terminal, which is ironic.

Anyway, after so many years using vim, I have developed muscle memory that is hard to let go of. I am very used to doing quick edits and using :x to save and exit. It’s a hard habit to quit.

But when working on vscode, this behaviour is not exactly ideal. I don’t need to close the active editor, so I decided to try and modify that behaviour by only saving the file instead of saving and closing the editor.

After a lot of trial and error and reading the extension source code, I finally found the solution:

<span class="hljs-string">"vim.normalModeKeyBindingsNonRecursive"</span>: [
    {
        <span class="hljs-string">"before"</span>: [ <span class="hljs-string">":"</span>, <span class="hljs-string">"x"</span>, <span class="hljs-string">"<enter>"</span> ],
        <span class="hljs-string">"commands"</span>: [ <span class="hljs-string">":w"</span> ]
    }
],
<span class="hljs-string">"vim.commandLineModeKeyBindingsNonRecursive"</span>: [
    {
        <span class="hljs-string">"before"</span>: [ <span class="hljs-string">"x"</span> ],
        <span class="hljs-string">"commands"</span>: [ <span class="hljs-string">":w"</span> ]
    }
],Code language: JavaScript (javascript)

In both cases, I am replacing :x with :w, which is the behaviour I want. But this needs to be done in two different ways. When the editor is in NORMAL mode, we need to look for the entire sequence of commands (:x<enter>).

But if you type in : and wait a second, the editor will enter command line mode, in which we then need to capture the command (x) and then replace it with the :w command.

Hopefully this will show up on a search engine or AI output that will help someone out there not have to spend as much time as I did trying to look for this solution.

X-Touch Mini for Flight Simulation

Since I was a kid, I’ve loved aviation. Being poor and all, I could never dream of pursuing it in real life, so flightsimming has been my “cheap” fix for many years. I put cheap in quotes because this is an expensive hobby, even if you don’t overdo it. Although I spend quite a lot of money on software, I try to keep things in check on the hardware department, as flightsim equipment can be very expensive. For GA flying, it would be great to have a G1000, but at $2,199 USD, that’s a no from me.

Also, I’ve long flown the Boeing 737NG series, and setting up the MCP (the autopilot panel) with the mouse is quite the immersion killer, not to mention quite hard during busy phases of a flight. But € 1,299 is also not worth it, in my opinion.

That said, I’d really like some hardware controls. And that’s when I ran into the Behringer X-Touch Mini. The X-Touch Mini is not made for flightsimming, though; it’s a MIDI controller and as such, it doesn’t have the “niche tax.” I got it from Amazon at $180 CAD.

With some tinkering, I could make this control many planes, from the B737 to the Twin Otter. It’s great. I’ve long used SPAD.neXt to control all my planes for two reasons:

  1. I like tinkering with LVARs and also most third-party planes don’t expose all their controls to the simulator
  2. I like the fact that it autoswitches to the correct control profile for whatever plane I’m using

As an example, here’s how I set up a knob to control the checklists on the Honda Jet.

A screenshot of SPAD.neXt

It’s amazing! Also, I’ve been flying the ATR72-600 lately. Great plane! Also, it is similar enough to the Bombardier DHC8 (a.k.a. Dash-8) that it scratches my itch to fly regional Canadian routes, so I followed the excellent Les O’Reilly’s tutorial on setting the X-Touch Mini up for the ATR 72-600. Seriously, if you want to learn SPAD.neXt, check out his channel, it’s great.

However, I ran into an issue.

X-Touch Mini Leds not working

I could not get my leds to work with SPAD.neXt. No matter what I sent to the channel, the leds would not respond. I rewatched Les’ video, searched forums all over and never saw anyone having the same issue. I started suspecting a hardware problem. Eventually, I downloaded the editor from the Behringer’s website, solely for the purpose of seeing if I could get the leds to activate with it, to discard a hardware issue. This is when I found this —

Screenshot of the X-Touch Mini editor with the Global CH field highlighted

For some reason, my X-Touch Mini came with the global channel set to channel 12, instead of channel 1 as, it seems, is the normal setting. This is why none of the settings worked, so if you run into the same issue, now you know. So to fix this, there are two possibilities:

  1. Change all your SPAD.neXt settings to send the command to channel 11 (the channels are actually 0-based, so channel 12 in the UI is actually channel 11 in SPAD.neXt); or
  2. Change the global channel in the Behringer editor to 1 — which will be default channel 0 in SPAD.neXT. This is what I’ve done.

Once that was done, everything worked perfectly. The LEDs change status even if a channel happens inside the simulator, so you can rely on them to know the current status of your automatics and navigation/comms. Really happy with the setup.