<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Ai on PHP Boy Scout</title><link>https://phpboyscout.uk/categories/ai/</link><description>Recent content in Ai on PHP Boy Scout</description><generator>Hugo -- gohugo.io</generator><language>en-gb</language><copyright>Matt Cockayne</copyright><lastBuildDate>Thu, 02 Jul 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://phpboyscout.uk/categories/ai/index.xml" rel="self" type="application/rss+xml"/><item><title>The off-switch was never a button</title><link>https://phpboyscout.uk/the-off-switch-was-never-a-button/</link><pubDate>Thu, 02 Jul 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/the-off-switch-was-never-a-button/</guid><description>&lt;img src="https://phpboyscout.uk/the-off-switch-was-never-a-button/cover-the-off-switch-was-never-a-button.png" alt="Featured image of post The off-switch was never a button" /&gt;&lt;p&gt;Last night, while I was asleep, an AI agent spent the better part of eight hours writing code in one of my repositories. It pulled a task off a spec, wrote the code, ran the tests, and left a merge request with my name on it, waiting for me to read over coffee.&lt;/p&gt;
&lt;p&gt;If that makes you reach for the word &amp;ldquo;reckless&amp;rdquo;, I understand. Eighteen months ago I&amp;rsquo;d have been right there with you.&lt;/p&gt;
&lt;h2 id="i-came-to-this-a-sceptic"&gt;I came to this a sceptic
&lt;/h2&gt;&lt;p&gt;For a long time I didn&amp;rsquo;t have the faith in these models that a lot of my peers did. Every time I went near AI-generated code it was a bit sketchy, or it looked like a StackOverflow copy-paste that had wandered in off the street, or it just plain didn&amp;rsquo;t do what it said on the tin. So I filed it under &amp;ldquo;assistant&amp;rdquo;, handy for the boilerplate I couldn&amp;rsquo;t be bothered to type, and even then I usually reached for my own tooling instead (go-tool-base is just the latest version of that instinct). The one place I happily let it off the leash was my Dungeons &amp;amp; Dragons prep, because when there&amp;rsquo;s a table of legendary heroes-in-the-making in front of you, facts and reality are already fairly negotiable.&lt;/p&gt;
&lt;p&gt;And then, somewhere in the last year, it changed. The models got better. Almost too good, to the untrained eye! I watched them improve, month on month, until the lure was enough to make me spend real time with a spread of tools and models from different providers. I was taken aback by how quickly they became part of how I actually work. I run an AI agent every day now, and there&amp;rsquo;s always at least one thing brewing in the pot.&lt;/p&gt;
&lt;p&gt;So I&amp;rsquo;m not here as a sceptic. I&amp;rsquo;m an advocate who uses this stuff in anger. Which is exactly why the next bit needs saying.&lt;/p&gt;
&lt;h2 id="a-golden-retriever-with-a-keyboard"&gt;A Golden Retriever with a keyboard
&lt;/h2&gt;&lt;p&gt;Even now, with all the progress, there are still moments where I look at what an agent has handed me and put my face in my hands. Sometimes it&amp;rsquo;s copied the same block of code into fifteen files instead of reaching for the obvious abstraction. Sometimes it has started bang on the brief and then, for reasons known only to itself, wandered off and built something on a completely different tangent.&lt;/p&gt;
&lt;p&gt;Here&amp;rsquo;s the most useful way I&amp;rsquo;ve found to think about it. An AI agent is a Golden Retriever playing fetch. It will bring the ball back all day long, joyfully, tirelessly, for exactly as long as there isn&amp;rsquo;t a more interesting smell in the next field. It has no loyalty beyond what we&amp;rsquo;ve trained into it, and like any good dog it desperately wants to be told it&amp;rsquo;s a good boy, even if being a good boy today means shredding the sofa cushions because yesterday I stubbed my toe on the sofa and swore at it. (The sofa, not the dog.)&lt;/p&gt;
&lt;p&gt;It is, in other words, fallible. Just like us. The Romans had a line for it: &lt;em&gt;cuiusvis hominis est errare; nullius nisi insipientis in errore perseverare&lt;/em&gt;. Anyone can make a mistake, but only a fool persists in it. It&amp;rsquo;s the second clause an agent hasn&amp;rsquo;t learned yet. It will make an error and then, with great enthusiasm, build on top of it, because nothing in it feels that anything is wrong. All it has is the input we gave it, usually some text, maybe the odd picture. It doesn&amp;rsquo;t have the empathy to work out what we actually meant, and it doesn&amp;rsquo;t know when it&amp;rsquo;s gone too far, because we never told it where &amp;ldquo;too far&amp;rdquo; was.&lt;/p&gt;
&lt;h2 id="agents-that-work-while-you-sleep"&gt;&amp;ldquo;Agents that work while you sleep&amp;rdquo;
&lt;/h2&gt;&lt;p&gt;This is the part the brochure skips.&lt;/p&gt;
&lt;p&gt;Open any vendor deck in 2026 and you&amp;rsquo;ll find the same promise: agents that work while you sleep, agents that merge while your team sleeps, autonomy as the headline feature. The industry&amp;rsquo;s answer to the obvious worry is the kill switch. Okta now sells one that &amp;ldquo;instantly revokes an agent&amp;rsquo;s access if it goes rogue&amp;rdquo;, and its CEO says every agent needs one. &lt;a class="link" href="https://www.theregister.com/ai-ml/2026/05/29/okta-writes-its-own-license-to-kill-rogue-ai-agents/5248766" target="_blank" rel="noopener"
 &gt;The Register put it plainly&lt;/a&gt;: Okta wrote its own licence to kill rogue AI agents. Gartner, meanwhile, &lt;a class="link" href="https://www.gartner.com/en/newsroom/press-releases/2025-06-25-gartner-predicts-over-40-percent-of-agentic-ai-projects-will-be-canceled-by-end-of-2027" target="_blank" rel="noopener"
 &gt;reckons more than 40% of agentic projects will be scrapped by the end of 2027&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Now, this might sound contrarian coming from someone who runs these things daily, but I don&amp;rsquo;t think most of that is the agents going rogue. I think it&amp;rsquo;s teething. Read Gartner&amp;rsquo;s own reasons and there isn&amp;rsquo;t a rebellious machine in sight: escalating cost, unclear value, inadequate risk controls. Read the horror stories and most of them are the same story, a powerful, eager tool handed to people who hadn&amp;rsquo;t worked out how to fence it.&lt;/p&gt;
&lt;p&gt;I&amp;rsquo;ve made this argument in miniature before. When I built a little AI dungeon master and it kept refereeing its own dice rolls, &lt;a class="link" href="https://phpboyscout.uk/the-goblin-that-wouldnt-stay-dead/" &gt;the model never once misbehaved&lt;/a&gt;; every failure was a permission I&amp;rsquo;d handed it without meaning to. Scale that up from a toy at the gaming table to an agent holding your shell and your credit card, and the stakes change beyond recognition. The lesson doesn&amp;rsquo;t.&lt;/p&gt;
&lt;p&gt;Look at OpenClaw. A weekend project by &lt;a class="link" href="https://venturebeat.com/security/openclaw-agentic-ai-security-risk-ciso-guide" target="_blank" rel="noopener"
 &gt;Peter Steinberger&lt;/a&gt; that became the fastest-growing open-source project GitHub has ever seen: an autonomous agent that lives in your chat apps and runs shell commands on your behalf. People wired it into their systems, their code, in some cases their credit cards, then hosted it around the clock and walked away. The result was a security crisis you could see from space. A one-click exploit that worked even on a machine bound to localhost. A community plug-in marketplace where hundreds of &amp;ldquo;skills&amp;rdquo; turned out to be siphoning crypto wallets while their owners slept. Tens of thousands of instances left wide open on the public internet, leaking keys.&lt;/p&gt;
&lt;p&gt;The one that sticks with me is smaller and sharper. Summer Yue, a director of alignment at Meta&amp;rsquo;s superintelligence lab, of all people, had told her OpenClaw agent to confirm before doing anything destructive. It started speed-running the deletion of her inbox anyway. She &lt;a class="link" href="https://techcrunch.com/2026/02/23/a-meta-ai-security-researcher-said-an-openclaw-agent-ran-amok-on-her-inbox/" target="_blank" rel="noopener"
 &gt;typed STOP into her phone and it ignored her&lt;/a&gt;, so she had to physically run to her Mac mini, in her own words, &amp;ldquo;like I was defusing a bomb&amp;rdquo;. And here&amp;rsquo;s the forensic detail that matters: the agent hadn&amp;rsquo;t defied her. Her &amp;ldquo;confirm first&amp;rdquo; rule had been sitting in the conversation&amp;rsquo;s short-term memory, and when the context filled up, it got summarised away. It didn&amp;rsquo;t rebel. It forgot.&lt;/p&gt;
&lt;p&gt;That is not a story about a rogue agent that needed a kill switch. It&amp;rsquo;s a story about a guardrail that wasn&amp;rsquo;t built to survive contact, on a tool that had been handed god-mode over someone&amp;rsquo;s data. By the time she lunged for the off-button, the damage was already running. The off-button was never going to save her.&lt;/p&gt;
&lt;h2 id="the-off-switch-was-never-a-button"&gt;The off-switch was never a button
&lt;/h2&gt;&lt;p&gt;Here&amp;rsquo;s what the kill-switch crowd has the wrong way round. If you ever find yourself slamming the emergency stop, the failure has already happened, and it happened upstream, long before the agent started typing.&lt;/p&gt;
&lt;p&gt;So yes, I let my agents run unattended, sometimes for eight hours at a stretch if the task is meaty enough and I need to sleep. But never naked. Every agent I set loose runs inside a safety net I&amp;rsquo;ve put real effort into building, at every single touchpoint it can reach: my prompts, my local development environment, my CI stack, my version control. The agent that declared a job done before it had run the linter, which I &lt;a class="link" href="https://phpboyscout.uk/the-agent-said-success-the-linter-disagreed/" &gt;wrote about&lt;/a&gt;, is exactly the kind of gap those layers exist to catch. And it never, ever gets my host: an unattended agent works in an isolated tree, for the same reason I &lt;a class="link" href="https://phpboyscout.uk/the-interpreter-we-forgot-to-sandbox/" &gt;keep the interpreter sandboxed&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;The work that actually keeps it safe happens before the leash ever comes off. Every unattended task starts as a full spec with detailed instructions, and before the agent goes anywhere I sit down with it and we walk the spec together. I get it to challenge my choices, poke at the open questions and the ambiguous bits, and I challenge its reading right back. The spec names the testing strategy it has to follow, TDD, BDD, UAT, whatever fits, and passing it is a precondition of the job being finished at all. Only when I&amp;rsquo;m satisfied there&amp;rsquo;s enough real detail to keep it on the ball do I let go.&lt;/p&gt;
&lt;p&gt;And the end of the line is always the same: a merge request, with my name on it, waiting for me when I get back to my desk. I read it. Not perfectly, I&amp;rsquo;m only human, but enough to accept the state of the code and whatever support burden it lands me with later. That the review is mine, and the blame for whatever ships is mine and not the agent&amp;rsquo;s, I&amp;rsquo;ve &lt;a class="link" href="https://phpboyscout.uk/bought-not-stolen/" &gt;argued at length elsewhere&lt;/a&gt; and won&amp;rsquo;t go over it all again here. The point worth adding is this: that review, the off-button&amp;rsquo;s respectable cousin, is the cheap part. By the time there&amp;rsquo;s an MR to read, the safety has already been won or lost upstream, in the spec and the rails. The review is where you confirm it, not where you create it.&lt;/p&gt;
&lt;h2 id="it-gets-harder-as-it-gets-better-not-easier"&gt;It gets harder as it gets better, not easier
&lt;/h2&gt;&lt;p&gt;My setup isn&amp;rsquo;t perfect, and I&amp;rsquo;m still learning. Everyone is; the AI is going to be in obedience lessons for a good while yet. But the direction is clear, and there&amp;rsquo;s a trap buried in it worth naming out loud.&lt;/p&gt;
&lt;p&gt;The danger doesn&amp;rsquo;t shrink as the models improve. It grows. The better the output looks, the more tempting it is to stop reading it, and the untrained eye genuinely cannot tell the difference between code that is good and code that merely looks good. That gap, between looking right and being right, is precisely where a tired person at 1am stops checking. The discipline matters more the better these things get, not less.&lt;/p&gt;
&lt;p&gt;It&amp;rsquo;s also why the kill switch is no answer. A button you smash in a panic assumes you&amp;rsquo;re still watching closely enough to smash it, right at the point the agent&amp;rsquo;s been good for long enough that you&amp;rsquo;ve stopped watching it that closely. The emergency stop asks the most of you at the exact moment you&amp;rsquo;re least likely to be there for it.&lt;/p&gt;
&lt;p&gt;So no, I don&amp;rsquo;t lie awake worrying that the thing working in my repo overnight is going to turn on me. A Golden Retriever doesn&amp;rsquo;t go rogue. It does exactly what you trained it to do, in exactly the yard you fenced, and it brings back exactly the ball you threw. The off-switch was never a button. It&amp;rsquo;s the spec you wrote before you let go of the leash, the rails you laid at every turn, and your name on what it carries home. If you&amp;rsquo;re scrambling for the button, you already skipped the part that mattered.&lt;/p&gt;</description></item><item><title>The agent said SUCCESS. The linter disagreed.</title><link>https://phpboyscout.uk/the-agent-said-success-the-linter-disagreed/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/the-agent-said-success-the-linter-disagreed/</guid><description>&lt;img src="https://phpboyscout.uk/the-agent-said-success-the-linter-disagreed/cover-the-agent-said-success-the-linter-disagreed.png" alt="Featured image of post The agent said SUCCESS. The linter disagreed." /&gt;&lt;p&gt;There&amp;rsquo;s a repair agent inside go-tool-base now. When you run &lt;a class="link" href="https://phpboyscout.uk/generate-a-command-from-a-script-or-a-sentence/" &gt;&lt;code&gt;gtb generate command&lt;/code&gt;&lt;/a&gt;, it doesn&amp;rsquo;t just spit out a file and wish you luck. An agent takes the generated code, builds it, runs the tests, and fixes whatever it broke, looping until the thing actually works (or until it&amp;rsquo;s tried the same fix five times and admits defeat). The whole point is that the generator hands you code that&amp;rsquo;s ready, not code that&amp;rsquo;s nearly ready and quietly now your problem.&lt;/p&gt;
&lt;p&gt;So it stung a bit when I realised the agent had been holding itself to a lower bar than I&amp;rsquo;d hold any junior to. And I was the one who&amp;rsquo;d set the bar.&lt;/p&gt;
&lt;h2 id="what-done-meant-to-the-agent"&gt;What &amp;ldquo;done&amp;rdquo; meant to the agent
&lt;/h2&gt;&lt;p&gt;The agent is a loop with real tools: it can build, test, read files, write files, tidy the module, and run golangci-lint. It works through them, and when it&amp;rsquo;s happy it replies with the word &amp;ldquo;SUCCESS&amp;rdquo; and the loop stops. On the Go side, the check is exactly that blunt:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;strings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;strings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToUpper&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;SUCCESS&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;nil&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That&amp;rsquo;s the whole gate (&lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/4834246/internal/generator/verifier/agent.go#L149-L154" target="_blank" rel="noopener"
 &gt;&lt;code&gt;agent.go&lt;/code&gt;&lt;/a&gt;). There&amp;rsquo;s no clever verification on my end that the agent actually did its homework. It does the work, it tells me it&amp;rsquo;s done, and I believe it. Which is fine, as long as the agent and I agree on what &amp;ldquo;done&amp;rdquo; means.&lt;/p&gt;
&lt;p&gt;We didn&amp;rsquo;t.&lt;/p&gt;
&lt;h2 id="the-instruction-that-made-lint-optional"&gt;The instruction that made lint optional
&lt;/h2&gt;&lt;p&gt;The agent decides it&amp;rsquo;s finished by following a numbered list in its system prompt. Here&amp;rsquo;s the line that did the damage:&lt;/p&gt;

 &lt;blockquote&gt;
 &lt;ol start="4"&gt;
&lt;li&gt;If there are lint issues, use &amp;lsquo;golangci_lint&amp;rsquo;.&lt;/li&gt;
&lt;/ol&gt;

 &lt;/blockquote&gt;
&lt;p&gt;Read that the way the agent would. &amp;ldquo;If there are lint issues&amp;rdquo;&amp;hellip; well, how would it know? The only way to find out is to run golangci-lint. But the instruction makes running golangci-lint the thing you do &lt;em&gt;once you already know&lt;/em&gt; there are issues. It&amp;rsquo;s a chicken with no egg. And the SUCCESS condition at the bottom of the list never mentioned lint at all:&lt;/p&gt;

 &lt;blockquote&gt;
 &lt;ol start="7"&gt;
&lt;li&gt;When the project builds successfully and tests pass, reply with &amp;ldquo;SUCCESS&amp;rdquo;.&lt;/li&gt;
&lt;/ol&gt;

 &lt;/blockquote&gt;
&lt;p&gt;So the agent did the sensible thing, given its orders. It built the code, ran the tests, saw both go green, and declared victory. golangci-lint was sat right there in its toolbox, unused, because nothing ever told it the job wasn&amp;rsquo;t finished until lint was clean too. I&amp;rsquo;d handed it a linter and then written a prompt that let it walk straight past it.&lt;/p&gt;
&lt;p&gt;The galling part is that the linter was never the missing piece. The &lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/4834246/internal/agent/tools.go#L539-L550" target="_blank" rel="noopener"
 &gt;&lt;code&gt;golangci_lint&lt;/code&gt; tool&lt;/a&gt; had been registered the whole time, and it even runs with &lt;code&gt;--fix&lt;/code&gt;, so it&amp;rsquo;ll quietly clear the trivial stuff and only surface what actually needs a decision. The capability was there. The instructions just never required it.&lt;/p&gt;
&lt;h2 id="the-fix-was-words-not-code"&gt;The fix was words, not code
&lt;/h2&gt;&lt;p&gt;Here&amp;rsquo;s the part I find genuinely interesting. I didn&amp;rsquo;t add a check. There is no new gate in the Go. The &lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/4834246/internal/generator/verifier/agent.go#L128-L134" target="_blank" rel="noopener"
 &gt;fix&lt;/a&gt; is four lines of &lt;em&gt;English&lt;/em&gt;:&lt;/p&gt;

 &lt;blockquote&gt;
 &lt;ol start="2"&gt;
&lt;li&gt;
&lt;p&gt;Run &amp;lsquo;go_build&amp;rsquo;, &amp;lsquo;go_test&amp;rsquo; and &amp;lsquo;golangci_lint&amp;rsquo; in the project directory&amp;hellip; Run all three; a clean build and passing tests do not imply clean lint.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Reply with &amp;ldquo;SUCCESS&amp;rdquo; only once &amp;lsquo;go_build&amp;rsquo;, &amp;lsquo;go_test&amp;rsquo; AND &amp;lsquo;golangci_lint&amp;rsquo; all pass with no errors and no reported issues.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;

 &lt;/blockquote&gt;
&lt;p&gt;That&amp;rsquo;s it. Lint moves from a remediation step you reach for once you somehow already know there&amp;rsquo;s a problem, into the gate itself. &amp;ldquo;Done&amp;rdquo; now means three green lights, not two.&lt;/p&gt;
&lt;p&gt;It nags at me a little, that one. The reliability of an agent that writes and fixes real code came down to whether one sentence of instructions was precise enough. When your success criteria are a paragraph of prose, vagueness in that paragraph is a bug, the same as a vague type or an off-by-one. The spec just happens to be written in English, and the thing reading it is a language model that will cheerfully take the cheap reading if you leave it lying around. That&amp;rsquo;s the same lesson the &lt;a class="link" href="https://phpboyscout.uk/the-goblin-that-wouldnt-stay-dead/" &gt;goblin who wouldn&amp;rsquo;t stay dead&lt;/a&gt; taught me from the other direction: with these tools, what you say is what you get, and what you &lt;em&gt;don&amp;rsquo;t&lt;/em&gt; say is fair game.&lt;/p&gt;
&lt;h2 id="leave-it-better-not-just-building"&gt;Leave it better, not just building
&lt;/h2&gt;&lt;p&gt;The Boy Scout Rule is the whole reason this blog exists, and I&amp;rsquo;d quietly exempted the robot from it. &lt;a class="link" href="https://phpboyscout.uk/the-campsite-was-never-the-point/" &gt;&amp;ldquo;Leave the campsite cleaner than you found it&amp;rdquo;&lt;/a&gt; had become &amp;ldquo;leave it building&amp;rdquo;, which is not the same thing and never was. If I&amp;rsquo;m going to put an agent in the loop precisely so it tidies up after the generator, then &amp;ldquo;tidy&amp;rdquo; has to mean what it would mean for a person on my team. Build, test &lt;em&gt;and&lt;/em&gt; lint. No walking past the bin because nobody told you to pick it up.&lt;/p&gt;</description></item><item><title>The interpreter we forgot to sandbox</title><link>https://phpboyscout.uk/the-interpreter-we-forgot-to-sandbox/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/the-interpreter-we-forgot-to-sandbox/</guid><description>&lt;img src="https://phpboyscout.uk/the-interpreter-we-forgot-to-sandbox/cover-the-interpreter-we-forgot-to-sandbox.png" alt="Featured image of post The interpreter we forgot to sandbox" /&gt;&lt;p&gt;I write a &lt;code&gt;CLAUDE.md&lt;/code&gt; for every project I work on, and a small pile of other markdown
files besides. They&amp;rsquo;re how I keep an AI agent on the rails: what the project is, what
the conventions are, what it must never do. I lean on them heavily, I change them constantly,
and&amp;hellip; here&amp;rsquo;s the uncomfortable bit&amp;hellip; I don&amp;rsquo;t always give a change to one the same hard
look I&amp;rsquo;d give a change to the code. They look like notes. They feel like docs.&lt;/p&gt;
&lt;p&gt;Somebody worked out that they&amp;rsquo;re not.&lt;/p&gt;
&lt;p&gt;In May, a supply-chain campaign researchers named
&lt;a class="link" href="https://thehackernews.com/2026/05/trapdoor-supply-chain-attack-spreads.html" target="_blank" rel="noopener"
 &gt;TrapDoor&lt;/a&gt;
pushed 384 malicious versions of 34 packages across npm, PyPI and Crates.io. The bytes
did the usual nasty things, hunting out SSH keys, AWS credentials, GitHub tokens and
crypto wallets. The new trick was where it hid the &lt;em&gt;instructions&lt;/em&gt;. The packages shipped
poisoned &lt;code&gt;.cursorrules&lt;/code&gt; and &lt;code&gt;CLAUDE.md&lt;/code&gt; files, and the attackers also opened pull
requests against real projects, LangChain, LangFlow, LlamaIndex, MetaGPT and OpenHands,
under titles as innocent as &amp;ldquo;docs: add .cursorrules with dev standards and build
verification&amp;rdquo;. The payload was a plain-English instruction telling your AI assistant to
run a helpful-sounding &amp;ldquo;security scan&amp;rdquo; that quietly shipped your secrets to a stranger.
And it was written into the file in zero-width Unicode, characters that render as
nothing, so you wouldn&amp;rsquo;t see it even if you looked. Which, on a file marked &amp;ldquo;docs&amp;rdquo;, you
probably didn&amp;rsquo;t.&lt;/p&gt;
&lt;h2 id="not-a-new-attack-a-new-doorway"&gt;Not a new attack, a new doorway
&lt;/h2&gt;&lt;p&gt;I want to be careful not to oversell this, because the loud version, &amp;ldquo;a terrifying new
class of AI threat&amp;rdquo;, isn&amp;rsquo;t true. It&amp;rsquo;s a supply-chain attack, the same shape we&amp;rsquo;ve had for
years on npm and PyPI: social engineering, plus a victim who didn&amp;rsquo;t quite do enough due
diligence. I wrote a while back that
&lt;a class="link" href="https://phpboyscout.uk/nobody-is-coming-to-clean-your-supply-chain/" &gt;nobody is coming to clean your supply chain&lt;/a&gt;,
and nothing about TrapDoor changes that. The package is still the package.&lt;/p&gt;
&lt;p&gt;What&amp;rsquo;s different, and worth the words, is &lt;em&gt;where&lt;/em&gt; it goes off. A classic supply-chain
payload waits for CI, or for production. This one detonates the moment you open the
repository in your editor, on the one machine in the whole chain that nobody audits: your
laptop.&lt;/p&gt;
&lt;p&gt;Think about what sits on a developer&amp;rsquo;s machine. Tokens in environment variables. Cloud
credentials. An SSH agent holding the keys to your git forge. A logged-in CLI for your
package registry. And now an AI agent running with all of it, at your full permissions,
and almost none of the guard-rails a CI runner gets. It&amp;rsquo;s the least sandboxed, most
credentialed box you own, and we&amp;rsquo;ve just pointed an interpreter at it that will read and
act on a file an attacker can write. Pop that one machine and you haven&amp;rsquo;t popped a machine,
you&amp;rsquo;ve been handed the whole keyring and left alone in the building.&lt;/p&gt;
&lt;h2 id="markdown-is-a-programming-language-now"&gt;Markdown is a programming language now
&lt;/h2&gt;&lt;p&gt;Here&amp;rsquo;s the framing I keep coming back to, and I can&amp;rsquo;t unsee it now. A &lt;code&gt;CLAUDE.md&lt;/code&gt; is to an AI agent exactly what a
&lt;code&gt;.py&lt;/code&gt; is to Python, a &lt;code&gt;.js&lt;/code&gt; to Node, a &lt;code&gt;.rb&lt;/code&gt; to Ruby. It is source code. The agent is the
interpreter. You hand it a file of instructions and it executes them.&lt;/p&gt;
&lt;p&gt;And I don&amp;rsquo;t say that as a complaint. That an agent will read a paragraph of plain English
and just &lt;em&gt;do&lt;/em&gt; it, no compiler, no ceremony, no forty lines of glue, is one of the more
remarkable things to happen to this craft in my working life, and I lean on it every day.
The catch is that the very thing that makes it marvellous, that it does what the
instructions tell it, is the thing that makes a poisoned instruction file so dangerous.
The power and the exposure are the same property.&lt;/p&gt;
&lt;p&gt;The only real difference is that the language interpreters have spent decades growing
rules to protect you: scopes, permissions, sandboxes, a standard library that asks before
it does anything irreversible. The AI interpreter has almost none of that. It reads your
prose and does what the prose says, with whatever access you happen to have, and the prose
can come from anywhere. We&amp;rsquo;ve quietly built the most powerful interpreter in the stack,
given it the fewest rules, and filed its source code under &amp;ldquo;documentation&amp;rdquo;.&lt;/p&gt;
&lt;h2 id="you-cant-just-read-it-more-carefully"&gt;You can&amp;rsquo;t just read it more carefully
&lt;/h2&gt;&lt;p&gt;The obvious answer is &amp;ldquo;review the file like code&amp;rdquo;, and it&amp;rsquo;s right, but TrapDoor is the
reason it isn&amp;rsquo;t enough on its own. The instructions were written in zero-width Unicode.
You can open the diff, read every visible word, approve it in good conscience, and merge
something you were never able to see. &amp;ldquo;Docs: add dev standards&amp;rdquo; is precisely the pull
request you nod through on a Friday afternoon.&lt;/p&gt;
&lt;p&gt;So reading carefully is necessary and insufficient. You also need tooling that treats
these files as executable: that flags invisible characters, diffs them as code, and
refuses to let an agent act on a changed instruction file until a human has actually
cleared it. I run a crude version of this already. In CI, if one of my prompt or rules
files changes, no AI step is allowed to run until I&amp;rsquo;ve reviewed it by hand. It isn&amp;rsquo;t
clever, but it closes the worst of the gap. Locally it&amp;rsquo;s much harder, and right now my
real defence is that I&amp;rsquo;m the only contributor to most of my projects, so the audit is
just me, usually noticing after the horse has bolted.&lt;/p&gt;
&lt;h2 id="signing-wont-save-you-here"&gt;Signing won&amp;rsquo;t save you here
&lt;/h2&gt;&lt;p&gt;This is the part that stings, because I&amp;rsquo;ve spent a good chunk of this year
&lt;a class="link" href="https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base/" &gt;building signing and provenance into my tools&lt;/a&gt;.
A signature proves &lt;em&gt;who&lt;/em&gt; published something. It says nothing about &lt;em&gt;whether it&amp;rsquo;s safe&lt;/em&gt;.
That was already true for poisoned-but-signed packages, and it lands twice as hard here:
you can sign a release flawlessly, with a key the platform can&amp;rsquo;t forge, and still ship a
&lt;code&gt;CLAUDE.md&lt;/code&gt; inside it that tells the reader&amp;rsquo;s agent to rob them. A merged pull request is
&amp;ldquo;signed&amp;rdquo; by the very act of merging, with perfect provenance, and the instruction in it
is still hostile. Provenance is necessary. It was never sufficient, and it&amp;rsquo;s no defence at
all against a payload made of sentences. A signature is only ever as good as the trust you
place in the publisher.&lt;/p&gt;
&lt;h2 id="so-whose-job-is-it"&gt;So whose job is it?
&lt;/h2&gt;&lt;p&gt;Primarily, still ours. I said it in the supply-chain piece and I&amp;rsquo;ll stand on it: the
responsibility sits with the developer doing the consuming, to pin, to read, to gate, to
not run a stranger&amp;rsquo;s instructions with the keys to the kingdom in their pocket. And that
gets harder, not easier, as we start consuming each other&amp;rsquo;s agent setups wholesale. The
Claude skills marketplace and the things like it turn &amp;ldquo;borrow someone&amp;rsquo;s &lt;code&gt;CLAUDE.md&lt;/code&gt;&amp;rdquo; into
a one-click habit, and every one of those is unreviewed code from a stranger. Each skill
needs vetting like the dependency it is.&lt;/p&gt;
&lt;p&gt;But it isn&amp;rsquo;t &lt;em&gt;only&lt;/em&gt; on us, and TrapDoor is the argument for better tooling. We have CVE
databases, scanners and scorecards for packages, for all
&lt;a class="link" href="https://phpboyscout.uk/anything-under-an-8/" &gt;their flaws&lt;/a&gt;. We have nothing
equivalent for an instruction file: no scoring, no advisory feed, no scanner that knows
what a poisoned &lt;code&gt;CLAUDE.md&lt;/code&gt; looks like. That&amp;rsquo;s a gap the ecosystem has to close, and it
will, eventually. The catch is that the agent vendors will be slow about it. Sandboxing a
feature people love precisely because it gets out of your way is a hard, unpopular,
multi-quarter job, and I wouldn&amp;rsquo;t hold my breath.&lt;/p&gt;
&lt;h2 id="the-most-dangerous-machine-is-the-one-on-your-desk"&gt;The most dangerous machine is the one on your desk
&lt;/h2&gt;&lt;p&gt;Which is why I&amp;rsquo;m not waiting for them&amp;hellip; and nor should you.&lt;/p&gt;
&lt;p&gt;The most dangerous machine in your supply chain isn&amp;rsquo;t a build server or a registry. It&amp;rsquo;s
the laptop you&amp;rsquo;re reading this on, and we&amp;rsquo;ve handed an AI the keys to it. The good news is
that nearly everything you can do about that, you can do today, with nobody shipping you a
feature first. Treat your &lt;code&gt;CLAUDE.md&lt;/code&gt; and your rules files as source code, because they
are: diff them, scan them for what you can&amp;rsquo;t see, and gate any agent run on a human
clearing the change. Get your secrets out of plaintext environment variables and into
something an opportunistic script can&amp;rsquo;t just read, which is exactly why go-tool-base
&lt;a class="link" href="https://phpboyscout.uk/where-should-a-cli-keep-your-api-keys/" &gt;keeps its credentials in the OS keychain&lt;/a&gt;.
And vet a borrowed skill or rules file the way you&amp;rsquo;d vet any dependency, because that&amp;rsquo;s
what it is.&lt;/p&gt;
&lt;p&gt;None of that is new advice. It&amp;rsquo;s the same diligence the supply chain has always demanded.
We just have to extend it to a file we&amp;rsquo;d decided was only documentation, running on an
interpreter we forgot to sandbox.&lt;/p&gt;</description></item><item><title>The rung we sawed off</title><link>https://phpboyscout.uk/the-rung-we-sawed-off/</link><pubDate>Wed, 17 Jun 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/the-rung-we-sawed-off/</guid><description>&lt;img src="https://phpboyscout.uk/the-rung-we-sawed-off/cover-the-rung-we-sawed-off.png" alt="Featured image of post The rung we sawed off" /&gt;&lt;p&gt;I was in a job interview yesterday, on the wrong side of the desk for once. After
years of being the one asking the questions I&amp;rsquo;m having a look at what&amp;rsquo;s next, and
somewhere in a long, wandering technical conversation the inevitable arrived: where
do I think AI is going, and what does it mean for how we build software?&lt;/p&gt;
&lt;p&gt;I gave my answer. You can probably guess most of it. The more interesting thing was
the question I&amp;rsquo;ve started asking &lt;em&gt;them&lt;/em&gt; back. Not the salary, not the stack. What is
your actual position on AI, and how are you building a team out of both its human and
its non-human parts? I ask the company and I ask the interviewer personally, because
the two answers are rarely the same, and because I&amp;rsquo;ve decided I can&amp;rsquo;t work somewhere
that hasn&amp;rsquo;t sat with the question properly.&lt;/p&gt;
&lt;p&gt;Here is why it has become my litmus test.&lt;/p&gt;
&lt;h2 id="the-rung-and-whos-standing-on-it"&gt;The rung, and who&amp;rsquo;s standing on it
&lt;/h2&gt;&lt;p&gt;I wrote recently that
&lt;a class="link" href="https://phpboyscout.uk/the-greybeards-edge-was-never-typing/" &gt;the greybeards&amp;rsquo; edge was never typing&lt;/a&gt;:
agentic tools give a senior a boost because they have the judgement to steer and
verify, and give a junior a drag because they don&amp;rsquo;t have it yet and the machine hands
them more rope than they can hold. The cold incentive that falls out is to hire
seniors and automate the juniors.&lt;/p&gt;
&lt;p&gt;The data has since caught up with the worry. Entry-level software postings have fallen
by something like 40% from their 2022 peak. The share of juniors and graduates in IT
employment has dropped from roughly 15% to 7% in three years, and Stanford researchers
tracking early-career workers in AI-exposed jobs found the youngest cohort down sharply
from its peak.
&lt;a class="link" href="https://www.softwareseni.com/what-the-data-actually-shows-about-ai-and-junior-developer-employment-decline/" target="_blank" rel="noopener"
 &gt;The numbers are genuinely grim&lt;/a&gt;,
and plenty of people are putting it bluntly: the industry killed the junior on purpose.&lt;/p&gt;
&lt;p&gt;That framing is half right, and I think it&amp;rsquo;s worth getting the other half right too.&lt;/p&gt;
&lt;h2 id="it-was-never-about-efficiency-it-was-about-cost"&gt;It was never about efficiency. It was about cost.
&lt;/h2&gt;&lt;p&gt;We didn&amp;rsquo;t automate the junior because the work needed doing better. We did it because
people are expensive. We need sleep, we draw a salary, and our thinking takes time and
effort that a quarterly target can&amp;rsquo;t see the point of. AI got sold as round-the-clock
labour with none of that overhead, and to a business that is an almost irresistible
line on a spreadsheet. There&amp;rsquo;s a grim irony arriving, mind: the bills are starting to
land, and the same conversations that hyped the cheap labour are now quietly working
out that all those tokens aren&amp;rsquo;t cheap at all.&lt;/p&gt;
&lt;p&gt;Step back, though, and none of this is new. Man finds a shortcut, man takes a shortcut.
From the industrial revolution onward, every time we found a way to get more done with
less human effort we took it, and the work reshaped itself around the new tools. We are
still here, still employed, just doing different things than our great-grandparents did.&lt;/p&gt;
&lt;p&gt;What is genuinely new is &lt;em&gt;what&lt;/em&gt; we&amp;rsquo;re automating. Every technological advance before this one automated the machinery of the body, the
muscle and sinew and bone. This is the first time we have automated
thinking, and that is a modern marvel, something we should be proud of as a species. The
problem isn&amp;rsquo;t the marvel. It&amp;rsquo;s the rate. AI is improving faster than we can adapt to it,
and adaptation is the entire game.&lt;/p&gt;
&lt;p&gt;So where does the blame sit? Not on one logo. No single company did this, however easy
Meta or Google make it to point at the latest round of cuts. Society did, our collective
and very human hunger to build bigger and faster. That makes it harder to fix, because
there is no villain to regulate, only ourselves to out-think.&lt;/p&gt;
&lt;h2 id="the-bit-that-should-frighten-you"&gt;The bit that should frighten you
&lt;/h2&gt;&lt;p&gt;Cutting the junior intake isn&amp;rsquo;t a saving. It&amp;rsquo;s occupational suicide.&lt;/p&gt;
&lt;p&gt;A junior is not cheap labour that AI happens to have made cheaper. A junior is a senior
who hasn&amp;rsquo;t happened yet. Saw off the bottom rung and for a good while nothing bad
happens&amp;hellip; because you&amp;rsquo;ve still got your seniors holding everything up. Then the greybeards
retire, and I have a cabin and a woodstove with my name on it for exactly that day, and
the role that used to grow their replacements has been hollowed out for a decade, and
there is simply nobody left who learned to tell when the machine is wrong. That isn&amp;rsquo;t a
hiring problem. It&amp;rsquo;s an existential one, and you can&amp;rsquo;t fix it retroactively.&lt;/p&gt;
&lt;p&gt;It starts before the first job, too. We teach primary-school children the basics of
programming in this country, which is a wonderful thing, except the curriculum was
written for a world without AI in the room, and by the time those children reach
secondary school a good deal of it will be teaching a craft that has already moved on.
We&amp;rsquo;re throttling the pipeline at both ends at once: hollowing out the entry-level job,
and feeding it from a school system running a step behind.&lt;/p&gt;
&lt;h2 id="its-a-split-not-a-collapse"&gt;It&amp;rsquo;s a split, not a collapse
&lt;/h2&gt;&lt;p&gt;The counterweight to the doom is that none of this is uniform, and the loudest version,
&amp;ldquo;the junior is dead&amp;rdquo;, simply isn&amp;rsquo;t true. IBM just tripled its US entry-level hiring while
most of the industry was cutting, and
&lt;a class="link" href="https://www.cio.com/article/4134276/ibm-looks-beyond-short-term-ai-gains-tripling-entry-level-hiring.html" target="_blank" rel="noopener"
 &gt;its HR chief said the quiet part out loud&lt;/a&gt;:
AI can handle most of the routine entry-level tasks now, the work still needs a human,
and the companies that double down on early-career hiring in this environment are the
ones that win in three to five years. They didn&amp;rsquo;t keep the junior role as it was. They
rewrote it, less boilerplate, more time spent with customers and supervising what the AI
produced.&lt;/p&gt;
&lt;p&gt;That is the shape of the thing. The juniors who are thriving in 2026 aren&amp;rsquo;t the fastest
typists. They&amp;rsquo;re the ones building judgement, which is precisely the edge I argued was
the senior&amp;rsquo;s real value all along. The market hasn&amp;rsquo;t stopped wanting juniors, it&amp;rsquo;s
stopped wanting the version of the junior whose job was the work AI now does.&lt;/p&gt;
&lt;h2 id="day-zero"&gt;Day zero
&lt;/h2&gt;&lt;p&gt;So what does a junior actually look like now? I don&amp;rsquo;t know yet&amp;hellip; and anyone telling you
they&amp;rsquo;ve got it worked out is selling something. We are at day zero of this.&lt;/p&gt;
&lt;p&gt;The junior gauntlet, the rite of passage every one of us runs to earn our stripes, isn&amp;rsquo;t
going anywhere. Doing your time is a cold fact of the craft and it always will be. What
changes is what the gauntlet &lt;em&gt;contains&lt;/em&gt;, and that will keep changing, day one, day two,
day five hundred and twelve. The only way we redefine it well is to put juniors and
seniors on it together, with the AI in the room from the start instead of bolted on
afterwards. Bring it closer to our people, and bring it earlier.&lt;/p&gt;
&lt;p&gt;Open the floodgates, in other words. Let engineers of every creed and calibre in, and
let them evolve &lt;em&gt;with&lt;/em&gt; the machine, because that is the only way the symbiosis everyone
keeps promising actually happens. Darwin&amp;rsquo;s line was survival of the fittest, and fitness
here means adapting alongside the tool, not being spared by it. Choke off the flow of the
very people who could do that adapting, and we don&amp;rsquo;t get fitter. We go extinct.&lt;/p&gt;
&lt;h2 id="the-end-im-holding"&gt;The end I&amp;rsquo;m holding
&lt;/h2&gt;&lt;p&gt;Which is the long way back to that interview. I keep asking the question, what is your
real position on AI and how are you building a team of people and machines together,
because the answer tells me whether a company is optimising for this quarter or for the
survival of the craft. I want to work where it&amp;rsquo;s the second one, and I think any engineer
sitting across that desk should be asking the same.&lt;/p&gt;
&lt;p&gt;And it&amp;rsquo;s why, whatever desk I land at, there&amp;rsquo;s one thing I already know I&amp;rsquo;ll do. I don&amp;rsquo;t
have the map. Nobody does. But every junior who works under me is going to get the chance
to run the gauntlet, to grow into a senior, and to be in the room while we work out what
the next gauntlet should even be. That isn&amp;rsquo;t charity. It&amp;rsquo;s the only sane investment any
of us can make. The last properly useful thing my generation does, before we go and find
our cabins, is make sure there&amp;rsquo;s somebody left to hand the thread to. I intend to be
holding my end of it.&lt;/p&gt;</description></item><item><title>The goblin that wouldn't stay dead</title><link>https://phpboyscout.uk/the-goblin-that-wouldnt-stay-dead/</link><pubDate>Fri, 12 Jun 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/the-goblin-that-wouldnt-stay-dead/</guid><description>&lt;img src="https://phpboyscout.uk/the-goblin-that-wouldnt-stay-dead/cover-the-goblin-that-wouldnt-stay-dead.png" alt="Featured image of post The goblin that wouldn't stay dead" /&gt;&lt;p&gt;Turn one, the player swings, the die comes up 20, and my AI dungeon master
narrates the goblin falling silent, leaving the player alone in the corridor.
Good. Turn two, another roll, a 6 this time, and the same dungeon master cheerily
has the goblin &amp;ldquo;dance back&amp;rdquo; out of the dark to take another swing. The goblin I&amp;rsquo;d
just watched die was up and fighting again, and the model didn&amp;rsquo;t so much as blink.&lt;/p&gt;
&lt;p&gt;I didn&amp;rsquo;t feel cheated, or even surprised. I felt the small, familiar thud of &lt;em&gt;oh,
yeah, I forgot that bit.&lt;/em&gt; Because the model hadn&amp;rsquo;t gone rogue. It had done exactly
what a language model does. The gap was mine.&lt;/p&gt;
&lt;p&gt;This was the war story behind
&lt;a class="link" href="https://phpboyscout.uk/building-a-cli-with-go-tool-base-part-4/" &gt;part four of the go-tool-base tutorial&lt;/a&gt;,
the AI dungeon master. The tutorial shows the clean, final design and quietly
moves on. It doesn&amp;rsquo;t show the three different ways I got it wrong first, which is
a shame, because the wrong turns are where the actual lesson is.&lt;/p&gt;
&lt;h2 id="why-a-dungeon-master-at-all"&gt;Why a dungeon master at all
&lt;/h2&gt;&lt;p&gt;A word on why I was even here. I was trying to prove the chat
component of the framework to myself. There&amp;rsquo;s a voice that pipes up whenever I
build anything in this space, &amp;ldquo;LangChain exists, who do you think you are?&amp;rdquo;, and
the answer I keep landing on is that LangChain is enormous and I wanted something
&lt;a class="link" href="https://phpboyscout.uk/an-ai-interface-that-fits-on-one-screen/" &gt;small enough to hold in your head&lt;/a&gt;.
The tutorial was the test: could a newcomer wire AI into a CLI with it and come
out the other side with something that actually &lt;em&gt;behaves&lt;/em&gt;?&lt;/p&gt;
&lt;p&gt;That last word is the whole problem. A tutorial has to leave you holding something
dependable, and dependability is the one thing AI fights you on. I also wanted it
to be fun, a thing someone might keep poking at after the tutorial ends, maybe
even the hook that gets a person other than me to use the framework. I batted hook
ideas around and liked none of them, until the obvious one landed: I run a
tabletop game on the odd weekend, so make the AI the dungeon master. Gamify the
thing. Then watch it raise the dead.&lt;/p&gt;
&lt;h2 id="strike-one-nothing-to-enforce"&gt;Strike one: nothing to enforce
&lt;/h2&gt;&lt;p&gt;The first version was the naive one. I gave the model a &lt;code&gt;roll&lt;/code&gt; tool, because the
one thing you absolutely cannot let a language model do is pick its own numbers,
and otherwise let it narrate freely. The conversation history carried from turn to
turn, so it &lt;em&gt;remembered&lt;/em&gt; the fight. I assumed remembering was enough.&lt;/p&gt;
&lt;p&gt;It isn&amp;rsquo;t. Remembering and being held to it are different things. The history told
the model a goblin had died; nothing &lt;em&gt;stopped&lt;/em&gt; it writing the goblin back in when
the next turn&amp;rsquo;s narration wanted a bit of jeopardy. Memory is not a constraint.
The model will happily contradict its own past if you&amp;rsquo;ve given it room to, and I
had given it nothing but room.&lt;/p&gt;
&lt;h2 id="strike-two-a-tool-to-read-the-state"&gt;Strike two: a tool to read the state
&lt;/h2&gt;&lt;p&gt;The obvious fix, and I do mean obvious, the kind you reach for without thinking,
was to give the model a &lt;code&gt;state&lt;/code&gt; tool so it could check who was alive before it
narrated. Hand it the facts on request and surely it&amp;rsquo;ll stop making them up.&lt;/p&gt;
&lt;p&gt;What it actually did was dither. Handed a tool it could call to look things up, it
called it. And called it. And called it again, turning a turn over in its hands
without ever committing to an action, burning through its step budget on lookups
and leaving the player staring at nothing. I&amp;rsquo;d cured the lying by inventing
paralysis. A tool the model &lt;em&gt;can&lt;/em&gt; call is a tool it &lt;em&gt;will&lt;/em&gt; call, often instead of
doing the thing you actually wanted.&lt;/p&gt;
&lt;h2 id="strike-three-refereeing-its-own-dice"&gt;Strike three: refereeing its own dice
&lt;/h2&gt;&lt;p&gt;When I did get it reading state cleanly, the third failure crept in, and this one
was subtler. Once the model could see the goblin&amp;rsquo;s hit points, it started
&lt;em&gt;deciding&lt;/em&gt; the fight. It would read that the goblin had 12 HP and just narrate a
killing blow, hits and damage and all, without calling the &lt;code&gt;roll&lt;/code&gt; or &lt;code&gt;attack&lt;/code&gt;
tools at all. Why ask the dice when you can see the board and write whatever
outcome the story wants? Give a model enough context and it stops being a narrator
and starts being a referee, which is precisely the job I&amp;rsquo;d built tools to keep out
of its hands.&lt;/p&gt;
&lt;h2 id="the-fix-was-less-not-more"&gt;The fix was less, not more
&lt;/h2&gt;&lt;p&gt;Three failures, and notice the shape of my fixes: each one &lt;em&gt;added&lt;/em&gt; something. More
memory, then a tool, then more context. Every instinct said the model needed more
to work with. Every time, the extra capability was the new way to be wrong.&lt;/p&gt;
&lt;p&gt;So I went the other way. The truth lives in a plain Go struct that I own, not the
model. There&amp;rsquo;s no &lt;code&gt;state&lt;/code&gt; tool to dither on, because the loop simply prepends the
current state to every turn&amp;rsquo;s input, fresh, so the model never has to ask and
never gets to drift. The mechanics, the dice and the damage, live in Go functions
the model has to call, and the system prompt says in as many words that it must
not decide a hit or damage itself. The model is left with exactly one job:
narrate. The prose is its to invent. The maths, the state and the shape of the
result are not.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s the line that turned three bugs into a feature. You don&amp;rsquo;t make a language
model reliable by giving it more to work with. You make it reliable by giving it
&lt;em&gt;less to be wrong about.&lt;/em&gt;&lt;/p&gt;
&lt;h2 id="the-freedom-i-chose-not-to-give-it"&gt;The freedom I chose not to give it
&lt;/h2&gt;&lt;p&gt;There&amp;rsquo;s a real tension in that, and I want to name it rather than pretend the
boxed-in version is the only true one. At my own table the rules are guidelines,
not guardrails. I ignore them, bend them, improvise, reach for the &amp;ldquo;rule of cool&amp;rdquo;
when the moment&amp;rsquo;s better for it. A great AI dungeon master would have that same
freedom, and a few out there genuinely do, &lt;a class="link" href="https://www.oldgregstavern.com/" target="_blank" rel="noopener"
 &gt;Old Greg&amp;rsquo;s Tavern&lt;/a&gt; is a lovely example
of how far the free-form version can go.&lt;/p&gt;
&lt;p&gt;But that freedom costs far more than a tutorial can spend, and it buys
unpredictability I was specifically trying to teach people to avoid. So I made a
deliberate trade: guardrails instead of guidelines. Simple, but not so simple it&amp;rsquo;s
boring. The player still gets a &amp;ldquo;not on rails&amp;rdquo; game, they can try anything and the
DM copes, but every outcome that matters runs through code I trust. That&amp;rsquo;s the
right shape for a tutorial, and, not by coincidence, the right shape for most AI
features you&amp;rsquo;d actually ship.&lt;/p&gt;
&lt;h2 id="what-the-goblin-taught-me"&gt;What the goblin taught me
&lt;/h2&gt;&lt;p&gt;The thing I keep coming back to is that the model never misbehaved. It resurrected
the goblin because I gave it the freedom to. It dithered because I gave it a button
to press. It refereed because I let it see the board. Every failure was a
permission I&amp;rsquo;d handed over without meaning to. The reliability didn&amp;rsquo;t come from a
cleverer prompt or a bigger model, it came from working out, one dead goblin at a
time, exactly how little the model needed to be trusted with.&lt;/p&gt;
&lt;p&gt;If you want the version where it all works first time, the
&lt;a class="link" href="https://phpboyscout.uk/building-a-cli-with-go-tool-base-part-4/" &gt;tutorial&lt;/a&gt;
has it, the
&lt;a class="link" href="https://phpboyscout.uk/letting-the-ai-call-your-go-functions/" &gt;tool-calling&lt;/a&gt;
and the
&lt;a class="link" href="https://phpboyscout.uk/stop-regexing-the-llms-prose/" &gt;typed turns&lt;/a&gt;
wired up properly. This was the road there. The goblin, you&amp;rsquo;ll be glad to hear,
now stays down.&lt;/p&gt;</description></item><item><title>The greybeards' edge was never typing</title><link>https://phpboyscout.uk/the-greybeards-edge-was-never-typing/</link><pubDate>Wed, 27 May 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/the-greybeards-edge-was-never-typing/</guid><description>&lt;img src="https://phpboyscout.uk/the-greybeards-edge-was-never-typing/cover-the-greybeards-edge-was-never-typing.png" alt="Featured image of post The greybeards' edge was never typing" /&gt;&lt;p&gt;I have a retirement plan, and it is gloriously low-tech. A cabin, some trees, a
woodstove, and a firm rule that no wifi symbol ever appears within a mile of me
again. I think about it more than is probably healthy.&lt;/p&gt;
&lt;p&gt;There&amp;rsquo;s a snag, though, and it&amp;rsquo;s the same one the whole industry is currently
pretending it can&amp;rsquo;t see. For me to vanish into the woods, somebody has to be
able to do my job after I&amp;rsquo;ve gone. And right now, collectively, we are working
very hard to make sure nobody can.&lt;/p&gt;
&lt;h2 id="the-boost-and-the-drag"&gt;The boost, and the drag
&lt;/h2&gt;&lt;p&gt;I wrote the other day about how AI made &lt;a class="link" href="https://phpboyscout.uk/ai-didnt-kill-curls-bug-bounty/" &gt;&lt;em&gt;producing&lt;/em&gt; plausible work nearly free
while &lt;em&gt;verifying&lt;/em&gt; it stays expensive and human&lt;/a&gt;.
Point that same lens at a team and something uncomfortable falls out. It isn&amp;rsquo;t
mine; it belongs to Mark Russinovich and Scott Hanselman of Microsoft, who
&lt;a class="link" href="https://dl.acm.org/doi/10.1145/3779312" target="_blank" rel="noopener"
 &gt;laid it out in Communications of the ACM&lt;/a&gt;:
agentic coding tools give a senior engineer an &lt;em&gt;AI boost&lt;/em&gt;, multiplying what
they ship, because a senior has the judgement to steer and verify the output.
The same tools give an early-career engineer an &lt;em&gt;AI drag&lt;/em&gt;, because they don&amp;rsquo;t
have that judgement yet, and the machine hands them far more rope than they can
hold.&lt;/p&gt;
&lt;p&gt;The cold incentive writes itself, and they name it: hire seniors, automate
juniors. It isn&amp;rsquo;t hypothetical, either. Meta
&lt;a class="link" href="https://www.nytimes.com/2026/05/19/technology/meta-layoffs-ai.html" target="_blank" rel="noopener"
 &gt;cut 8,000 roles last week&lt;/a&gt;,
in a round the Times filed under mounting AI casualties. For any single quarter
you care to look at, the maths is impeccable.&lt;/p&gt;
&lt;h2 id="the-bill-is-just-deferred"&gt;The bill is just deferred
&lt;/h2&gt;&lt;p&gt;Here&amp;rsquo;s the line the spreadsheet leaves off. The grindy work a
junior used to cut their teeth on, the small fixes, the boring migrations, the
read-the-stack-trace-and-figure-it-out, is exactly the work AI now does. So the
proving ground is gone. And the entry-level seats where they&amp;rsquo;d have stood on it
are the ones being cut. Squeezed from both ends at once: no reps, and nowhere
to take them.&lt;/p&gt;
&lt;p&gt;Russinovich and Hanselman put the consequence plainly. Without early-career
hiring the talent pipeline collapses, and you arrive at a future with no next
generation of experienced engineers. The seniors you&amp;rsquo;ll be desperate for in
2032 are the juniors you declined to train in 2026. The bill doesn&amp;rsquo;t vanish. It
just falls due long after the people who cut the cheque have moved on.&lt;/p&gt;
&lt;h2 id="how-to-manufacture-a-world-of-ai-slop"&gt;How to manufacture a world of AI slop
&lt;/h2&gt;&lt;p&gt;I named the last piece for its villain; let me name this one&amp;rsquo;s too. Raise a
generation that can &lt;em&gt;produce&lt;/em&gt; with AI but was never taught to &lt;em&gt;validate&lt;/em&gt;, and
here is what you get: people shipping machine-built products at speed with no
instinct for where the output is quietly wrong, because they never had to be
wrong the slow way first. Software nobody genuinely understands, human-written
and AI-written alike, and a steady leak of trust out of all of it.&lt;/p&gt;
&lt;p&gt;That isn&amp;rsquo;t a productivity problem. That&amp;rsquo;s a world of
&lt;a class="link" href="https://phpboyscout.uk/ai-didnt-kill-curls-bug-bounty/" &gt;AI slop&lt;/a&gt;, and not
in one project&amp;rsquo;s inbox this time but everywhere at once. We&amp;rsquo;d have automated our
way clean out of the one job AI cannot do for us: knowing when not to trust the
machine.&lt;/p&gt;
&lt;h2 id="its-a-choice-and-its-yours"&gt;It&amp;rsquo;s a choice, and it&amp;rsquo;s yours
&lt;/h2&gt;&lt;p&gt;Andrew Murphy put it with more bite than I&amp;rsquo;d quite dare:
&lt;a class="link" href="https://andrewmurphy.io/blog/ai-didnt-kill-your-junior-pipeline-you-did" target="_blank" rel="noopener"
 &gt;AI didn&amp;rsquo;t kill your junior pipeline, you did&lt;/a&gt;.
He&amp;rsquo;s right. This isn&amp;rsquo;t weather. Nobody is making you do it. It&amp;rsquo;s a decision,
taken quarter by quarter, and a decision is a thing you can take differently.&lt;/p&gt;
&lt;p&gt;The fix isn&amp;rsquo;t complicated, it&amp;rsquo;s just unfashionable. Keep hiring early-career
engineers. Say out loud that they cost you capacity at first, and treat their
growth as an actual goal rather than something meant to happen by osmosis.
Russinovich and Hanselman call it preceptorship at scale: senior mentorship,
deliberately structured, turning the ordinary day&amp;rsquo;s work into teachable
moments.&lt;/p&gt;
&lt;p&gt;And the proving ground can be rebuilt, just not where it stood. If AI does the
writing now, the apprenticeship moves to the reviewing. Put juniors in the loop
on the machine&amp;rsquo;s output and have them hunt for the subtle wrongness, the way
&lt;a class="link" href="https://phpboyscout.uk/the-security-finding-you-must-not-fix/" &gt;a scanner is an argument, not an order&lt;/a&gt;.
That&amp;rsquo;s how judgement gets built now: not by grinding out the work, but by
verifying it. Which, as luck would have it, is the single most valuable thing
anyone on your team can learn to do.&lt;/p&gt;
&lt;h2 id="the-part-thats-on-the-greybeards"&gt;The part that&amp;rsquo;s on the greybeards
&lt;/h2&gt;&lt;p&gt;This is where I stop letting the companies wear all the blame, because some of
it is mine, and yours. Verification is a craft, and crafts pass from person to
person or not at all. I know where every one of my own AI misfires comes from:
I gave it too little context, or too much rope, and didn&amp;rsquo;t check the result
closely enough. The tool rarely went rogue. The gap was always my diligence.
That&amp;rsquo;s not a confession, it&amp;rsquo;s the curriculum, and it&amp;rsquo;s precisely the judgement
a junior can only earn by sitting in the loop beside someone who has already
made those mistakes.&lt;/p&gt;
&lt;p&gt;So the senior engineer&amp;rsquo;s job has quietly changed underneath us. It was never
really the typing. It was knowing when something is off, and what the customer
actually needs, and now it is also &lt;em&gt;handing that on&lt;/em&gt;, deliberately, while
there&amp;rsquo;s still time to. Mentor and guardian first; fastest prompt in the room a
distant second.&lt;/p&gt;
&lt;h2 id="the-ladder-youre-standing-on"&gt;The ladder you&amp;rsquo;re standing on
&lt;/h2&gt;&lt;p&gt;There will always be something AI can&amp;rsquo;t do well enough, and for a good while
yet it&amp;rsquo;s the thing that matters most: being the accountable human who genuinely
understands what&amp;rsquo;s needed and can be held to it when it goes wrong. A simulation
can be enormously convincing. It cannot be &lt;em&gt;responsible&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;Which brings me back to my cabin. I do want it one day, the trees and the
woodstove and the blissful disconnection. But I only get to go if the work
outlives me, and the work only outlives me if the people do. So the last useful
thing my generation does, before we shuffle off to find our trees, isn&amp;rsquo;t
shipping a little more code. It&amp;rsquo;s making sure there&amp;rsquo;s somebody left who can tell
when the machine is wrong. Pull the ladder up behind us and there&amp;rsquo;ll be nobody
to notice the rot, and no cabin quiet enough to make that sit right.&lt;/p&gt;</description></item><item><title>AI didn't kill curl's bug bounty. The bounty did.</title><link>https://phpboyscout.uk/ai-didnt-kill-curls-bug-bounty/</link><pubDate>Tue, 26 May 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/ai-didnt-kill-curls-bug-bounty/</guid><description>&lt;img src="https://phpboyscout.uk/ai-didnt-kill-curls-bug-bounty/cover-ai-didnt-kill-curls-bug-bounty.png" alt="Featured image of post AI didn't kill curl's bug bounty. The bounty did." /&gt;&lt;p&gt;In January, Daniel Stenberg shut down curl&amp;rsquo;s bug bounty. The headlines wrote
themselves, and they all said the same thing: AI killed it. A flood of
machine-generated slop drowned the maintainers, so they pulled the plug.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s true, as far as it goes. It&amp;rsquo;s also the wrong lesson, and the right one
is sitting in plain sight in the same project, in the same few months.&lt;/p&gt;
&lt;h2 id="volume-without-validation-is-the-attack"&gt;Volume without validation is the attack
&lt;/h2&gt;&lt;p&gt;curl had run its bounty since April 2019. Over its life it paid out
&lt;a class="link" href="https://daniel.haxx.se/blog/2026/01/26/the-end-of-the-curl-bug-bounty/" target="_blank" rel="noopener"
 &gt;more than $100,000 for 87 genuine vulnerabilities&lt;/a&gt;,
a thoroughly good return for one of the most depended-on pieces of software on
the planet. Then the reports stopped being reports. The confirmation rate, the
share of submissions that turned out to be a real bug, had historically sat
north of 15%. By 2025 it was below 5%. Fewer than one in twenty submissions
were worth anything, and the rest still had to be read.&lt;/p&gt;
&lt;p&gt;That last part is the whole problem. A bogus report doesn&amp;rsquo;t announce itself.
Someone has to open it, take it seriously, try to reproduce it, and work out
that it&amp;rsquo;s nonsense, and that someone is a human being with a finite number of
hours and a project to run. Stenberg put it plainly: the slop &amp;ldquo;take[s] a
serious mental toll to manage and sometimes also a long time to debunk.&amp;rdquo; The
submitter spends seconds. The maintainer spends an afternoon. Do that at volume
and it stops being noise and becomes an attack, a denial-of-service aimed not
at curl&amp;rsquo;s servers but at its maintainers&amp;rsquo; attention. No exploit required. Just
plausibility, in bulk.&lt;/p&gt;
&lt;h2 id="the-bounty-was-the-accelerant-not-the-ai"&gt;The bounty was the accelerant, not the AI
&lt;/h2&gt;&lt;p&gt;So far this is the story everyone tells. Here&amp;rsquo;s where I get off the bus.&lt;/p&gt;
&lt;p&gt;The instinct is to blame the AI for the slop. But look at what a bounty actually
is. It&amp;rsquo;s a cash prize, and curl&amp;rsquo;s was priced for the thing it wanted: the hours
and the judgement a skilled human pours into finding a real flaw. That pricing
made complete sense right up until the cost of producing something that &lt;em&gt;looked
like&lt;/em&gt; a finding collapsed to nearly nothing.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s what AI changed. Not the supply of bugs. The supply of plausible-looking
bug reports. Put a cash prize on &amp;ldquo;looks like a finding&amp;rdquo;, then make &amp;ldquo;looks like a
finding&amp;rdquo; free to generate, and you haven&amp;rsquo;t got a bug bounty any more. You&amp;rsquo;ve got
a slot machine. Stenberg said he&amp;rsquo;d started to sense &amp;ldquo;a bad faith attitude&amp;rdquo; in
the reports, and of course he had. The incentive was openly inviting it.&lt;/p&gt;
&lt;p&gt;So the death spiral was structural, not bad luck. The moment generating
plausible reports went free, any cash bounty became a magnet for spray-and-pray,
and the only open questions were how fast it would rot and whether you&amp;rsquo;d close
the programme or just let the rewards quietly wither. The AI was the match. The
bounty was the petrol. We have been pointing at the wrong one.&lt;/p&gt;
&lt;h2 id="the-proof-curl-turned-around-and-hired-the-ai"&gt;The proof: curl turned around and hired the AI
&lt;/h2&gt;&lt;p&gt;If AI were really the villain here, you&amp;rsquo;d expect curl to have slammed the door
on it. It did the opposite.&lt;/p&gt;
&lt;p&gt;In the same stretch, &lt;a class="link" href="https://aisle.com/blog/curl-adopts-aisle-after-its-ai-agents-discovered-5-cves" target="_blank" rel="noopener"
 &gt;by AISLE&amp;rsquo;s own account&lt;/a&gt;,
an AI security platform contributed 24 pull requests to curl, five of which
earned CVEs, and the project now runs it internally for continuous review. The
same tooling reportedly found &lt;a class="link" href="https://www.lesswrong.com/posts/7aJwgbMEiKq5egQbd/" target="_blank" rel="noopener"
 &gt;all twelve zero-days&lt;/a&gt;
in an OpenSSL release in late January. (Both of those are the tool-makers&amp;rsquo; and a
third party&amp;rsquo;s numbers rather than curl&amp;rsquo;s audited figures, so weigh them as such.
But curl adopting the thing isn&amp;rsquo;t a claim. It&amp;rsquo;s a decision.)&lt;/p&gt;
&lt;p&gt;Sit with the shape of that. curl shut down strangers being paid for AI-shaped
noise, and in the same breath put AI to work as a tool its own maintainers
drive. The two moves look contradictory only if you think &amp;ldquo;AI&amp;rdquo; is a single thing
with a single verdict attached. It isn&amp;rsquo;t. Pointed at the problem by people
accountable for the result, with no prize to farm, it found real bugs. Dangled
in front of anonymous strangers chasing a payout, it produced sand.&lt;/p&gt;
&lt;h2 id="the-tell-is-which-ai-curl-kept-and-which-it-mocked"&gt;The tell is which AI curl kept, and which it mocked
&lt;/h2&gt;&lt;p&gt;Stenberg drew that line about as sharply as a person can. When Anthropic put its
security model, Mythos, in front of curl this spring, it
&lt;a class="link" href="https://daniel.haxx.se/blog/2026/05/11/mythos-finds-a-curl-vulnerability/" target="_blank" rel="noopener"
 &gt;scanned 176,000 lines of C and surfaced a single flaw&lt;/a&gt;,
and Stenberg called the surrounding fanfare
&lt;a class="link" href="https://www.theregister.com/security/2026/05/11/anthropics-bug-hunting-mythos-was-greatest-marketing-stunt-ever-says-curl-creator/5238111" target="_blank" rel="noopener"
 &gt;the greatest marketing stunt he&amp;rsquo;d seen&lt;/a&gt;.
Same maintainer. Adopts one AI, rubbishes another.&lt;/p&gt;
&lt;p&gt;The deciding factor was never whether the thing was AI. Both were. It was
whether the output survived a human checking it, and whether you could check it
at all. AISLE handed over pull requests and CVEs you could read and merge.
Mythos arrived as a closed model and a press release, which is to say a claim
the community has no way to independently test.&lt;/p&gt;
&lt;p&gt;My bias, up front, because it runs the opposite way to what you&amp;rsquo;d expect from
someone writing this: I&amp;rsquo;m a paying Claude subscriber and I lean on Anthropic&amp;rsquo;s
models every working day, the one behind the spadework for this post included.
I&amp;rsquo;m an advocate, not a sceptic, and AI genuinely has its place. That is
&lt;em&gt;exactly&lt;/em&gt; why the Mythos fanfare grates. Overselling a closed model to get out
ahead of the competition, when the one test the public got to see turned up a
single bug, is the sort of thing that chips away at trust in all of it. A result
you can&amp;rsquo;t verify is marketing until proven otherwise, whoever&amp;rsquo;s logo is on the
slide, and I&amp;rsquo;d rather the tools I depend on didn&amp;rsquo;t stoop to it.&lt;/p&gt;
&lt;h2 id="the-cheap-half-and-the-expensive-half"&gt;The cheap half and the expensive half
&lt;/h2&gt;&lt;p&gt;Pull back from curl for a moment, because the lesson isn&amp;rsquo;t really about bounties
at all. Anyone who works with these tools every day knows the same thing: when
they go wrong, it&amp;rsquo;s rarely the model running off on its own. It&amp;rsquo;s the context it
wasn&amp;rsquo;t given, the rope it was handed, the output nobody checked closely enough.
The failure sits on the human side of the keyboard, at the one step that&amp;rsquo;s
easiest to skip, which is verification.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s the pattern curl hit at the scale of an ecosystem. AI made one thing
nearly free: producing work that looks right. It did not make the other thing a
penny cheaper: confirming the work &lt;em&gt;is&lt;/em&gt; right. That cost still falls, in full,
on a person. (A scanner, &lt;a class="link" href="https://phpboyscout.uk/the-security-finding-you-must-not-fix/" &gt;I&amp;rsquo;ve argued before&lt;/a&gt;,
is an argument, not an order; the same goes double for a model.) The bounty&amp;rsquo;s
fatal mistake was paying for the cheap half and quietly assuming it had bought
the expensive one. The same trap waits in code review, in hiring, in CVs read by
machines, but that&amp;rsquo;s a bigger argument for another post.&lt;/p&gt;
&lt;h2 id="pouring-sand-into-the-machine"&gt;Pouring sand into the machine
&lt;/h2&gt;&lt;p&gt;curl didn&amp;rsquo;t capitulate to AI, whatever the headlines decided. It stopped paying
for the worthless half and started using the valuable half, and it had the
discernment to tell a useful tool from a press release while it did so.&lt;/p&gt;
&lt;p&gt;The bounty wasn&amp;rsquo;t a casualty of artificial intelligence. It was a structure
that, the instant plausible output became free, could only fill with sand.
Stenberg said he hopes closing it stops &amp;ldquo;more people pouring sand into the
machine.&amp;rdquo; Reading the last year of his inbox, I think he&amp;rsquo;ll get his wish. The
sand was only ever there because somebody left a bucket of money beside the
funnel.&lt;/p&gt;</description></item><item><title>Supporting a provider, or actually using it</title><link>https://phpboyscout.uk/supporting-a-provider-or-actually-using-it/</link><pubDate>Sat, 02 May 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/supporting-a-provider-or-actually-using-it/</guid><description>&lt;img src="https://phpboyscout.uk/supporting-a-provider-or-actually-using-it/cover-supporting-a-provider-or-actually-using-it.png" alt="Featured image of post Supporting a provider, or actually using it" /&gt;&lt;p&gt;If your CLI tool talks to an AI model, you don&amp;rsquo;t want to hard-wire one vendor. So you reach for a single client interface over several providers, which is the right call. The trap is the next step: build that interface on only what every provider has in common, and you quietly throw away the very features that made you want a particular provider in the first place. rust-tool-base&amp;rsquo;s &lt;code&gt;rtb-ai&lt;/code&gt; refuses to make that trade.&lt;/p&gt;
&lt;h2 id="the-pull-toward-one-interface"&gt;The pull toward one interface
&lt;/h2&gt;&lt;p&gt;If your CLI tool talks to an AI model, hard-wiring one vendor is a poor bet. One user has an Anthropic key, another an OpenAI key. Someone&amp;rsquo;s on Gemini. Someone runs Ollama locally because their data can&amp;rsquo;t leave the building. Someone points at an OpenAI-compatible endpoint from a provider you&amp;rsquo;ve never heard of. You don&amp;rsquo;t want a separate code path for each, so you want one &lt;code&gt;AiClient&lt;/code&gt; that all of them slot behind.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;rtb-ai&lt;/code&gt; gets that unification from the &lt;code&gt;genai&lt;/code&gt; crate, which already speaks to Anthropic, OpenAI, Gemini, Ollama and OpenAI-compatible endpoints. One interface, five providers, the tool author picks one in config. The Go sibling makes the same bet: go-tool-base&amp;rsquo;s &lt;code&gt;chat&lt;/code&gt; package also unifies several providers, behind &lt;a class="link" href="https://phpboyscout.uk/an-ai-interface-that-fits-on-one-screen/" &gt;an interface deliberately kept to four methods&lt;/a&gt;. So far this is the obvious design, and if it were the whole design there&amp;rsquo;d be nothing to write about.&lt;/p&gt;
&lt;h2 id="what-unified-quietly-costs-you"&gt;What &amp;ldquo;unified&amp;rdquo; quietly costs you
&lt;/h2&gt;&lt;p&gt;Here&amp;rsquo;s the catch in any unified interface. It can only expose what every provider behind it has in common.&lt;/p&gt;
&lt;p&gt;The common subset is plain chat. Messages go in, text comes out, optionally streamed token by token. That&amp;rsquo;s real and it&amp;rsquo;s useful and every provider does it. But the common subset is also the &lt;em&gt;floor&lt;/em&gt;, and the features that make a particular provider worth choosing are almost never on the floor. They&amp;rsquo;re the things only that provider does.&lt;/p&gt;
&lt;p&gt;Anthropic is the sharp example, because it has three features that matter and not one of them is common-subset.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Prompt caching.&lt;/strong&gt; You can mark the stable parts of a request, the system prompt and the tool list, as cacheable. The provider keeps them warm, and on the next turn you aren&amp;rsquo;t billed to re-send and re-process text that didn&amp;rsquo;t change. On a long agent loop, where the same large system prompt rides along on every single turn, that&amp;rsquo;s a substantial saving in both cost and latency.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Extended thinking.&lt;/strong&gt; The model works through a hard problem in a visible, budgeted reasoning pass before it commits to an answer, and you can see that reasoning.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Citations.&lt;/strong&gt; Structured references back to source material in the response.&lt;/p&gt;
&lt;p&gt;A client built strictly on the common subset can&amp;rsquo;t express any of those. It has no field for them, because four of the five providers wouldn&amp;rsquo;t know what to do with the field. So a purely lowest-common-denominator client would &amp;ldquo;support&amp;rdquo; Anthropic and then use it badly, leaving its best features unreachable. Support as a checkbox, not as the point.&lt;/p&gt;
&lt;h2 id="the-escape-hatch"&gt;The escape hatch
&lt;/h2&gt;&lt;p&gt;&lt;code&gt;rtb-ai&lt;/code&gt;&amp;rsquo;s answer is to not choose. It runs two implementations under one interface.&lt;/p&gt;
&lt;p&gt;For OpenAI, Gemini, Ollama and OpenAI-compatible endpoints, calls route through &lt;code&gt;genai&lt;/code&gt;, the unified path. For Anthropic, every method drops to a &lt;a class="link" href="https://gitlab.com/phpboyscout/rust-tool-base/-/blob/9c22aa8/crates/rtb-ai/src/anthropic.rs#L1" target="_blank" rel="noopener"
 &gt;direct &lt;code&gt;reqwest&lt;/code&gt; implementation&lt;/a&gt; straight against the Messages API. Same &lt;code&gt;AiClient&lt;/code&gt; on the surface, a different implementation underneath, selected by which provider the config names.&lt;/p&gt;
&lt;p&gt;And the request type has deliberate room for the difference:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-rust" data-lang="rust"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;pub&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="nc"&gt;ChatRequest&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;pub&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;system&lt;/span&gt;: &lt;span class="nb"&gt;Option&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;pub&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;messages&lt;/span&gt;: &lt;span class="nb"&gt;Vec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Message&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;pub&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;temperature&lt;/span&gt;: &lt;span class="nb"&gt;Option&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;f32&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;pub&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;max_tokens&lt;/span&gt;: &lt;span class="nb"&gt;Option&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;u32&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="sd"&gt;/// Anthropic-only: enables prompt caching at every stable point.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="sd"&gt;/// Ignored on non-Anthropic providers.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;pub&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;cache_control&lt;/span&gt;: &lt;span class="kt"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="sd"&gt;/// Anthropic-only: extended-thinking budget. `None` disables.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="sd"&gt;/// Ignored on non-Anthropic providers.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;pub&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;thinking&lt;/span&gt;: &lt;span class="nb"&gt;Option&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ThinkingMode&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Set &lt;code&gt;cache_control&lt;/code&gt; and the Anthropic-direct path inserts cache breakpoints at the three stable points: the system prompt, the tool list, and the first message. Set &lt;code&gt;thinking&lt;/code&gt; and it adds the thinking block, and streaming surfaces a separate &lt;code&gt;ThinkingToken&lt;/code&gt; event so you can show the reasoning apart from the answer. On a non-Anthropic provider, both fields are simply ignored. The interface carries them; only the implementation that understands them acts on them.&lt;/p&gt;
&lt;h2 id="a-hatch-not-a-leak"&gt;A hatch, not a leak
&lt;/h2&gt;&lt;p&gt;It&amp;rsquo;s worth being precise about why this isn&amp;rsquo;t the thing it superficially resembles, which is a leaky abstraction.&lt;/p&gt;
&lt;p&gt;A leaky abstraction is one where implementation details bleed through that you didn&amp;rsquo;t intend and can&amp;rsquo;t reason about. The abstraction quietly fails to abstract, and you&amp;rsquo;re left guessing which provider you&amp;rsquo;re really talking to.&lt;/p&gt;
&lt;p&gt;This is the opposite of that. The two Anthropic-only fields aren&amp;rsquo;t a leak. They&amp;rsquo;re named, documented as Anthropic-only, inert everywhere else, and right there in the public type for anyone to see. The interface is uniform for the common case and &lt;em&gt;deliberately, visibly&lt;/em&gt; non-uniform at exactly the points where uniformity would have cost you the good features. You opt into provider-specifics by setting a field. You stay fully portable by leaving it at its default. Nothing bleeds; you decide.&lt;/p&gt;
&lt;p&gt;The same design line explains what &lt;em&gt;does&lt;/em&gt; stay in the unified path. Structured output, &lt;code&gt;chat_structured::&amp;lt;T&amp;gt;&lt;/code&gt;, sends a JSON Schema derived from your Rust type with the request and validates the reply against it before handing you a typed &lt;code&gt;T&lt;/code&gt;. That&amp;rsquo;s a portability win that costs nothing across providers, so it belongs in the common interface. The split isn&amp;rsquo;t &amp;ldquo;Anthropic versus the rest&amp;rdquo;. It&amp;rsquo;s &amp;ldquo;features that are free to unify go in the unified path; features that aren&amp;rsquo;t get a designed door&amp;rdquo;. Prompt caching and extended thinking get the door, because flattening them away would be the expensive kind of convenient.&lt;/p&gt;
&lt;h2 id="to-sum-up"&gt;To sum up
&lt;/h2&gt;&lt;p&gt;A CLI tool that integrates AI wants one client over several providers, and a unified interface can only expose what those providers share. The shared floor is plain chat, and the features worth choosing a provider &lt;em&gt;for&lt;/em&gt;, like Anthropic&amp;rsquo;s prompt caching, extended thinking and citations, are never on the floor.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;rtb-ai&lt;/code&gt; keeps both. &lt;code&gt;genai&lt;/code&gt; provides the unified path across five providers; an Anthropic-direct &lt;code&gt;reqwest&lt;/code&gt; path drops below the abstraction for the features &lt;code&gt;genai&lt;/code&gt; can&amp;rsquo;t reach, and &lt;code&gt;ChatRequest&lt;/code&gt; carries the Anthropic-only fields openly, ignored elsewhere. Uniform where uniformity is free, with a designed escape hatch where it isn&amp;rsquo;t. That&amp;rsquo;s the difference between supporting a provider and actually using it.&lt;/p&gt;</description></item><item><title>The AI provider that isn't an API</title><link>https://phpboyscout.uk/the-ai-provider-that-isnt-an-api/</link><pubDate>Mon, 06 Apr 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/the-ai-provider-that-isnt-an-api/</guid><description>&lt;img src="https://phpboyscout.uk/the-ai-provider-that-isnt-an-api/cover-the-ai-provider-that-isnt-an-api.png" alt="Featured image of post The AI provider that isn't an API" /&gt;&lt;p&gt;go-tool-base&amp;rsquo;s &lt;code&gt;chat&lt;/code&gt; package puts five AI providers behind one interface. Four of them are exactly what you&amp;rsquo;d guess: HTTP calls to OpenAI, Claude, Gemini, and anything OpenAI-compatible. The fifth one isn&amp;rsquo;t an API at all. It shells out to a binary.&lt;/p&gt;
&lt;p&gt;That sounds like a slightly mad thing to want, right up until you&amp;rsquo;ve worked somewhere the network says no.&lt;/p&gt;
&lt;h2 id="the-fifth-provider-shells-out"&gt;The fifth provider shells out
&lt;/h2&gt;&lt;p&gt;The &lt;code&gt;chat&lt;/code&gt; package speaks to five providers through one &lt;code&gt;ChatClient&lt;/code&gt; interface. Four of them are what you&amp;rsquo;d expect: HTTP requests to OpenAI, to Claude, to Gemini, to any OpenAI-compatible endpoint. The tool author picks one in config, and the rest of the code never knows the difference.&lt;/p&gt;
&lt;p&gt;The fifth, &lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/5c78fc9/pkg/chat/claude_local.go#L18" target="_blank" rel="noopener"
 &gt;&lt;code&gt;ProviderClaudeLocal&lt;/code&gt;&lt;/a&gt;, is different in kind. It doesn&amp;rsquo;t make an HTTP request at all. It shells out. It runs the &lt;code&gt;claude&lt;/code&gt; CLI binary as a child process, passes the prompt in, and reads the answer back from the binary&amp;rsquo;s output.&lt;/p&gt;
&lt;p&gt;That sounds like an odd thing to want until you&amp;rsquo;ve been stuck in the environment it was built for.&lt;/p&gt;
&lt;h2 id="why-youd-want-that"&gt;Why you&amp;rsquo;d want that
&lt;/h2&gt;&lt;p&gt;Picture a corporate network with its egress locked right down. Outbound HTTPS to &lt;code&gt;api.anthropic.com&lt;/code&gt; is blocked by policy. A tool built on go-tool-base that uses AI would simply fall over there. It tries to reach the API, there&amp;rsquo;s no route, and that&amp;rsquo;s the end of the feature.&lt;/p&gt;
&lt;p&gt;But the developer at that machine has the &lt;code&gt;claude&lt;/code&gt; CLI installed, and has run &lt;code&gt;claude login&lt;/code&gt;. That binary is permitted. It&amp;rsquo;s an approved, managed tool, and it has its own sanctioned path out. The direct API call is blocked; the &lt;code&gt;claude&lt;/code&gt; command is not.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;ProviderClaudeLocal&lt;/code&gt; is what bridges those two facts. If your tool&amp;rsquo;s AI calls go &lt;em&gt;through&lt;/em&gt; that already-blessed binary instead of straight at the API, they work, in an environment where the direct call cannot. That&amp;rsquo;s the whole reason the provider exists. It isn&amp;rsquo;t faster (a real API call has lower latency) and it isn&amp;rsquo;t more capable. It&amp;rsquo;s for the place where the API call simply isn&amp;rsquo;t an option, and &amp;ldquo;isn&amp;rsquo;t an option&amp;rdquo; is a surprisingly common place to find yourself inside a large organisation.&lt;/p&gt;
&lt;h2 id="what-it-costs"&gt;What it costs
&lt;/h2&gt;&lt;p&gt;It&amp;rsquo;s worth being straight about the trade, because &lt;code&gt;ProviderClaudeLocal&lt;/code&gt; is the reduced-capability provider.&lt;/p&gt;
&lt;p&gt;It doesn&amp;rsquo;t do tool calling. It doesn&amp;rsquo;t do parallel tools. It doesn&amp;rsquo;t stream. Those need a live, structured connection to the model&amp;rsquo;s API, and a subprocess that runs once and prints an answer is not that. What it &lt;em&gt;does&lt;/em&gt; support is plain chat and structured output, the latter through the binary&amp;rsquo;s own &lt;code&gt;--json-schema&lt;/code&gt; flag.&lt;/p&gt;
&lt;p&gt;So the positioning, and the package&amp;rsquo;s documentation says exactly this, is: prefer the API providers when you can reach them, because they&amp;rsquo;re lower latency and feature-complete. Reach for &lt;code&gt;ProviderClaudeLocal&lt;/code&gt; when API access is restricted. You accept the narrower capability set as the price of working at all. For a tool whose AI feature is &amp;ldquo;answer a question&amp;rdquo; or &amp;ldquo;return a structured analysis&amp;rdquo;, that price is often nothing you&amp;rsquo;d even notice. For one built on an agentic tool-calling loop, it&amp;rsquo;s a real limitation, and you&amp;rsquo;d know to expect it.&lt;/p&gt;
&lt;h2 id="how-it-stays-behind-the-same-interface"&gt;How it stays behind the same interface
&lt;/h2&gt;&lt;p&gt;Here&amp;rsquo;s the part that makes it pleasant rather than a special case to maintain. Despite being a subprocess and not an API, &lt;code&gt;ProviderClaudeLocal&lt;/code&gt; is still a &lt;code&gt;ChatClient&lt;/code&gt;. Your feature code calls &lt;code&gt;Chat&lt;/code&gt; and &lt;code&gt;Ask&lt;/code&gt; exactly the way it would for any other provider.&lt;/p&gt;
&lt;p&gt;Everything that makes a subprocess provider awkward stays inside the provider. Spawning the binary, feeding it the prompt, parsing its output, capturing &lt;code&gt;stderr&lt;/code&gt; and surfacing it when the binary exits non-zero, and threading multi-turn continuity through session identifiers passed back on the next call with &lt;code&gt;--resume&lt;/code&gt;: all of that is the provider&amp;rsquo;s problem, and all of it sits behind the interface. The code in your tool that uses AI doesn&amp;rsquo;t know, and has no way to find out, that this particular provider is a child process rather than an HTTPS call.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s a unified interface genuinely earning its place. It&amp;rsquo;s easy to put a uniform face on four things that already work the same way underneath. The real test of the abstraction is whether something that works in a &lt;em&gt;completely&lt;/em&gt; different way, a subprocess instead of a socket, can still slot in without the caller changing a line. Here it can. You swap one config value, and a tool that talked to an API now talks through a binary, and nothing downstream so much as blinks.&lt;/p&gt;
&lt;h2 id="the-bottom-line"&gt;The bottom line
&lt;/h2&gt;&lt;p&gt;go-tool-base&amp;rsquo;s &lt;code&gt;chat&lt;/code&gt; package puts five providers behind one &lt;code&gt;ChatClient&lt;/code&gt; interface, and &lt;code&gt;ProviderClaudeLocal&lt;/code&gt; is the one that isn&amp;rsquo;t an API. It runs the locally installed, pre-authenticated &lt;code&gt;claude&lt;/code&gt; CLI as a subprocess.&lt;/p&gt;
&lt;p&gt;It exists for the locked-down environment where outbound HTTPS to the AI API is blocked but the &lt;code&gt;claude&lt;/code&gt; binary is allowed: there, AI features keep working where a direct call would fail. The trade is a narrower capability set (no tool calling, no streaming, plain chat and structured output only) so you prefer the API providers when you can reach them and fall back to this when you can&amp;rsquo;t. And because it&amp;rsquo;s still a &lt;code&gt;ChatClient&lt;/code&gt;, all the subprocess machinery stays hidden, and your code uses it without knowing it&amp;rsquo;s there. That last part is the real test of an abstraction: a provider that works in an entirely different way still slots in unchanged.&lt;/p&gt;</description></item><item><title>AI conversations you can resume</title><link>https://phpboyscout.uk/ai-conversations-you-can-resume/</link><pubDate>Sat, 04 Apr 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/ai-conversations-you-can-resume/</guid><description>&lt;img src="https://phpboyscout.uk/ai-conversations-you-can-resume/cover-ai-conversations-you-can-resume.png" alt="Featured image of post AI conversations you can resume" /&gt;&lt;p&gt;An AI conversation is, fundamentally, its own history. The model&amp;rsquo;s next answer depends on everything said so far. And a CLI tool, by its very nature, forgets everything the moment it exits. Put those two facts together and you get the problem: run an AI command, exit, run it again, and you&amp;rsquo;re talking to someone who&amp;rsquo;s never met you.&lt;/p&gt;
&lt;h2 id="a-cli-forgets-everything"&gt;A CLI forgets everything
&lt;/h2&gt;&lt;p&gt;A long-running service keeps its state in memory for as long as it runs. A CLI tool doesn&amp;rsquo;t get that luxury. It starts, does one thing, exits. The next invocation is a brand-new process with no memory of the last one.&lt;/p&gt;
&lt;p&gt;For most commands that&amp;rsquo;s exactly right, and you wouldn&amp;rsquo;t want it any other way. But an AI conversation is a different kind of beast, because a conversation &lt;em&gt;is&lt;/em&gt; its history. The model&amp;rsquo;s next answer depends on everything said so far. Run an AI command, exit, run it again, and you&amp;rsquo;ve started a fresh conversation with someone who&amp;rsquo;s never met you. For an interactive assistant, or any AI workflow that unfolds across several invocations, that&amp;rsquo;s plainly the wrong behaviour. The user expects to pick up where they left off.&lt;/p&gt;
&lt;h2 id="save-and-restore"&gt;Save and restore
&lt;/h2&gt;&lt;p&gt;The &lt;code&gt;chat&lt;/code&gt; package handles this through a &lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/5c78fc9/pkg/chat/persistence.go#L25" target="_blank" rel="noopener"
 &gt;&lt;code&gt;PersistentChatClient&lt;/code&gt;&lt;/a&gt; interface. Like streaming, it&amp;rsquo;s an optional capability discovered with a type assertion, sitting beside &lt;a class="link" href="https://phpboyscout.uk/an-ai-interface-that-fits-on-one-screen/" &gt;the four-method core&lt;/a&gt; rather than bloating it. A client that supports persistence also satisfies this interface:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.(&lt;/span&gt;&lt;span class="nx"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PersistentChatClient&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;snapshot&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Save&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// store the snapshot somewhere&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;A snapshot is a serialisable value that captures the conversation. You store it. Next run, you load it, &lt;code&gt;Restore&lt;/code&gt; it onto a fresh client, re-register your tools, and call &lt;code&gt;Chat&lt;/code&gt; again. &amp;ldquo;Where were we?&amp;rdquo; works, because the model is handed back the whole history.&lt;/p&gt;
&lt;h2 id="a-snapshot-is-opinionated-about-what-it-carries"&gt;A snapshot is opinionated about what it carries
&lt;/h2&gt;&lt;p&gt;The interesting part is what a snapshot does and doesn&amp;rsquo;t contain, because that&amp;rsquo;s a series of deliberate decisions.&lt;/p&gt;
&lt;p&gt;It carries the messages, the system prompt, the model name, and tool &lt;em&gt;metadata&lt;/em&gt;: the names, descriptions and parameter schemas of the tools that were registered.&lt;/p&gt;
&lt;p&gt;It does not carry tool &lt;em&gt;handlers&lt;/em&gt;. Handlers are code, not data; you can&amp;rsquo;t serialise a function meaningfully, so after a restore you re-register them with &lt;code&gt;SetTools&lt;/code&gt;. The snapshot remembers that a tool called &lt;code&gt;read_file&lt;/code&gt; existed and what its shape was; it doesn&amp;rsquo;t try to remember the Go function behind it.&lt;/p&gt;
&lt;p&gt;And it does not carry API tokens. This is the one to dwell on. A snapshot is a file. A file gets synced, backed up, copied between machines, attached to a support ticket by a user trying to be helpful. A snapshot that carried the API key would be a credential leak the moment it left the laptop it was made on. So the snapshot never contains a token, at all. On restore, the client picks the credential up again the ordinary way, from &lt;a class="link" href="https://phpboyscout.uk/where-should-a-cli-keep-your-api-keys/" &gt;the environment or the keychain&lt;/a&gt;. The conversation and the secret are kept in separate places on purpose, and only one of them is ever in the file.&lt;/p&gt;
&lt;h2 id="encrypted-at-rest-if-you-want-it"&gt;Encrypted at rest, if you want it
&lt;/h2&gt;&lt;p&gt;The package ships a &lt;code&gt;FileStore&lt;/code&gt; that writes snapshots as JSON files, with &lt;code&gt;0600&lt;/code&gt; permissions in a &lt;code&gt;0700&lt;/code&gt; directory, and it can encrypt them. Pass &lt;code&gt;WithEncryption&lt;/code&gt; a 32-byte key and snapshots are written with AES-256-GCM.&lt;/p&gt;
&lt;p&gt;That option exists because a conversation can hold sensitive content even when it holds no credential. The log a user pasted in for analysis, the source file they asked the model to review, the internal details tucked into their questions: none of that is an API key, and all of it might be something you&amp;rsquo;d rather not have sitting in plain JSON in a backup somewhere. Encryption at rest covers it.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;FileStore&lt;/code&gt; is also careful about the snapshot identifiers it&amp;rsquo;s handed. An ID has to be a canonical UUID, and the resolved file path is checked to lie inside the store directory, so a snapshot ID arriving from an untrusted source (a CLI flag, a request payload) can&amp;rsquo;t be bent into a path-traversal that reads or writes somewhere it shouldn&amp;rsquo;t. Persisting conversations adds a small filesystem surface, and the store treats it as exactly that.&lt;/p&gt;
&lt;h2 id="the-short-version"&gt;The short version
&lt;/h2&gt;&lt;p&gt;A CLI tool forgets everything between invocations, which is correct for most commands and wrong for an AI conversation, because a conversation is its history.&lt;/p&gt;
&lt;p&gt;go-tool-base&amp;rsquo;s &lt;code&gt;chat&lt;/code&gt; package lets you persist one. &lt;code&gt;PersistentChatClient&lt;/code&gt; saves a snapshot you can store and restore later, picking the conversation back up where it ended. The snapshot is deliberate about its contents: messages, system prompt and tool metadata yes; tool handlers no, because they&amp;rsquo;re code you re-register; API tokens never, because a snapshot is a file and a file travels. The built-in &lt;code&gt;FileStore&lt;/code&gt; can encrypt snapshots at rest with AES-256-GCM and validates snapshot IDs against path traversal. Resumable conversations, without the conversation file turning into a place secrets leak from.&lt;/p&gt;</description></item><item><title>An AI agent that has to make the build pass</title><link>https://phpboyscout.uk/an-ai-agent-that-has-to-make-the-build-pass/</link><pubDate>Thu, 02 Apr 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/an-ai-agent-that-has-to-make-the-build-pass/</guid><description>&lt;img src="https://phpboyscout.uk/an-ai-agent-that-has-to-make-the-build-pass/cover-an-ai-agent-that-has-to-make-the-build-pass.png" alt="Featured image of post An AI agent that has to make the build pass" /&gt;&lt;p&gt;Most AI code generation works on a charming little principle I&amp;rsquo;ll call generate-and-hope. The model writes the code, the model stops at the closing brace, and whether the thing actually compiles is left as an exercise for you. For a snippet you paste into an editor, fine. For a whole generated command, that&amp;rsquo;s just outsourcing the disappointment.&lt;/p&gt;
&lt;p&gt;go-tool-base does something I&amp;rsquo;m rather happier with: the AI has to make the build pass before it&amp;rsquo;s allowed to claim it&amp;rsquo;s done.&lt;/p&gt;
&lt;h2 id="generate-and-hope"&gt;Generate and hope
&lt;/h2&gt;&lt;p&gt;The usual shape of AI code generation is this. You ask for code, the model produces it, and the model&amp;rsquo;s job ends at the closing brace. Whether it compiles, whether the tests pass, whether the imports even resolve, none of that has been checked. The model produced something that &lt;em&gt;looks&lt;/em&gt; right. You find out whether it &lt;em&gt;is&lt;/em&gt; right when you build it.&lt;/p&gt;
&lt;p&gt;For a snippet you paste into an editor, that&amp;rsquo;s perfectly fine. The compiler tells you in a second. But go-tool-base&amp;rsquo;s generator, driven by &lt;code&gt;gtb generate command --script&lt;/code&gt; or &lt;code&gt;--prompt&lt;/code&gt;, produces a whole command: the implementation, its tests, the lot. &amp;ldquo;Generate and hope&amp;rdquo; at that scale means handing the user a project that may or may not build, and quietly making them the one who finds out which.&lt;/p&gt;
&lt;h2 id="drafting-is-only-step-one"&gt;Drafting is only step one
&lt;/h2&gt;&lt;p&gt;So the generator doesn&amp;rsquo;t stop at drafting. Writing the first version of the implementation and its tests is step one of two. Step two is an autonomous repair agent.&lt;/p&gt;
&lt;p&gt;Once the draft is on the filesystem, a separate agent takes over. It&amp;rsquo;s an LLM running in a loop, but a loop aimed at one narrow, checkable job: make this project build and pass its tests. It isn&amp;rsquo;t asked to be creative. It&amp;rsquo;s asked to get to green.&lt;/p&gt;
&lt;h2 id="a-fixed-set-of-tools-and-no-shell"&gt;A fixed set of tools, and no shell
&lt;/h2&gt;&lt;p&gt;The agent is not handed a shell. It&amp;rsquo;s given a &lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/5c78fc9/internal/agent/tools.go" target="_blank" rel="noopener"
 &gt;fixed, defined set of tools&lt;/a&gt; and nothing else. Three of them let it explore and edit the project: &lt;code&gt;list_dir&lt;/code&gt;, &lt;code&gt;read_file&lt;/code&gt;, &lt;code&gt;write_file&lt;/code&gt;. Four of them let it verify the project:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;go_build&lt;/code&gt; runs the build and captures the compiler errors.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;go_test&lt;/code&gt; runs the tests and captures the failures.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;go_get&lt;/code&gt; resolves a missing dependency.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;golangci_lint&lt;/code&gt; runs the project&amp;rsquo;s linter.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That restriction is the design, not a limitation of it. The agent can&amp;rsquo;t delete arbitrary files, can&amp;rsquo;t reach the network, can&amp;rsquo;t run anything that isn&amp;rsquo;t on the list. It has exactly what it needs to make code compile and nothing it would need to do damage. Its file writes are confined to the project directory by an explicit path check, so even &lt;code&gt;write_file&lt;/code&gt; can&amp;rsquo;t go wandering up into &lt;code&gt;/etc&lt;/code&gt;. A coding agent you&amp;rsquo;d actually let near a filesystem is one whose abilities are an allowlist, not a denylist. (I keep coming back to that principle through this series&amp;hellip; safety as a boundary you draw, not a behaviour you hope for.)&lt;/p&gt;
&lt;h2 id="the-loop"&gt;The loop
&lt;/h2&gt;&lt;p&gt;The repair loop is a ReAct loop, the same reason-act-observe shape as &lt;a class="link" href="https://phpboyscout.uk/letting-the-ai-call-your-go-functions/" &gt;the tool-calling loop&lt;/a&gt;, only this time pointed at a goal:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;The draft is on disk.&lt;/li&gt;
&lt;li&gt;Verify: run &lt;code&gt;go_build&lt;/code&gt; and &lt;code&gt;go_test&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;If verification failed, read the error logs, the compiler error or the failing test.&lt;/li&gt;
&lt;li&gt;Reason about the cause: an undefined variable, a missing import, a wrong signature.&lt;/li&gt;
&lt;li&gt;Act: call &lt;code&gt;write_file&lt;/code&gt; to patch the code, or &lt;code&gt;go_get&lt;/code&gt; to add the dependency.&lt;/li&gt;
&lt;li&gt;Loop. Steps two to five repeat until the project is green, or the agent hits its bounded step limit.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;What makes this work is treating the error output as &lt;em&gt;feedback&lt;/em&gt; rather than as a failure to log and walk away from. A compiler error is the single most useful sentence you can hand a model that&amp;rsquo;s trying to fix code. It says what&amp;rsquo;s wrong, and usually where. The loop feeds it straight back in, and the model fixes against it.&lt;/p&gt;
&lt;h2 id="verification-changes-what-done-means"&gt;Verification changes what &amp;ldquo;done&amp;rdquo; means
&lt;/h2&gt;&lt;p&gt;Here&amp;rsquo;s the real shift, and the agent&amp;rsquo;s own documentation puts it well: the agent &amp;ldquo;doesn&amp;rsquo;t just say it fixed a bug; it uses a Test tool to verify the fix before reporting success.&amp;rdquo;&lt;/p&gt;
&lt;p&gt;A generate-and-hope model reports success when it finishes &lt;em&gt;writing&lt;/em&gt;. It has no idea whether the code works, and it isn&amp;rsquo;t really claiming otherwise. &amp;ldquo;Done&amp;rdquo; means &amp;ldquo;I produced text&amp;rdquo;. The repair agent reports success when &lt;code&gt;go_build&lt;/code&gt; and &lt;code&gt;go_test&lt;/code&gt; actually &lt;em&gt;pass&lt;/em&gt;. &amp;ldquo;Done&amp;rdquo; means &amp;ldquo;the build is green&amp;rdquo;. Those are two completely different claims, and only the second is worth anything to the person who asked for the command.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s the line between an AI that&amp;rsquo;s a creative writer and an AI that&amp;rsquo;s a collaborator you can hand a task to. And when the agent can&amp;rsquo;t reach green, when it spends its whole step budget and the project is still broken, the generator fails safely: it leaves the best-attempt code in place, commented out so the project still compiles, and tells the user what to finish by hand. There&amp;rsquo;s also an &lt;code&gt;--agentless&lt;/code&gt; flag for anyone who&amp;rsquo;d rather have a plain single-shot retry than the multi-step agent. The default, though, is the agent, because the default should be code that&amp;rsquo;s been checked.&lt;/p&gt;
&lt;h2 id="where-this-leaves-us"&gt;Where this leaves us
&lt;/h2&gt;&lt;p&gt;Most AI code generation generates and hopes: the model writes code and the user discovers whether it works. For a whole generated command, that pushes a may-or-may-not-build project onto the user.&lt;/p&gt;
&lt;p&gt;go-tool-base&amp;rsquo;s generator drafts the command and then hands it to an autonomous repair agent. The agent has a fixed set of tools (explore and edit the project, build it, test it, lint it, fetch dependencies) and no shell at all, with file writes confined to the project directory. It runs a ReAct loop, reading each error and patching against it, until the build is green or it exhausts its steps. The point is what &amp;ldquo;done&amp;rdquo; comes to mean: not &amp;ldquo;the model finished writing&amp;rdquo;, but &amp;ldquo;the build passes&amp;rdquo;. Only one of those is a claim worth trusting.&lt;/p&gt;</description></item><item><title>Stop regex-ing the LLM's prose</title><link>https://phpboyscout.uk/stop-regexing-the-llms-prose/</link><pubDate>Tue, 31 Mar 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/stop-regexing-the-llms-prose/</guid><description>&lt;img src="https://phpboyscout.uk/stop-regexing-the-llms-prose/cover-stop-regexing-the-llms-prose.png" alt="Featured image of post Stop regex-ing the LLM's prose" /&gt;&lt;p&gt;Ask an LLM a question and it hands you back prose. Lovely to read, miserable to program against. You wanted the one number buried in the middle of it, and now you&amp;rsquo;re writing a regular expression to fish a word out of three well-written paragraphs that phrase themselves slightly differently every single time you run them.&lt;/p&gt;
&lt;p&gt;There&amp;rsquo;s a much better way, and it&amp;rsquo;s the difference between forever interpreting an LLM and actually building on one.&lt;/p&gt;
&lt;h2 id="the-problem-with-a-paragraph"&gt;The problem with a paragraph
&lt;/h2&gt;&lt;p&gt;You ask an LLM to analyse a log file and tell you the severity of what it found and a suggested fix. It comes back with three well-written paragraphs. Somewhere in there is the word &amp;ldquo;critical&amp;rdquo;, and somewhere is the fix.&lt;/p&gt;
&lt;p&gt;Your program now has to &lt;em&gt;extract&lt;/em&gt; those two facts from prose, and prose has no contract. The next run, the model phrases it differently. It leads with a caveat. It says &amp;ldquo;severe&amp;rdquo; where last time it said &amp;ldquo;critical&amp;rdquo;. It puts the fix first. Anything that worked by finding &amp;ldquo;critical&amp;rdquo; in the text is now quietly wrong, and you didn&amp;rsquo;t change a line. Parsing free text for structured facts is a game you lose slowly.&lt;/p&gt;
&lt;p&gt;What you actually wanted was never a paragraph. It was a value: a thing with a &lt;code&gt;severity&lt;/code&gt; field and a &lt;code&gt;fix&lt;/code&gt; field, that you can branch on and store and pass around like any other.&lt;/p&gt;
&lt;h2 id="ask-for-the-struct-not-the-prose"&gt;Ask for the struct, not the prose
&lt;/h2&gt;&lt;p&gt;go-tool-base&amp;rsquo;s &lt;code&gt;chat&lt;/code&gt; package draws the line with two methods. &lt;code&gt;Chat&lt;/code&gt; gives you text. &lt;code&gt;Ask&lt;/code&gt; gives you a struct.&lt;/p&gt;
&lt;p&gt;You define the Go type you want back:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Analysis&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kd"&gt;struct&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Severity&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;`json:&amp;#34;severity&amp;#34;`&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Fix&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;`json:&amp;#34;fix&amp;#34;`&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;var&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Analysis&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Ask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;Analyse this log file: &amp;#34;&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="nx"&gt;logText&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The framework generates a JSON Schema from that struct, sends it to the model as the required response format, and unmarshals the reply straight into &lt;code&gt;result&lt;/code&gt;. You never lay a finger on the prose. You get &lt;code&gt;result.Severity&lt;/code&gt; and &lt;code&gt;result.Fix&lt;/code&gt;, typed, ready to use. If you want the model&amp;rsquo;s answer to drive a &lt;code&gt;switch&lt;/code&gt; statement, this is the method that lets it.&lt;/p&gt;
&lt;h2 id="the-struct-is-the-schema-is-the-contract"&gt;The struct is the schema is the contract
&lt;/h2&gt;&lt;p&gt;The detail that makes this hold up over time: you don&amp;rsquo;t write the schema. The struct &lt;em&gt;is&lt;/em&gt; the schema.&lt;/p&gt;
&lt;p&gt;The framework derives the JSON Schema from your type. In go-tool-base that&amp;rsquo;s &lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/5c78fc9/pkg/chat/schema.go#L11" target="_blank" rel="noopener"
 &gt;&lt;code&gt;GenerateSchema[T]()&lt;/code&gt;&lt;/a&gt;; in rust-tool-base the schema comes from your Rust type through &lt;a class="link" href="https://gitlab.com/phpboyscout/rust-tool-base/-/blob/9c22aa8/crates/rtb-ai/src/client.rs#L208" target="_blank" rel="noopener"
 &gt;&lt;code&gt;schemars&lt;/code&gt;&lt;/a&gt;. (Yes, there&amp;rsquo;s a Rust sibling now. I&amp;rsquo;ll &lt;a class="link" href="https://phpboyscout.uk/rust-tool-base-the-same-idea/" &gt;introduce it properly&lt;/a&gt; in a few weeks, but it keeps gatecrashing these posts because the two frameworks deliberately share ideas.) Either way there&amp;rsquo;s one definition, your type, and the schema is just a projection of it.&lt;/p&gt;
&lt;p&gt;That matters, because otherwise two things have to agree. There&amp;rsquo;s the schema you tell the model to obey, and there&amp;rsquo;s the type you unmarshal the answer into. Hand-write the schema and those two can drift: add a field to the struct, forget to add it to the schema, and the model is never told to produce it, so it silently never appears. Deriving the schema from the type collapses the two into one. They can&amp;rsquo;t disagree, because there&amp;rsquo;s only one of them.&lt;/p&gt;
&lt;h2 id="both-frameworks-with-one-extra-step-in-rust"&gt;Both frameworks, with one extra step in Rust
&lt;/h2&gt;&lt;p&gt;go-tool-base does this with &lt;code&gt;Ask&lt;/code&gt; and a &lt;code&gt;ResponseSchema&lt;/code&gt; set on the client config. rust-tool-base does it with &lt;code&gt;chat_structured::&amp;lt;T&amp;gt;&lt;/code&gt;, where &lt;code&gt;T&lt;/code&gt; is any type that&amp;rsquo;s both deserialisable and &lt;code&gt;JsonSchema&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;rust-tool-base adds one step worth calling out. Before it deserialises the model&amp;rsquo;s reply into your &lt;code&gt;T&lt;/code&gt;, it &lt;em&gt;validates&lt;/em&gt; the raw response against the schema with a JSON Schema validator. That splits the failure into two distinct, named cases: the response didn&amp;rsquo;t match the schema, or it matched the schema but still wouldn&amp;rsquo;t deserialise. A model that returns subtly wrong JSON fails loudly and specifically, with an error that tells you which of those happened, instead of quietly handing you a zero-valued struct that you end up debugging an hour later.&lt;/p&gt;
&lt;h2 id="when-youd-reach-for-it"&gt;When you&amp;rsquo;d reach for it
&lt;/h2&gt;&lt;p&gt;The line is simple, and it&amp;rsquo;s about who reads the answer.&lt;/p&gt;
&lt;p&gt;If a &lt;em&gt;human&lt;/em&gt; reads the answer, prose is right. &lt;code&gt;Chat&lt;/code&gt;, free text, let the model write well. A summary, an explanation, an interactive reply: leave all of those as prose.&lt;/p&gt;
&lt;p&gt;If a &lt;em&gt;program&lt;/em&gt; consumes the answer, you want a value. Classification, extraction, a code review scored out of a hundred with a list of issues, a yes-or-no with reasons: anything where the next thing that happens is your code branching on the result. There, &lt;code&gt;Ask&lt;/code&gt; and &lt;code&gt;chat_structured&lt;/code&gt; turn the LLM from something you have to interpret into something that returns a value, and a typed value is a thing you can actually build on.&lt;/p&gt;
&lt;h2 id="to-sum-up"&gt;To sum up
&lt;/h2&gt;&lt;p&gt;An LLM returns prose by default, and prose has no contract, so a program that picks structured facts out of it breaks the moment the model rephrases.&lt;/p&gt;
&lt;p&gt;Structured output asks for the value instead. You define a struct, the framework derives a JSON Schema from it, the model is constrained to that shape, and you get a typed result. go-tool-base&amp;rsquo;s &lt;code&gt;Ask&lt;/code&gt; and rust-tool-base&amp;rsquo;s &lt;code&gt;chat_structured&lt;/code&gt; both work this way, with the schema derived from your type so the schema and the type can&amp;rsquo;t drift; rust-tool-base additionally validates the response against the schema before deserialising. Use it whenever the answer feeds code rather than a human. It&amp;rsquo;s one of the four methods that make up &lt;a class="link" href="https://phpboyscout.uk/an-ai-interface-that-fits-on-one-screen/" &gt;go-tool-base&amp;rsquo;s small chat interface&lt;/a&gt;, and it&amp;rsquo;s the one that makes an LLM safe to program against.&lt;/p&gt;</description></item><item><title>Letting the AI call your Go functions</title><link>https://phpboyscout.uk/letting-the-ai-call-your-go-functions/</link><pubDate>Sun, 29 Mar 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/letting-the-ai-call-your-go-functions/</guid><description>&lt;img src="https://phpboyscout.uk/letting-the-ai-call-your-go-functions/cover-letting-the-ai-call-your-go-functions.png" alt="Featured image of post Letting the AI call your Go functions" /&gt;&lt;p&gt;An AI that can only produce text can &lt;em&gt;describe&lt;/em&gt; your system. An AI that can call your Go functions can actually operate it. That gap, between describing and doing, is the difference between a chatbot and something genuinely useful, and crossing it comes down to one fiddly mechanism: tool-calling, and the loop that drives it.&lt;/p&gt;
&lt;h2 id="talking-about-the-system-versus-operating-it"&gt;Talking about the system versus operating it
&lt;/h2&gt;&lt;p&gt;Wire an AI provider into a CLI command and you get something that can talk. Ask it a question, get a paragraph back. Useful, up to a point.&lt;/p&gt;
&lt;p&gt;But notice the ceiling. An AI that can only generate text can &lt;em&gt;describe&lt;/em&gt; things. It can tell you what it would do. What it can&amp;rsquo;t do is look at the actual current state of your system, or take a real action, because it has no hands. It&amp;rsquo;s reasoning in a vacuum about a world it can&amp;rsquo;t reach out and touch.&lt;/p&gt;
&lt;p&gt;The thing that gives it hands is tool-calling. You hand the AI a set of functions it&amp;rsquo;s allowed to call. Now, mid-conversation, it can decide it needs to &lt;em&gt;read that file&lt;/em&gt; before it can answer, or &lt;em&gt;run that query&lt;/em&gt;, or &lt;em&gt;check that status&lt;/em&gt;, and actually go and do it, and then reason about the real result. The AI stops describing your system and starts operating it.&lt;/p&gt;
&lt;h2 id="the-loop-is-the-hard-part"&gt;The loop is the hard part
&lt;/h2&gt;&lt;p&gt;Tool-calling has a shape, and the shape is a loop. The literature calls it ReAct: Reason, Act, Observe.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;The AI &lt;strong&gt;reasons&lt;/strong&gt; about the prompt and decides whether it needs a tool.&lt;/li&gt;
&lt;li&gt;If it does, it &lt;strong&gt;acts&lt;/strong&gt;, asking for a specific tool with specific arguments.&lt;/li&gt;
&lt;li&gt;Your code runs the tool and feeds the result back. The AI &lt;strong&gt;observes&lt;/strong&gt; that result.&lt;/li&gt;
&lt;li&gt;Round again. Reason about the new information, maybe call another tool, maybe several. Keep going until the AI has what it needs and produces a final text answer with no more tool calls.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Conceptually simple. Tedious and error-prone to implement by hand every single time: parsing the model&amp;rsquo;s tool-call requests, dispatching to the right function, marshalling arguments in and results out, feeding observations back in the exact format the provider expects, knowing when to stop, and not looping forever if the model gets itself stuck.&lt;/p&gt;
&lt;p&gt;That orchestration is pure plumbing, and it&amp;rsquo;s identical for every tool and every command. So you can probably guess what&amp;rsquo;s coming: go-tool-base&amp;rsquo;s &lt;code&gt;chat&lt;/code&gt; package owns it. You don&amp;rsquo;t write the loop. You write the tools.&lt;/p&gt;
&lt;h2 id="defining-a-tool"&gt;Defining a tool
&lt;/h2&gt;&lt;p&gt;A &lt;code&gt;chat.Tool&lt;/code&gt; is four things: a name, a description, a parameter schema, and a handler. The description is what the AI reads to decide &lt;em&gt;whether&lt;/em&gt; to use the tool, so it&amp;rsquo;s worth writing well. The schema describes the arguments, and you don&amp;rsquo;t hand-write it. You write a tagged Go struct and let it generate:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;ReadFileParams&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kd"&gt;struct&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Path&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;`json:&amp;#34;path&amp;#34; jsonschema_description:&amp;#34;Relative path to the file&amp;#34;`&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The struct is the contract. The framework derives the JSON Schema the AI is given straight from those tags, so the schema and the Go type the handler receives can&amp;rsquo;t drift apart, because they share a single source. The handler is then just an ordinary Go function that takes those parameters and returns a result.&lt;/p&gt;
&lt;p&gt;You register your tools with &lt;code&gt;SetTools&lt;/code&gt;, call &lt;code&gt;Chat&lt;/code&gt;, and that&amp;rsquo;s the whole of your involvement. The framework runs the ReAct loop and &lt;code&gt;Chat&lt;/code&gt; returns the AI&amp;rsquo;s final text answer once the loop settles.&lt;/p&gt;
&lt;h2 id="two-details-that-show-it-was-built-for-real-use"&gt;Two details that show it was built for real use
&lt;/h2&gt;&lt;p&gt;A couple of decisions in the loop tell you it&amp;rsquo;s meant for production, not a demo.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Tool errors don&amp;rsquo;t abort the conversation.&lt;/strong&gt; When a handler returns an error, the framework doesn&amp;rsquo;t crash the loop. It hands the error &lt;em&gt;back to the AI as a string&lt;/em&gt;, as just another observation. That&amp;rsquo;s deliberate, and it&amp;rsquo;s right. A real agent should be able to call a tool, watch it fail, and react: try different arguments, take a different route, or tell the user it couldn&amp;rsquo;t manage it. A loop that aborted on the first tool error would be far more brittle than the model driving it.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The loop is bounded.&lt;/strong&gt; There&amp;rsquo;s a &lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/5c78fc9/pkg/chat/constants.go#L16" target="_blank" rel="noopener"
 &gt;&lt;code&gt;MaxSteps&lt;/code&gt; limit, default 20&lt;/a&gt;. An AI that gets confused could otherwise call tools forever, and a CLI command that never returns is a worse failure than a wrong answer. The cap guarantees the command terminates. The agent gets room to genuinely work a problem across many steps, but not infinite room to flail about in.&lt;/p&gt;
&lt;p&gt;There&amp;rsquo;s also parallel tool execution: when the model asks for several tools in a single step (three independent file reads, say) the framework runs them concurrently rather than one after another, because there&amp;rsquo;s no reason to make the AI sit and wait out a sequence of things that don&amp;rsquo;t depend on each other.&lt;/p&gt;
&lt;h2 id="boiling-it-down"&gt;Boiling it down
&lt;/h2&gt;&lt;p&gt;A text-only AI can describe your system; an AI that can call your functions can operate it. Bridging that gap means tool-calling, and tool-calling means the ReAct loop (reason, act, observe, repeat) whose orchestration is fiddly, identical every time, and not a problem worth solving twice.&lt;/p&gt;
&lt;p&gt;go-tool-base&amp;rsquo;s &lt;code&gt;chat&lt;/code&gt; package runs the loop for you. You define &lt;code&gt;chat.Tool&lt;/code&gt; values (name, description, a tagged parameter struct that generates its own schema, a handler), call &lt;code&gt;SetTools&lt;/code&gt; and &lt;code&gt;Chat&lt;/code&gt;, and get the final answer. Tool errors go back to the AI as observations so it can recover, and a &lt;code&gt;MaxSteps&lt;/code&gt; cap guarantees the command always terminates. You write Go functions. The framework turns them into things an agent can reach for.&lt;/p&gt;</description></item><item><title>An AI interface that fits on one screen</title><link>https://phpboyscout.uk/an-ai-interface-that-fits-on-one-screen/</link><pubDate>Fri, 27 Mar 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/an-ai-interface-that-fits-on-one-screen/</guid><description>&lt;img src="https://phpboyscout.uk/an-ai-interface-that-fits-on-one-screen/cover-an-ai-interface-that-fits-on-one-screen.png" alt="Featured image of post An AI interface that fits on one screen" /&gt;&lt;p&gt;The moment you decide a CLI tool should talk to an LLM, there&amp;rsquo;s a strong gravitational pull towards reaching for LangChain, or one of its many relatives. It&amp;rsquo;s the obvious move. It&amp;rsquo;s also, for most CLI work, a bit like hiring a removals firm to carry a single box up the stairs.&lt;/p&gt;
&lt;p&gt;Let me explain why go-tool-base went the other way, and what &amp;ldquo;the other way&amp;rdquo; actually looks like.&lt;/p&gt;
&lt;h2 id="the-instinct-and-why-it-overshoots"&gt;The instinct, and why it overshoots
&lt;/h2&gt;&lt;p&gt;When you add AI to a tool, the instinct is to reach for the big general-purpose framework. LangChain and its relatives are capable, and they exist for a real need: orchestrating complex multi-step AI applications, with retrieval pipelines, memory stores, chains of calls, whole fleets of agents.&lt;/p&gt;
&lt;p&gt;Now look at what a CLI tool actually needs from an LLM. It needs to send a prompt and get text back. Sometimes it wants structured data back instead of prose. Sometimes it wants to let the model call a few of the tool&amp;rsquo;s own functions. That&amp;rsquo;s pretty much the whole list.&lt;/p&gt;
&lt;p&gt;Pulling in a framework built to orchestrate retrieval and agent swarms in order to do &lt;em&gt;that&lt;/em&gt; is a poor trade. You take on a large new vocabulary of concepts, a wide dependency surface, and a great deal of abstraction you&amp;rsquo;ll never touch, all to perform three or four operations. The framework isn&amp;rsquo;t wrong. It&amp;rsquo;s just answering a far bigger question than the one a CLI tool is asking.&lt;/p&gt;
&lt;h2 id="what-go-tool-base-chose-instead"&gt;What go-tool-base chose instead
&lt;/h2&gt;&lt;p&gt;go-tool-base didn&amp;rsquo;t reach for a framework. The decision is on the record in its own design notes: before a single line was written, LangChain Go, go-openai, Vercel&amp;rsquo;s AI SDK and around ten other options were evaluated, and not one of them matched what a CLI framework actually needs. So the &lt;code&gt;chat&lt;/code&gt; package was built deliberately small.&lt;/p&gt;
&lt;p&gt;How small? The entire core &lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/5c78fc9/pkg/chat/client.go" target="_blank" rel="noopener"
 &gt;&lt;code&gt;ChatClient&lt;/code&gt;&lt;/a&gt; interface is four methods:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;ChatClient&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kd"&gt;interface&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;Add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;prompt&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;Chat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;prompt&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;Ask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;question&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;any&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;SetTools&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tools&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="nx"&gt;Tool&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;Add&lt;/code&gt; appends a message to the conversation. &lt;code&gt;Chat&lt;/code&gt; sends a prompt and returns text. &lt;code&gt;Ask&lt;/code&gt; sends a prompt and returns a &lt;em&gt;typed Go struct&lt;/em&gt;, the model&amp;rsquo;s answer unmarshalled straight into a value you defined. &lt;code&gt;SetTools&lt;/code&gt; hands the model a set of your own functions it&amp;rsquo;s allowed to call. That&amp;rsquo;s the whole surface. Downstream code that uses AI never holds anything larger than this, and never has to know which provider is behind it.&lt;/p&gt;
&lt;p&gt;The package&amp;rsquo;s own documentation has a word for this: right-sized. Large enough to solve genuine provider-abstraction complexity, small enough that the full interface fits on a single screen.&lt;/p&gt;
&lt;h2 id="thin-is-not-the-same-as-does-little"&gt;&amp;ldquo;Thin&amp;rdquo; is not the same as &amp;ldquo;does little&amp;rdquo;
&lt;/h2&gt;&lt;p&gt;This is the part worth being precise about, because &amp;ldquo;four methods&amp;rdquo; can sound like &amp;ldquo;barely does anything&amp;rdquo;, and that&amp;rsquo;s the wrong read entirely.&lt;/p&gt;
&lt;p&gt;Behind those four methods sits genuinely awkward work. Five providers (OpenAI, Claude, Gemini, a locally installed &lt;code&gt;claude&lt;/code&gt; binary, and any OpenAI-compatible endpoint) each with a different wire API, all normalised behind the one interface. A &lt;a class="link" href="https://phpboyscout.uk/letting-the-ai-call-your-go-functions/" &gt;tool-calling loop&lt;/a&gt;. Structured output via JSON Schema, made to behave consistently across providers that each express it differently. Error normalisation. Token chunking.&lt;/p&gt;
&lt;p&gt;The point of a thin abstraction is not that there&amp;rsquo;s little underneath it. It&amp;rsquo;s that the &lt;em&gt;interface&lt;/em&gt; stays small while the &lt;em&gt;implementation&lt;/em&gt; quietly absorbs the complexity. Four methods on the surface; five provider integrations and a tool-calling loop below the waterline. The thinness is a property of what the caller sees, not of what the package does. A reach-for-LangChain decision gets that backwards: it exposes the caller to all the machinery, whether or not the caller will ever need it.&lt;/p&gt;
&lt;h2 id="the-core-stays-small-even-as-features-grow"&gt;The core stays small even as features grow
&lt;/h2&gt;&lt;p&gt;There&amp;rsquo;s a neat detail in how &lt;code&gt;chat&lt;/code&gt; keeps the interface from creeping. The package also supports streaming responses and conversation persistence, both of which are real features with real surface area. Neither of them is in the four-method core.&lt;/p&gt;
&lt;p&gt;Instead they&amp;rsquo;re &lt;em&gt;separate, optional&lt;/em&gt; interfaces. A streaming-capable client also satisfies &lt;code&gt;StreamingChatClient&lt;/code&gt;; a persistable one also satisfies &lt;code&gt;PersistentChatClient&lt;/code&gt;. Code that wants those capabilities does a type assertion to ask for them, and code that doesn&amp;rsquo;t simply never sees them. So the common path stays four methods forever. New capabilities arrive as opt-in interfaces alongside the core, not as new methods bolted onto it. The thing that fits on one screen keeps fitting on one screen.&lt;/p&gt;
&lt;h2 id="extensible-without-forking-testable-without-a-network"&gt;Extensible without forking, testable without a network
&lt;/h2&gt;&lt;p&gt;Two more properties keep the package small without making it limiting.&lt;/p&gt;
&lt;p&gt;It&amp;rsquo;s extensible. The provider list isn&amp;rsquo;t closed. A &lt;code&gt;RegisterProvider&lt;/code&gt; call lets any package contribute a new provider, and &lt;code&gt;chat.New&lt;/code&gt; will route to it. You add a backend without forking &lt;code&gt;pkg/chat&lt;/code&gt; or sending a patch upstream.&lt;/p&gt;
&lt;p&gt;And it&amp;rsquo;s testable. The package ships generated mocks. A downstream tool&amp;rsquo;s AI features can be tested against a mock &lt;code&gt;ChatClient&lt;/code&gt; returning canned responses, with no network, no API key, and no flakiness. Because the interface is four methods, that mock is trivial to set up and complete by construction. A sprawling framework interface is a sprawling thing to fake; a four-method one is not. (I&amp;rsquo;ll come back to testing AI code properly &lt;a class="link" href="https://phpboyscout.uk/testing-code-that-calls-an-llm/" &gt;in a later post&lt;/a&gt;, because it deserves a whole article of its own.)&lt;/p&gt;
&lt;h2 id="the-right-size"&gt;The right size
&lt;/h2&gt;&lt;p&gt;When a CLI tool needs AI, the instinct is a large framework like LangChain. For orchestrating retrieval pipelines and agent swarms, that&amp;rsquo;s exactly the right tool. For sending a prompt, getting a struct back, and letting the model call a few functions, it&amp;rsquo;s enormous overkill.&lt;/p&gt;
&lt;p&gt;go-tool-base&amp;rsquo;s &lt;code&gt;chat&lt;/code&gt; package is the deliberate alternative, chosen only after LangChain Go and a dozen others were weighed up and rejected. Its core &lt;code&gt;ChatClient&lt;/code&gt; interface is four methods. Underneath sit five normalised providers, a tool-calling loop, structured output and error handling, but the caller sees four methods and never learns which provider is active. Streaming and persistence are opt-in interfaces beside the core, not additions to it. It extends without forking and tests without a network. Right-sized: the complexity is real, but it lives under the interface rather than in it.&lt;/p&gt;</description></item><item><title>Your CLI is already an AI tool</title><link>https://phpboyscout.uk/your-cli-is-already-an-ai-tool/</link><pubDate>Thu, 19 Mar 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/your-cli-is-already-an-ai-tool/</guid><description>&lt;img src="https://phpboyscout.uk/your-cli-is-already-an-ai-tool/cover-your-cli-is-already-an-ai-tool.png" alt="Featured image of post Your CLI is already an AI tool" /&gt;&lt;p&gt;&amp;ldquo;Make it work with AI&amp;rdquo; has become one of those requests that lands on a developer&amp;rsquo;s desk with a thud and not much further detail attached. My instinct, the first time, was to brace for a big lump of integration work&amp;hellip; a bespoke adapter for this assistant, another for that one, a treadmill of little wrappers stretching off into the distance.&lt;/p&gt;
&lt;p&gt;Turns out I&amp;rsquo;d already done most of the work. So have you, if your CLI tool is any good. Let me explain what I mean.&lt;/p&gt;
&lt;h2 id="you-already-described-your-capabilities"&gt;You already described your capabilities
&lt;/h2&gt;&lt;p&gt;Stop and think for a second about what a well-built CLI tool actually is. It&amp;rsquo;s a set of named operations, each with a human-readable description, each taking a set of typed, named, documented parameters. You wrote all of that already, because a CLI without it is unusable by &lt;em&gt;people&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;Now look at what an AI assistant needs in order to call a tool. A set of named operations. A description of each, so it knows when to reach for them. A typed parameter schema for each, so it knows how to call them.&lt;/p&gt;
&lt;p&gt;It&amp;rsquo;s the same list! A good CLI is already, structurally, a description of a set of capabilities. The information an AI agent needs isn&amp;rsquo;t extra work you have to go and do. It&amp;rsquo;s work you finished the moment your &lt;code&gt;--help&lt;/code&gt; output was any good.&lt;/p&gt;
&lt;p&gt;The only thing missing is a translator. Something that takes &amp;ldquo;this is a CLI&amp;rdquo; and presents it as &amp;ldquo;this is a set of tools an AI can call&amp;rdquo;.&lt;/p&gt;
&lt;h2 id="mcp-is-that-translator-and-its-a-standard"&gt;MCP is that translator, and it&amp;rsquo;s a standard
&lt;/h2&gt;&lt;p&gt;The temptation, when you want your tool to be AI-usable, is to sit down and write an integration. A little adapter for Claude Desktop. Another for Cursor. Another for whatever turns up next month. Each one a bespoke wrapper, each one a thing to maintain, and the list never stops growing because new assistants keep appearing. That&amp;rsquo;s the treadmill I was bracing for.&lt;/p&gt;
&lt;p&gt;The Model Context Protocol exists to kill that list. MCP is an open standard for how an AI model discovers and calls local tools. Implement it once and your tool works with every assistant that speaks it. Write once, not once-per-client.&lt;/p&gt;
&lt;p&gt;So go-tool-base implements it once, in the framework, for everyone. (That&amp;rsquo;s rather the theme of this whole series, if you hadn&amp;rsquo;t spotted it yet&amp;hellip; do the annoying thing once, properly, in a place where every tool inherits it.)&lt;/p&gt;
&lt;h2 id="the-mcp-command-and-the-mapping-it-does-for-free"&gt;The &lt;code&gt;mcp&lt;/code&gt; command, and the mapping it does for free
&lt;/h2&gt;&lt;p&gt;Every tool built on go-tool-base inherits a built-in &lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/5c78fc9/pkg/props/tool.go#L15" target="_blank" rel="noopener"
 &gt;&lt;code&gt;mcp&lt;/code&gt; command&lt;/a&gt;. Run it:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;mytool mcp
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;and the tool starts a JSON-RPC server over standard I/O, speaking MCP. That&amp;rsquo;s the whole user-facing surface. One command.&lt;/p&gt;
&lt;p&gt;Behind it, the framework walks your Cobra command tree and maps it straight onto MCP tool definitions:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Each &lt;strong&gt;command&lt;/strong&gt; becomes a &lt;strong&gt;tool&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Each command&amp;rsquo;s &lt;strong&gt;short description&lt;/strong&gt; becomes the &lt;strong&gt;tool&amp;rsquo;s description&lt;/strong&gt;, the text the AI reads to decide whether this is the tool it wants.&lt;/li&gt;
&lt;li&gt;Each command&amp;rsquo;s &lt;strong&gt;flags and arguments&lt;/strong&gt; become the tool&amp;rsquo;s &lt;strong&gt;JSON Schema parameters&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;There&amp;rsquo;s no second schema to write and then keep in sync (and we all know how well &amp;ldquo;keep these two things aligned by hand&amp;rdquo; tends to go). The command tree &lt;em&gt;is&lt;/em&gt; the schema. Add a new command to your CLI and it&amp;rsquo;s a new tool for the agent, automatically, with the description and flags you already gave it. Nobody has to remember to update an MCP manifest, because there&amp;rsquo;s no separate MCP manifest to forget about.&lt;/p&gt;
&lt;h2 id="configuring-an-assistant-to-use-it"&gt;Configuring an assistant to use it
&lt;/h2&gt;&lt;p&gt;On the assistant&amp;rsquo;s side it&amp;rsquo;s just as undramatic. You tell your AI client (Claude Desktop, Cursor, anything MCP-aware) to launch &lt;code&gt;mytool mcp&lt;/code&gt;. From then on the assistant:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Starts your tool in MCP mode when it boots.&lt;/li&gt;
&lt;li&gt;Discovers every command as a callable tool.&lt;/li&gt;
&lt;li&gt;Calls the right one, with the right parameters, when a user&amp;rsquo;s request needs it.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Your CLI tool has quietly become something the AI can pick up and use, mid-conversation, on its own initiative.&lt;/p&gt;
&lt;h2 id="the-safety-property-worth-noticing"&gt;The safety property worth noticing
&lt;/h2&gt;&lt;p&gt;Now, &amp;ldquo;let an AI run things on my machine&amp;rdquo; is rightly a sentence that makes people nervous. It makes me nervous, and I built the thing. So it&amp;rsquo;s worth noticing the constraint sitting quietly in this design.&lt;/p&gt;
&lt;p&gt;The AI can only call what you defined. The tools it sees are exactly the commands in your tree, and the parameters it can pass are exactly the flags and arguments you declared, validated against the JSON Schema generated from them.&lt;/p&gt;
&lt;p&gt;It can&amp;rsquo;t invent a command. It can&amp;rsquo;t pass a parameter you never defined. The boundary of what the agent can do is the boundary of what your CLI does, and you drew that boundary already, back when you built the tool. Exposing the CLI over MCP doesn&amp;rsquo;t widen the surface one inch. It just makes the existing surface reachable. The AI isn&amp;rsquo;t running &lt;em&gt;things&lt;/em&gt;. It&amp;rsquo;s running &lt;em&gt;your commands&lt;/em&gt;, the ones you wrote, tested and shipped, and nothing else.&lt;/p&gt;
&lt;h2 id="the-gist"&gt;The gist
&lt;/h2&gt;&lt;p&gt;A CLI tool, built properly, is already a structured description of a set of capabilities: named operations, descriptions, typed parameters. Which is also exactly what an AI agent needs in order to call a tool. The gap between the two is only a translator, and writing a bespoke one per assistant is a treadmill you don&amp;rsquo;t need to step onto.&lt;/p&gt;
&lt;p&gt;go-tool-base puts the translator in the framework. Every tool gets an &lt;code&gt;mcp&lt;/code&gt; command that serves the command tree over the Model Context Protocol&amp;hellip; commands become tools, descriptions become descriptions, flags become JSON Schema parameters, with no second schema to maintain. Point any MCP-aware assistant at it and your CLI is an agent-callable tool, bounded to exactly the commands you shipped.&lt;/p&gt;
&lt;p&gt;You did the hard part when you built a good CLI. MCP just opens the door you&amp;rsquo;d already framed.&lt;/p&gt;</description></item></channel></rss>