<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>AkitaOnRails.com</title><link>https://www.akitaonrails.com/</link><description>Fabio Akita's blog — tech, career, and assorted geek topics. English edition.</description><generator>Hugo -- gohugo.io</generator><language>en</language><lastBuildDate>Tue, 07 Apr 2026 05:18:02 GMT</lastBuildDate><atom:link href="https://www.akitaonrails.com/index.xml" rel="self" type="application/rss+xml"/><item><title>Is RAG Dead? Long Context, Grep, and the End of the Mandatory Vector DB</title><link>https://www.akitaonrails.com/en/2026/04/06/rag-is-dead-long-context/</link><guid isPermaLink="true">https://www.akitaonrails.com/en/2026/04/06/rag-is-dead-long-context/</guid><pubDate>Mon, 06 Apr 2026 11:00:00 GMT</pubDate><description>&lt;p&gt;This is one of those itches I can&amp;rsquo;t scratch. Back in the early LLM days, around 2022/2023, we had 4k of context on GPT 3.5, 8k if you were lucky, 32k was a luxury. To do anything with a real document you had no choice: chop the text into pieces, generate embeddings, throw them in a vector database, do similarity search, grab the top-5 chunks, and pray the right ones came back.&lt;/p&gt;</description><content:encoded><![CDATA[<p>This is one of those itches I can&rsquo;t scratch. Back in the early LLM days, around 2022/2023, we had 4k of context on GPT 3.5, 8k if you were lucky, 32k was a luxury. To do anything with a real document you had no choice: chop the text into pieces, generate embeddings, throw them in a vector database, do similarity search, grab the top-5 chunks, and pray the right ones came back.</p>
<p>Then it became an industry. Pinecone, Weaviate, Qdrant, Chroma, Milvus, pgvector, LangChain, LlamaIndex, Haystack. Tutorials everywhere, &ldquo;build your chatbot with your PDFs,&rdquo; entire consultancies feeding off this. It became the &ldquo;hello world&rdquo; of applied LLMs: document → chunk → embed → vector DB → query.</p>
<p>Today, in April 2026, Claude Opus 4.6 has 1 million tokens of context. Sonnet 4.6 too. Gemini 3.1 Pro too. GPT 5.4 has a smaller window but still in the comfortable range, in the hundreds of thousands. And some models already have experimental 2M token modes. The question that keeps nagging at me: what on earth do I need a vector stack for, to solve a problem that fits inside the model&rsquo;s window?</p>
<p>And there&rsquo;s more: vector databases have real problems nobody wants to talk about. False neighbors. Arbitrary chunking that splits a definition from its usage. Embeddings that age badly. Not to mention that when the result is wrong, you have absolutely no idea why.</p>
<p>The thesis I&rsquo;ve been chewing on is simple: in most cases, a well-aimed <code>grep</code> plus a generous context window beats a full RAG stack. It&rsquo;s cheaper, it&rsquo;s easier to maintain, and when it breaks you can actually debug it. Let&rsquo;s break this down.</p>
<h2>What the Claude Code leak showed<span class="hx:absolute hx:-mt-20" id="what-the-claude-code-leak-showed"></span>
    <a href="#what-the-claude-code-leak-showed" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Before we get into the theory, let me bring up something that happened a few days ago that backs this whole argument up. On March 31, 2026, Anthropic, by accident, published version 2.1.88 of the <code>@anthropic-ai/claude-code</code> package on npm with a nearly 60 MB source map attached, and roughly 512,000 lines of TypeScript from their internal tool leaked into the wild. I already <a href="/en/2026/03/31/claude-code-source-code-leaked-what-we-found-inside/">wrote about the incident last week</a>, with more detail on what showed up in the code.</p>
<p>The part that matters for this discussion is Claude Code&rsquo;s memory system. Instead of dumping everything into a vector DB, the architecture has three layers. There&rsquo;s a <code>MEMORY.md</code> that stays permanently loaded in context, but it doesn&rsquo;t hold any actual data: it&rsquo;s just an index of pointers, around 150 characters per line, kept under 200 lines and about 25 KB. The real facts live in &ldquo;topic files&rdquo; that get pulled on demand when the agent needs them. And the raw transcripts from previous sessions are never reloaded whole, only searched with grep, hunting for specific identifiers. No embedding. No Pinecone. Just write discipline (topic file first, index after) and lexical search. That&rsquo;s it.</p>
<p>Claude Code&rsquo;s main loop also has a tiered system for handling a context that&rsquo;s filling up. As <a href="/en/2026/03/31/claude-code-source-code-leaked-what-we-found-inside/">I detailed in the previous post</a>, there are five different context compaction strategies, with names like <code>microcompact</code> (clears old tool results based on age), <code>context collapse</code> (summarizes long stretches of conversation), and <code>autocompact</code> (which fires when the context gets close to the limit). The CLAUDE.md file, which a lot of people thought was just a convention, is first-class in the architecture: the system re-reads it at every iteration of the query.</p>
<p>What this tells me: the best coding agent on the market right now, built by the company selling the most expensive model out there, <strong>does not use a vector DB</strong>. It uses files on disk, a markdown index, lexical search, and smart compaction strategies for when the context overflows. They could&rsquo;ve slapped embeddings on top, they have the money to run whatever they wanted, and they chose not to. The reason, in my reading, is exactly what this post is arguing: to retrieve text from files you control, with generous context available, a vector DB is dead weight. Better to invest in compacting the window you already have than indexing everything into an external store.</p>
<p>There&rsquo;s a curious security detail that came along with the leak: people noticed the compaction pipeline has a vulnerability they&rsquo;re calling &ldquo;context poisoning.&rdquo; Content that looks like an instruction, coming from a file the model reads (say, a CLAUDE.md from a cloned repo), can end up being preserved by the compaction model as if it were &ldquo;user feedback,&rdquo; and the next model takes that as a real user instruction. It&rsquo;s a new attack vector. But that&rsquo;s a topic for another post.</p>
<h3>The &ldquo;Dream&rdquo; system and memory consolidation<span class="hx:absolute hx:-mt-20" id="the-dream-system-and-memory-consolidation"></span>
    <a href="#the-dream-system-and-memory-consolidation" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>But what really caught my eye for the RAG debate, which I <a href="/en/2026/03/31/claude-code-source-code-leaked-what-we-found-inside/">unpacked in detail last week</a>, is the system called <code>autoDream</code>. It&rsquo;s a forked subagent, with read-only bash access to the project, that runs in the background while you&rsquo;re not using the tool. Its job is literally to dream: to consolidate memory. The name isn&rsquo;t accidental, and the obvious analogy (which I couldn&rsquo;t resist) is the human brain consolidating memory during sleep, turning short-term experience into something more stable.</p>
<p>For a dream to actually run, three gates have to open at once: 24 hours since the last dream, at least 5 sessions since the last dream, and a consolidation lock that prevents concurrent dreams. When it fires, it goes through four phases. Orient (does an <code>ls</code> on the memory directory, reads the index). Gather (looks for new signals in logs, stale memories, transcripts). Consolidate (writes or updates the topic files, converts relative dates into absolute ones, deletes facts that have been contradicted). And Prune, the final cleanup that keeps the index under 200 lines.</p>
<p>The decision to make <code>autoDream</code> a forked subagent is the detail that matters here. It does not run in the same loop as the main agent. Why? Because memory consolidation is a noisy process. The model has to re-read old transcripts, compare them against what&rsquo;s in <code>MEMORY.md</code>, decide what stays and what goes, form hypotheses about things it saw in earlier sessions. If that ran in the main context, it would pollute the &ldquo;train of thought&rdquo; of the agent that&rsquo;s trying to help you with your current task. By forking, you keep the two separate. The main agent stays focused on what you asked for, and <code>autoDream</code> does the housekeeping in parallel, with no write permission on the project.</p>
<p>And the way it figures out what needs to be consolidated is plain old lexical search. The transcripts live as JSONL files on disk, and <code>autoDream</code> uses grep to look for new signals. Just grep, on text logs. Stop and think about that for a second. The memory consolidation of the most advanced agent in the world, built by one of the richest AI companies out there, is a forked subagent running grep on text logs. If a vector DB were the right answer for this kind of problem, Anthropic would&rsquo;ve put a vector DB in there. They didn&rsquo;t.</p>
<p>And there&rsquo;s a detail that, to me, is the buried gold of the entire leak, and it fits this argument like a glove. In <code>autoDream</code>, memory is treated as a hint. The system assumes that what&rsquo;s stored may be stale, wrong, contradicted by something that happened later, and the model has to verify before it trusts it. The vector DB pitch is the opposite of that: index everything, search by similarity, return the top-k, trust the result. Claude Code went the conservative route. Index little, search by word, return a hint, and stay skeptical until you&rsquo;ve laid eyes on the actual fact.</p>
<p>The whole strategy works in two layers. Inside a single session: generous context plus grep plus smart compaction (<code>microcompact</code>, <code>context collapse</code>, <code>autocompact</code>). Between sessions: a subagent that consolidates memory asynchronously, using grep on the transcripts and treating the result as a tip, not as truth. Embeddings and vector DBs don&rsquo;t show up in either layer. The deliberate choice was a smart reader chewing on raw text, not a dumb reader being spoonfed the top-k of an embedding.</p>
<p>The practical lesson for our debate is simple. The most advanced agents on the market are heading toward generous context, lexical search, and smart compaction, not toward classic RAG pipelines. If Anthropic, with all the infrastructure and talent they&rsquo;ve got, picked this path for Claude Code, those of us building internal applications on a fraction of that budget should at least think about going the same way.</p>
<h2>Where the story started turning<span class="hx:absolute hx:-mt-20" id="where-the-story-started-turning"></span>
    <a href="#where-the-story-started-turning" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>When the ceiling was 32k of context, retrieval was the bottleneck of the entire problem. You had to pre-filter aggressively, because anything that made it into the window was sacred space. A vector DB was the only halfway-decent way to do that semantic pre-filtering. The logic was: &ldquo;the reader (LLM) is expensive and dumb, so the retriever has to be smart and selective.&rdquo;</p>
<p>Today the equation has flipped. The reader is now the smartest one at the table, and the window grew big enough to hold an entire document. So the retriever can (and maybe should) go back to being dumb. The dumber, the better. You want high recall and low precision, and you let the model do the fine work. Grep does exactly that. So does BM25. And ripgrep flies through millions of lines without breaking a sweat.</p>
<p>And this isn&rsquo;t just my hunch. The BEIR benchmarks have shown for a while now that BM25 matches or beats a lot of dense retrievers when the domain drifts away from where the embeddings were trained. Anthropic itself published a post on <a href="https://www.anthropic.com/news/contextual-retrieval"target="_blank" rel="noopener">Contextual Retrieval</a> that basically says the same thing: a lexical signal plus an LLM&rsquo;s judgment beats pure embeddings on most knowledge tasks. And take a look at Claude Code, the tool I&rsquo;ve been using every day for 500 hours: it navigates the repo with <code>Glob</code> and <code>Grep</code>. No vector DB, no embedding, no LangChain. It works ridiculously well.</p>
<h2>The real problems with vector databases nobody advertises<span class="hx:absolute hx:-mt-20" id="the-real-problems-with-vector-databases-nobody-advertises"></span>
    <a href="#the-real-problems-with-vector-databases-nobody-advertises" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The vector DB marketing sells the dream of perfect semantic search. Reality is messier.</p>
<p>False neighbors come first. Cosine similarity rewards topical similarity, not relevance. You ask &ldquo;how do we handle authentication errors&rdquo; and the DB returns every chunk that mentions authentication. The chunk that actually answers the question may be in tenth place, or may not have been retrieved at all because the doc author wrote &ldquo;login&rdquo; instead of &ldquo;auth.&rdquo;</p>
<p>Chunking is the second one, and it&rsquo;s a disguised disaster. A 512-token window with a 64-token overlap sounds reasonable, until you realize your important table got cut in half, the function definition ended up separated from its usage, and the piece of documentation with the exact command got orphaned without the context of its section. The chunk boundary tends to land exactly where the answer was living.</p>
<p>When it fails, it fails without leaving a trace. When BM25 misses, you know why: the word isn&rsquo;t there. When a vector DB returns garbage, you get a plausible-looking wrong chunk, with no diagnostic signal at all. Good luck debugging that in production at two in the morning.</p>
<p>The index gets stale. Every document update calls for re-embedding. If you have 10,000 docs and 200 of them change per day, that turns into a batch process, monitoring, a queue, retries, embedding API costs, and an unavoidable inconsistency window between what&rsquo;s on disk and what&rsquo;s in the index. Grep has none of that. File changed? The next query already sees it.</p>
<p>And there&rsquo;s the operating cost nobody adds up. Pinecone charges per vector. Weaviate wants a cluster to maintain. pgvector saves you a new server but you still own a schema, an index, and a re-embedding pipeline. Each of those things wants engineer time, monitoring, tests, deploys. All of that to do a search that <code>rg</code> would often crack in 200ms.</p>
<h2>Comparing the complexity<span class="hx:absolute hx:-mt-20" id="comparing-the-complexity"></span>
    <a href="#comparing-the-complexity" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Look at the diagram:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/06/rag/rag-vs-grep-complexity.png" alt="Complexity: classic RAG vs grep &#43; long context"  loading="lazy" /></p>
<p>On one side, eight steps, four or five services, an external index that needs to be maintained and kept up to date. On the other, four steps, zero new infrastructure. This isn&rsquo;t a caricature: it is literally what you have to set up for each case.</p>
<p>The honest question: does the left column pay off? In 2023, yes, because the right column didn&rsquo;t exist (no LLM had a 200k window). In 2026, in most cases, it doesn&rsquo;t.</p>
<h2>Pros and cons of each side<span class="hx:absolute hx:-mt-20" id="pros-and-cons-of-each-side"></span>
    <a href="#pros-and-cons-of-each-side" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><h3>Classic RAG (vector DB)<span class="hx:absolute hx:-mt-20" id="classic-rag-vector-db"></span>
    <a href="#classic-rag-vector-db" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p><strong>For:</strong></p>
<ul>
<li>Works for huge document bases, on the order of hundreds of GB, where even <code>rg</code> won&rsquo;t cut it without prior indexing</li>
<li>Handles heavy paraphrase and cross-lingual queries (&ldquo;how do I cancel&rdquo; vs. &ldquo;subscription termination process&rdquo;) where the user&rsquo;s vocabulary doesn&rsquo;t match the document&rsquo;s</li>
<li>Works for non-textual modalities (image, audio) where grep has nothing to look at</li>
<li>Saves input tokens if you&rsquo;re tight on budget or absolute latency</li>
</ul>
<p><strong>Against:</strong></p>
<ul>
<li>Complex stack: embedding, vector DB, chunking, reranker, re-indexing pipeline</li>
<li>Opaque failures, hard to debug</li>
<li>Chunking destroys the context of tables, code, long definitions</li>
<li>Operational overhead (index, queue, monitoring, re-embedding cost)</li>
<li>The semantic search the marketing is selling rarely works the way the marketing promises</li>
</ul>
<h3>Grep + long context<span class="hx:absolute hx:-mt-20" id="grep--long-context"></span>
    <a href="#grep--long-context" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p><strong>For:</strong></p>
<ul>
<li>Practically zero new infrastructure: ripgrep, sqlite, or a plain <code>LIKE</code> in Postgres</li>
<li>Always fresh: file changes, the next query sees them</li>
<li>Transparent failures: the word is either there or it isn&rsquo;t</li>
<li>Loads the document in generous chunks, the model does the fine filtering with actual semantics</li>
<li>Cheaper in dev and ops, cheaper to pivot domains</li>
</ul>
<p><strong>Against:</strong></p>
<ul>
<li>Doesn&rsquo;t scale to terabytes of raw text without some kind of indexing</li>
<li>Suffers when the user&rsquo;s vocabulary is very different from the document&rsquo;s</li>
<li>Doesn&rsquo;t work for non-textual modalities</li>
<li>Per-query latency is higher in absolute terms (loading 100k tokens always costs more than loading 5k)</li>
<li>Per-query input cost is higher if you don&rsquo;t have prompt caching</li>
</ul>
<h2>But what about cost?<span class="hx:absolute hx:-mt-20" id="but-what-about-cost"></span>
    <a href="#but-what-about-cost" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>This is the argument I get hit with the most when I defend the &ldquo;load everything into context&rdquo; thesis. &ldquo;It&rsquo;ll get crazy expensive, 200k tokens of input per query is absurd.&rdquo; Let&rsquo;s actually run the numbers.</p>
<p>In <a href="/en/2026/04/05/testing-llms-open-source-and-commercial-can-anyone-beat-claude-opus/">yesterday&rsquo;s LLM benchmark post</a> I mapped out the per-token price of every model. Take Claude Sonnet 4.6: $3 per million input tokens, $15 per million output. Take GLM 5 (which I proved actually works): $0.60 input, $2.20 output. Take GPT 5.4 Pro at the top of the heap: $15 input, $180 output (yeah, that one stings, I know).</p>
<p>Before we turn &ldquo;200k tokens&rdquo; into dollars, let&rsquo;s land that number on something tangible, because &ldquo;100k tokens&rdquo; doesn&rsquo;t mean anything to anyone. A token, on average, is roughly 0.75 of a word in English (Portuguese is similar, maybe a touch heavier because of longer words). So, translating:</p>
<ul>
<li><strong>100k tokens</strong> ≈ 75,000 words ≈ a whole short novel like Hemingway&rsquo;s <em>The Old Man and the Sea</em> with room to spare, or about three long Wikipedia articles glued together.</li>
<li><strong>200k tokens</strong> ≈ 150,000 words ≈ a big novel, like <em>Crime and Punishment</em> in full, or half of the first <em>Game of Thrones</em> book (which clocks in around 298k words, so roughly 400k tokens).</li>
<li><strong>400k tokens</strong> ≈ 300,000 words ≈ <em>A Game of Thrones</em> in full, the entire first book of the series in your window.</li>
<li><strong>1M tokens</strong> ≈ 750,000 words ≈ the entire <em>Lord of the Rings</em> trilogy plus <em>The Hobbit</em>, or the whole Bible (King James is around 783k words, roughly 1M tokens), or about two and a half <em>Game of Thrones</em> books stacked on top of each other.</li>
</ul>
<p>So when I say &ldquo;throw 200k tokens of input at the model,&rdquo; what that actually means in the real world is &ldquo;throw the entire <em>Crime and Punishment</em> in as the context for your question.&rdquo; That&rsquo;s a lot. And that&rsquo;s exactly what makes the argument of this post viable: today&rsquo;s models can read an entire novel in one go and still answer a specific question about it. In 2023, this was science fiction. In 2026, it&rsquo;s the base case.</p>
<p>So picture a query that throws 200k tokens of input at the model (there goes <em>Crime and Punishment</em> again) and produces 2k tokens of output (about three pages of response):</p>
<table>
  <thead>
      <tr>
          <th>Model</th>
          <th style="text-align: right">Input ($)</th>
          <th style="text-align: right">Output ($)</th>
          <th style="text-align: right">Total per query</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Claude Sonnet 4.6</td>
          <td style="text-align: right">$0.60</td>
          <td style="text-align: right">$0.03</td>
          <td style="text-align: right"><strong>$0.63</strong></td>
      </tr>
      <tr>
          <td>Claude Opus 4.6</td>
          <td style="text-align: right">$3.00</td>
          <td style="text-align: right">$0.15</td>
          <td style="text-align: right"><strong>$3.15</strong></td>
      </tr>
      <tr>
          <td>GLM 5</td>
          <td style="text-align: right">$0.12</td>
          <td style="text-align: right">$0.0044</td>
          <td style="text-align: right"><strong>$0.12</strong></td>
      </tr>
      <tr>
          <td>Gemini 3.1 Pro</td>
          <td style="text-align: right">$0.40</td>
          <td style="text-align: right">$0.024</td>
          <td style="text-align: right"><strong>$0.42</strong></td>
      </tr>
      <tr>
          <td>GPT 5.4 Pro</td>
          <td style="text-align: right">$3.00</td>
          <td style="text-align: right">$0.36</td>
          <td style="text-align: right"><strong>$3.36</strong></td>
      </tr>
  </tbody>
</table>
<p>Now throw prompt caching into the mix. Claude has a cache that drops cached input to a fraction of the full price (in the ballpark of 10%, depending on the model). Gemini has a similar mechanism. When you fire a sequence of queries against the same 200k-token dump, the cost of subsequent queries plummets to pennies. With Sonnet cached, you can fairly call it about $0.10 per follow-up query without making things up.</p>
<p>Now compare that to the cost of running a Pinecone, or a Weaviate, or a pgvector. Setting aside the price of the subscription itself (which varies a lot), you need an engineer to wire up the pipeline, maintain it, monitor it, deal with embedding failures, redo the chunking when the domain shifts. Conservatively, you&rsquo;re looking at somewhere between 40 and 80 hours of engineering to make the thing stable. At R$ 200/hour, that&rsquo;s between R$ 8,000 and R$ 16,000. In USD, somewhere between $1,600 and $3,200 just to stand it up.</p>
<p>With $3,200, on Sonnet 4.6 with prompt caching, you can run something on the order of 30,000 queries of 200k tokens each. Thirty thousand queries, depending on the scale of the project, gives you several months or even an entire year of an average internal tool. And you didn&rsquo;t pay an engineer to wire up a pipeline. There&rsquo;s no vector DB server to maintain. And if the document changes, the system already sees it on the next query.</p>
<p>The &ldquo;RAG is cheaper in tokens&rdquo; argument ignores that tokens are the cheapest thing in the entire equation. Engineers cost a lot, servers cost a lot, bugs in production cost a whole lot more. Tokens have become a commodity, and they&rsquo;re getting cheaper with every new model release.</p>
<p>The classic RAG argument was &ldquo;the model is expensive, retrieval is cheap.&rdquo; Today it&rsquo;s the opposite: the model is the cheap part of the stack, smart retrieval is what costs a fortune to build and maintain.</p>
<h2>Where the thesis doesn&rsquo;t hold<span class="hx:absolute hx:-mt-20" id="where-the-thesis-doesnt-hold"></span>
    <a href="#where-the-thesis-doesnt-hold" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>I don&rsquo;t want to come off as a fanboy. There are cases where classic RAG still wins:</p>
<ol>
<li><strong>Massive corpora.</strong> If you have 500 GB of raw text, even <code>rg</code> won&rsquo;t solve it in acceptable time. You need some kind of indexing. It can be indexed BM25 (Tantivy, Elasticsearch), it can be a vector DB. But notice: the first option is still lexical, not vector.</li>
<li><strong>Wildly scattered vocabulary.</strong> Customer support, where the user types &ldquo;my wifi&rsquo;s down&rdquo; and the documentation says &ldquo;loss of connectivity at the physical layer.&rdquo; BM25 won&rsquo;t catch that. Embedding will. Vector DB scores a point here.</li>
<li><strong>Non-textual modalities.</strong> Image-by-image search, audio-by-audio. Embedding is mandatory.</li>
<li><strong>Critical absolute latency.</strong> If you have to answer in 100ms with a 5k input budget, a generous dump won&rsquo;t fit. Pre-filtering is necessary.</li>
<li><strong>Compliance and audit.</strong> If you have to prove that a specific document was consulted to answer a specific query, having indexed and trackable chunks helps. A 200k-token context dump is more opaque from an audit standpoint.</li>
</ol>
<p>For those cases, classic RAG still makes sense. But notice the size of the list. These are specific cases. The general case, things like &ldquo;chat with our internal docs&rdquo; or &ldquo;ask the product manual,&rdquo; almost all of it falls into the &ldquo;grep + long context handles it better&rdquo; bucket.</p>
<h2>Lazy retrieval: the recipe I&rsquo;d defend<span class="hx:absolute hx:-mt-20" id="lazy-retrieval-the-recipe-id-defend"></span>
    <a href="#lazy-retrieval-the-recipe-id-defend" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>If I were building a &ldquo;chat with docs&rdquo; tool today, from scratch, it would look more or less like this:</p>
<ol>
<li><strong>Keep the documents raw.</strong> Markdown, converted PDF, code, whatever. On disk, organized in folders that make sense for the domain.</li>
<li><strong>Fast lexical filter.</strong> <code>ripgrep</code> with regex, or BM25 with Tantivy/SQLite FTS5, or a <code>LIKE</code> in Postgres if you already have one. Returns 100-300 hits.</li>
<li><strong>Load generously.</strong> Grab not just the matching snippet, but the entire file, or a wide window around it. Throw all of it into the context.</li>
<li><strong>Let the LLM do the fine work.</strong> Pass the original question, tell the model to find what matters, drop the rest, and answer with citations.</li>
<li><strong>(Optional) Add embeddings only for the query classes where lexical fails</strong>, after you have real data showing that it fails.</li>
</ol>
<p>This is the opposite of the old advice (&ldquo;start with vectors, fall back to keyword&rdquo;). It&rsquo;s: <strong>start with keyword, and add vector only if you feel the gap</strong>. In most projects, you never will.</p>
<h2>A toy implementation in Ruby<span class="hx:absolute hx:-mt-20" id="a-toy-implementation-in-ruby"></span>
    <a href="#a-toy-implementation-in-ruby" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>To make it concrete. Here&rsquo;s a Ruby script using the <a href="https://github.com/crmne/ruby_llm"target="_blank" rel="noopener"><code>ruby_llm</code></a> gem (the same one from yesterday&rsquo;s benchmark) that does exactly this flow: grep through the files, load the snippets with context, send to Claude, get the answer back. No vector DB, no chunking, no embedding, no LangChain.</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="ch">#!/usr/bin/env ruby</span>
</span></span><span class="line"><span class="cl"><span class="nb">require</span> <span class="s2">&#34;ruby_llm&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nb">require</span> <span class="s2">&#34;open3&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="no">DOCS_DIR</span> <span class="o">=</span> <span class="no">ARGV</span><span class="o">[</span><span class="mi">0</span><span class="o">]</span> <span class="o">||</span> <span class="s2">&#34;./docs&#34;</span>
</span></span><span class="line"><span class="cl"><span class="no">QUERY</span>    <span class="o">=</span> <span class="no">ARGV</span><span class="o">[</span><span class="mi">1</span><span class="o">]</span> <span class="ow">or</span> <span class="nb">abort</span> <span class="s2">&#34;uso: ./ask.rb &lt;pasta&gt; &lt;pergunta&gt;&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 1. Fast lexical filter with ripgrep.</span>
</span></span><span class="line"><span class="cl"><span class="c1">#    -i case insensitive, -l file names only, --type-add covers md/txt/extracted-pdf.</span>
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">lexical_search</span><span class="p">(</span><span class="n">dir</span><span class="p">,</span> <span class="n">query</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">terms</span> <span class="o">=</span> <span class="n">query</span><span class="o">.</span><span class="n">downcase</span><span class="o">.</span><span class="n">scan</span><span class="p">(</span><span class="sr">/\w{4,}/</span><span class="p">)</span><span class="o">.</span><span class="n">uniq</span><span class="o">.</span><span class="n">first</span><span class="p">(</span><span class="mi">8</span><span class="p">)</span>  <span class="c1"># words with 4+ letters</span>
</span></span><span class="line"><span class="cl">  <span class="n">pattern</span> <span class="o">=</span> <span class="n">terms</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="s2">&#34;|&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">cmd</span> <span class="o">=</span> <span class="o">[</span><span class="s2">&#34;rg&#34;</span><span class="p">,</span> <span class="s2">&#34;-l&#34;</span><span class="p">,</span> <span class="s2">&#34;-i&#34;</span><span class="p">,</span> <span class="s2">&#34;-e&#34;</span><span class="p">,</span> <span class="n">pattern</span><span class="p">,</span> <span class="n">dir</span><span class="o">]</span>
</span></span><span class="line"><span class="cl">  <span class="n">files</span><span class="p">,</span> <span class="n">_</span> <span class="o">=</span> <span class="no">Open3</span><span class="o">.</span><span class="n">capture2</span><span class="p">(</span><span class="o">*</span><span class="n">cmd</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">files</span><span class="o">.</span><span class="n">split</span><span class="p">(</span><span class="s2">&#34;</span><span class="se">\n</span><span class="s2">&#34;</span><span class="p">)</span><span class="o">.</span><span class="n">reject</span><span class="p">(</span><span class="o">&amp;</span><span class="ss">:empty?</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 2. Load entire files (up to a reasonable cap).</span>
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">load_context</span><span class="p">(</span><span class="n">files</span><span class="p">,</span> <span class="ss">max_chars</span><span class="p">:</span> <span class="mi">600_000</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">total</span> <span class="o">=</span> <span class="mi">0</span>
</span></span><span class="line"><span class="cl">  <span class="n">files</span><span class="o">.</span><span class="n">map</span> <span class="k">do</span> <span class="o">|</span><span class="n">path</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">    <span class="n">body</span> <span class="o">=</span> <span class="no">File</span><span class="o">.</span><span class="n">read</span><span class="p">(</span><span class="n">path</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">next</span> <span class="k">if</span> <span class="n">total</span> <span class="o">+</span> <span class="n">body</span><span class="o">.</span><span class="n">size</span> <span class="o">&gt;</span> <span class="n">max_chars</span>
</span></span><span class="line"><span class="cl">    <span class="n">total</span> <span class="o">+=</span> <span class="n">body</span><span class="o">.</span><span class="n">size</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;## </span><span class="si">#{</span><span class="n">path</span><span class="si">}</span><span class="se">\n\n</span><span class="si">#{</span><span class="n">body</span><span class="si">}</span><span class="se">\n</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span><span class="o">.</span><span class="n">compact</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="s2">&#34;</span><span class="se">\n</span><span class="s2">---</span><span class="se">\n</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 3. Send to Claude with the question and the documents.</span>
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">ask</span><span class="p">(</span><span class="n">query</span><span class="p">,</span> <span class="n">context</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">chat</span> <span class="o">=</span> <span class="no">RubyLLM</span><span class="o">.</span><span class="n">chat</span><span class="p">(</span><span class="ss">model</span><span class="p">:</span> <span class="s2">&#34;anthropic/claude-sonnet-4-6&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">prompt</span> <span class="o">=</span> <span class="o">&lt;&lt;~</span><span class="no">PROMPT</span>
</span></span><span class="line"><span class="cl">    <span class="no">Você</span> <span class="n">tem</span> <span class="n">acesso</span> <span class="n">aos</span> <span class="n">documentos</span> <span class="n">abaixo</span><span class="o">.</span> <span class="no">Responda</span> <span class="n">a</span> <span class="n">pergunta</span> <span class="k">do</span> <span class="n">usuário</span>
</span></span><span class="line"><span class="cl">    <span class="n">usando</span> <span class="n">apenas</span> <span class="n">o</span> <span class="n">que</span> <span class="n">está</span> <span class="n">nos</span> <span class="n">documentos</span><span class="o">.</span> <span class="no">Cite</span> <span class="n">o</span> <span class="n">nome</span> <span class="k">do</span> <span class="n">arquivo</span> <span class="n">nas</span>
</span></span><span class="line"><span class="cl">    <span class="n">referências</span><span class="o">.</span> <span class="no">Se</span> <span class="n">a</span> <span class="n">resposta</span> <span class="n">não</span> <span class="n">estiver</span> <span class="n">nos</span> <span class="n">documentos</span><span class="p">,</span> <span class="n">diga</span> <span class="n">isso</span><span class="o">.</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="o">---</span> <span class="no">DOCUMENTOS</span> <span class="o">---</span>
</span></span><span class="line"><span class="cl">    <span class="c1">#{context}</span>
</span></span><span class="line"><span class="cl">    <span class="o">---</span> <span class="no">FIM</span> <span class="no">DOS</span> <span class="no">DOCUMENTOS</span> <span class="o">---</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="ss">Pergunta</span><span class="p">:</span> <span class="c1">#{query}</span>
</span></span><span class="line"><span class="cl">  <span class="no">PROMPT</span>
</span></span><span class="line"><span class="cl">  <span class="n">chat</span><span class="o">.</span><span class="n">ask</span><span class="p">(</span><span class="n">prompt</span><span class="p">)</span><span class="o">.</span><span class="n">content</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">files</span> <span class="o">=</span> <span class="n">lexical_search</span><span class="p">(</span><span class="no">DOCS_DIR</span><span class="p">,</span> <span class="no">QUERY</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="nb">abort</span> <span class="s2">&#34;nenhum arquivo bateu&#34;</span> <span class="k">if</span> <span class="n">files</span><span class="o">.</span><span class="n">empty?</span>
</span></span><span class="line"><span class="cl"><span class="nb">puts</span> <span class="s2">&#34;Encontrei </span><span class="si">#{</span><span class="n">files</span><span class="o">.</span><span class="n">size</span><span class="si">}</span><span class="s2"> arquivos. Carregando contexto...&#34;</span>
</span></span><span class="line"><span class="cl"><span class="n">context</span> <span class="o">=</span> <span class="n">load_context</span><span class="p">(</span><span class="n">files</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="nb">puts</span> <span class="n">ask</span><span class="p">(</span><span class="no">QUERY</span><span class="p">,</span> <span class="n">context</span><span class="p">)</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>About 40 lines. No Pinecone dependency, no vector schema, no re-indexing pipeline. You run it as <code>./ask.rb ./docs &quot;how do I configure the payment webhook&quot;</code> and that&rsquo;s it.</p>
<p>That example is one-shot. You run it, it answers, done. For a real chat, with multiple questions in a row over the same documents, the design changes. Instead of running <code>lexical_search</code> upfront and shoving everything into the context at once, you expose the search as a tool to the model. Then it&rsquo;s the agent that decides when it needs to pull more docs, what term to look for, which file is worth opening in full. That&rsquo;s how Claude Code actually works: <code>Glob</code>, <code>Grep</code> and <code>Read</code> are tools, and the model picks the sequence. <code>ruby_llm</code> supports tool calling, so you can do the same thing in Ruby. It looks something like this:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="nb">require</span> <span class="s2">&#34;ruby_llm&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nb">require</span> <span class="s2">&#34;open3&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="no">DOCS_DIR</span> <span class="o">=</span> <span class="s2">&#34;./docs&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">SearchFiles</span> <span class="o">&lt;</span> <span class="no">RubyLLM</span><span class="o">::</span><span class="no">Tool</span>
</span></span><span class="line"><span class="cl">  <span class="n">description</span> <span class="s2">&#34;Procura arquivos cujo conteúdo casa com o padrão dado (regex). Retorna lista de paths.&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="n">param</span> <span class="ss">:pattern</span><span class="p">,</span> <span class="ss">desc</span><span class="p">:</span> <span class="s2">&#34;Padrão regex pra busca lexical (case-insensitive)&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">execute</span><span class="p">(</span><span class="ss">pattern</span><span class="p">:)</span>
</span></span><span class="line"><span class="cl">    <span class="n">out</span><span class="p">,</span> <span class="n">_</span> <span class="o">=</span> <span class="no">Open3</span><span class="o">.</span><span class="n">capture2</span><span class="p">(</span><span class="s2">&#34;rg&#34;</span><span class="p">,</span> <span class="s2">&#34;-l&#34;</span><span class="p">,</span> <span class="s2">&#34;-i&#34;</span><span class="p">,</span> <span class="s2">&#34;-e&#34;</span><span class="p">,</span> <span class="n">pattern</span><span class="p">,</span> <span class="no">DOCS_DIR</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">out</span><span class="o">.</span><span class="n">split</span><span class="p">(</span><span class="s2">&#34;</span><span class="se">\n</span><span class="s2">&#34;</span><span class="p">)</span><span class="o">.</span><span class="n">reject</span><span class="p">(</span><span class="o">&amp;</span><span class="ss">:empty?</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">ReadFile</span> <span class="o">&lt;</span> <span class="no">RubyLLM</span><span class="o">::</span><span class="no">Tool</span>
</span></span><span class="line"><span class="cl">  <span class="n">description</span> <span class="s2">&#34;Lê o conteúdo completo de um arquivo do projeto.&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="n">param</span> <span class="ss">:path</span><span class="p">,</span> <span class="ss">desc</span><span class="p">:</span> <span class="s2">&#34;Caminho relativo do arquivo&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">execute</span><span class="p">(</span><span class="ss">path</span><span class="p">:)</span>
</span></span><span class="line"><span class="cl">    <span class="no">File</span><span class="o">.</span><span class="n">read</span><span class="p">(</span><span class="n">path</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">rescue</span> <span class="o">=&gt;</span> <span class="n">e</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;erro: </span><span class="si">#{</span><span class="n">e</span><span class="o">.</span><span class="n">message</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">chat</span> <span class="o">=</span> <span class="no">RubyLLM</span><span class="o">.</span><span class="n">chat</span><span class="p">(</span><span class="ss">model</span><span class="p">:</span> <span class="s2">&#34;anthropic/claude-sonnet-4-6&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">            <span class="o">.</span><span class="n">with_tools</span><span class="p">(</span><span class="no">SearchFiles</span><span class="p">,</span> <span class="no">ReadFile</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">            <span class="o">.</span><span class="n">with_instructions</span><span class="p">(</span><span class="o">&lt;&lt;~</span><span class="no">SYS</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">              <span class="no">Você</span> <span class="n">responde</span> <span class="n">perguntas</span> <span class="n">sobre</span> <span class="n">os</span> <span class="n">documentos</span> <span class="n">em</span> <span class="c1">#{DOCS_DIR}.</span>
</span></span><span class="line"><span class="cl">              <span class="no">Use</span> <span class="n">search_files</span> <span class="n">pra</span> <span class="n">encontrar</span> <span class="n">arquivos</span> <span class="n">relevantes</span> <span class="n">e</span> <span class="n">read_file</span>
</span></span><span class="line"><span class="cl">              <span class="n">pra</span> <span class="n">ler</span> <span class="n">o</span> <span class="n">conteúdo</span><span class="o">.</span> <span class="no">Sempre</span> <span class="n">cite</span> <span class="n">o</span> <span class="n">arquivo</span> <span class="n">na</span> <span class="n">resposta</span><span class="o">.</span>
</span></span><span class="line"><span class="cl">            <span class="no">SYS</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kp">loop</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="nb">print</span> <span class="s2">&#34;&gt; &#34;</span>
</span></span><span class="line"><span class="cl">  <span class="n">msg</span> <span class="o">=</span> <span class="nb">gets</span><span class="o">&amp;.</span><span class="n">chomp</span>
</span></span><span class="line"><span class="cl">  <span class="k">break</span> <span class="k">if</span> <span class="n">msg</span><span class="o">.</span><span class="n">nil?</span> <span class="o">||</span> <span class="n">msg</span><span class="o">.</span><span class="n">empty?</span>
</span></span><span class="line"><span class="cl">  <span class="nb">puts</span> <span class="n">chat</span><span class="o">.</span><span class="n">ask</span><span class="p">(</span><span class="n">msg</span><span class="p">)</span><span class="o">.</span><span class="n">content</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The model gets the question, decides whether it needs to search, calls <code>search_files</code>, sees what came back, decides whether it needs to open any file, calls <code>read_file</code>, and only then answers. On the next question it already has the previous context in the session and can ask for more if it needs to. The context only receives what the model asked for, not the whole grep dump from the earlier example.</p>
<p>The same idea works for databases: swap <code>rg</code> for a SQL query with <code>LIKE</code> or <code>tsvector</code> (Postgres full-text), load the relevant rows, throw them in the context. If you have 10k records in an internal database, this handles it. If you have 10 million, you start needing smarter pagination or a more serious pre-filtering layer. But the mental model is the same: <strong>dumb filter + smart reader</strong>.</p>
<h2>The point that matters<span class="hx:absolute hx:-mt-20" id="the-point-that-matters"></span>
    <a href="#the-point-that-matters" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The most interesting thing in all of this isn&rsquo;t even the Pinecone savings. It&rsquo;s that the nature of the bottleneck has changed. In 2023, the bottleneck was retrieval: the reader was small, slow, expensive, and you needed a clever retriever to fill the window with the bare minimum. In 2026, the bottleneck is reasoning over messy context: the reader is big, relatively fast, and cheap. So it makes more sense to have a dumb retriever with high recall and let the model do the heavy lifting.</p>
<p>Anyone still designing systems with the 2023 mindset is paying a premium to solve a problem whose shape has changed. RAG didn&rsquo;t die, the &ldquo;R&rdquo; got dumber and cheaper, and that&rsquo;s an upgrade. The vector DB vendors aren&rsquo;t going to tell you this, but it&rsquo;s the path the more experienced folks have been quietly walking.</p>
<p>The next wave of LLM applications, in my bet, is going to be dominated by the people who got this inversion. Smaller stacks, simpler infrastructure, generous context, and a whole lot less LangChain.</p>
<h2>What the recent literature says<span class="hx:absolute hx:-mt-20" id="what-the-recent-literature-says"></span>
    <a href="#what-the-recent-literature-says" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Before I close out, I went and checked what the research crowd published on this. Blog hot takes age in three months in this field, so it&rsquo;s better to look at the papers.</p>
<p><a href="https://arxiv.org/abs/2407.16833"target="_blank" rel="noopener"><strong>Retrieval Augmented Generation or Long-Context LLMs?</strong></a>, out of Google DeepMind, published at EMNLP 2024, is probably the most cited piece in the debate. Their conclusion: when the model has enough resources, long context beats RAG on average quality, but RAG is still much cheaper in tokens. They propose Self-Route, an approach where the model itself decides whether it needs retrieval or whether it can just go straight through context. The token savings are big and the quality loss is small.</p>
<p>Then <a href="https://openreview.net/forum?id=CLF25dahgA"target="_blank" rel="noopener"><strong>LaRA</strong></a>, presented at ICML 2025, is more measured. The authors built 2326 test cases across four QA task types and three long-context types, ran them across 11 different LLMs, and the conclusion was: there is no silver bullet. The choice between RAG and long context depends on the model, the context size, the task type, and the retrieval characteristics. RAG wins on dialogue and generic queries, long context wins on Wikipedia-style QA.</p>
<p><a href="https://arxiv.org/abs/2501.01880"target="_blank" rel="noopener"><strong>Long Context vs. RAG for LLMs: An Evaluation and Revisits</strong></a>, from January 2025, is the one that most reinforces this post&rsquo;s thesis. Long context tends to beat RAG on QA benchmarks, especially when the base document is stable. Summarization-based retrieval comes close, and chunk-based retrieval lags behind. In other words: the old way, chunk plus embed plus top-k, is the one that comes out worst.</p>
<p>Worth keeping on the radar too is the original <a href="https://arxiv.org/abs/2307.03172"target="_blank" rel="noopener"><strong>Lost in the Middle</strong></a> (Liu et al., 2023, published in TACL in 2024). That&rsquo;s the paper that showed even models with big windows have performance that depends on the position of the relevant information. Stuff at the beginning or end of the context is found easily; stuff in the middle degrades. For a long time this got used as the argument against long context, but the paper is from 2023, with 2023 models. Today&rsquo;s models, the Claude 4.x and Gemini 3.x line, handle the middle a lot better. It&rsquo;s not a solved problem, but it&rsquo;s much smaller than it was.</p>
<p>On the lexical retrieval side, <a href="https://arxiv.org/abs/2104.08663"target="_blank" rel="noopener"><strong>BEIR</strong></a> is still the canonical reference. The classic result is that BM25, all the way from the 90s, is still ridiculously competitive in out-of-domain scenarios. Dense models only win consistently when you have in-domain data to fine-tune the embeddings. In zero-shot scenarios, which is where most projects live, BM25 is hard to beat without serious work.</p>
<p>To wrap up, the <a href="https://www.anthropic.com/news/contextual-retrieval"target="_blank" rel="noopener"><strong>Anthropic post on Contextual Retrieval</strong></a>, from September 2024, is the most practical piece on the list. They show that combining contextual embedding with contextual BM25 drops the top-20 failure rate from 5.7% to 2.9%. Add a reranker and it drops to 1.9%. Important detail: BM25 is the centerpiece of their result, not a sidekick. The right reading is &ldquo;lexical plus vector plus reranker is the combination that works.&rdquo; Anyone who can only pick one picks BM25 and still gets pretty far.</p>
<p>To sum up what we can actually nail down: the literature isn&rsquo;t claiming &ldquo;RAG is dead.&rdquo; It&rsquo;s saying that long context, when you can use it, tends to win on quality. It&rsquo;s saying RAG&rsquo;s cost is still its main argument. It&rsquo;s saying lexical BM25 is much stronger than the vector DB marketing makes it sound. And it&rsquo;s saying that when you really do need heavy retrieval, the robust combination is hybrid (lexical plus vector plus reranker), not pure vector. All of that lines up with what I&rsquo;ve been defending in practice.</p>
<h2>Sources<span class="hx:absolute hx:-mt-20" id="sources"></span>
    <a href="#sources" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><ul>
<li>Li, Z. et al. (2024). <a href="https://arxiv.org/abs/2407.16833"target="_blank" rel="noopener">Retrieval Augmented Generation or Long-Context LLMs? A Comprehensive Study and Hybrid Approach</a>. EMNLP 2024 Industry Track.</li>
<li>Yuan, K. et al. (2025). <a href="https://openreview.net/forum?id=CLF25dahgA"target="_blank" rel="noopener">LaRA: Benchmarking Retrieval-Augmented Generation and Long-Context LLMs – No Silver Bullet for LC or RAG Routing</a>. ICML 2025.</li>
<li>Yu, T. et al. (2025). <a href="https://arxiv.org/abs/2501.01880"target="_blank" rel="noopener">Long Context vs. RAG for LLMs: An Evaluation and Revisits</a>. arXiv:2501.01880.</li>
<li>Liu, N. F. et al. (2023). <a href="https://arxiv.org/abs/2307.03172"target="_blank" rel="noopener">Lost in the Middle: How Language Models Use Long Contexts</a>. TACL 2024.</li>
<li>Thakur, N. et al. (2021). <a href="https://arxiv.org/abs/2104.08663"target="_blank" rel="noopener">BEIR: A Heterogenous Benchmark for Zero-shot Evaluation of Information Retrieval Models</a>. NeurIPS Datasets and Benchmarks 2021.</li>
<li>Anthropic (2024). <a href="https://www.anthropic.com/news/contextual-retrieval"target="_blank" rel="noopener">Introducing Contextual Retrieval</a>. Blog post.</li>
<li>Akita, F. (2026). <a href="/en/2026/03/31/claude-code-source-code-leaked-what-we-found-inside/">Claude Code&rsquo;s Source Code Leaked. Here&rsquo;s What We Found Inside.</a> — my coverage of the leak, with more detail on the memory architecture, KAIROS and <code>autoDream</code>.</li>
</ul>
]]></content:encoded><category>llm</category><category>rag</category><category>vibecoding</category><category>ai</category></item><item><title>Testing Open Source and Commercial LLMs - Can Anyone Beat Claude Opus?</title><link>https://www.akitaonrails.com/en/2026/04/05/testing-llms-open-source-and-commercial-can-anyone-beat-claude-opus/</link><guid isPermaLink="true">https://www.akitaonrails.com/en/2026/04/05/testing-llms-open-source-and-commercial-can-anyone-beat-claude-opus/</guid><pubDate>Sun, 05 Apr 2026 18:00:00 GMT</pubDate><description>&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; If you don&amp;rsquo;t want to read the whole analysis: the only models that produced code that actually works in our benchmark were Claude Sonnet 4.6, Claude Opus 4.6, GLM 5 and GLM 5.1 (from Z.AI, ~89% cheaper than Opus), and GPT 5.4 (which failed the benchmark due to runner incompatibility but which I tested extensively in Codex and works as well as Opus). Everything else — Kimi, DeepSeek, MiniMax, Qwen, Gemini, Grok 4.20 — invented APIs that don&amp;rsquo;t exist or ignored the gem we asked for.&lt;/p&gt;</description><content:encoded><![CDATA[<p><strong>TL;DR:</strong> If you don&rsquo;t want to read the whole analysis: the only models that produced code that actually works in our benchmark were Claude Sonnet 4.6, Claude Opus 4.6, GLM 5 and GLM 5.1 (from Z.AI, ~89% cheaper than Opus), and GPT 5.4 (which failed the benchmark due to runner incompatibility but which I tested extensively in Codex and works as well as Opus). Everything else — Kimi, DeepSeek, MiniMax, Qwen, Gemini, Grok 4.20 — invented APIs that don&rsquo;t exist or ignored the gem we asked for.</p>
<p>There&rsquo;s a new wrinkle in this update: I redid the local part of the benchmark on an RTX 5090 (instead of the AMD Strix Halo) and added a fresh batch of Qwen models, including a Qwen 3.5 27B distilled directly from Claude 4.6 Opus. That reopened the conversation on running open source models locally. The 5090&rsquo;s memory bandwidth flips the game from &ldquo;unworkable&rdquo; to &ldquo;workable with 1-2 follow-up prompts.&rdquo; The bottleneck for open source models has moved to a lack of factual knowledge about specific libraries, which I unpack in detail in the new section on the Qwen family. The Claude distillation gamble, by the way, gave a pretty frustrating result that I haven&rsquo;t seen documented in these terms before.</p>
<hr>
<p>If you&rsquo;ve been following <a href="/en/tags/vibecoding/">my previous vibe coding pieces</a>, you know I spent the last two months in a 500-hour marathon using Claude Opus as my main coding agent. The results were good, as I reported in the <a href="/en/2026/03/05/37-days-of-vibe-coding-immersion-conclusions-on-business-models/">conclusion about business models</a>. But there was an itch I couldn&rsquo;t scratch: am I locked into one model? Is there a real alternative to Claude Opus for daily use on real projects?</p>
<p>I&rsquo;ve got an RTX 5090 with 32 GB of GDDR7. I know I can run the latest open source models. I bought a <a href="/en/2026/03/31/minisforum-ms-s1-max-amd-ai-max-395-review/">Minisforum MS-S1</a> with an AMD Ryzen AI Max 395 and 128 GB of unified memory, and built a <a href="/en/2026/03/31/migrating-my-home-server-with-claude-code/">home server with Docker</a> to serve local models. The infrastructure was ready. What was missing was actually testing it.</p>
<p>I built an automated benchmark to compare open source and commercial models under identical conditions. 33 models configured in total (25 from the original run plus 8 added in the NVIDIA rerun), 27 executed, 16 completed in some form. The code is on <a href="https://github.com/akitaonrails/llm-coding-benchmark"target="_blank" rel="noopener">GitHub</a>.</p>
<h2>The bottleneck nobody explains: VRAM and KV Cache<span class="hx:absolute hx:-mt-20" id="the-bottleneck-nobody-explains-vram-and-kv-cache"></span>
    <a href="#the-bottleneck-nobody-explains-vram-and-kv-cache" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Before getting to the results, I have to explain why running large models locally is much harder than it looks.</p>
<p>Take Qwen3 32B. The model in FP16 (full precision) takes ~64 GB. Quantized to Q4 (4 bits), it drops to ~19 GB. So it fits in my RTX 5090&rsquo;s 32 GB, right? Wrong. That&rsquo;s just the model weights. There&rsquo;s a part nobody tells you about: the <strong>KV Cache</strong>.</p>
<p>KV Cache is the memory the model uses to &ldquo;remember&rdquo; what it has already read. Every time it processes a token (a word or piece of a word), it computes two vectors — K (key) and V (value) — for every attention layer. Those vectors stick around so the model doesn&rsquo;t have to recompute everything when it generates the next token. Without that, generation would be quadratically slow.</p>
<p>The KV Cache scales linearly with the size of the context. The formula:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>KV Memory = 2 × Layers × KV_Heads × Head_Dimension × Bytes_per_Element × Context_Tokens</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>For a model like Llama 3.1 70B in BF16, that comes out to ~0.31 MB per token. Sounds tiny, until you realize that a 128K context eats <strong>40 GB</strong> of KV Cache alone. The model itself plus KV Cache adds up to way more VRAM than most GPUs have.</p>
<p>And for actual coding agent use, 128K tokens isn&rsquo;t a luxury, it&rsquo;s the bare minimum. The agent has to read files, keep conversation history, receive command output. In long benchmark sessions, our models consumed between 39K and 156K tokens. Less than 100K of context isn&rsquo;t practical for day-to-day project work.</p>
<p>Google published <a href="https://research.google/blog/turboquant-redefining-ai-efficiency-with-extreme-compression/"target="_blank" rel="noopener">TurboQuant</a> (ICLR 2026), which compresses the KV Cache to 3 bits without accuracy loss — a 6x memory reduction and up to 8x speedup. It uses random vector rotation (PolarQuant) followed by a 1-bit algorithm on the residuals. Works online during inference, compressing on write and decompressing on read. Not yet implemented in the runtimes we use (llama.cpp, Ollama), but when it lands it&rsquo;ll change the equation a lot.</p>
<p>For anyone wanting to dig deeper into the VRAM math, I recommend <a href="https://x.com/TheAhmadOsman/status/2040103488714068245"target="_blank" rel="noopener">this link from Ahmad Osman</a> for the article &ldquo;GPU Memory Math for LLMs (2026 Edition)&rdquo;.</p>
<h2>The hardware problem: not all memory is created equal<span class="hx:absolute hx:-mt-20" id="the-hardware-problem-not-all-memory-is-created-equal"></span>
    <a href="#the-hardware-problem-not-all-memory-is-created-equal" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>&ldquo;But I have 128 GB of RAM!&rdquo; Cool, but that&rsquo;s not what matters. What matters is memory bandwidth, and the difference between types is wild:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/05/llm-benchmark/en/memory-bandwidth.png" alt="Memory bandwidth by type"  loading="lazy" /></p>
<p>The RTX 5090 has 7x the bandwidth of the LPDDR5x memory in my Minisforum. That means even if a model fits in the AMD&rsquo;s unified RAM, inference will be proportionally slower. On my Minisforum with LPDDR5x at 256 GB/s, Qwen3 32B runs at ~7 tok/s. On the RTX 5090 at 1,792 GB/s, it&rsquo;d be much faster — if it fit entirely in VRAM alongside the KV Cache.</p>
<p>Most folks running local models are still on DDR4. At 50 GB/s, 32B models are basically unusable. And there&rsquo;s another factor people forget: storage. When the RAM can&rsquo;t keep up and the system swaps, the storage speed becomes the bottleneck:</p>
<table>
  <thead>
      <tr>
          <th>Storage</th>
          <th style="text-align: right">Sequential Speed</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>SATA SSD</td>
          <td style="text-align: right">~550 MB/s</td>
      </tr>
      <tr>
          <td>NVMe Gen3</td>
          <td style="text-align: right">~3,500 MB/s</td>
      </tr>
      <tr>
          <td>NVMe Gen4</td>
          <td style="text-align: right">~7,000 MB/s</td>
      </tr>
      <tr>
          <td>NVMe Gen5</td>
          <td style="text-align: right">~12,000 MB/s</td>
      </tr>
  </tbody>
</table>
<p>From SATA to NVMe Gen5 you&rsquo;re looking at a 22x difference. If you&rsquo;re doing partial offloading to disk (which is common when the model doesn&rsquo;t fit entirely on the GPU), NVMe Gen4 or Gen5 makes a real difference. SATA is a non-starter.</p>
<p>To sum up: running local models isn&rsquo;t just &ldquo;having enough RAM.&rdquo; You need the right kind of memory, with the right bandwidth, and fast storage as a fallback. For a lot of people, a Mac Studio with high-bandwidth unified memory (up to 800 GB/s on the M4 Ultra with 512 GB) would be the more practical option, but it costs more than US$ 10,000. The AMD Ryzen AI Max is the cheaper alternative with unified memory, but its LPDDR5 caps out at 256 GB/s.</p>
<h2>Ollama vs llama.cpp: why Ollama falls apart on benchmarks<span class="hx:absolute hx:-mt-20" id="ollama-vs-llamacpp-why-ollama-falls-apart-on-benchmarks"></span>
    <a href="#ollama-vs-llamacpp-why-ollama-falls-apart-on-benchmarks" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p><a href="https://ollama.com/"target="_blank" rel="noopener">Ollama</a> is the most popular way to run local models. Install, pull the model, run. For casual use it works. But when I tried to use it for automated benchmarks with long unattended sessions, it broke in 6 different ways across 8 models:</p>
<ol>
<li>Unloads the model mid-session. On long runs, Ollama decides the model isn&rsquo;t being used and unloads it from the GPU. The agent sits there waiting for a response from a model that no longer exists.</li>
<li>Ignores the requested context. You ask for <code>num_ctx=131072</code>, Ollama accepts, then halfway through the run it reverts to the default without warning.</li>
<li>Unstable lifecycle. Asking for <code>keep_alive: 0</code> to unload doesn&rsquo;t always work. The model stays resident and blocks the next one.</li>
<li>Incompatible formats. Native bf16 variants on Ollama failed, while the same model as a Q8 GGUF from HuggingFace worked fine.</li>
</ol>
<p>The fix: migrate to <a href="https://github.com/mostlygeek/llama-swap"target="_blank" rel="noopener">llama-swap</a>, a Go wrapper that manages llama.cpp processes with hot-swap. A request comes in for a different model than the one currently loaded, it kills the current process and starts the new one. No context negotiation, no flaky lifecycle.</p>
<p>llama-swap fixed the loading of 6 of the 8 models that had failed under Ollama:</p>
<table>
  <thead>
      <tr>
          <th>Model</th>
          <th>Ollama</th>
          <th>llama-swap</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Gemma 4 27B</td>
          <td>HTTP 500</td>
          <td>47.6 tok/s</td>
      </tr>
      <tr>
          <td>GLM 4.7 Flash</td>
          <td>No output</td>
          <td>47.4 tok/s</td>
      </tr>
      <tr>
          <td>Llama 4 Scout</td>
          <td>Unloaded</td>
          <td>17.5 tok/s</td>
      </tr>
      <tr>
          <td>Qwen 3.5 35B</td>
          <td>Output off-spec</td>
          <td>49.7 tok/s</td>
      </tr>
      <tr>
          <td>Qwen 3.5 122B</td>
          <td>Context drift</td>
          <td>23.1 tok/s</td>
      </tr>
      <tr>
          <td>GPT OSS 20B</td>
          <td>Model not found</td>
          <td>78.3 tok/s</td>
      </tr>
  </tbody>
</table>
<p>But llama-swap isn&rsquo;t magic.</p>
<h2>Why &ldquo;just use llama.cpp&rdquo; doesn&rsquo;t fix everything<span class="hx:absolute hx:-mt-20" id="why-just-use-llamacpp-doesnt-fix-everything"></span>
    <a href="#why-just-use-llamacpp-doesnt-fix-everything" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>llama.cpp solves Ollama&rsquo;s lifecycle problems but brings its own:</p>
<p>Each model needs specific flags. GLM and Qwen 3.5 emit <code>&lt;think&gt;</code> tags that break clients if you don&rsquo;t pass <code>--reasoning-format none</code>. Gemma 4 needs build b8665+ for the tool call parser to work.</p>
<p>Not every model supports tool calling. llama.cpp needs a dedicated parser for each model&rsquo;s tool call format. Llama 4 Scout uses a &ldquo;pythonic&rdquo; format (<code>[func(param=&quot;value&quot;)]</code>) that llama.cpp simply doesn&rsquo;t parse and emits as plain text. vLLM has a parser for it, llama.cpp doesn&rsquo;t.</p>
<p>And then there are the repetition loops. Gemma 4, even with the right parser, gets into an infinite loop after ~11 tool calls in long sessions. It&rsquo;s a <a href="https://github.com/ggml-org/llama.cpp/issues/21375"target="_blank" rel="noopener">known bug</a> that PR #21418 didn&rsquo;t fully fix.</p>
<p>Tool calling compatibility per model:</p>
<table>
  <thead>
      <tr>
          <th>Model</th>
          <th>Tool Calling</th>
          <th>Required Flags</th>
          <th>Benchmark Result</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Gemma 4 27B</td>
          <td>Partial (b8665+)</td>
          <td><code>--jinja --reasoning-format none</code></td>
          <td>Infinite loop after ~11 steps</td>
      </tr>
      <tr>
          <td>GLM 4.7 Flash</td>
          <td>Yes</td>
          <td><code>--jinja --reasoning-format none</code></td>
          <td>2029 files, ended mid-tool-call</td>
      </tr>
      <tr>
          <td>Qwen 3.5 (35B, 122B)</td>
          <td>Yes</td>
          <td><code>--jinja --reasoning-format none</code></td>
          <td>Completed successfully</td>
      </tr>
      <tr>
          <td>Qwen 3 Coder Next</td>
          <td>Yes</td>
          <td><code>--jinja</code></td>
          <td>Completed (best local result)</td>
      </tr>
      <tr>
          <td>GPT OSS 20B</td>
          <td>Yes</td>
          <td><code>--jinja</code></td>
          <td>Tool calls ok, but app in wrong directory</td>
      </tr>
      <tr>
          <td>Llama 4 Scout</td>
          <td>No</td>
          <td>—</td>
          <td>No parser in llama.cpp</td>
      </tr>
  </tbody>
</table>
<p>At the end of the day, llama.cpp is better than Ollama for automated runs, but &ldquo;plug and play&rdquo; it ain&rsquo;t. Each model requires specific configuration, and some just don&rsquo;t work for agentic coding yet.</p>
<h2>Reasoning: models that think vs models that wing it<span class="hx:absolute hx:-mt-20" id="reasoning-models-that-think-vs-models-that-wing-it"></span>
    <a href="#reasoning-models-that-think-vs-models-that-wing-it" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>There&rsquo;s one difference between models worth explaining: reasoning. The idea is that the model &ldquo;thinks before answering&rdquo; instead of generating tokens straight from left to right. Models with reasoning go through an internal chain-of-thought step where they evaluate the problem, consider alternatives, plan, and only then emit the response.</p>
<p>In practice this shows up as <code>&lt;think&gt;...&lt;/think&gt;</code> tags in the output, blocks of text the model writes to itself that shouldn&rsquo;t go to the end user. Claude Opus 4.6, GPT 5.4, DeepSeek V3.2 and the Qwen 3.5 line support reasoning natively. The smaller ones (Gemma 4, GPT OSS 20B, older models) don&rsquo;t have that capability.</p>
<p>Why does it matter for coding? When a coding agent gets &ldquo;build a Rails app with 9 components,&rdquo; it has to decompose the task into steps, decide the order, anticipate dependencies, adapt when something fails. Without reasoning, the model generates code sequentially with no planning. It works for simple tasks, falls apart on projects with interdependent parts.</p>
<p>In the benchmark, the difference was clear:</p>
<ul>
<li>GPT OSS 20B (no reasoning, 20B parameters) created the app in the wrong directory. Couldn&rsquo;t keep workspace instructions in mind while generating code.</li>
<li>Qwen 3 32B has reasoning, but at 7 tok/s it was too slow. The &ldquo;thinking&rdquo; tokens drag out the generation time.</li>
<li>Gemma 4 31B, with no reasoning trained for agentic use, fell into repetitive tool calling loops.</li>
<li>GLM 5 (cloud, 745B MoE) with reasoning and 44B active parameters, finished cleanly and used the correct API.</li>
</ul>
<p>There&rsquo;s a trade-off: reasoning consumes extra tokens (the <code>&lt;think&gt;</code> blocks), which take up VRAM in the KV Cache and slow generation down. That&rsquo;s why flags like <code>--reasoning-format none</code> are needed in llama.cpp. Some clients don&rsquo;t know what to do with reasoning tokens and break. Models that emit reasoning when the runtime isn&rsquo;t expecting it can produce garbage in the output.</p>
<p>And reasoning isn&rsquo;t something you &ldquo;turn on&rdquo; in any model. It&rsquo;s a capability trained with reinforcement learning on top of the base model, using data from problems that require multi-step thinking. The smaller open source models (20B-35B) typically didn&rsquo;t go through that training, or went through it on a smaller scale. They know how to generate code, but they don&rsquo;t know how to <em>plan</em> code. On tasks that require 50+ coordinated tool calls, that difference is fatal.</p>
<h2>The benchmark: methodology<span class="hx:absolute hx:-mt-20" id="the-benchmark-methodology"></span>
    <a href="#the-benchmark-methodology" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>To compare models fairly, I built an automated harness in Python. Each model gets the exact same prompt: build a complete Ruby on Rails application, a ChatGPT-style chat SPA using the RubyLLM gem, with Hotwire/Stimulus/Turbo Streams, Tailwind CSS, Minitest tests, CI tools (Brakeman, RuboCop, SimpleCov, bundle-audit), Dockerfile, docker-compose and README.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/05/llm-benchmark/crush-screenshot.png" alt="Crush — coding CLI from Charm"  loading="lazy" /></p>
<p>The runner is <a href="https://github.com/opencode-ai/opencode"target="_blank" rel="noopener">opencode</a>, which at the time of the benchmark was the most popular open source coding CLI, competing with Claude Code and Codex. Since then the project has been archived and development continued as <a href="https://github.com/charmbracelet/crush"target="_blank" rel="noopener">Crush</a>, maintained by the original author together with the <a href="https://charm.sh/"target="_blank" rel="noopener">Charm</a> team (the folks behind Bubble Tea, Lip Gloss and several other Go terminal tools). If you read <a href="/en/2026/01/09/omarchy-3-one-of-the-best-coding-agents-crush/">my piece on Crush</a>, you already know it. Crush inherits everything from opencode — support for 75+ providers, LSP, MCP, persistent sessions — and adds the polished aesthetic that&rsquo;s a Charm trademark. It runs everywhere: macOS, Linux, Windows, Android, FreeBSD.</p>
<p>I actually tried to use Crush for the benchmark first. The problem: it advertised a <code>--yolo</code> flag in its help to auto-approve every action (essential for unattended automated runs), but at runtime it rejected the flag. Without auto-approve there&rsquo;s no way to do an unattended benchmark. opencode, on the other hand, had the <code>opencode run --agent build --format json</code> mode that emits JSON events with session IDs and token counts, perfect for automation. So we went with opencode.</p>
<p>I picked opencode (and not Claude Code or Codex) for two reasons:</p>
<ol>
<li>Neutrality. Claude Code is optimized for Anthropic models. Codex is optimized for OpenAI models. opencode is agnostic, same interface for all.</li>
<li>Automation. opencode exposes a machine-readable JSON format. Claude Code and Codex don&rsquo;t have an equivalent interface for external benchmarking.</li>
</ol>
<p>Cloud models ran in two phases: phase 1 (build the app) and phase 2 (validate local boot, docker build, docker compose). Local models only ran phase 1.</p>
<p>Worth mentioning: the entire benchmark cost less than $10 in tokens on OpenRouter. Apart from GPT 5.4 Pro which torched $7.20 to fail, the other 11 cloud models added up to about $2.50 total. Local models cost only electricity. The point is: running your own benchmark is cheap. If you want to know whether a model works for your use case, drop the $2 and test it. The harness code is on GitHub, just swap the prompt for your own project.</p>
<h2>Why GPT 5.4 failed the benchmark (but not in real life)<span class="hx:absolute hx:-mt-20" id="why-gpt-54-failed-the-benchmark-but-not-in-real-life"></span>
    <a href="#why-gpt-54-failed-the-benchmark-but-not-in-real-life" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>GPT 5.4 Pro is the only cloud model that consistently failed our benchmark. Two separate runs, same result: the model generated files but never reached <code>finish_reason: stop</code>. It always ended on <code>finish_reason: tool-calls</code> — wanted to keep calling tools but the loop kept breaking.</p>
<p>For folks who don&rsquo;t know: tool calling is when an LLM needs to perform an action (read a file, run a command, edit code) and emits a &ldquo;tool call&rdquo; in a structured format. The client (opencode, Claude Code, Codex) interprets it, executes it, and returns the result back to the model. Each provider has its own format: Anthropic uses <code>tool_use</code> blocks, OpenAI uses <code>function_calling</code> with proprietary JSON schemas, Google uses <code>FunctionCall</code>.</p>
<p>GPT 5.4 is heavily trained for OpenAI&rsquo;s native function calling format — <code>tool_choice</code>, <code>tools</code> with proprietary JSON schemas. When the benchmark routes through opencode → OpenRouter → GPT 5.4, the tool schemas get translated at every hop. If GPT emits tool calls in a format that OpenRouter or opencode doesn&rsquo;t parse correctly, the agent loop breaks.</p>
<p>The evidence: every other cloud model (Claude Opus, Claude Sonnet, Kimi K2.5, DeepSeek V3.2, MiniMax M2.7, GLM 5, Qwen 3.6 Plus, Step 3.5 Flash) ended on <code>finish_reason: stop</code>. Only GPT ends on <code>finish_reason: tool-calls</code>.</p>
<p>A fair comparison for GPT 5.4 would require running it in its native environment — Codex or ChatGPT Pro ($200/month). On opencode through OpenRouter, this isn&rsquo;t a fair test of GPT&rsquo;s coding ability. That said, I used Codex extensively during my vibe coding marathon and I can vouch that GPT 5.4 is as good as Opus for real projects. In some ways I actually prefer Codex: it tends to think more &ldquo;outside the box&rdquo; and arrives at more creative solutions than Opus. On the other hand, it&rsquo;s less disciplined — tends to forget previous instructions in long sessions and sometimes wanders off scope. Opus is more predictable and methodical. For me, that predictability is worth more day to day.</p>
<p>Sonnet and Opus through opencode/OpenRouter were probably also not pushed to their limits. Claude Code offers native tool support that opencode doesn&rsquo;t replicate — meaning the benchmark results represent a floor, not a ceiling, for those models.</p>
<h2>Open source models: reality vs the narrative<span class="hx:absolute hx:-mt-20" id="open-source-models-reality-vs-the-narrative"></span>
    <a href="#open-source-models-reality-vs-the-narrative" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>A lot of people are saying open source models have already caught up with the commercial ones and you can run your own &ldquo;Claude&rdquo; at home. In practice, not really.</p>
<p>The scale isn&rsquo;t comparable. Frontier models like Claude Opus 4.6 and GPT 5.4 are closed-source, but estimates put them in the hundreds of billions to trillions of parameters range, trained with compute and data no open source company can replicate. The best models that fit on reasonable hardware are:</p>
<table>
  <thead>
      <tr>
          <th>Model</th>
          <th style="text-align: right">Total Parameters</th>
          <th style="text-align: right">Active Parameters</th>
          <th>Architecture</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Qwen 3.5 35B-A3B</td>
          <td style="text-align: right">35B</td>
          <td style="text-align: right">3B</td>
          <td>MoE (A3B)</td>
      </tr>
      <tr>
          <td>Qwen 3.5 27B</td>
          <td style="text-align: right">27B</td>
          <td style="text-align: right">27B</td>
          <td>Dense</td>
      </tr>
      <tr>
          <td>Qwen 3 32B</td>
          <td style="text-align: right">32B</td>
          <td style="text-align: right">32B</td>
          <td>Dense</td>
      </tr>
      <tr>
          <td>Qwen 3.5 122B</td>
          <td style="text-align: right">122B</td>
          <td style="text-align: right">122B</td>
          <td>Dense</td>
      </tr>
      <tr>
          <td>GPT OSS 20B</td>
          <td style="text-align: right">20B</td>
          <td style="text-align: right">20B</td>
          <td>Dense</td>
      </tr>
      <tr>
          <td>Gemma 4 31B</td>
          <td style="text-align: right">31B</td>
          <td style="text-align: right">31B</td>
          <td>Dense</td>
      </tr>
  </tbody>
</table>
<p>Post-publication correction: Qwen 3.5 35B is actually the <strong>35B-A3B</strong>, an MoE with only 3B active parameters per token (not dense, as I&rsquo;d originally written). That&rsquo;s why it runs relatively fast for its size. And for folks with 24 GB of VRAM, the model recommended by <a href="https://unsloth.ai/docs/models/qwen3.5#qwen3.5-27b"target="_blank" rel="noopener">Unsloth</a> themselves is the <strong>Qwen 3.5 27B</strong> dense — that one I didn&rsquo;t get around to testing in the benchmark, but it&rsquo;s worth a look. For anyone wanting to dig deeper into local models, <a href="https://x.com/sudoingX"target="_blank" rel="noopener">@sudoingX</a> has been doing some serious experimentation in this space. Thanks to <a href="https://x.com/thpmacedo/status/2041105305111502927"target="_blank" rel="noopener">@thpmacedo</a> for the heads-up.</p>
<p>Even the largest open source MoE (Mixture of Experts) models that companies make publicly available activate only a small fraction of parameters per token:</p>
<table>
  <thead>
      <tr>
          <th>Model</th>
          <th style="text-align: right">Total Parameters</th>
          <th style="text-align: right">Active Parameters</th>
          <th>Notes</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Kimi K2.5</td>
          <td style="text-align: right">1T</td>
          <td style="text-align: right">32B</td>
          <td>384 experts, top-8 + shared</td>
      </tr>
      <tr>
          <td>GLM 5</td>
          <td style="text-align: right">745B</td>
          <td style="text-align: right">44B</td>
          <td>256 experts, 8 activated</td>
      </tr>
      <tr>
          <td>DeepSeek V3.2</td>
          <td style="text-align: right">671B</td>
          <td style="text-align: right">37B</td>
          <td>Sparse Attention</td>
      </tr>
      <tr>
          <td>Qwen 3.5 397B</td>
          <td style="text-align: right">397B</td>
          <td style="text-align: right">17B</td>
          <td>MoE, cloud-only</td>
      </tr>
  </tbody>
</table>
<p>These large models aren&rsquo;t self-hostable. Kimi K2.5 with 1T parameters needs GPU clusters with hundreds of GBs of VRAM. GLM 5 with 745B is the same. Even if Alibaba or Z.AI release the weights (and some do), nobody has home hardware to run them.</p>
<p>What fits on your home GPU are the 20B-35B models — and those have real limitations.</p>
<h3>What each local model did in the benchmark<span class="hx:absolute hx:-mt-20" id="what-each-local-model-did-in-the-benchmark"></span>
    <a href="#what-each-local-model-did-in-the-benchmark" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Results from the original run on the AMD Strix Halo:</p>
<p><strong>Qwen 3 Coder Next (30B)</strong> — Completed in 17 minutes on the Strix, generated 1675 files, Rails app with all the artifacts. But only 3 tests. And more importantly: it invented <code>RubyLLM::Client.new</code>, a class that doesn&rsquo;t exist in the gem. The app doesn&rsquo;t run.</p>
<p><strong>Qwen 3.5 35B</strong> — Completed in 28 minutes on the Strix, 1478 files, 11 tests. Used <code>RubyLLM.chat</code> without a model parameter — works only if the default is configured. No LLM mocking in the tests.</p>
<p><strong>Qwen 3.5 122B</strong> — Completed in 43 minutes on the Strix, 1503 files, 16 tests. But it ignored the RubyLLM gem completely and built a custom HTTP client for OpenRouter. The prompt explicitly asked for ruby_llm.</p>
<p><strong>GLM 4.7 Flash (local, Strix)</strong> — Produced 2029 files with all the artifacts, but the session ended mid-tool-call. The cloud version (GLM 5) works perfectly.</p>
<p><strong>Gemma 4 31B (Strix)</strong> — Infinite tool call loop after ~11 productive steps. Known llama.cpp bug.</p>
<p><strong>GPT OSS 20B (Strix)</strong> — Created the Rails app in the wrong directory (<code>project/app/</code> instead of <code>project/</code>). A 20B model doesn&rsquo;t follow workspace instructions reliably.</p>
<p><strong>Qwen 3 32B (Strix)</strong> — Way too slow (7.3 tok/s). The hardware can&rsquo;t keep up.</p>
<p>And the results from the rerun on the NVIDIA RTX 5090 (all with Q3_K_M or Q4_K_M and contexts between 64k and 128k to fit the 32 GB of VRAM):</p>
<p><strong>Qwen 3.5 35B-A3B (5090)</strong> — 5 minutes at 273 tok/s. Recognizable Rails project, entry point <code>RubyLLM.chat(model:)</code> is right, but it hallucinates <code>chat.add_message(role:, content:)</code> and <code>chat.complete</code> instead of <code>.ask</code>. Fixable in 1-2 follow-ups. The best candidate for &ldquo;OSS local that&rsquo;s actually worth trying.&rdquo;</p>
<p><strong>Qwen 3.5 27B Claude-distilled (5090)</strong> — 12 minutes at 129 tok/s. Impeccable Claude style, total API hallucination (<code>RubyLLM::Chat.new.with_model{}</code>, <code>add_message</code>, <code>response.text</code>). More details in the distillation section below.</p>
<p><strong>Qwen 3 Coder 30B (5090)</strong> — 6 minutes at 145 tok/s. Returned a hardcoded mock string instead of calling the API. Tier 3 unusable.</p>
<p><strong>Qwen 2.5 Coder 32B (5090)</strong> — 90 minutes of timeout, zero files. The model spun without ever calling a write tool.</p>
<p><strong>Qwen 3 32B (5090)</strong> — 4 minutes at 69 tok/s, partial scaffold, errors. The general version is better than the Coder one but still breaks.</p>
<p><strong>Gemma 4 31B (5090)</strong> — 8 minutes at 213 tok/s. Same repetition loop it had on the Strix. The llama.cpp bug isn&rsquo;t a hardware issue.</p>
<p><strong>Qwen 3.5 27B Sushi Coder RL (5090)</strong> — Infrastructure failure (<code>ProviderModelNotFoundError</code>), couldn&rsquo;t be evaluated. Redo on a future run.</p>
<p><strong>GPT OSS 20B (5090)</strong> — Pulled from this run because of a recent llama.cpp main regression in the harmony family tool call parser. The logs show <code>Failed to parse input at pos 755: &lt;|channel|&gt;...</code> in multi-turn sessions. It worked on the Strix with llama.cpp <code>b8643</code>, broken on today&rsquo;s main. Waiting on upstream to fix it.</p>
<h2>Cloud models: what actually works<span class="hx:absolute hx:-mt-20" id="cloud-models-what-actually-works"></span>
    <a href="#cloud-models-what-actually-works" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Of the 12 models that completed the benchmark, all of them generated a recognizable Rails project with all the requested artifacts (Gemfile, routes, views, JS, tests, README, Dockerfile, docker-compose). 9 out of 9 on the completeness checklist.</p>
<p>But here comes the question that matters: does the code run?</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/05/llm-benchmark/en/cost-vs-quality.png" alt="Cost vs time — and does the code work?"  loading="lazy" /></p>
<p>The correct RubyLLM API is simple:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="n">chat</span> <span class="o">=</span> <span class="no">RubyLLM</span><span class="o">.</span><span class="n">chat</span><span class="p">(</span><span class="ss">model</span><span class="p">:</span> <span class="s2">&#34;anthropic/claude-sonnet-4&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">response</span> <span class="o">=</span> <span class="n">chat</span><span class="o">.</span><span class="n">ask</span><span class="p">(</span><span class="s2">&#34;Hello&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">response</span><span class="o">.</span><span class="n">content</span>  <span class="c1"># =&gt; &#34;Hi there!&#34;</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>8 of the 12 models invented APIs that don&rsquo;t exist. The most common pattern: hallucinating an interface that doesn&rsquo;t match the actual gem:</p>
<table>
  <thead>
      <tr>
          <th>Model</th>
          <th>What It Invented</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>DeepSeek V3.2</td>
          <td><code>RubyLLM::Client.new</code> — nonexistent class</td>
      </tr>
      <tr>
          <td>Qwen 3 Coder Next</td>
          <td><code>RubyLLM::Client.new</code> — same error</td>
      </tr>
      <tr>
          <td>Qwen 3.5 122B</td>
          <td><code>Openrouter::Client</code> — nonexistent gem</td>
      </tr>
      <tr>
          <td>Kimi K2.5</td>
          <td><code>add_message()</code> and <code>complete()</code> — nonexistent methods</td>
      </tr>
      <tr>
          <td>MiniMax M2.7</td>
          <td><code>RubyLLM.chat(messages: [...])</code> — nonexistent signature</td>
      </tr>
      <tr>
          <td>Qwen 3.6 Plus</td>
          <td><code>chat.add_message()</code> — nonexistent method</td>
      </tr>
      <tr>
          <td>Gemini 3.1 Pro</td>
          <td><code>RubyLLM::Chat.new()</code> and <code>add_message()</code> — internal API, not public</td>
      </tr>
      <tr>
          <td>Grok 4.20</td>
          <td>Ignores the gem completely — uses <code>OpenAI::Client</code> (ruby-openai) hitting the OpenRouter URL directly</td>
      </tr>
  </tbody>
</table>
<p>The models that got it right — both Claudes, GLM 5 and GLM 5.1 — used the simple two-step pattern (<code>chat = RubyLLM.chat(model:)</code> then <code>chat.ask(message)</code>). The ones that got it wrong tried to make RubyLLM look like the OpenAI Python SDK, which is a different thing. Grok 4.20 was the most brazen case: it didn&rsquo;t even try to use the gem, it went straight for <code>OpenAI::Client</code> pointing at the OpenRouter URL, ignoring the explicit prompt.</p>
<p>And the tests? Only Opus, Sonnet, GLM 5 and GLM 5.1 did proper mocking of the LLM calls. All the others either hit the real API (which fails without a key) or mocked the invented API (tests pass but prove nothing). Test count is a misleading metric: Kimi K2.5 wrote 37 tests, more than anyone else, but none of them test real functionality because the API it uses doesn&rsquo;t exist.</p>
<h3>Real viability table<span class="hx:absolute hx:-mt-20" id="real-viability-table"></span>
    <a href="#real-viability-table" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><table>
  <thead>
      <tr>
          <th>Model</th>
          <th style="text-align: center">Correct API?</th>
          <th style="text-align: center">Runs?</th>
          <th style="text-align: center">Test Mocking?</th>
          <th>Problem</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><strong>Claude Sonnet 4.6</strong></td>
          <td style="text-align: center">Yes</td>
          <td style="text-align: center"><strong>Yes</strong></td>
          <td style="text-align: center">Yes (mocha)</td>
          <td>Clean implementation</td>
      </tr>
      <tr>
          <td><strong>Claude Opus 4.6</strong></td>
          <td style="text-align: center">Yes</td>
          <td style="text-align: center"><strong>Yes</strong></td>
          <td style="text-align: center">Yes (mocha)</td>
          <td>Clean implementation</td>
      </tr>
      <tr>
          <td><strong>GLM 5</strong></td>
          <td style="text-align: center">Yes</td>
          <td style="text-align: center"><strong>Yes</strong></td>
          <td style="text-align: center">Yes (mocha)</td>
          <td>Correct API, works</td>
      </tr>
      <tr>
          <td><strong>GLM 5.1</strong></td>
          <td style="text-align: center">Yes</td>
          <td style="text-align: center"><strong>Yes</strong></td>
          <td style="text-align: center">Yes</td>
          <td>Correct API, works</td>
      </tr>
      <tr>
          <td>Step 3.5 Flash</td>
          <td style="text-align: center">N/A</td>
          <td style="text-align: center"><strong>Yes</strong>*</td>
          <td style="text-align: center">No</td>
          <td>Bypasses RubyLLM, uses HTTP directly</td>
      </tr>
      <tr>
          <td>Grok 4.20</td>
          <td style="text-align: center">N/A</td>
          <td style="text-align: center"><strong>Yes</strong>*</td>
          <td style="text-align: center">No</td>
          <td>Bypasses RubyLLM, uses <code>OpenAI::Client</code> directly</td>
      </tr>
      <tr>
          <td>Qwen 3.6 Plus</td>
          <td style="text-align: center">Partial</td>
          <td style="text-align: center">Only 1st msg</td>
          <td style="text-align: center">No</td>
          <td><code>add_message()</code> doesn&rsquo;t exist</td>
      </tr>
      <tr>
          <td>Qwen 3.5 35B</td>
          <td style="text-align: center">Partial</td>
          <td style="text-align: center">Maybe</td>
          <td style="text-align: center">No</td>
          <td>No model parameter</td>
      </tr>
      <tr>
          <td>Kimi K2.5</td>
          <td style="text-align: center">No</td>
          <td style="text-align: center"><strong>No</strong></td>
          <td style="text-align: center">No</td>
          <td><code>add_message()</code>/<code>complete()</code> invented</td>
      </tr>
      <tr>
          <td>MiniMax M2.7</td>
          <td style="text-align: center">No</td>
          <td style="text-align: center"><strong>No</strong></td>
          <td style="text-align: center">No</td>
          <td><code>RubyLLM.chat</code> signature wrong</td>
      </tr>
      <tr>
          <td>DeepSeek V3.2</td>
          <td style="text-align: center">No</td>
          <td style="text-align: center"><strong>No</strong></td>
          <td style="text-align: center">No</td>
          <td><code>RubyLLM::Client</code> nonexistent</td>
      </tr>
      <tr>
          <td>Qwen 3 Coder Next</td>
          <td style="text-align: center">No</td>
          <td style="text-align: center"><strong>No</strong></td>
          <td style="text-align: center">No</td>
          <td><code>RubyLLM::Client</code> nonexistent</td>
      </tr>
      <tr>
          <td>Gemini 3.1 Pro</td>
          <td style="text-align: center">No</td>
          <td style="text-align: center"><strong>No</strong></td>
          <td style="text-align: center">Wrong mock</td>
          <td><code>RubyLLM::Chat.new()</code> and <code>add_message()</code> don&rsquo;t exist</td>
      </tr>
      <tr>
          <td>Qwen 3.5 122B</td>
          <td style="text-align: center">No</td>
          <td style="text-align: center"><strong>No</strong></td>
          <td style="text-align: center">No</td>
          <td><code>Openrouter::Client</code> gem doesn&rsquo;t exist</td>
      </tr>
  </tbody>
</table>
<p>*Step 3.5 Flash works by calling the OpenRouter REST API directly with <code>Net::HTTP</code>, completely bypassing the gem the prompt asked for.</p>
<p>Now, this doesn&rsquo;t mean those models are useless. If you take Kimi K2.5 or DeepSeek V3.2 and tell it &ldquo;the RubyLLM::Client class doesn&rsquo;t exist, fix it to use the gem&rsquo;s real API&rdquo;, it&rsquo;ll probably fix it. One or two follow-ups and the project becomes functional. Most of the models that failed here could deliver a working project with a few more rounds of conversation.</p>
<p>But that&rsquo;s where the trade-off lives. With Opus or GPT 5.4, the first output already works. You ask, they deliver, you test, it runs. With the cheaper models, you&rsquo;ll spend time fixing API hallucinations, debugging code that &ldquo;looks right&rdquo; but crashes, steering the model in the right direction. Each of those rounds is 10-30 minutes. Three extra rounds and you&rsquo;ve spent an hour of your time to save $0.90 in tokens.</p>
<p>You save dollars, you spend time. And time is money. For someone learning or exploring without urgency, that trade can make sense. For someone who needs to ship, the frontier models pay for themselves fast.</p>
<h3>Comparing the models that work<span class="hx:absolute hx:-mt-20" id="comparing-the-models-that-work"></span>
    <a href="#comparing-the-models-that-work" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><table>
  <thead>
      <tr>
          <th>Model</th>
          <th>Provider</th>
          <th style="text-align: right">Time</th>
          <th style="text-align: right">Tests</th>
          <th style="text-align: right">Cost/Run</th>
          <th>vs Opus</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Claude Sonnet 4.6</td>
          <td>OpenRouter</td>
          <td style="text-align: right">16m</td>
          <td style="text-align: right">30</td>
          <td style="text-align: right">~$0.63</td>
          <td>40% cheaper, more tests</td>
      </tr>
      <tr>
          <td>Claude Opus 4.6</td>
          <td>OpenRouter</td>
          <td style="text-align: right">16m</td>
          <td style="text-align: right">16</td>
          <td style="text-align: right">~$1.05</td>
          <td>Baseline</td>
      </tr>
      <tr>
          <td>GLM 5</td>
          <td>OpenRouter</td>
          <td style="text-align: right">17m</td>
          <td style="text-align: right">7</td>
          <td style="text-align: right">~$0.11</td>
          <td>89% cheaper</td>
      </tr>
      <tr>
          <td>GLM 5.1</td>
          <td>Z.AI direct</td>
          <td style="text-align: right">22m</td>
          <td style="text-align: right">24</td>
          <td style="text-align: right">~$0.13</td>
          <td>~88% cheaper, more tests than GLM 5</td>
      </tr>
  </tbody>
</table>
<h3>Full ranking by time and tokens<span class="hx:absolute hx:-mt-20" id="full-ranking-by-time-and-tokens"></span>
    <a href="#full-ranking-by-time-and-tokens" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/05/llm-benchmark/en/time-to-complete.png" alt="Time to complete by model"  loading="lazy" /></p>
<table>
  <thead>
      <tr>
          <th>Model</th>
          <th>Provider</th>
          <th style="text-align: right">Time</th>
          <th style="text-align: right">Total Tokens</th>
          <th style="text-align: right">Tok/s</th>
          <th style="text-align: right">Cost/Run</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Grok 4.20</td>
          <td>OpenRouter</td>
          <td style="text-align: right">8m</td>
          <td style="text-align: right">63,457</td>
          <td style="text-align: right">412.54</td>
          <td style="text-align: right">~$0.04</td>
      </tr>
      <tr>
          <td>Gemini 3.1 Pro</td>
          <td>OpenRouter</td>
          <td style="text-align: right">14m</td>
          <td style="text-align: right">104,034</td>
          <td style="text-align: right">128.28</td>
          <td style="text-align: right">~$0.50</td>
      </tr>
      <tr>
          <td>MiniMax M2.7</td>
          <td>OpenRouter</td>
          <td style="text-align: right">14m</td>
          <td style="text-align: right">79,743</td>
          <td style="text-align: right">574.52</td>
          <td style="text-align: right">~$0.05</td>
      </tr>
      <tr>
          <td>Claude Opus 4.6</td>
          <td>OpenRouter</td>
          <td style="text-align: right">16m</td>
          <td style="text-align: right">136,806</td>
          <td style="text-align: right">347.18</td>
          <td style="text-align: right">~$1.05</td>
      </tr>
      <tr>
          <td>Claude Sonnet 4.6</td>
          <td>OpenRouter</td>
          <td style="text-align: right">16m</td>
          <td style="text-align: right">127,067</td>
          <td style="text-align: right">532.26</td>
          <td style="text-align: right">~$0.63</td>
      </tr>
      <tr>
          <td>GLM 5</td>
          <td>OpenRouter</td>
          <td style="text-align: right">17m</td>
          <td style="text-align: right">59,378</td>
          <td style="text-align: right">400.01</td>
          <td style="text-align: right">~$0.11</td>
      </tr>
      <tr>
          <td>Qwen 3.6 Plus</td>
          <td>OpenRouter</td>
          <td style="text-align: right">17m</td>
          <td style="text-align: right">88,940</td>
          <td style="text-align: right">182.91</td>
          <td style="text-align: right">Free</td>
      </tr>
      <tr>
          <td>GLM 5.1</td>
          <td>Z.AI direct</td>
          <td style="text-align: right">22m</td>
          <td style="text-align: right">81,666</td>
          <td style="text-align: right">166.62</td>
          <td style="text-align: right">~$0.13</td>
      </tr>
      <tr>
          <td>Qwen 3 Coder Next</td>
          <td>Local</td>
          <td style="text-align: right">17m</td>
          <td style="text-align: right">39,054</td>
          <td style="text-align: right">37.49</td>
          <td style="text-align: right">Electricity</td>
      </tr>
      <tr>
          <td>Qwen 3.5 35B</td>
          <td>Local</td>
          <td style="text-align: right">28m</td>
          <td style="text-align: right">76,919</td>
          <td style="text-align: right">46.03</td>
          <td style="text-align: right">Electricity</td>
      </tr>
      <tr>
          <td>Kimi K2.5</td>
          <td>OpenRouter</td>
          <td style="text-align: right">29m</td>
          <td style="text-align: right">63,638</td>
          <td style="text-align: right">160.14</td>
          <td style="text-align: right">~$0.07</td>
      </tr>
      <tr>
          <td>Step 3.5 Flash</td>
          <td>OpenRouter</td>
          <td style="text-align: right">38m</td>
          <td style="text-align: right">156,267</td>
          <td style="text-align: right">242.11</td>
          <td style="text-align: right">~$0.02</td>
      </tr>
      <tr>
          <td>Qwen 3.5 122B</td>
          <td>Local</td>
          <td style="text-align: right">43m</td>
          <td style="text-align: right">57,472</td>
          <td style="text-align: right">22.41</td>
          <td style="text-align: right">Electricity</td>
      </tr>
      <tr>
          <td>DeepSeek V3.2</td>
          <td>OpenRouter</td>
          <td style="text-align: right">60m</td>
          <td style="text-align: right">115,278</td>
          <td style="text-align: right">53.37</td>
          <td style="text-align: right">~$0.04</td>
      </tr>
  </tbody>
</table>
<p>DeepSeek V3.2 is the slowest despite being cloud — it has no prompt caching, so it resends the full context on every turn.</p>
<h3>Token efficiency and cache<span class="hx:absolute hx:-mt-20" id="token-efficiency-and-cache"></span>
    <a href="#token-efficiency-and-cache" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Models with prompt caching pay much less in effective tokens:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/05/llm-benchmark/en/token-efficiency.png" alt="Token efficiency: cache vs new"  loading="lazy" /></p>
<table>
  <thead>
      <tr>
          <th>Model</th>
          <th style="text-align: right">Total Tokens</th>
          <th style="text-align: right">Cache Read</th>
          <th style="text-align: right">Effective New Tokens</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Claude Sonnet 4.6</td>
          <td style="text-align: right">127,067</td>
          <td style="text-align: right">126,429</td>
          <td style="text-align: right">638</td>
      </tr>
      <tr>
          <td>Claude Opus 4.6</td>
          <td style="text-align: right">136,806</td>
          <td style="text-align: right">135,976</td>
          <td style="text-align: right">830</td>
      </tr>
      <tr>
          <td>GLM 5</td>
          <td style="text-align: right">59,378</td>
          <td style="text-align: right">58,240</td>
          <td style="text-align: right">1,138</td>
      </tr>
      <tr>
          <td>GLM 5.1</td>
          <td style="text-align: right">81,666</td>
          <td style="text-align: right">81,216</td>
          <td style="text-align: right">450</td>
      </tr>
      <tr>
          <td>Grok 4.20</td>
          <td style="text-align: right">63,457</td>
          <td style="text-align: right">62,400</td>
          <td style="text-align: right">1,057</td>
      </tr>
      <tr>
          <td>Gemini 3.1 Pro</td>
          <td style="text-align: right">104,034</td>
          <td style="text-align: right">98,129</td>
          <td style="text-align: right">5,905</td>
      </tr>
      <tr>
          <td>DeepSeek V3.2</td>
          <td style="text-align: right">115,278</td>
          <td style="text-align: right">0</td>
          <td style="text-align: right">115,278</td>
      </tr>
      <tr>
          <td>Kimi K2.5</td>
          <td style="text-align: right">63,638</td>
          <td style="text-align: right">0</td>
          <td style="text-align: right">63,638</td>
      </tr>
  </tbody>
</table>
<h2>Speed: the chasm between cloud and local<span class="hx:absolute hx:-mt-20" id="speed-the-chasm-between-cloud-and-local"></span>
    <a href="#speed-the-chasm-between-cloud-and-local" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>There&rsquo;s an aspect that the cost tables hide: inference speed. And the difference is brutal.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/05/llm-benchmark/en/speed-comparison.png" alt="Inference speed by model"  loading="lazy" /></p>
<p>Claude Sonnet generates 532 tok/s. Qwen 3.5 122B running locally on my Minisforum (AMD Strix Halo) generates 22 tok/s. That&rsquo;s a 24x difference. In practice, what Sonnet does in 16 minutes, Qwen 3.5 122B takes 43 minutes. Qwen 3 Coder Next at 37 tok/s is the fastest of the local models on the Strix and even so it&rsquo;s 14x slower than Sonnet.</p>
<p>And it&rsquo;s not just clock time. When you&rsquo;re in an interactive coding loop — ask for a change, wait for output, test, ask for another — the model&rsquo;s speed sets your rhythm. At 37 tok/s, every long response makes you wait 30-60 seconds. At 530 tok/s, it appears almost instantly. Over a day, you feel it.</p>
<p>DeepSeek V3.2 is a curious case: it&rsquo;s cloud but it runs at 53 tok/s, slower than the locally-running Qwen 3.5 35B on the Strix (46 tok/s). The reason is that DeepSeek has no prompt caching — it resends the full context on every turn, strangling throughput. Paying for a cloud model that&rsquo;s slower than running it locally doesn&rsquo;t make any sense.</p>
<p>Local models are free in tokens, but they pay in time. On the AMD Strix, that math was a non-starter for every Qwen I tested: two minutes waiting for a long response, multiplied by 50 turns, eats your whole afternoon. But that changes when the hardware changes, and that&rsquo;s why I redid the local part of the benchmark on a different machine.</p>
<h2>AMD Strix Halo vs NVIDIA RTX 5090: what changes when the memory bandwidth doubles<span class="hx:absolute hx:-mt-20" id="amd-strix-halo-vs-nvidia-rtx-5090-what-changes-when-the-memory-bandwidth-doubles"></span>
    <a href="#amd-strix-halo-vs-nvidia-rtx-5090-what-changes-when-the-memory-bandwidth-doubles" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>To check whether the bottleneck was hardware or model, I took the same Qwen models and reran the benchmark on a workstation with an NVIDIA RTX 5090 (Blackwell, 32 GB GDDR7, 1,792 GB/s bandwidth). The numbers shift in a way that&rsquo;s worth looking at carefully.</p>
<table>
  <thead>
      <tr>
          <th>Model</th>
          <th style="text-align: right">AMD Strix (LPDDR5x)</th>
          <th style="text-align: right">NVIDIA 5090 (GDDR7)</th>
          <th style="text-align: right">Speedup</th>
          <th style="text-align: right">Total time on 5090</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Qwen 3 32B (dense)</td>
          <td style="text-align: right">7 tok/s</td>
          <td style="text-align: right">69 tok/s</td>
          <td style="text-align: right">~10x</td>
          <td style="text-align: right">4 min</td>
      </tr>
      <tr>
          <td>Qwen 3 Coder 30B (Coder)</td>
          <td style="text-align: right">37 tok/s</td>
          <td style="text-align: right">145 tok/s</td>
          <td style="text-align: right">~4x</td>
          <td style="text-align: right">6 min</td>
      </tr>
      <tr>
          <td>Qwen 3.5 35B-A3B (MoE)</td>
          <td style="text-align: right">46 tok/s</td>
          <td style="text-align: right"><strong>273 tok/s</strong></td>
          <td style="text-align: right">~6x</td>
          <td style="text-align: right">5 min</td>
      </tr>
      <tr>
          <td>Qwen 3.5 27B Claude (distilled)</td>
          <td style="text-align: right">timeout 90m</td>
          <td style="text-align: right">129 tok/s</td>
          <td style="text-align: right">n/a</td>
          <td style="text-align: right">12 min</td>
      </tr>
      <tr>
          <td>Gemma 4 31B</td>
          <td style="text-align: right">(didn&rsquo;t test on Strix)</td>
          <td style="text-align: right">213 tok/s</td>
          <td style="text-align: right">n/a</td>
          <td style="text-align: right">8 min</td>
      </tr>
      <tr>
          <td>Qwen 2.5 Coder 32B</td>
          <td style="text-align: right">(didn&rsquo;t test on Strix)</td>
          <td style="text-align: right">2.86 tok/s</td>
          <td style="text-align: right">n/a</td>
          <td style="text-align: right">timeout 90m</td>
      </tr>
  </tbody>
</table>
<p>To put those speeds in context, remember that in the cloud Sonnet runs at 532 tok/s, Opus at 347 tok/s, Step 3.5 Flash at 242 tok/s, Gemini 3.1 Pro at 128 tok/s and Kimi K2.5 at 160 tok/s. Qwen 3.5 35B-A3B on the 5090, at 273 tok/s, is in the same neighborhood as Step 3.5 Flash, faster than Gemini, Kimi and GLM 5.1. Qwen 3 Coder 30B at 145 tok/s is in Gemini territory. The classic line &ldquo;local models are ten times slower than cloud&rdquo; stopped being true the moment the 5090 entered the conversation.</p>
<p>The practical consequence is that the &ldquo;time is money&rdquo; argument shifts. On the Strix, &ldquo;waiting an hour for a Qwen 3.5 122B to do what Sonnet does in 16 minutes&rdquo; is straight-up loss. On the 5090, waiting 5 minutes for Qwen 3.5 35B-A3B to do the work, plus 10-15 minutes for you to do 1-2 correction prompts, gives you a total in the 20-25 minute range. Sonnet does it in 16 minutes with zero corrections. The difference shrank enough that, if cost matters a lot, it&rsquo;s worth it.</p>
<p>The catch: for this to be worth it, the model has to be close enough to the right answer that 1-2 correction prompts can fix it. When the error is &ldquo;the model decided not to use the gem I asked for and returned a hardcoded mock string,&rdquo; like Qwen 3 Coder 30B did, no easy correction prompt fixes that. That&rsquo;s a redo.</p>
<h3>Before you spend money on hardware thinking it&rsquo;s the answer<span class="hx:absolute hx:-mt-20" id="before-you-spend-money-on-hardware-thinking-its-the-answer"></span>
    <a href="#before-you-spend-money-on-hardware-thinking-its-the-answer" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>I&rsquo;ve got to give a warning here, because it&rsquo;s the most common buying mistake I see right now. Every other week somebody tells me they&rsquo;re going to grab a Ryzen AI Max because it has 128 GB of unified memory and that &ldquo;lets you run huge models.&rdquo; Technically, sure — the model fits. In practice, it&rsquo;s almost unusable. The memory is LPDDR5x at 256 GB/s, seven times slower than the 5090&rsquo;s GDDR7. What fits doesn&rsquo;t run at human speed. My own Strix with Qwen 3.5 122B hit 22 tok/s and the run took 43 minutes. To do anything serious day to day, that&rsquo;s not workable.</p>
<p>The 5090 is clearly superior, and it starts to make sense even for smaller models precisely because of the memory bandwidth. A Mac Studio with high-speed unified memory (up to 800 GB/s on the M4 Ultra) is the other viable option, and costs proportionally the same. But neither of those comes anywhere close to beating the commercial models on quality — and the per-token price of Claude, GPT or GLM, combined with their brutal inference speed, makes the math hard to justify for anyone who isn&rsquo;t an enthusiast or a researcher. Expensive local AI hardware is a weekend hobby, a tool for people who need to run offline for compliance reasons, or a research playground. For day-after-day production work, right now, cloud is still the rational choice. A 128 GB Ryzen AI Max may look tempting on the spec sheet, but if the goal is serious coding agent work, it&rsquo;s money badly spent.</p>
<h2>The Qwen family: Coder vs General, distillation, and why nothing is a silver bullet<span class="hx:absolute hx:-mt-20" id="the-qwen-family-coder-vs-general-distillation-and-why-nothing-is-a-silver-bullet"></span>
    <a href="#the-qwen-family-coder-vs-general-distillation-and-why-nothing-is-a-silver-bullet" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>With so many different Qwens running in this rerun, it&rsquo;s worth doing a more focused analysis. What I learned might surprise people who follow model benchmarks on Twitter.</p>
<h3>Before getting to the results: what quantization is and what distillation is<span class="hx:absolute hx:-mt-20" id="before-getting-to-the-results-what-quantization-is-and-what-distillation-is"></span>
    <a href="#before-getting-to-the-results-what-quantization-is-and-what-distillation-is" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>These two concepts come up constantly in this discussion and they deserve a quick explanation.</p>
<p><strong>Quantization</strong> is the technique of compressing the model&rsquo;s weights so they take up less memory. A model trained in FP16 (16 bits per weight) can be quantized to Q8 (8 bits), Q4 (4 bits), Q3_K_M (3 bits, but with medium-sized groupings), and so on. Each step halves the size of the model on disk and in VRAM, at the cost of some loss of precision. Q8 is practically lossless. Q4 already loses something measurable. Q3 loses more. Q2 is the line where the model starts saying real nonsense. The rule of thumb is that for coding and multi-step reasoning, you want to stay at Q4 or higher. Q3_K_M is the minimum that still works for many models, and it&rsquo;s what fits a 27B on the 5090 with 128k context.</p>
<p>The surprise from my test, and look, this goes against the consensus, is that quantization wasn&rsquo;t the bottleneck here. I ran the Qwen 3.5 27B Claude-distilled in two versions: Q8 on the AMD Strix (~27 GB of weights) and Q3_K_M on the 5090 (~12 GB of weights). Both hallucinated exactly the same fake RubyLLM APIs. Q3_K_M even produced a cleaner Gemfile. The model&rsquo;s limitation was in what those weights know, not in the precision they were compressed to.</p>
<p><strong>Distillation</strong> is the technique of training a smaller model (the &ldquo;student&rdquo;) to imitate the output or behavior of a larger model (the &ldquo;teacher&rdquo;). The classic version is logit distillation — the student learns to approximate the teacher&rsquo;s probability distributions. The modern, more popular version for coding agents is distillation of <strong>reasoning traces</strong>: you take chain-of-thought from the big model on real problems and train the smaller one to reproduce the same reasoning style.</p>
<p>The hype of the moment is distilling Claude and GPT into open source models. The promise is that you can have &ldquo;Claude-at-home&rdquo; running locally. I wanted to test this, and that&rsquo;s why I added <a href="https://huggingface.co/Jackrong/Qwen3.5-27B-Claude-4.6-Opus-Reasoning-Distilled"target="_blank" rel="noopener">Jackrong&rsquo;s Qwen 3.5 27B distilled from Claude 4.6 Opus</a> to the benchmark. If any open source model was going to use RubyLLM correctly, this was the bet — after all, in the entire benchmark, Claude and GLM 5 are the only ones that get the API right.</p>
<h3>What the Claude-distilled learned (and what it didn&rsquo;t)<span class="hx:absolute hx:-mt-20" id="what-the-claude-distilled-learned-and-what-it-didnt"></span>
    <a href="#what-the-claude-distilled-learned-and-what-it-didnt" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>I ran the same distillation twice: once at Q8 on the AMD Strix (which blew through the 90-minute timeout), and once at Q3_K_M on the 5090 (completed in 12 minutes). Both produced the same elegant frustration.</p>
<p>The code that comes out looks like Claude. It has <code># frozen_string_literal: true</code> at the top of every file. It has a separate <code>Response</code> class as a value object with explicit attribute readers. It has a clear separation between service, controller and model. It has doc comments at the top of every file. It correctly comments out things like <code>active_record</code>, <code>active_job</code> and <code>action_mailer</code> in <code>application.rb</code>. It has defensive <code>case</code> statements trying multiple return formats. Stylistically, it&rsquo;s Claude.</p>
<p>Functionally, it&rsquo;s a complete RubyLLM hallucination. Look at the service generated by the 5090 run:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="no">RubyLLM</span><span class="o">::</span><span class="no">Chat</span><span class="o">.</span><span class="n">new</span><span class="o">.</span><span class="n">with_model</span><span class="p">(</span><span class="vi">@model</span><span class="p">)</span> <span class="k">do</span> <span class="o">|</span><span class="n">chat</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">  <span class="n">conversation_history</span><span class="o">.</span><span class="n">each</span> <span class="k">do</span> <span class="o">|</span><span class="n">msg</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">    <span class="n">chat</span><span class="o">.</span><span class="n">add_message</span><span class="p">(</span><span class="ss">role</span><span class="p">:</span> <span class="ss">:user</span><span class="p">,</span> <span class="ss">content</span><span class="p">:</span> <span class="n">msg</span><span class="o">[</span><span class="ss">:content</span><span class="o">]</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">  <span class="n">response</span> <span class="o">=</span> <span class="n">chat</span><span class="o">.</span><span class="n">ask</span><span class="p">(</span><span class="n">message</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="no">Response</span><span class="o">.</span><span class="n">new</span><span class="p">(</span><span class="ss">content</span><span class="p">:</span> <span class="n">response</span><span class="o">.</span><span class="n">text</span><span class="p">,</span> <span class="ss">usage</span><span class="p">:</span> <span class="n">build_usage</span><span class="p">(</span><span class="n">response</span><span class="p">))</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Every primitive in this code is invented:</p>
<ul>
<li><code>RubyLLM::Chat.new</code> — the constructor isn&rsquo;t public, the correct entry is <code>RubyLLM.chat(model:)</code></li>
<li><code>.with_model(@model) do |chat| ... end</code> — there&rsquo;s no block API like that</li>
<li><code>chat.add_message(role:, content:)</code> — doesn&rsquo;t exist</li>
<li><code>response.text</code> — the real API exposes <code>response.content</code></li>
<li><code>response.usage.prompt_tokens</code> — the object doesn&rsquo;t have that shape</li>
</ul>
<p>This will blow up with a <code>NoMethodError</code> on the first request. The initializer also tries <code>config.openrouter_api_base=</code> which doesn&rsquo;t exist on <code>RubyLLM.configure</code>, so the app probably won&rsquo;t even boot.</p>
<p>The Q8 version on the AMD Strix does the exact same thing, with one difference: the entry call is <code>RubyLLM.chat(model:, provider: :openrouter)</code> — the entry point is right, but <code>provider:</code> is invented and it&rsquo;s immediately followed by the same fake <code>chat.add_message(role:, content:)</code>. Worse, the Gemfile from the 90-minute run lists <code>gem &quot;ruby-openai&quot;</code> (wrong gem!), <code>gem &quot;minitest&quot;, &quot;~&gt; 6.0&quot;</code> (minitest 6.0 doesn&rsquo;t exist) and <code>gem &quot;tailwindcss&quot;</code> (wrong gem name, it&rsquo;s <code>tailwindcss-rails</code>). The Gemfile doesn&rsquo;t include the gem the service code itself is trying to use.</p>
<p>For comparison, look at the actual Claude Opus 4.6 baseline, in the same benchmark, getting it all right:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="vi">@chat</span> <span class="o">=</span> <span class="no">RubyLLM</span><span class="o">.</span><span class="n">chat</span><span class="p">(</span><span class="ss">model</span><span class="p">:</span> <span class="n">model_id</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">response</span> <span class="o">=</span> <span class="vi">@chat</span><span class="o">.</span><span class="n">ask</span><span class="p">(</span><span class="n">message</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">response</span><span class="o">.</span><span class="n">content</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Twelve lines in the entire service. Zero hallucination. Includes streaming via block. The distilled model produced three times the code volume and got the API wrong.</p>
<p>The honest reading is that distillation transferred one layer and stopped. The layer that came along was the style: code organization, comments, class structure, the order of things. The layer that got left behind was factual memory about specific libraries. That makes sense when you think about it: Claude&rsquo;s reasoning traces, even when written carefully, rarely contain repeated references to <code>chat.ask(msg).content</code> in some obscure Ruby gem. The student only learns what the teacher repeats, and Claude never had any reason to keep whispering &ldquo;use ask, not complete&rdquo; throughout its chains of thought. Library API knowledge is binary recall memory, the kind that&rsquo;s either in the weights or it isn&rsquo;t. Decomposing that into reasoning steps is impossible because it isn&rsquo;t reasoning, it&rsquo;s just raw memorization.</p>
<p>To wrap up the practical recommendation: if you need the model to actually use RubyLLM, or any less-popular library for that matter, Claude distillation won&rsquo;t save you. Use real Claude or GLM 5. The &ldquo;Claude-stand-ins&rdquo; in open source will fail the same way the Qwen base would, just with prettier handwriting.</p>
<h3>Coder vs General: the surprise of the &ldquo;for coding&rdquo; models<span class="hx:absolute hx:-mt-20" id="coder-vs-general-the-surprise-of-the-for-coding-models"></span>
    <a href="#coder-vs-general-the-surprise-of-the-for-coding-models" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Almost everyone&rsquo;s instinct is that models with &ldquo;Coder&rdquo; in the name are the best for programming. Makes sense, they were specifically fine-tuned on code. But in the benchmark, it was exactly the opposite.</p>
<table>
  <thead>
      <tr>
          <th>Model</th>
          <th>Type</th>
          <th>Hardware</th>
          <th style="text-align: right">Time</th>
          <th>Result</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Qwen 3.5 35B-A3B</td>
          <td>General (MoE)</td>
          <td>5090</td>
          <td style="text-align: right">5 min</td>
          <td>Runs Rails, hallucinates <code>add_message</code>/<code>complete</code> (1-2 follow-ups fix it)</td>
      </tr>
      <tr>
          <td>Qwen 3 Coder 30B</td>
          <td>Coder</td>
          <td>5090</td>
          <td style="text-align: right">6 min</td>
          <td>Returned a hardcoded mock string instead of calling RubyLLM</td>
      </tr>
      <tr>
          <td>Qwen 2.5 Coder 32B</td>
          <td>Coder</td>
          <td>5090</td>
          <td style="text-align: right">timeout 90m</td>
          <td>Zero files, model froze</td>
      </tr>
      <tr>
          <td>Qwen 3 32B</td>
          <td>General</td>
          <td>5090</td>
          <td style="text-align: right">4 min</td>
          <td>Partial scaffold, errors</td>
      </tr>
      <tr>
          <td>Qwen 3.5 27B Claude-distilled</td>
          <td>General + distilled</td>
          <td>5090</td>
          <td style="text-align: right">12 min</td>
          <td>Runs Rails, hallucinates the entire API</td>
      </tr>
      <tr>
          <td>Qwen 3.5 27B Sushi Coder RL</td>
          <td>Coder (RL)</td>
          <td>5090</td>
          <td style="text-align: right">6 min</td>
          <td>Infrastructure failure, couldn&rsquo;t be tested</td>
      </tr>
  </tbody>
</table>
<p>Of the three dedicated Coders, two failed catastrophically (full timeout and hardcoded mock string) and one didn&rsquo;t even run properly because of an infra bug. Meanwhile, the Qwen 3.5 35B-A3B, which is the general model in the line (not the Coder), came closest to something usable: 5 minutes of execution, recognizable Rails project, and the problem is fixable in 1-2 prompts.</p>
<p>Qwen 3 Coder 30B is particularly disappointing. It went so far past trying to use the API that it didn&rsquo;t really try at all: the controller it generated has this:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">Api</span><span class="o">::</span><span class="no">V1</span><span class="o">::</span><span class="no">MessagesController</span> <span class="o">&lt;</span> <span class="no">ApplicationController</span>
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">create</span>
</span></span><span class="line"><span class="cl">    <span class="n">render</span> <span class="ss">json</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="ss">response</span><span class="p">:</span> <span class="s2">&#34;This is a mock response. In a real implementation, this would connect to RubyLLM with Claude Sonnet via OpenRouter.&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The Gemfile lists <code>gem &quot;ruby_llm&quot;</code> but nothing imports it. The service layer is nonexistent. The model decided it was easier to return a fake string and call it a day. That&rsquo;s Tier 3 garbage in a way no correction prompt fixes — you have to tell it to start over.</p>
<p>Qwen 2.5 Coder 32B is even worse: 90 minutes running, zero files. The 1.8 MB <code>opencode-output.ndjson</code> shows the model spinning without managing to write anything. It probably got stuck in a planning loop without ever calling the write tools. Total slot waste.</p>
<p>Why did the &ldquo;Coder&rdquo; Qwens do so badly? My read is that the coding-specific fine-tuning they got was trained on more isolated problems (Codeforces, Leetcode, short snippets), far from agentic flows with long-running tool calling. The general Qwen 3.5 35B-A3B has broader training and handles the orchestration part better. The popular intuition &ldquo;Coder = best for coding agent&rdquo; is wrong for this kind of task. The use case where Coders shine is &ldquo;complete an isolated function,&rdquo; which is exactly what they were trained for, and that&rsquo;s a tiny fraction of what a coding agent does day to day.</p>
<h3>The question I wanted to answer<span class="hx:absolute hx:-mt-20" id="the-question-i-wanted-to-answer"></span>
    <a href="#the-question-i-wanted-to-answer" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>It was this: running locally on the 5090, which Qwen model is worth the 1-2 correction prompts to deliver code that works?</p>
<p>The honest answer is: only Qwen 3.5 35B-A3B, and maybe the Claude-distilled if you don&rsquo;t mind spending 12 minutes more.</p>
<ul>
<li>Qwen 3.5 35B-A3B on the 5090: 5 minutes, correct entry point (<code>RubyLLM.chat(model:)</code>), errors on the subsequent calls. Realistic total until it works: in the 15-20 minute range with 1-2 follow-ups. Beats cloud OSS on cost.</li>
<li>Qwen 3.5 27B Claude-distilled on the 5090: 12 minutes, deeper hallucination (entry point is invented too). Realistic total: 25-30 minutes with 2-3 follow-ups. Still competes on cost, and loses on absolute time to the real Claude.</li>
<li>The others (Coder 30B, Coder 2.5 32B, 3 32B): don&rsquo;t pay back the correction time. Each one has a structural problem that calls for a full rewrite from scratch.</li>
</ul>
<p>For folks with hardware in this category who want to escape Anthropic vendor lock-in, it now works. It didn&rsquo;t work on the 5090 from last year, and forget about it on the Strix Halo. In 2026, on NVIDIA Blackwell, with the right model, it works. For folks with low-bandwidth hardware (LPDDR5x, DDR4, DDR5), it&rsquo;s still a waste of time: the clock alone takes down any plan to make this practical.</p>
<h2>The Deep Code Review: Sonnet vs GLM 5 vs Gemini vs Kimi vs MiniMax<span class="hx:absolute hx:-mt-20" id="the-deep-code-review-sonnet-vs-glm-5-vs-gemini-vs-kimi-vs-minimax"></span>
    <a href="#the-deep-code-review-sonnet-vs-glm-5-vs-gemini-vs-kimi-vs-minimax" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The tables above measure structural completeness. But does the project work? I did detailed code review of the models that completed the benchmark.</p>
<p><strong>Claude Sonnet 4.6 — works and is the most complete.</strong> Synchronous responses via Turbo Stream. Chat history persisted in a session cookie with full replay of previous messages on every request. Correct LLM mocking in the tests with mocha (30 tests in 328 lines). LLM logic extracted into a separate <code>LlmChatService</code>. Views decomposed into 9 partials. Minor problems: duplicated model constant, leak in the auto-resize event listener. None are blockers. Of the generated projects, it&rsquo;s the closest to something you&rsquo;d actually put into production.</p>
<p><strong>GLM 5 — works, but it&rsquo;s the bare minimum.</strong> Uses the correct API (<code>RubyLLM.chat(model:)</code> then <code>.ask()</code>), does mocking with mocha in the tests. But the project is way leaner than Sonnet&rsquo;s: 21-line controller (vs Sonnet&rsquo;s 52), no service layer (LLM logic inline in the controller), no chat history persistence, every message handled in isolation. The first message works, but the app doesn&rsquo;t keep conversation context, so you can&rsquo;t have a multi-turn dialog. The tests exist (7 methods) but they&rsquo;re skeletal: <code>ruby_llm_test.rb</code> only checks that the module is loaded, <code>chat_flow_test.rb</code> is a copy of the controller test. The Dockerfile, on the other hand, is the best of the four: multi-stage, non-root, jemalloc. But as a chat app? It&rsquo;s more of a proof of concept than something functional. Funny detail: the README says &ldquo;Powered by Claude Sonnet 4&rdquo; instead of the model that actually generated the project.</p>
<p><strong>Gemini 3.1 Pro — the fastest, but trips on the API.</strong> Completed in 14 minutes, the fastest along with MiniMax. The Rails code itself is well written: uses <code>Rails.cache</code> with session ID and a 2-hour expiration to keep state (instead of a database), Turbo Streams nicely integrated, Stimulus controller for auto-scroll, and the Dockerfile is the best of the group (multi-stage, non-root, jemalloc). The problem is the usual one: it uses <code>RubyLLM::Chat.new()</code> instead of <code>RubyLLM.chat()</code>, and calls <code>add_message()</code> which doesn&rsquo;t exist. The app boots, Docker runs, the health check passes, but the first chat message returns 500. The tests (5 methods) mock with a <code>FakeChat</code> that replicates the wrong signature, so they pass. It&rsquo;s frustrating because the rest of the code is the most &ldquo;Rails way&rdquo; of the non-Anthropic models. Fixing it would be 3 lines, but the benchmark measures what comes out the first time.</p>
<p><strong>Kimi K2.5 — ambitious but broken.</strong> Tried the most sophisticated architecture: ActionCable streaming, configurable models, dual Dockerfiles, 37 tests in 374 lines. Problem: the streaming depends on ActionCable, which is commented out in <code>config/application.rb</code>. The <code>return unless defined?(ActionCable)</code> guard makes the method do nothing. The assistant never responds. The Stimulus controller has a scope bug: <code>submitTarget</code> references a button outside the controller&rsquo;s subtree. Thread-unsafe storage with a hash in a class variable. Kimi wrote more tests than any other model (37), but none of them mock the LLM calls — so the tests pass without proving any of the functionality works.</p>
<p><strong>Grok 4.20 — fast and wrong.</strong> It was the fastest in the entire benchmark: 8 minutes, 412 tok/s. Except it was fast because it cut corners. The prompt explicitly asked for the <code>ruby_llm</code> gem, and Grok ignored it. It went straight for <code>OpenAI::Client</code> from the <code>ruby-openai</code> gem pointing at the OpenRouter URL. Technically the first message comes back, so yeah, it &ldquo;works.&rdquo; But it&rsquo;s the same trick as Step 3.5 Flash and Qwen 3.5 122B: skip the part that was actually being tested. No history, 33-line controller calling the HTTP client by hand, two tests, no real mocks. It was fast because it did less than what was asked.</p>
<p><strong>MiniMax M2.7 — looks right, crashes.</strong> Calls <code>RubyLLM.chat(model: '...', messages: [...])</code> — that signature doesn&rsquo;t exist. No message persistence. Duplicated HTML (DOCTYPE inside the layout). Committed master.key. And the tests? They mock the wrong API, so they pass but they don&rsquo;t prove anything.</p>
<p>Code review summary:</p>
<table>
  <thead>
      <tr>
          <th>Aspect</th>
          <th style="text-align: center">Sonnet 4.6</th>
          <th style="text-align: center">GLM 5</th>
          <th style="text-align: center">Gemini 3.1 Pro</th>
          <th style="text-align: center">Kimi K2.5</th>
          <th style="text-align: center">MiniMax M2.7</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Correct API</td>
          <td style="text-align: center">Yes</td>
          <td style="text-align: center">Yes</td>
          <td style="text-align: center">No</td>
          <td style="text-align: center">No</td>
          <td style="text-align: center">No</td>
      </tr>
      <tr>
          <td>Chat history</td>
          <td style="text-align: center">Session cookie</td>
          <td style="text-align: center">None</td>
          <td style="text-align: center">Rails.cache (2h)</td>
          <td style="text-align: center">Broken (ActionCable off)</td>
          <td style="text-align: center">None</td>
      </tr>
      <tr>
          <td>Service layer</td>
          <td style="text-align: center">LlmChatService</td>
          <td style="text-align: center">Inline in controller</td>
          <td style="text-align: center">LlmService</td>
          <td style="text-align: center">LlmService</td>
          <td style="text-align: center">ChatService (wrong API)</td>
      </tr>
      <tr>
          <td>Tests (methods)</td>
          <td style="text-align: center">30</td>
          <td style="text-align: center">7</td>
          <td style="text-align: center">5</td>
          <td style="text-align: center">37</td>
          <td style="text-align: center">12</td>
      </tr>
      <tr>
          <td>LLM mocking</td>
          <td style="text-align: center">Yes (mocha)</td>
          <td style="text-align: center">Yes (mocha)</td>
          <td style="text-align: center">FakeChat (wrong API)</td>
          <td style="text-align: center">No</td>
          <td style="text-align: center">Mocks wrong API</td>
      </tr>
      <tr>
          <td>Dockerfile</td>
          <td style="text-align: center">Multi-stage</td>
          <td style="text-align: center">Multi-stage + jemalloc</td>
          <td style="text-align: center">Multi-stage + jemalloc</td>
          <td style="text-align: center">Dual (dev/prod)</td>
          <td style="text-align: center">Single-stage</td>
      </tr>
      <tr>
          <td>Actually runs?</td>
          <td style="text-align: center">Yes</td>
          <td style="text-align: center">Yes (no history)</td>
          <td style="text-align: center">No (500 in chat)</td>
          <td style="text-align: center">No</td>
          <td style="text-align: center">No</td>
      </tr>
  </tbody>
</table>
<h3>GLM 5 vs GLM 5.1: what changed<span class="hx:absolute hx:-mt-20" id="glm-5-vs-glm-51-what-changed"></span>
    <a href="#glm-5-vs-glm-51-what-changed" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>GLM 5 was one of the few models that spat out functional code on the first try, so it was obvious to test the new version. One important detail before the numbers: GLM 5 ran via OpenRouter, GLM 5.1 wasn&rsquo;t there yet when I ran this test, so I used the Z.AI direct API. Different provider, different infra, different cache. The numbers below are reference, not exact measurement.</p>
<table>
  <thead>
      <tr>
          <th>Aspect</th>
          <th>GLM 5</th>
          <th>GLM 5.1</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Provider</td>
          <td>OpenRouter</td>
          <td>Z.AI direct</td>
      </tr>
      <tr>
          <td>Total time</td>
          <td>17m</td>
          <td>22m</td>
      </tr>
      <tr>
          <td>Tok/s (final phase)</td>
          <td>400</td>
          <td>167</td>
      </tr>
      <tr>
          <td>Effective new tokens</td>
          <td>1,138</td>
          <td>450</td>
      </tr>
      <tr>
          <td>Cache read</td>
          <td>58,240</td>
          <td>81,216</td>
      </tr>
      <tr>
          <td>Correct RubyLLM API</td>
          <td>Yes</td>
          <td>Yes</td>
      </tr>
      <tr>
          <td>Test mocking</td>
          <td>Yes (mocha)</td>
          <td>Yes</td>
      </tr>
      <tr>
          <td>Tests</td>
          <td>7</td>
          <td>24</td>
      </tr>
      <tr>
          <td>Chat history</td>
          <td>No</td>
          <td>Yes (in-memory)</td>
      </tr>
      <tr>
          <td>Service layer</td>
          <td>Inline in controller</td>
          <td><code>ChatSession</code> model with <code>add_user_message</code>/<code>add_assistant_message</code></td>
      </tr>
  </tbody>
</table>
<p>The GLM 5.1 project came out way more complete. 24 tests vs 7. Real separation between <code>ChatSession</code>, <code>ChatMessage</code> and the controller, instead of GLM 5 cramming everything inline. Chat history persisted in memory during the session, so you can actually have a real multi-turn conversation (GLM 5 treated every message like it was the first). And the RubyLLM API is still correct, the same <code>RubyLLM.chat(model:, provider:)</code> pattern followed by <code>c.user</code>/<code>c.assistant</code> to build the context. There&rsquo;s even a test covering the <code>MODEL</code> constant, which usually nobody does.</p>
<p>The price was speed. 22 minutes vs 17, and throughput dropped from 400 to 167 tok/s. Could be the provider (Z.AI direct isn&rsquo;t the same infra as OpenRouter), could be a more loaded server during the run, could be that 5.1 reasons more. I didn&rsquo;t run it multiple times to take an average, so I won&rsquo;t say 5.1 is &ldquo;slower.&rdquo; A single run doesn&rsquo;t prove a regression. What I can say is that, in my test, 5.1 delivered a better-structured project and took a bit longer to do it.</p>
<p>For folks who want to get out from under Anthropic without losing quality, GLM 5 and GLM 5.1 are the two options that work. If you need centralized billing on OpenRouter, GLM 5. If you can use Z.AI direct and want a more rounded project on the first try, GLM 5.1.</p>
<h2>Costs: API vs Subscription<span class="hx:absolute hx:-mt-20" id="costs-api-vs-subscription"></span>
    <a href="#costs-api-vs-subscription" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>First, the per-token price of each model on OpenRouter:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/05/llm-benchmark/en/token-pricing.png" alt="Per-token price on OpenRouter"  loading="lazy" /></p>
<p>GPT 5.4 Pro charges $180 per million output tokens. Claude Opus charges $25. GLM 5 charges $2.30. And Qwen 3.6 Plus is free (with a rate limit). The log scale on the chart hides some of the brutality of the gap: from free Qwen to GPT 5.4 Pro is orders of magnitude.</p>
<p>But per-token price isn&rsquo;t the whole story. If you use Claude or GPT daily for coding, the monthly subscription can come out way cheaper than paying per token via the API:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/05/llm-benchmark/en/monthly-pricing.png" alt="Subscription vs API: how much it costs to use Claude and GPT per month"  loading="lazy" /></p>
<table>
  <thead>
      <tr>
          <th>Approach</th>
          <th style="text-align: right">Est. $/month*</th>
          <th>Notes</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Qwen 3.6 Plus (OpenRouter)</td>
          <td style="text-align: right">$0</td>
          <td>Free but rate-limited</td>
      </tr>
      <tr>
          <td>Local models</td>
          <td style="text-align: right">Electricity</td>
          <td>Needs hardware</td>
      </tr>
      <tr>
          <td>Claude Pro</td>
          <td style="text-align: right">$20</td>
          <td>~44K tokens/5hr</td>
      </tr>
      <tr>
          <td>ChatGPT Plus</td>
          <td style="text-align: right">$20</td>
          <td>Includes Codex</td>
      </tr>
      <tr>
          <td>Claude Max 5x</td>
          <td style="text-align: right">$100</td>
          <td>~88K tokens/5hr</td>
      </tr>
      <tr>
          <td>Claude Sonnet (OpenRouter API)</td>
          <td style="text-align: right">~$150</td>
          <td>No cap, pay-as-you-go</td>
      </tr>
      <tr>
          <td>Claude Max 20x</td>
          <td style="text-align: right">$200</td>
          <td>~220K tokens/5hr</td>
      </tr>
      <tr>
          <td>ChatGPT Pro</td>
          <td style="text-align: right">$200</td>
          <td>GPT 5.4 Pro unlimited</td>
      </tr>
      <tr>
          <td>Claude Opus (OpenRouter API)</td>
          <td style="text-align: right">~$450</td>
          <td>No cap, pay-as-you-go</td>
      </tr>
      <tr>
          <td>GPT 5.4 Pro (OpenRouter API)</td>
          <td style="text-align: right">~$990</td>
          <td>Absurdly expensive</td>
      </tr>
  </tbody>
</table>
<p>*Estimate for moderate coding use (~15M input + ~3M output tokens/month).</p>
<p>The main point: if you use GPT 5.4 Pro, the ChatGPT Pro subscription at $200/month with unlimited use is 5x cheaper than paying per token on the API. For Claude, Pro at $20/month covers light use, but for heavy users (a coding marathon like mine), the Max 20x at $200/month comes out cheaper than paying for Opus per token on OpenRouter (~$450/month). The open source models on OpenRouter all sit below $2.50/M output tokens, but as we saw, most of them generate code that doesn&rsquo;t run.</p>
<h2>What works for real use<span class="hx:absolute hx:-mt-20" id="what-works-for-real-use"></span>
    <a href="#what-works-for-real-use" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>After testing 33 models across both runs and looking at the generated code in detail:</p>
<p>Tier 1 (works plug and play):</p>
<table>
  <thead>
      <tr>
          <th>Model</th>
          <th>Quality</th>
          <th style="text-align: right">Cost/Run</th>
          <th>Trade-off</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Claude Sonnet 4.6</td>
          <td>Better than Opus on opencode (30 vs 16 tests)</td>
          <td style="text-align: right">~$0.63</td>
          <td>Cheaper, but on Claude Code Opus might do better</td>
      </tr>
      <tr>
          <td>Claude Opus 4.6</td>
          <td>Gold standard</td>
          <td style="text-align: right">~$1.05</td>
          <td>Baseline</td>
      </tr>
      <tr>
          <td>GPT 5.4 Pro</td>
          <td>Practically equivalent to Opus</td>
          <td style="text-align: right">~$7.20*</td>
          <td>Failed the benchmark due to opencode incompatibility, but I tested extensively in Codex and it works as well as Opus</td>
      </tr>
      <tr>
          <td>GLM 5</td>
          <td>Good (7 tests, correct API)</td>
          <td style="text-align: right">~$0.11</td>
          <td>89% cheaper, non-Anthropic/OpenAI alternative that works</td>
      </tr>
      <tr>
          <td>GLM 5.1</td>
          <td>Good (24 tests, history, correct API)</td>
          <td style="text-align: right">~$0.13</td>
          <td>~88% cheaper, more complete project than GLM 5</td>
      </tr>
  </tbody>
</table>
<p>*GPT 5.4 Pro failed the automated benchmark because opencode doesn&rsquo;t support OpenAI&rsquo;s native tool calling format. Through Codex or ChatGPT Pro ($200/month with unlimited use), it works without problems.</p>
<p>Tier 2 (works with caveats):</p>
<table>
  <thead>
      <tr>
          <th>Model</th>
          <th style="text-align: center">Hardware</th>
          <th style="text-align: right">Cost/Run</th>
          <th>Caveat</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Step 3.5 Flash</td>
          <td style="text-align: center">Cloud</td>
          <td style="text-align: right">~$0.02</td>
          <td>Bypasses the requested gem, slow (38m)</td>
      </tr>
      <tr>
          <td>Grok 4.20</td>
          <td style="text-align: center">Cloud</td>
          <td style="text-align: right">~$0.04</td>
          <td>Bypasses the requested gem (goes straight to <code>OpenAI::Client</code>), but it&rsquo;s the fastest in the benchmark</td>
      </tr>
      <tr>
          <td>Qwen 3.5 35B-A3B</td>
          <td style="text-align: center">NVIDIA 5090</td>
          <td style="text-align: right">Free</td>
          <td>Correct entry point, hallucinates <code>add_message</code>/<code>complete</code>. Fixable in 1-2 follow-ups. ~15-20 min total</td>
      </tr>
      <tr>
          <td>Qwen 3.5 27B Claude-distilled</td>
          <td style="text-align: center">NVIDIA 5090</td>
          <td style="text-align: right">Free</td>
          <td>Claude style, complete API hallucination. 2-3 follow-ups to fix. ~25-30 min total</td>
      </tr>
      <tr>
          <td>Qwen 3.5 35B (local)</td>
          <td style="text-align: center">AMD Strix</td>
          <td style="text-align: right">Free</td>
          <td>Works if default is configured, no mocking, and slow</td>
      </tr>
  </tbody>
</table>
<p>Tier 3 (broken code, easier to redo than to fix):</p>
<p>Kimi K2.5, MiniMax M2.7, DeepSeek V3.2, Gemini 3.1 Pro, Qwen 3 Coder Next (Strix), Qwen 3 Coder 30B (5090, returned a hardcoded mock string), Qwen 3.5 122B, Qwen 3.6 Plus — all of them either invent APIs that don&rsquo;t exist or don&rsquo;t even try to use the gem.</p>
<p>Tier 4 (didn&rsquo;t complete):</p>
<p>Gemma 4 (infinite loop on both hardware), Llama 4 Scout (no parser), GPT OSS 20B (wrong directory on Strix, parser regression on 5090), Qwen 3 32B (too slow on Strix, partial scaffold on 5090), Qwen 2.5 Coder 32B (90m timeout with zero files).</p>
<h3>Simplified ranking (quality, time, price)<span class="hx:absolute hx:-mt-20" id="simplified-ranking-quality-time-price"></span>
    <a href="#simplified-ranking-quality-time-price" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>For folks who only want the report-card summary. Quality is whether the code runs and how complete it is. Time is the total runtime. Price is the estimated cost per execution on opencode. <strong>Hardware</strong> indicates where the model ran — Cloud, Strix (AMD Strix Halo, LPDDR5x 256 GB/s) or 5090 (NVIDIA RTX 5090, GDDR7 1792 GB/s). Cloud models ran via OpenRouter or the provider&rsquo;s direct API.</p>
<table>
  <thead>
      <tr>
          <th>Model</th>
          <th style="text-align: center">Type</th>
          <th style="text-align: center">Hardware</th>
          <th style="text-align: center">Quality</th>
          <th style="text-align: center">Time</th>
          <th style="text-align: center">Price</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Claude Sonnet 4.6</td>
          <td style="text-align: center">Commercial</td>
          <td style="text-align: center">Cloud</td>
          <td style="text-align: center">A+</td>
          <td style="text-align: center">A</td>
          <td style="text-align: center">C</td>
      </tr>
      <tr>
          <td>Claude Opus 4.6</td>
          <td style="text-align: center">Commercial</td>
          <td style="text-align: center">Cloud</td>
          <td style="text-align: center">A+</td>
          <td style="text-align: center">A</td>
          <td style="text-align: center">D</td>
      </tr>
      <tr>
          <td>GPT 5.4 Pro</td>
          <td style="text-align: center">Commercial</td>
          <td style="text-align: center">Cloud</td>
          <td style="text-align: center">A+</td>
          <td style="text-align: center">—</td>
          <td style="text-align: center">F</td>
      </tr>
      <tr>
          <td>GLM 5.1</td>
          <td style="text-align: center">OSS</td>
          <td style="text-align: center">Cloud</td>
          <td style="text-align: center">A</td>
          <td style="text-align: center">B</td>
          <td style="text-align: center">A</td>
      </tr>
      <tr>
          <td>GLM 5</td>
          <td style="text-align: center">OSS</td>
          <td style="text-align: center">Cloud</td>
          <td style="text-align: center">A−</td>
          <td style="text-align: center">A</td>
          <td style="text-align: center">A</td>
      </tr>
      <tr>
          <td>Qwen 3.5 35B-A3B</td>
          <td style="text-align: center">OSS</td>
          <td style="text-align: center">5090</td>
          <td style="text-align: center">B</td>
          <td style="text-align: center">A+</td>
          <td style="text-align: center">A+ (free)</td>
      </tr>
      <tr>
          <td>Qwen 3.5 27B Claude-distilled</td>
          <td style="text-align: center">OSS</td>
          <td style="text-align: center">5090</td>
          <td style="text-align: center">C+</td>
          <td style="text-align: center">B</td>
          <td style="text-align: center">A+ (free)</td>
      </tr>
      <tr>
          <td>Gemini 3.1 Pro</td>
          <td style="text-align: center">Commercial</td>
          <td style="text-align: center">Cloud</td>
          <td style="text-align: center">C</td>
          <td style="text-align: center">A+</td>
          <td style="text-align: center">B</td>
      </tr>
      <tr>
          <td>Grok 4.20</td>
          <td style="text-align: center">Commercial</td>
          <td style="text-align: center">Cloud</td>
          <td style="text-align: center">C−</td>
          <td style="text-align: center">A+</td>
          <td style="text-align: center">A+</td>
      </tr>
      <tr>
          <td>Step 3.5 Flash</td>
          <td style="text-align: center">Commercial</td>
          <td style="text-align: center">Cloud</td>
          <td style="text-align: center">C−</td>
          <td style="text-align: center">D</td>
          <td style="text-align: center">A+</td>
      </tr>
      <tr>
          <td>Qwen 3.5 35B-A3B</td>
          <td style="text-align: center">OSS</td>
          <td style="text-align: center">Strix</td>
          <td style="text-align: center">C</td>
          <td style="text-align: center">C</td>
          <td style="text-align: center">A+ (free)</td>
      </tr>
      <tr>
          <td>Qwen 3 Coder Next</td>
          <td style="text-align: center">OSS</td>
          <td style="text-align: center">Strix</td>
          <td style="text-align: center">D+</td>
          <td style="text-align: center">A</td>
          <td style="text-align: center">A+ (free)</td>
      </tr>
      <tr>
          <td>Qwen 3 32B</td>
          <td style="text-align: center">OSS</td>
          <td style="text-align: center">5090</td>
          <td style="text-align: center">D</td>
          <td style="text-align: center">A+</td>
          <td style="text-align: center">A+ (free)</td>
      </tr>
      <tr>
          <td>Qwen 3 Coder 30B</td>
          <td style="text-align: center">OSS</td>
          <td style="text-align: center">5090</td>
          <td style="text-align: center">D−</td>
          <td style="text-align: center">A+</td>
          <td style="text-align: center">A+ (free)</td>
      </tr>
      <tr>
          <td>Qwen 3.6 Plus</td>
          <td style="text-align: center">Commercial</td>
          <td style="text-align: center">Cloud</td>
          <td style="text-align: center">D</td>
          <td style="text-align: center">A</td>
          <td style="text-align: center">A+ (free)</td>
      </tr>
      <tr>
          <td>Kimi K2.5</td>
          <td style="text-align: center">OSS</td>
          <td style="text-align: center">Cloud</td>
          <td style="text-align: center">D</td>
          <td style="text-align: center">C</td>
          <td style="text-align: center">A</td>
      </tr>
      <tr>
          <td>MiniMax M2.7</td>
          <td style="text-align: center">OSS</td>
          <td style="text-align: center">Cloud</td>
          <td style="text-align: center">D</td>
          <td style="text-align: center">A</td>
          <td style="text-align: center">A+</td>
      </tr>
      <tr>
          <td>Qwen 3.5 122B</td>
          <td style="text-align: center">OSS</td>
          <td style="text-align: center">Strix</td>
          <td style="text-align: center">D</td>
          <td style="text-align: center">F</td>
          <td style="text-align: center">A+ (free)</td>
      </tr>
      <tr>
          <td>DeepSeek V3.2</td>
          <td style="text-align: center">OSS</td>
          <td style="text-align: center">Cloud</td>
          <td style="text-align: center">F</td>
          <td style="text-align: center">F</td>
          <td style="text-align: center">A+</td>
      </tr>
      <tr>
          <td>Qwen 2.5 Coder 32B</td>
          <td style="text-align: center">OSS</td>
          <td style="text-align: center">5090</td>
          <td style="text-align: center">F</td>
          <td style="text-align: center">F</td>
          <td style="text-align: center">A+ (free)</td>
      </tr>
      <tr>
          <td>Gemma 4 31B</td>
          <td style="text-align: center">OSS</td>
          <td style="text-align: center">5090</td>
          <td style="text-align: center">F</td>
          <td style="text-align: center">A+</td>
          <td style="text-align: center">A+ (free)</td>
      </tr>
      <tr>
          <td>Gemma 4 31B</td>
          <td style="text-align: center">OSS</td>
          <td style="text-align: center">Strix</td>
          <td style="text-align: center">F</td>
          <td style="text-align: center">—</td>
          <td style="text-align: center">A+ (free)</td>
      </tr>
      <tr>
          <td>GLM 4.7 Flash</td>
          <td style="text-align: center">OSS</td>
          <td style="text-align: center">Strix</td>
          <td style="text-align: center">F</td>
          <td style="text-align: center">—</td>
          <td style="text-align: center">A+ (free)</td>
      </tr>
      <tr>
          <td>Llama 4 Scout</td>
          <td style="text-align: center">OSS</td>
          <td style="text-align: center">Strix</td>
          <td style="text-align: center">F</td>
          <td style="text-align: center">—</td>
          <td style="text-align: center">A+ (free)</td>
      </tr>
      <tr>
          <td>GPT OSS 20B</td>
          <td style="text-align: center">OSS</td>
          <td style="text-align: center">Strix</td>
          <td style="text-align: center">F</td>
          <td style="text-align: center">—</td>
          <td style="text-align: center">A+ (free)</td>
      </tr>
      <tr>
          <td>Qwen 3 32B</td>
          <td style="text-align: center">OSS</td>
          <td style="text-align: center">Strix</td>
          <td style="text-align: center">F</td>
          <td style="text-align: center">—</td>
          <td style="text-align: center">A+ (free)</td>
      </tr>
  </tbody>
</table>
<p>Quality criteria: A+ works and the code is well structured. A/B works with small to medium caveats. C runs but skips a prompt requirement or has a serious structural issue. D breaks on the first message because of an invented API. F didn&rsquo;t complete the benchmark or produced garbage. GPT 5.4 Pro stays at A+ for real use in Codex, but didn&rsquo;t run in this benchmark, hence the dash in time. &ldquo;Type&rdquo; separates commercial models (closed weights) from OSS (open weights, even when used through a hosted API). Some Qwens appear twice when they ran on both hardware profiles, because the results are different enough to justify it — Qwen 3.5 35B-A3B on the 5090 jumps to Tier B, on the Strix it stays at Tier C because of the wait time. Of the 33 models configured across both runs, some don&rsquo;t appear in this table because they never even executed (no quota, broken runner, infra failure, or timeout before the first message).</p>
<h3>The verdict<span class="hx:absolute hx:-mt-20" id="the-verdict"></span>
    <a href="#the-verdict" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>If cost matters and you want to leave Anthropic: <strong>GLM 5 or GLM 5.1</strong> are the plug-and-play alternatives that work. Correct API, mocking in the tests, ~$0.11-$0.13 per run, ~88-89% cheaper than Opus. GLM 5.1 delivered a more complete project (24 tests, chat history) at the cost of about 5 more minutes.</p>
<p>If you want the best result regardless of cost: <strong>Claude Sonnet 4.6</strong> beat Opus in this benchmark — cheaper, same speed, more tests, code that works. But there are two important caveats before you generalize that conclusion.</p>
<p>First, this result is on opencode, not on Claude Code. In the native environment (Claude Code), where Opus and Sonnet have access to Anthropic&rsquo;s full tool support, Opus might do better. In my 500-hour Claude Code marathon, I used Opus and the experience was consistently good.</p>
<p>Second, and this is the bigger one: our test is a small, well-defined web app. Sonnet 4.6 and Opus 4.6 share the <a href="https://platform.claude.com/docs/en/about-claude/models/whats-new-claude-4-6"target="_blank" rel="noopener">same 1M token context window</a>, so what separates the two is the reasoning capacity they can apply inside that context. Opus 4.6 has a 128K max output token ceiling vs Sonnet&rsquo;s 64K, and its training was specifically aimed at long-horizon tasks, multi-step planning and deep reasoning over complex code. On a small project like ours, those muscles stay idle, and in that scenario it&rsquo;s either a tie or Sonnet wins by being faster. In larger projects, with weeks of work, big monorepos, architectural decisions that carry real consequences, that&rsquo;s where the actual difference between Opus and Sonnet shows up. You can&rsquo;t conclude that Sonnet is better than Opus in general just from this benchmark.</p>
<p>If you want to avoid total vendor lock-in and you have decent hardware: <strong>Qwen 3.5 35B-A3B</strong> running locally on an NVIDIA RTX 5090. Five minutes of execution at 273 tok/s, a Rails project that boots, and the API error fixes itself in 1-2 follow-ups. Realistic total until it works: ~15-20 minutes. Beats Sonnet on cost (zero) and lands close on total time. This option simply didn&rsquo;t exist in the previous round of the benchmark, and it marks the point where &ldquo;running OSS local&rdquo; stops being a toy and becomes a real alternative. Important: this is specific to hardware with high memory bandwidth. On an RTX 4090 it should work similarly. On a laptop with LPDDR5x or a desktop with DDR4, forget it — you&rsquo;ll wait 10x longer and the total time kills the argument.</p>
<p>If you want to avoid vendor lock-in but you&rsquo;re on weak hardware: <strong>GLM 5 or GLM 5.1</strong> remain the choice. They&rsquo;re cloud, true, but at $0.11-$0.13 per run it&rsquo;s basically the price of electricity.</p>
<p>If you want to test the &ldquo;Claude at home&rdquo; gamble via distillation: the <strong>Qwen 3.5 27B Claude-distilled</strong> is sitting there to play with, but I already warned you it hallucinates exactly the same fake APIs as the base Qwen. Distillation transferred Claude&rsquo;s style, not its factual knowledge about libraries. It&rsquo;s worth it as an experiment, not as production.</p>
<p>Yes, maybe with days of tweaking llama.cpp, calibrating flags, adjusting prompts, testing different builds, you could make Gemma 4 or other models work better. For most people, that isn&rsquo;t realistic. The distance between frontier models (Claude, GPT) and self-hosted open source models is real. It isn&rsquo;t marketing. The gap is shrinking, but it still exists, and the nature of it has changed: today what&rsquo;s missing in open source is factual knowledge about specific libraries, not raw reasoning capacity. Hardware stopped being the bottleneck, at least for anyone with a recent GPU.</p>
<p>In the end, what matters is whether the code runs. A model can generate 3,405 files, write 37 tests, produce a 181-line README, and the app still won&rsquo;t work because the API it uses doesn&rsquo;t exist. Completeness metrics and test counts are necessary but not sufficient. The only reliable signal is whether the model uses real APIs correctly.</p>
<p>The full benchmark, with code, configuration, prompts and per-model results, is on <a href="https://github.com/akitaonrails/llm-coding-benchmark"target="_blank" rel="noopener">GitHub</a>.</p>
]]></content:encoded><category>llm</category><category>benchmark</category><category>open-source</category><category>claude</category><category>ai</category><category>self-hosting</category></item><item><title>Turning YouTube into a Karaoke App | Frank Karaoke</title><link>https://www.akitaonrails.com/en/2026/04/05/turning-youtube-into-a-karaoke-app-frank-karaoke/</link><guid isPermaLink="true">https://www.akitaonrails.com/en/2026/04/05/turning-youtube-into-a-karaoke-app-frank-karaoke/</guid><pubDate>Sun, 05 Apr 2026 12:00:00 GMT</pubDate><description>&lt;p&gt;Project on GitHub: &lt;a href="https://github.com/akitaonrails/frank_karaoke"target="_blank" rel="noopener"&gt;github.com/akitaonrails/frank_karaoke&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/05/frank-karaoke/scoring-in-action.jpg" alt="Real-time scoring overlaid on a YouTube karaoke video" loading="lazy" /&gt;&lt;/p&gt;
&lt;p&gt;I&amp;rsquo;ve always loved karaoke. I go out to sing with family or friends every now and then. In São Paulo there are good places in Liberdade and Bom Retiro, for instance, with private Japanese-style booths. If you&amp;rsquo;ve never been to a karaoke like that: you rent a private room by the hour, there&amp;rsquo;s a huge song catalog, two microphones, and a scoring system that grades your singing in real time. The best systems are Japanese, like &lt;a href="https://www.joysound.com/"target="_blank" rel="noopener"&gt;Joysound&lt;/a&gt; and &lt;a href="https://www.clubdam.com/"target="_blank" rel="noopener"&gt;DAM&lt;/a&gt;. A score above 90 (out of 100) is considered advanced. DAM, in the LIVE DAM Ai series, even uses AI to give scores that feel more &amp;ldquo;human.&amp;rdquo;&lt;/p&gt;</description><content:encoded><![CDATA[<p>Project on GitHub: <a href="https://github.com/akitaonrails/frank_karaoke"target="_blank" rel="noopener">github.com/akitaonrails/frank_karaoke</a></p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/05/frank-karaoke/scoring-in-action.jpg" alt="Real-time scoring overlaid on a YouTube karaoke video"  loading="lazy" /></p>
<p>I&rsquo;ve always loved karaoke. I go out to sing with family or friends every now and then. In São Paulo there are good places in Liberdade and Bom Retiro, for instance, with private Japanese-style booths. If you&rsquo;ve never been to a karaoke like that: you rent a private room by the hour, there&rsquo;s a huge song catalog, two microphones, and a scoring system that grades your singing in real time. The best systems are Japanese, like <a href="https://www.joysound.com/"target="_blank" rel="noopener">Joysound</a> and <a href="https://www.clubdam.com/"target="_blank" rel="noopener">DAM</a>. A score above 90 (out of 100) is considered advanced. DAM, in the LIVE DAM Ai series, even uses AI to give scores that feel more &ldquo;human.&rdquo;</p>
<p>But not every place has that level.</p>
<h2>The problem with karaoke in Brazil<span class="hx:absolute hx:-mt-20" id="the-problem-with-karaoke-in-brazil"></span>
    <a href="#the-problem-with-karaoke-in-brazil" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>In Brazil we grew up with <a href="https://www.videoland.com.br/wwwroot/historia.asp"target="_blank" rel="noopener">Videokê</a>, the brand the Korean Seok Ha Hwang brought to the country in 1996, importing equipment from Korea. It became a craze in the 90s and 2000s, showed up in every bar, barbecue and birthday party. The problem is that those machines stopped in time. The current models, like the VSK 5.0, ship with around 12-13 thousand songs in the catalog, which you expand by buying cartridges or song packs. In practice, the repertoire is old, the interface is straight out of the 2000s, and if the song you want to sing came out after 2015, good luck.</p>
<p>The workaround a lot of bars adopted was to allow Chromecast or screen mirroring so that customers can search for songs directly on YouTube. Makes sense: on YouTube you can find karaoke for any song. With-lyrics version, instrumental version, vocal guide version.</p>
<p>But there&rsquo;s a downgrade: you lose the scoring. One of the most fun parts of karaoke is the competition. Watching your score climb, comparing with friends, trying to beat the night&rsquo;s record. If you&rsquo;re just singing on top of a YouTube video, you get no feedback. It&rsquo;s like bowling without a scoreboard.</p>
<p>And buying a professional system for home? Importing a Joysound F1 runs north of US$ 2,000 just for the hardware, not counting the monthly catalog subscription. For casual use it makes no sense.</p>
<h2>The idea: YouTube with real-time scoring<span class="hx:absolute hx:-mt-20" id="the-idea-youtube-with-real-time-scoring"></span>
    <a href="#the-idea-youtube-with-real-time-scoring" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p><a href="https://github.com/akitaonrails/frank_karaoke"target="_blank" rel="noopener">Frank Karaoke</a> came out of that frustration. If YouTube already has every song, why not build an app that works as a YouTube wrapper with a real-time scoring overlay? You search for any karaoke video, sing along, and the app analyzes your voice through the mic and shows a live score.</p>
<p>It&rsquo;s a Flutter app for Android. Internally it loads YouTube into a webview and injects an overlay in HTML/CSS/JavaScript right into the page. The score display, the pitch trail, the settings panel, the mode selector — all of it rendered inside the webview through JS injection.</p>
<picture>
  <source media="(prefers-color-scheme: dark)" srcset="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/05/frank-karaoke/karaoke-full-dark.png">
  <img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/05/frank-karaoke/karaoke-full-light.png" alt="Frank Karaoke">
</picture>
<h2>Scoring without a reference<span class="hx:absolute hx:-mt-20" id="scoring-without-a-reference"></span>
    <a href="#scoring-without-a-reference" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Now, the real problem. Every professional karaoke system depends on prebuilt reference files for each song. Every single one.</p>
<p>Sony&rsquo;s <a href="https://en.wikipedia.org/wiki/SingStar"target="_blank" rel="noopener">SingStar</a>, which sold over 12 million copies between 2004 and the end of the PS3 era, had a hand-crafted note track for every song. Every note, every syllable, all mapped manually. The mechanism compared the singer&rsquo;s pitch via FFT against that reference in real time. A detail I thought was clever: octave was ignored. If the right note was a C, it didn&rsquo;t matter if you sang C3 or C4. Men sing women&rsquo;s songs no problem.</p>
<p>Joysound and DAM in Japan go further and evaluate three separate dimensions: pitch accuracy (音感), rhythm/timing (リズム感) and expressiveness/dynamic volume (表現力). All based on MIDI data from the operator&rsquo;s server. The open source equivalent format is UltraStar, where each song has a <code>.txt</code> file like:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>: 12 4 5 Hel-    (NoteType StartBeat Duration Pitch Syllable)</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p><code>Pitch 5</code> = MIDI 65 (F4). Scoring compares the singer&rsquo;s pitch against the note&rsquo;s pitch, modulo octave, with a tolerance of 1 semitone.</p>
<p>Frank Karaoke works with any YouTube video. There&rsquo;s no reference file. There&rsquo;s no MIDI. There&rsquo;s no melody annotation. Zero metadata about what note you&rsquo;re supposed to be singing.</p>
<p>I don&rsquo;t know anything about karaoke scoring. I don&rsquo;t know anything about audio processing, pitch detection, music theory applied to software. Nothing. So I asked Claude Code to do extensive research on the subject. What it brought back is documented in <a href="https://github.com/akitaonrails/frank_karaoke/blob/main/docs/scoring.md"target="_blank" rel="noopener"><code>docs/scoring.md</code></a> in the repository, and it&rsquo;s a lot: academic papers on singing evaluation (Nakano et al. 2006, Tsai &amp; Lee 2012, Molina et al. 2013), patents (Yamaha has one from 1999, US5889224A, that details MIDI-based scoring with 3 tolerance bands), and the source code of open source projects like UltraStar Deluxe, AllKaraoke, Vocaluxe and Nightingale.</p>
<p>The conclusion of the research: without a per-song reference, you have to evaluate vocal quality generically. Measure <em>how</em> the person is singing, not <em>what</em> they should be singing. And since no single metric works for every case, we decided to implement four different scoring modes, each measuring a different dimension of vocal quality.</p>
<h2>The phone microphone problem<span class="hx:absolute hx:-mt-20" id="the-phone-microphone-problem"></span>
    <a href="#the-phone-microphone-problem" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Before the scoring modes, I have to explain a more fundamental problem the research uncovered: the phone microphone.</p>
<p>When you sing karaoke with the phone, the mic picks up three things at once: your voice, the music coming out of the speaker, and ambient noise from the room. Your voice is physically closer to the mic, so it dominates the signal. But not enough for clean separation.</p>
<p>I tried several approaches to isolate the voice:</p>
<p>Spectral subtraction using YouTube&rsquo;s reference audio. Dropped it. The YouTube CDN blocks direct audio extraction by non-browser user-agents, and even with the reference audio in hand, the speaker&rsquo;s EQ, the room reverberation and the Bluetooth delay make the signal too different from what the mic captures. Naive subtraction produces artifacts worse than no subtraction at all.</p>
<p>Pre-emphasis + center clipping. Dropped that too. Center clipping destroys the waveform that the YIN algorithm needs for autocorrelation, and pre-emphasis amplifies noise as much as it amplifies voice.</p>
<p>What works is a 200-3500 Hz bandpass filter: a second-order IIR (Butterworth, Q=0.707) in cascade. The high-pass at 200 Hz kills bass, kick drum, bass guitar bleed from the speaker. The low-pass at 3500 Hz kills cymbals, hi-hats, high-frequency noise. Human voice fundamentals (85-300 Hz) and formants (300-3000 Hz) pass through the filter. It&rsquo;s not perfect isolation, but it improves the voice/music ratio enough for pitch detection.</p>
<p>But the bandpass alone doesn&rsquo;t solve everything. Guitars, synths and piano produce periodic signals in the same frequency range as voice, and YIN detects pitch in them too. To deal with that, the app does adaptive calibration: in the first 5 seconds of warmup (when nobody&rsquo;s singing yet), it collects RMS samples from the signal to establish a baseline of the speaker&rsquo;s level. During the song, it keeps that baseline updated (25th percentile of the last ~4 seconds of frames). For a frame to be scored, the RMS has to be at least 1.3x above the baseline. Your voice is closer to the mic, so it pushes the RMS above the speaker&rsquo;s level. The instrumental melody stays near the baseline and gets filtered out. In testing, the original singer coming out of the speaker scored around 37 with sparse dots in the trail, while someone actually singing scored ~59 with dense dots.</p>
<p>Another annoying detail: on Android, specifically on Samsungs, the DSP&rsquo;s <code>AutomaticGainControl</code> (AGC) attenuates the signal instead of amplifying it. On Galaxies, enabling AGC drops the mic peak from ~0.06 to ~0.003. Silence as far as pitch detection is concerned. So the app disables AGC, echo cancellation and noise suppression. When the peak falls below 0.01, it applies software gain (up to 30x) to bring the signal up to usable levels.</p>
<h2>The YIN algorithm<span class="hx:absolute hx:-mt-20" id="the-yin-algorithm"></span>
    <a href="#the-yin-algorithm" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>To detect the voice&rsquo;s pitch I use <a href="http://audition.ens.fr/adc/pdf/2002_JASA_YIN.pdf"target="_blank" rel="noopener">YIN</a>, by Alain de Cheveigné (IRCAM-CNRS) and Hideki Kawahara (Wakayama University). It&rsquo;s a fundamental frequency estimator in the time domain. The central idea is the Cumulative Mean Normalized Difference Function (CMNDF), which basically measures how periodic the signal is at each lag, normalizes it to reduce false positives, and uses parabolic interpolation to refine the result. It&rsquo;s lightweight enough to run in real time on a phone, which is what matters here.</p>
<p>In the app, the YIN threshold is 0.70 (tuned for mixed voice + music signals), and frames with confidence below 0.3 get discarded. Below that, it&rsquo;s probably noise or an instrument.</p>
<h2>The 4 scoring modes<span class="hx:absolute hx:-mt-20" id="the-4-scoring-modes"></span>
    <a href="#the-4-scoring-modes" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Each mode evaluates a different aspect of vocal quality. They all share the same audio pipeline (bandpass → YIN → confidence gate). The difference is how they interpret the detected pitch.</p>
<h3>Pitch Match<span class="hx:absolute hx:-mt-20" id="pitch-match"></span>
    <a href="#pitch-match" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Measures how cleanly you sustain notes. Uses Gaussian decay based on the standard deviation of MIDI values in a rolling ~15-frame window. Steady notes (deviation &lt; 0.3 semitones) score 85-100%. A trembling voice (deviation &gt; 2 semitones) scores near zero. Good for songs you already know well.</p>
<h3>Contour<span class="hx:absolute hx:-mt-20" id="contour"></span>
    <a href="#contour" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Measures the melodic shape of your singing. It doesn&rsquo;t matter which exact note you hit, only the direction and the flow. Evaluates the pitch range and melodic movements (jumps &gt; 0.5 semitone) in a rolling window. Monotone singing scores ~10%. Smooth melodic movement with a 2-6 semitone range scores 70-100%. Good for when you&rsquo;re learning a new song.</p>
<h3>Intervals<span class="hx:absolute hx:-mt-20" id="intervals"></span>
    <a href="#intervals" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Measures the musical quality of jumps between consecutive notes. A whole tone (2 semitones) scores highest. Thirds and fourths score well. Wild jumps of an octave or more score low. Uses a Gaussian curve centered on the whole tone. Works when you&rsquo;re singing in a different key from the original.</p>
<h3>Streak<span class="hx:absolute hx:-mt-20" id="streak"></span>
    <a href="#streak" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>It&rsquo;s Pitch Match with a combo multiplier. Each consecutive frame with a score above 0.4 increments the streak counter. The streak adds bonus points (up to +0.4 on a streak of 30+). Breaking a streak &gt; 5 frames pushes a 0.05 penalty into the EMA. Silence freezes the streak, so instrumental breaks don&rsquo;t hurt you. The most fun mode for parties.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/05/frank-karaoke/score-display-detail.jpg" alt="Score display detail: live score, overall score, pitch trail with note grid (C3-G5)"  loading="lazy" /></p>
<p>The logic behind these four modes came from the research Claude did across academic papers. Each one measures a different dimension: pitch accuracy, melodic contour, phrasing and consistency. None of them is sufficient on its own, but together they cover, reasonably well, what you can evaluate without having the song&rsquo;s reference melody.</p>
<h2>The Pitch Oracle<span class="hx:absolute hx:-mt-20" id="the-pitch-oracle"></span>
    <a href="#the-pitch-oracle" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Beyond the four purely vocal modes, the app has what I call the Pitch Oracle. The idea: instead of evaluating your voice in isolation, the app downloads the video&rsquo;s reference audio via <code>youtube_explode_dart</code>, decodes it to PCM, runs YIN on it, and builds a timestamped pitch timeline of the entire song. During scoring, if the mic&rsquo;s pitch matches the reference&rsquo;s pitch at that moment in the video, it&rsquo;s probably speaker bleed, and gets ignored. If it differs, it&rsquo;s your voice, and gets scored.</p>
<p>The synchronization works through the <code>currentTime</code> of the HTML5 video element, sent to Dart through a JS <code>timeupdate</code> listener every ~250ms. The oracle queries the reference pitch at the exact playback position, accounting for pause, seek and speed change.</p>
<p>The first time you play a song, the oracle takes 5-15 seconds to download and analyze the audio. But the timeline is saved as JSON in the app&rsquo;s local cache (<code>pitch_oracle/&lt;videoId&gt;.json</code>). If you play the same song again, it loads instantly from cache, no network request. That also fixes YouTube&rsquo;s rate limiting problem for the songs you sing the most.</p>
<p>With the oracle active, the modes change behavior. Pitch Match compares the singer&rsquo;s pitch class against the reference&rsquo;s, agnostic to octave (like SingStar). Contour uses cross-correlation between the singer&rsquo;s pitch movement and the reference&rsquo;s. Intervals compares semitone jumps against the reference&rsquo;s.</p>
<p>When YouTube blocks the download with rate limiting (happens after many consecutive requests from the same IP, clears in 15-30 minutes), the oracle silently fails and the modes fall back to purely vocal analysis.</p>
<h2>The road to here<span class="hx:absolute hx:-mt-20" id="the-road-to-here"></span>
    <a href="#the-road-to-here" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The app you see now went through a lot of iteration before reaching this state.</p>
<p>First, I tried to make a Linux desktop version to make debugging easier. Makes sense, right? Test on the desktop, iterate fast, then port to mobile. The problem is that Flutter has no webview backend for Linux desktop. <code>webview_flutter</code> simply doesn&rsquo;t work. I tried <code>webview_cef</code>, which is based on the Chromium Embedded Framework. CEF spawns its own GPU process, and on Hyprland (a Wayland compositor based on wlroots) that conflicts with the compositor&rsquo;s render pipeline. On my NVIDIA setup, the entire Hyprland session froze. Locked screen, no keyboard response, I had to kill it from a TTY. On top of that, CEF requires downloading a ~200MB binary on the first build. I gave up on CEF and wrote a native bridge in C++ with Claude using WebKitGTK and Flutter method channels. It worked, but every YouTube quirk required separate code for Linux and Android. <code>just_audio</code> also has no Linux desktop implementation. The Linux version turned into dead weight. I deleted ~1,500 lines of Linux-specific code and focused only on Android.</p>
<p>Then came the Samsung mic saga. On my Galaxy Z Fold, the mic was capturing an absurdly low signal. Peaks of ~0.005, basically silence as far as pitch detection was concerned. I spent two hours trying to figure it out. I lowered thresholds, raised software gain to 50x, disabled audio preprocessors. Nothing was working right. Until I figured out the real problem: Android&rsquo;s <code>AutomaticGainControl</code>. The name says &ldquo;automatic gain control,&rdquo; which suggests it <em>amplifies</em> weak signals. In the Samsung DSP implementation, it does the opposite. It <em>attenuates</em> the signal to a low reference level, optimized for voice calls. With AGC on, the peak dropped from ~0.06 to ~0.003. Disabling AGC fixed it. But then the <code>audio_session</code> package was re-enabling AGC under the hood. I removed that one too. It was three rounds of fixes, each finding one more layer of the problem.</p>
<p>And the scoring. The scoring took longer than everything else combined. The first implementation used a cumulative average, which kept the score stuck at one value and never responded to live singing. I switched to a rolling window. Then the score was stuck at ~50% because of a bug in the primary score weight. I fixed it, and it started showing 70% even with nobody singing. Fixed it again. Streak mode wasn&rsquo;t resetting properly during silence. The chromatic snap was giving high scores for anything. The pitch history wasn&rsquo;t being cleared on silence gaps and the modes were going stagnant. Every fix revealed another bug. It took more than 25 commits just on the scoring, from the first prototype to the current state.</p>
<p>The result isn&rsquo;t perfect. I know. But it works well enough to be fun, which was the goal from the start.</p>
<h2>Settings<span class="hx:absolute hx:-mt-20" id="settings"></span>
    <a href="#settings" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/05/frank-karaoke/settings-panel.jpg" alt="Settings panel: mic presets, pitch shift, calibration"  loading="lazy" /></p>
<p>The settings panel lives behind the gear icon on the overlay. There are three mic presets for different environments (clean external mic, normal room, loud party), each adjusting confidence and amplitude thresholds. There&rsquo;s a pitch shift for when the song is too high for your vocal range. The shift moves both the video audio and the scoring at the same time: it uses the HTML5 element&rsquo;s <code>playbackRate</code> with <code>preservesPitch=false</code>, so +2 semitones speeds the audio up to 1.12x (pitch goes up) and -2 semitones slows it down to 0.89x (pitch goes down). The scoring compensates for the offset, so you sing in your comfortable range and the system grades you correctly. There&rsquo;s mic calibration, a 3-second process that measures the room noise and adapts the thresholds. And there&rsquo;s a restart to reset the score without reloading the video.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/05/frank-karaoke/scoring-modes-selector.jpg" alt="Scoring mode selector"  loading="lazy" /></p>
<p>To switch scoring modes, tap the score box during playback.</p>
<h2>Usage flow<span class="hx:absolute hx:-mt-20" id="usage-flow"></span>
    <a href="#usage-flow" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><ol>
<li>Open the app. YouTube loads inside the app with the Frank Karaoke logo.</li>
<li>Search for a karaoke video. Any video works, but instrumental tracks with on-screen lyrics give better results.</li>
<li>The video pauses briefly to initialize the mic, download the song&rsquo;s data for the pitch oracle, and prepare the overlay. The first time with a new song this takes 5-15 seconds. If you&rsquo;ve played it before, it loads from cache instantly.</li>
<li>Sing. The &ldquo;live&rdquo; score reflects your current performance (exponential moving average with alpha 0.15, ~1 second response). The &ldquo;overall&rdquo; score is the cumulative average of the entire song.</li>
<li>When the video pauses, scoring pauses with it (so it doesn&rsquo;t score ambient noise). If you seek, the score resets and gets a 5-second warmup.</li>
</ol>
<h2>How to install<span class="hx:absolute hx:-mt-20" id="how-to-install"></span>
    <a href="#how-to-install" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The app isn&rsquo;t on the Play Store yet, I&rsquo;m waiting for Google to verify my developer identity. It should show up there in the next few days. In the meantime, it&rsquo;s an open project and you can install it directly.</p>
<p>The easiest way is to download the signed APK directly from the <a href="https://github.com/akitaonrails/frank_karaoke/releases"target="_blank" rel="noopener">GitHub releases page</a>. On your Android phone or tablet, download <code>FrankKaraoke-0.2.0-android.apk</code>, open it and tap Install. If Android complains about &ldquo;unknown sources,&rdquo; enable it under Settings &gt; Security for your browser. On the first run the app will ask for mic permission. Then go into settings (the gear icon) and calibrate the mic before singing — three seconds.</p>
<p>If you want to compile from source or contribute, the repository is on <a href="https://github.com/akitaonrails/frank_karaoke"target="_blank" rel="noopener">GitHub</a>. You&rsquo;ll need Flutter SDK 3.10+, Android SDK API 24+, and a physical device for mic testing (an emulator doesn&rsquo;t give representative results).</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">git clone https://github.com/akitaonrails/frank_karaoke.git
</span></span><span class="line"><span class="cl"><span class="nb">cd</span> frank_karaoke
</span></span><span class="line"><span class="cl">flutter pub get
</span></span><span class="line"><span class="cl">flutter run -d &lt;device_id&gt;</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The README has the rest.</p>
<p>Stack: Flutter + Riverpod for state management, <code>webview_flutter</code> for YouTube, <code>youtube_explode_dart</code> for audio extraction, <code>record</code> for PCM mic capture, <code>audio_decoder</code> for reference decoding via Android MediaCodec, and the YIN algorithm implemented in pure Dart.</p>
<p>The technical documentation for the scoring system is in <a href="https://github.com/akitaonrails/frank_karaoke/blob/main/docs/scoring.md"target="_blank" rel="noopener"><code>docs/scoring.md</code></a> in the repository. It covers how SingStar, Joysound and DAM work, the academic papers, the pitch oracle architecture, the voice isolation problems on Android, and the roadmap.</p>
<h2>The scoring is experimental<span class="hx:absolute hx:-mt-20" id="the-scoring-is-experimental"></span>
    <a href="#the-scoring-is-experimental" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>I have to be straight: the scoring system is experimental. Without per-song reference files, the evaluation is approximate. The app measures whether you&rsquo;re in tune, whether you follow a melodic contour, whether your intervals are musical, whether you&rsquo;re consistent. But it doesn&rsquo;t tell you whether you&rsquo;re singing the correct melody for this specific song (unless the pitch oracle manages to download the audio, and that doesn&rsquo;t always work).</p>
<p>If you have experience with audio processing, pitch detection, or music evaluation, the repository is open and the research documentation in <a href="https://github.com/akitaonrails/frank_karaoke/blob/main/docs/scoring.md"target="_blank" rel="noopener"><code>docs/scoring.md</code></a> details what was tried, what works and what doesn&rsquo;t. In particular: tuning the modes&rsquo; thresholds, improving voice isolation, and integrating with <a href="https://github.com/rakuri255/UltraSinger"target="_blank" rel="noopener">UltraSinger</a> (which generates reference files from songs using Demucs + basic-pitch + WhisperX) are areas where contribution from people who know the subject would make a real difference. I&rsquo;d appreciate any help from specialists on calibrating these systems.</p>
<p>Oh, and the name. Frank Karaoke. It&rsquo;s a tribute to Sinatra. Who else?</p>
<p>Project on GitHub: <a href="https://github.com/akitaonrails/frank_karaoke"target="_blank" rel="noopener">github.com/akitaonrails/frank_karaoke</a></p>
]]></content:encoded><category>flutter</category><category>android</category><category>karaoke</category><category>audio</category><category>pitch-detection</category><category>open-source</category></item><item><title>Bitcoin on the Home Server: Sovereignty and Privacy with Coldcard, Sparrow and Fulcrum</title><link>https://www.akitaonrails.com/en/2026/04/01/bitcoin-on-the-home-server-sovereignty-with-coldcard-sparrow-fulcrum/</link><guid isPermaLink="true">https://www.akitaonrails.com/en/2026/04/01/bitcoin-on-the-home-server-sovereignty-with-coldcard-sparrow-fulcrum/</guid><pubDate>Wed, 01 Apr 2026 19:00:00 GMT</pubDate><description>&lt;p&gt;This post is a direct follow-up to my recent articles about the &lt;a href="https://www.akitaonrails.com/en/2026/03/31/migrating-my-home-server-with-claude-code/"&gt;new home server with openSUSE MicroOS&lt;/a&gt; and the &lt;a href="https://www.akitaonrails.com/en/2026/03/31/minisforum-ms-s1-max-amd-ai-max-395-review/"&gt;Minisforum MS-S1 Max&lt;/a&gt;. Those covered the foundation. Here I want to show one concrete use for it: putting together a decent Bitcoin stack at home, focused on privacy, operational sovereignty and safe transactions on my side.&lt;/p&gt;
&lt;p&gt;First things first: this isn&amp;rsquo;t an evangelism piece or a day-trading pitch. Quite the opposite. As I write this, on April 1, 2026, Bitcoin is around US$ 68k and close to R$ 391k, below the 2025 peaks. Plenty of people look at that and either panic or start fantasizing about leveraged trades. I think both reactions are wrong. There&amp;rsquo;s a &amp;ldquo;super cycle&amp;rdquo; thesis floating around based on institutional demand, spot ETFs and the lagged halving effect. Maybe. Maybe not. What I do know is that short-term candles don&amp;rsquo;t change the part I actually care about: infrastructure. If you need leverage to &amp;ldquo;speed up your gains,&amp;rdquo; you&amp;rsquo;re probably just speeding up your chances of getting liquidated.&lt;/p&gt;</description><content:encoded><![CDATA[<p>This post is a direct follow-up to my recent articles about the <a href="/en/2026/03/31/migrating-my-home-server-with-claude-code/">new home server with openSUSE MicroOS</a> and the <a href="/en/2026/03/31/minisforum-ms-s1-max-amd-ai-max-395-review/">Minisforum MS-S1 Max</a>. Those covered the foundation. Here I want to show one concrete use for it: putting together a decent Bitcoin stack at home, focused on privacy, operational sovereignty and safe transactions on my side.</p>
<p>First things first: this isn&rsquo;t an evangelism piece or a day-trading pitch. Quite the opposite. As I write this, on April 1, 2026, Bitcoin is around US$ 68k and close to R$ 391k, below the 2025 peaks. Plenty of people look at that and either panic or start fantasizing about leveraged trades. I think both reactions are wrong. There&rsquo;s a &ldquo;super cycle&rdquo; thesis floating around based on institutional demand, spot ETFs and the lagged halving effect. Maybe. Maybe not. What I do know is that short-term candles don&rsquo;t change the part I actually care about: infrastructure. If you need leverage to &ldquo;speed up your gains,&rdquo; you&rsquo;re probably just speeding up your chances of getting liquidated.</p>
<p>For me, the useful question isn&rsquo;t &ldquo;is it going up tomorrow?&rdquo; The useful question is: &ldquo;if I want to store and move Bitcoin without outsourcing everything to an exchange, a web wallet and a public API, how do I set that up properly at home?&rdquo;</p>
<h2>The real problem: too much convenience costs too much privacy<span class="hx:absolute hx:-mt-20" id="the-real-problem-too-much-convenience-costs-too-much-privacy"></span>
    <a href="#the-real-problem-too-much-convenience-costs-too-much-privacy" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Most people&rsquo;s default flow is simple: buy on an exchange, leave the balance sitting there, or install some random wallet on the phone and call it done. It works. It also concentrates risk and leaks metadata everywhere.</p>
<p>If you leave a balance on an exchange, you have custody risk. If you use a desktop wallet pointed at a public server, you have privacy risk. If you use a hardware wallet casually, bought second-hand on Mercado Livre, you have supply chain risk. Mix all that with hurry, and it gets worse.</p>
<p>That&rsquo;s why I ended up at a combination that, for someone technical who wants to run their own infra, feels pretty solid:</p>
<ul>
<li>Coldcard for cold storage</li>
<li>Sparrow Wallet on Linux as the desktop wallet and transaction coordinator</li>
<li>Fulcrum on the home server as a private Electrum server</li>
<li>bitcoind on the same server as a real full node, validating the chain and broadcasting without depending on third parties</li>
</ul>
<p>It&rsquo;s not the easiest path. But that&rsquo;s exactly the point. Real security rarely comes from the easiest path.</p>
<h2>The concepts that confuse beginners<span class="hx:absolute hx:-mt-20" id="the-concepts-that-confuse-beginners"></span>
    <a href="#the-concepts-that-confuse-beginners" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Before getting into the stack, it&rsquo;s worth aligning on four terms that usually get tossed around like everyone already knows them:</p>
<table>
  <thead>
      <tr>
          <th>Concept</th>
          <th>What it is</th>
          <th>Why it matters</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Airgap</td>
          <td>A device that never touches the internet, not even over data USB</td>
          <td>Reduces the signer&rsquo;s attack surface</td>
      </tr>
      <tr>
          <td>PSBT</td>
          <td>Partially Signed Bitcoin Transaction</td>
          <td>Standard format for preparing, signing and finalizing transactions in stages</td>
      </tr>
      <tr>
          <td>Watch-only wallet</td>
          <td>A wallet that sees balances/addresses but doesn&rsquo;t hold a private key</td>
          <td>Great for the desktop: it observes and assembles the transaction, but doesn&rsquo;t sign</td>
      </tr>
      <tr>
          <td>Full node</td>
          <td>A node that validates blocks and protocol rules locally</td>
          <td>You don&rsquo;t have to &ldquo;trust&rdquo; anyone&rsquo;s API</td>
      </tr>
      <tr>
          <td>Electrum server</td>
          <td>An indexing layer that quickly answers wallet queries</td>
          <td>Without one, desktop wallets end up dependent on public servers</td>
      </tr>
  </tbody>
</table>
<p>In plain language, the flow looks like this:</p>
<ol>
<li>Sparrow, on the desktop, builds the transaction.</li>
<li>That transaction becomes a PSBT.</li>
<li>The PSBT goes to the Coldcard via microSD.</li>
<li>The Coldcard signs it offline.</li>
<li>The signed file goes back to Sparrow.</li>
<li>Sparrow broadcasts through your own server, not through someone else&rsquo;s public infrastructure.</li>
</ol>
<p>That&rsquo;s what people mean by &ldquo;airgapped workflow.&rdquo; It&rsquo;s not magic. It&rsquo;s just disciplined separation of roles.</p>
<h2>Coldcard: cold signer, offline, the right kind of annoying<span class="hx:absolute hx:-mt-20" id="coldcard-cold-signer-offline-the-right-kind-of-annoying"></span>
    <a href="#coldcard-cold-signer-offline-the-right-kind-of-annoying" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>I use <a href="https://coldcard.com/"target="_blank" rel="noopener">Coldcard</a> as cold storage. The reason is simple: it was designed from day one as a Bitcoin-only device, with a heavy focus on airgapped operation through microSD. That alone eliminates an entire category of &ldquo;conveniences&rdquo; that many people find practical, but that I&rsquo;d rather not have anywhere near my keys.</p>
<p><a href="https://coldcard.com/mk4"target="_blank" rel="noopener"><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/01/bitcoin-sovereignty/coldcard-mk4-official.png" alt="Coldcard Mk4 on the official Coinkite site"  loading="lazy" /></a></p>
<p>In practice, the Coldcard holds the most important part of the system: the private key. It doesn&rsquo;t need to know about a server, Electrum, public API, exchange, or any of that. Its job is one thing: sign transactions offline.</p>
<p>That decoupling is great for two reasons:</p>
<ul>
<li>The desktop can be convenient without becoming a single point of failure.</li>
<li>The signer stays isolated even if your main machine has problems.</li>
</ul>
<p>And here&rsquo;s a warning I really want to put in mental all-caps:</p>
<p><strong>Never buy a hardware wallet second-hand. Ever.</strong></p>
<p>This isn&rsquo;t an exaggeration. You have no way to actually know what happened to that device before it reached your hands. It could have a pre-generated seed, tampered firmware, swapped components, repackaged box, compromised supply chain, or simply some dumb trick waiting for you to let your guard down. Hardware wallet is one of those categories where saving R$ 300 buying used is insanity. Always buy from the manufacturer&rsquo;s official site or from a reseller officially authorized by the manufacturer. And even then, check seals, provenance and firmware.</p>
<h2>Can you do something similar with an old phone?<span class="hx:absolute hx:-mt-20" id="can-you-do-something-similar-with-an-old-phone"></span>
    <a href="#can-you-do-something-similar-with-an-old-phone" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>You can. But I&rsquo;d treat it as a study or budget alternative, not as an obvious substitute for a Coldcard.</p>
<p>The most serious path for that today is <a href="https://airgap.it/"target="_blank" rel="noopener">AirGap Vault</a>, which was specifically designed to use an old smartphone as an offline signer over QR codes, keeping the device off the network. The idea is good, and for many people it might be the right entry point.</p>
<p>But there are trade-offs:</p>
<ul>
<li>An old smartphone wasn&rsquo;t designed as a dedicated hardware wallet</li>
<li>The device&rsquo;s prior history matters</li>
<li>An aged battery, bad screen and abandoned Android are real problems</li>
<li>The threat model is less clear than on a dedicated device</li>
</ul>
<p>So my view is simple: can you use it? Yes. Would I recommend it as the main solution for storing meaningful wealth? No. For that I still prefer dedicated hardware bought from the right source.</p>
<h2>Sparrow Wallet: the best desktop piece in this puzzle<span class="hx:absolute hx:-mt-20" id="sparrow-wallet-the-best-desktop-piece-in-this-puzzle"></span>
    <a href="#sparrow-wallet-the-best-desktop-piece-in-this-puzzle" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>On Linux, I use <a href="https://sparrowwallet.com/"target="_blank" rel="noopener">Sparrow Wallet</a>. For me, today, it&rsquo;s one of the best pieces of software in this ecosystem.</p>
<p><a href="https://www.sparrowwallet.com/features/"target="_blank" rel="noopener"><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/01/bitcoin-sovereignty/sparrow-transactions.png" alt="Sparrow Wallet running, showing a detailed history of the wallet and transactions"  loading="lazy" /></a></p>
<p>What I like about it:</p>
<ul>
<li>works very well on Linux desktop</li>
<li>supports hardware wallets properly</li>
<li>understands PSBT without drama</li>
<li>makes it crystal clear what&rsquo;s happening in a transaction</li>
<li>it&rsquo;s great as a watch-only wallet</li>
</ul>
<p>In my flow, Sparrow does three things:</p>
<ol>
<li>Holds the watch-only wallet.</li>
<li>Builds the transaction with outputs and fees.</li>
<li>Receives the signature back from the Coldcard and broadcasts it.</li>
</ol>
<p>That separation is elegant. The desktop becomes the coordinator. The signer stays cold.</p>
<h2>Why Coldcard + Sparrow works so well<span class="hx:absolute hx:-mt-20" id="why-coldcard--sparrow-works-so-well"></span>
    <a href="#why-coldcard--sparrow-works-so-well" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>This combo is good because each piece does what it does best:</p>
<ul>
<li>the Coldcard protects the key</li>
<li>Sparrow organizes the human use of the wallet</li>
<li>the server handles the infrastructure</li>
</ul>
<p>A lot of wallets try to do everything. I prefer this modular design. It&rsquo;s less &ldquo;magic,&rdquo; more explicit, and easier to reason about without lying to yourself.</p>
<p>If I&rsquo;m at the desktop, I want visibility. If I&rsquo;m at the signer, I want isolation. If I&rsquo;m at the server, I want validation and a local index. That division is clean.</p>
<h2>The Sparrow problem when you don&rsquo;t run your own infra<span class="hx:absolute hx:-mt-20" id="the-sparrow-problem-when-you-dont-run-your-own-infra"></span>
    <a href="#the-sparrow-problem-when-you-dont-run-your-own-infra" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Now comes the important detail. Sparrow alone doesn&rsquo;t solve privacy.</p>
<p>If you install it, open it and just use public servers, the people on the other end learn quite a lot about your wallet: your address set, xpubs or derivations, balance, history, query behavior, broadcast. It&rsquo;s not custody, but it&rsquo;s still exposure.</p>
<p>That&rsquo;s the hole Fulcrum fills.</p>
<h2>Fulcrum: the home&rsquo;s private Electrum server<span class="hx:absolute hx:-mt-20" id="fulcrum-the-homes-private-electrum-server"></span>
    <a href="#fulcrum-the-homes-private-electrum-server" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p><a href="https://github.com/cculianu/Fulcrum"target="_blank" rel="noopener">Fulcrum</a> is an Electrum server. Instead of letting Sparrow ask things of a third-party public server, it asks my own server.</p>
<p>In practice, that means:</p>
<ul>
<li>local balance lookups</li>
<li>local history</li>
<li>local address discovery</li>
<li>local broadcast</li>
</ul>
<p>In other words: the desktop wallet stops &ldquo;phoning home&rdquo; to the world every time you open the program.</p>
<p>In my current setup, Sparrow points at a Fulcrum running on the home server on the LAN, with port <code>50001</code> on the internal network and <code>50002</code> with TLS.</p>
<h2>And why Fulcrum isn&rsquo;t enough on its own<span class="hx:absolute hx:-mt-20" id="and-why-fulcrum-isnt-enough-on-its-own"></span>
    <a href="#and-why-fulcrum-isnt-enough-on-its-own" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Because Fulcrum doesn&rsquo;t replace a full node. It indexes on top of a full node.</p>
<p>The thing actually validating blocks, consensus rules, scripts, transactions and the chain is <code>bitcoind</code>. Fulcrum sits in front of it as an indexing layer, because plain Bitcoin Core wasn&rsquo;t built to serve a desktop wallet with that kind of fast querying.</p>
<p>So the correct architecture is:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Coldcard (offline signer)
</span></span><span class="line"><span class="cl">        ^
</span></span><span class="line"><span class="cl">        | microSD / PSBT
</span></span><span class="line"><span class="cl">        v
</span></span><span class="line"><span class="cl">Sparrow Wallet (desktop watch-only + coordinator)
</span></span><span class="line"><span class="cl">        |
</span></span><span class="line"><span class="cl">        v
</span></span><span class="line"><span class="cl">Fulcrum (private Electrum server)
</span></span><span class="line"><span class="cl">        |
</span></span><span class="line"><span class="cl">        v
</span></span><span class="line"><span class="cl">bitcoind (full node)</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<h2>What I actually brought up on the home server<span class="hx:absolute hx:-mt-20" id="what-i-actually-brought-up-on-the-home-server"></span>
    <a href="#what-i-actually-brought-up-on-the-home-server" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>On my home server, the stack lives in a dedicated Docker Compose folder and is made of two containers:</p>
<ul>
<li><code>bitcoin-bitcoind</code></li>
<li><code>bitcoin-fulcrum</code></li>
</ul>
<p>The compose is simple. And that&rsquo;s good. Sensitive infra doesn&rsquo;t gain anything by getting clever in YAML.</p>
<p>The main design is this:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">services</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">bitcoin</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">lncm/bitcoind:v28.0</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">container_name</span><span class="p">:</span><span class="w"> </span><span class="l">bitcoin-bitcoind</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">user</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;${BITCOIN_UID}:${BITCOIN_GID}&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">always</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">security_opt</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">label:disable</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">/srv/bitcoin/data:/data/.bitcoin</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">ports</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;8333:8333&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">stop_grace_period</span><span class="p">:</span><span class="w"> </span><span class="l">5m</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">healthcheck</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">test</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&#34;CMD&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;bitcoin-cli&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;-datadir=/data/.bitcoin&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;ping&#34;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">interval</span><span class="p">:</span><span class="w"> </span><span class="l">30s</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">timeout</span><span class="p">:</span><span class="w"> </span><span class="l">10s</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">retries</span><span class="p">:</span><span class="w"> </span><span class="m">5</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">start_period</span><span class="p">:</span><span class="w"> </span><span class="l">60s</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">fulcrum</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">cculianu/fulcrum:latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">container_name</span><span class="p">:</span><span class="w"> </span><span class="l">bitcoin-fulcrum</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">always</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">security_opt</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">label:disable</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">/srv/bitcoin/fulcrum:/data</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">/srv/bitcoin/data:/bitcoin:ro</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">command</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&#34;Fulcrum&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;/data/fulcrum.conf&#34;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">ports</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;50001:50001&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;50002:50002&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">depends_on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">bitcoin</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">condition</span><span class="p">:</span><span class="w"> </span><span class="l">service_healthy</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>In my case, the restriction of <code>50001</code> to LAN happens at the host&rsquo;s network layer. The YAML above is the skeleton of the stack, not the entire firewall policy.</p>
<p>The most important parts of this:</p>
<ul>
<li><code>restart: always</code> because this is a long-running service</li>
<li>explicit volume so state isn&rsquo;t lost</li>
<li><code>user: &quot;${BITCOIN_UID}:${BITCOIN_GID}&quot;</code> because the persistent directory needs to match the storage&rsquo;s real ownership, so I&rsquo;d rather pin UID/GID explicitly than trust the image&rsquo;s default</li>
<li>the RPC isn&rsquo;t published on the host; it stays on the Compose internal network, which is all Fulcrum needs</li>
<li>the healthcheck uses Bitcoin Core&rsquo;s own local <code>.cookie</code>, so there&rsquo;s no need to spread a fixed password through commands</li>
<li>Fulcrum mounts the node&rsquo;s datadir as read-only just to authenticate via the <code>.cookie</code> without inventing parallel credentials</li>
<li>in <code>fulcrum.conf</code>, that becomes a simple configuration: talk to <code>bitcoin:8332</code> and read the mounted <code>.cookie</code>, instead of repeating credentials in plaintext</li>
<li><code>security_opt: label:disable</code> because on this host with MicroOS, SELinux and sensitive bind mounts, I preferred the pragmatic route of disarming this specific friction rather than wasting time fighting labels on a volume that&rsquo;s already being handled in a controlled way</li>
<li><code>depends_on</code> with <code>service_healthy</code> so Fulcrum only comes up after bitcoind&rsquo;s RPC is responding</li>
<li><code>stop_grace_period: 5m</code> because bitcoind needs real time to flush state on a graceful shutdown</li>
</ul>
<h2>The final version<span class="hx:absolute hx:-mt-20" id="the-final-version"></span>
    <a href="#the-final-version" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Today, the design I want to keep is this: <code>bitcoind</code> with <code>txindex</code>, <code>dbcache=1024</code>, persistent volume, 5-minute graceful stop, <code>.cookie</code> authentication, and Fulcrum in front serving Sparrow over LAN or TLS.</p>
<p>The current stack looks like this:</p>
<table>
  <thead>
      <tr>
          <th>Component</th>
          <th>State</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Bitcoin Core</td>
          <td><code>28.0</code></td>
      </tr>
      <tr>
          <td>Fulcrum</td>
          <td><code>2.1.0</code></td>
      </tr>
      <tr>
          <td>Container stop timeout</td>
          <td><code>300</code> seconds</td>
      </tr>
      <tr>
          <td>Node data dir</td>
          <td>dedicated persistent volume mounted at <code>/data/.bitcoin</code></td>
      </tr>
      <tr>
          <td>Network</td>
          <td><code>8333</code> for P2P, RPC only on the Compose internal network, <code>50001/50002</code> for the private Electrum</td>
      </tr>
  </tbody>
</table>
<p>I&rsquo;m not interested in turning this into a spectacle. The point is simpler: the final infrastructure has to be boring, predictable and stable.</p>
<h2>The tunings that actually matter<span class="hx:absolute hx:-mt-20" id="the-tunings-that-actually-matter"></span>
    <a href="#the-tunings-that-actually-matter" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>There&rsquo;s no magic here. There are a few parameters that make a real difference and a bunch of stuff that just decorates compose files.</p>
<p><code>stop_grace_period: 5m</code> exists because bitcoind isn&rsquo;t a disposable stateless API container. It maintains chainstate, indexes and an in-memory cache. If you don&rsquo;t give the process time to finish properly, you create unnecessary work for the next start.</p>
<p><code>user: &quot;${BITCOIN_UID}:${BITCOIN_GID}&quot;</code> is there for a much less glamorous and much more important reason: persistent storage with the wrong permissions is an excellent way to break a working service. So I&rsquo;d rather align the container with the volume&rsquo;s actual ownership instead of leaving that implicit.</p>
<p><code>dbcache=1024</code> is the spot I find most reasonable for a domestic node that&rsquo;s always on. Big enough not to suffer constant I/O, small enough that every restart isn&rsquo;t a labor.</p>
<p><code>txindex=1</code> I keep because I want the complete node, not a minimalist install just to claim &ldquo;it runs Bitcoin.&rdquo; If the goal here is operational autonomy, I&rsquo;d rather have the full index.</p>
<p><code>rpcworkqueue=512</code> and <code>rpcthreads=16</code> are the kind of tweak that makes sense when you know you&rsquo;ll have Fulcrum querying the node all day and you want some headroom.</p>
<p>On the Fulcrum side, the main parameters are:</p>
<ul>
<li><code>db_mem = 8192</code></li>
<li><code>db_max_open_files = -1</code></li>
<li><code>bitcoind_clients = 8</code></li>
<li><code>worker_threads = 0</code></li>
<li><code>peering = false</code></li>
</ul>
<p>Again: nothing esoteric. Just enough cache, reasonable parallelism and absolutely no announcing this server as a public service.</p>
<p>In my current <code>bitcoin.conf</code>, the important core ended up like this:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="cl"><span class="na">server</span><span class="o">=</span><span class="s">1</span>
</span></span><span class="line"><span class="cl"><span class="na">txindex</span><span class="o">=</span><span class="s">1</span>
</span></span><span class="line"><span class="cl"><span class="na">prune</span><span class="o">=</span><span class="s">0</span>
</span></span><span class="line"><span class="cl"><span class="na">rpcbind</span><span class="o">=</span><span class="s">0.0.0.0</span>
</span></span><span class="line"><span class="cl"><span class="na">rpcallowip</span><span class="o">=</span><span class="s">172.0.0.0/8</span>
</span></span><span class="line"><span class="cl"><span class="na">rpcthreads</span><span class="o">=</span><span class="s">16</span>
</span></span><span class="line"><span class="cl"><span class="na">rpcworkqueue</span><span class="o">=</span><span class="s">512</span>
</span></span><span class="line"><span class="cl"><span class="na">dbcache</span><span class="o">=</span><span class="s">1024</span>
</span></span><span class="line"><span class="cl"><span class="na">maxmempool</span><span class="o">=</span><span class="s">512</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>All of this makes sense on a server with decent RAM and fast NVMe. But the detail that matters most is still the clean shutdown. Wallet infrastructure has no room for &ldquo;we&rsquo;ll deal with it later&rdquo; thinking.</p>
<h2>The actual size of all this<span class="hx:absolute hx:-mt-20" id="the-actual-size-of-all-this"></span>
    <a href="#the-actual-size-of-all-this" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>This is another point a lot of people underestimate.</p>
<p>If you look at older Bitcoin Core documentation, you&rsquo;ll find numbers like 350 GB of disk for a node with default config. That&rsquo;s outdated. More current data on the size of the blockchain points to something around <strong>725.82 GB on March 11, 2026</strong>, and that&rsquo;s just the raw chain, without the extra indexes that many technical folks will want to keep.</p>
<p>And here comes the catch: the stack I&rsquo;m describing isn&rsquo;t &ldquo;a bare Bitcoin Core just to claim you run a node.&rdquo; It&rsquo;s <code>bitcoind</code> with <code>txindex</code>, plus Fulcrum, plus headroom for rebuild, logs, snapshots and normal network growth.</p>
<p>So to put together something similar today, I&rsquo;d think like this:</p>
<ul>
<li>below 1 TB: I wouldn&rsquo;t even start</li>
<li>1 TB: pragmatic minimum</li>
<li>2 TB: comfortable range</li>
<li>above that: if you want long-term headroom, snapshots and less operational anxiety</li>
</ul>
<p>And here&rsquo;s the most important observation of all in self-hosting: don&rsquo;t assume persistence, mount and backup are right just because the YAML looks clean. Verify.</p>
<p>Another thing I wouldn&rsquo;t forget on a btrfs host: put Fulcrum&rsquo;s database (<code>fulc2_db</code>) on a separate subvolume. The reason is mundane. That directory grows, changes constantly and has nothing to do with generic automated snapshots of <code>/var</code>. If you mix everything, you end up dragging a large rebuildable index along with system snapshots, burning space and making maintenance more annoying than it needed to be. The Fulcrum index isn&rsquo;t sensitive configuration. It&rsquo;s heavy, volatile, rebuildable data. I treat it exactly like that.</p>
<h2>Hardening: what I&rsquo;ve already applied<span class="hx:absolute hx:-mt-20" id="hardening-what-ive-already-applied"></span>
    <a href="#hardening-what-ive-already-applied" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>This is where the difference between &ldquo;ran on my laptop&rdquo; and &ldquo;I&rsquo;d trust this to operate my wallet&rdquo; shows up.</p>
<p>In the current state of the stack, the points I consider important ended up like this:</p>
<ul>
<li>Bitcoin Core&rsquo;s RPC no longer relies on unnecessary host exposure; Fulcrum talks to <code>bitcoind</code> over the Docker internal network, which is what actually matters</li>
<li><code>50001</code> is restricted to internal LAN use</li>
<li><code>50002</code> is available with TLS, which is the right move when you need to leave plaintext behind</li>
<li>shutdown is graceful, with <code>stop_grace_period: 5m</code>, so <code>bitcoind</code> has time to flush state instead of dying any old way</li>
<li>the storage mount isn&rsquo;t on a &ldquo;we&rsquo;ll see later&rdquo; basis; there&rsquo;s a mount check before Docker comes up, precisely to avoid silent drift</li>
</ul>
<p>Each of those items exists for a very concrete reason.</p>
<p>Pulling the RPC off the host&rsquo;s surface reduces attack at zero cost. Fulcrum is already in the same Compose and can already talk to the service by its internal name. There&rsquo;s no real gain in leaving that port exposed where it doesn&rsquo;t need to be exposed.</p>
<p>Separating <code>50001</code> and <code>50002</code> also helps keep the house in order. Within a controlled LAN, plaintext is acceptable. Outside of that, the minimum reasonable thing is TLS. Mixing the two scenarios usually turns into a mess.</p>
<p><code>stop_grace_period: 5m</code> looks like a container detail, but it isn&rsquo;t. Anyone who&rsquo;s ever had a database, an index or a blockchain node killed without grace knows how that turns into hours of work later. A stateful service needs a decent stop.</p>
<p>And the mount check is one of those annoying things that saves you from yourself. The YAML can look beautiful. If the storage didn&rsquo;t mount and the service came up writing where it shouldn&rsquo;t, you&rsquo;ve just manufactured a really irritating problem.</p>
<p>There&rsquo;s also one detail I really like in this final version of the stack: Fulcrum authenticates to <code>bitcoind</code> through the <code>.cookie</code> file, not through a fixed plaintext password. That&rsquo;s interesting for two reasons:</p>
<ul>
<li>you don&rsquo;t need to leave a static credential showing up in compose, inspect, healthcheck or documentation</li>
<li>the authentication is more aligned with the way Bitcoin Core already knows how to operate locally</li>
</ul>
<p>In practical terms, that reduces accidental leakage of operational secrets. It&rsquo;s not a magic solution to everything, but it&rsquo;s much better than spreading <code>rpcuser</code> and <code>rpcpassword</code> across files, logs and commands.</p>
<p>The only kind of hardening I try to avoid here is the one that&rsquo;s overly performative in YAML and loose in operation. I&rsquo;d rather have less &ldquo;stage engineering&rdquo; and more basic discipline:</p>
<ul>
<li>minimum network</li>
<li>minimum secrets</li>
<li>minimum privilege</li>
<li>clean shutdown</li>
<li>verified storage</li>
<li>separate subvolume for large rebuildable data, like the Fulcrum index</li>
</ul>
<p>And, again, document everything. Good infrastructure isn&rsquo;t the kind that just works today. It&rsquo;s the kind that keeps working when you come back to it six months later.</p>
<h2>Why this improves transactions on your side<span class="hx:absolute hx:-mt-20" id="why-this-improves-transactions-on-your-side"></span>
    <a href="#why-this-improves-transactions-on-your-side" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>When I build a transaction in Sparrow and sign it on the Coldcard, the chain of trust is much better defined:</p>
<ul>
<li>the private key never touches the internet</li>
<li>the desktop wallet doesn&rsquo;t have to trust a public server</li>
<li>the broadcast can come out of my own node</li>
<li>the address history doesn&rsquo;t need to land on a third-party Electrum server</li>
</ul>
<p>This doesn&rsquo;t make anything invulnerable. There&rsquo;s still a risk of malware on the desktop, badly stored seeds, human error, social engineering and physical disaster. But the design becomes much more coherent.</p>
<h2>What about Lightning? Especially in Brazil?<span class="hx:absolute hx:-mt-20" id="what-about-lightning-especially-in-brazil"></span>
    <a href="#what-about-lightning-especially-in-brazil" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>There I separate things.</p>
<p>Reserves and larger-value transactions I treat one way. Day-to-day spending I treat another.</p>
<p>For day-to-day spending, especially in Brazil, I think it&rsquo;s operationally dumb to carry a lot of balance in a hot wallet. Lightning wallets and spending apps have to be almost like a &ldquo;pocket wallet&rdquo;: just enough for daily life.</p>
<p>That goes double if you use a hybrid or custodial solution like <a href="https://www.redotpay.com/"target="_blank" rel="noopener">RedotPay</a>. I get why it&rsquo;s interesting for Brazilians: a Hong Kong company, international focus, a reasonably practical bridge between crypto and card spending. For travel, online shopping and life outside the Brazilian banking axis, it makes sense. But I&rsquo;d never treat it as a place to store wealth. That&rsquo;s a spending tool, not a vault.</p>
<p>Same logic for <a href="https://www.bitrefill.com/br/pt/"target="_blank" rel="noopener">Bitrefill Brasil</a>. I think the service is interesting precisely because it solves a real pain in Brazil: turning sats into concrete utility without selling your full position or depending on banking integration all the time. Gift cards, top-ups, small expenses. As a use tool, it makes a lot of sense.</p>
<p>For a Lightning wallet on the phone, I&rsquo;d look at first:</p>
<ul>
<li><a href="https://phoenix.acinq.co/"target="_blank" rel="noopener">Phoenix</a> for people who want something very good and simple</li>
<li><a href="https://breez.technology/"target="_blank" rel="noopener">Breez</a> for people who want a great payments experience</li>
<li><a href="https://zeusln.com/"target="_blank" rel="noopener">ZEUS</a> if you&rsquo;re more technical and you eventually plan to operate your own Lightning node</li>
</ul>
<p>All of them, in my head, fit into the &ldquo;pocket wallet&rdquo; category. Small balance. Daily use. Don&rsquo;t turn a phone app into a retirement vault.</p>
<h2>Recent news that reinforces this reasoning<span class="hx:absolute hx:-mt-20" id="recent-news-that-reinforces-this-reasoning"></span>
    <a href="#recent-news-that-reinforces-this-reasoning" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>I&rsquo;m not building this kind of stack because I think it&rsquo;s pretty. I&rsquo;m building it because outsourcing too much goes wrong too often.</p>
<p>Two recent examples:</p>
<ul>
<li>the <a href="https://www.cnbc.com/2025/02/21/hackers-steal-1point5-billion-from-exchange-bybit-biggest-crypto-heist.html"target="_blank" rel="noopener">Bybit hack in 2025</a> showed, again, the basic risk of leaving meaningful custody at an exchange</li>
<li>the <a href="https://techcrunch.com/2025/05/15/coinbase-says-customers-personal-information-stolen-in-data-breach/"target="_blank" rel="noopener">Coinbase customer data leak in 2025</a> showed the other side of the problem: even when custody isn&rsquo;t the immediate concern, your identity, balance and history become an attack surface</li>
</ul>
<p>A stack like Coldcard + Sparrow + Fulcrum + full node doesn&rsquo;t eliminate every risk in the world. But it avoids two very real classes of problem:</p>
<ul>
<li>losing custody sovereignty</li>
<li>handing over wallet and transaction privacy on a silver platter to third parties</li>
</ul>
<h2>So is it worth it?<span class="hx:absolute hx:-mt-20" id="so-is-it-worth-it"></span>
    <a href="#so-is-it-worth-it" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>For most people, honestly, probably not in the first month. It&rsquo;s labor-intensive, has a learning curve, and demands discipline.</p>
<p>But for programmers, engineers and any technical person who wants to learn not to depend on someone else&rsquo;s service all the time, I think it&rsquo;s an excellent exercise.</p>
<p>You learn about:</p>
<ul>
<li>separation of concerns</li>
<li>persistence and state</li>
<li>graceful shutdown</li>
<li>observability</li>
<li>secret isolation</li>
<li>the trade-off between convenience and security</li>
</ul>
<p>And all of that is valuable beyond Bitcoin.</p>
<p>In the end, that&rsquo;s what interests me most about this stack. It isn&rsquo;t about preaching &ldquo;hyperbitcoinization&rdquo; or posing as a price prophet. It&rsquo;s about building a system at home that I can trust more because I&rsquo;m the one who installed it, measured it, broke it, fixed it and documented it.</p>
<p>Is it work? Yes.</p>
<p>But that kind of work teaches exactly what modern software tries to make you forget: depending less on others is more work upfront, but it usually buys a lot more control in the long run.</p>
]]></content:encoded><category>bitcoin</category><category>homeserver</category><category>self-hosting</category><category>privacy</category><category>security</category><category>lightning</category></item><item><title>My Sim Racing Cockpit - Formula FX1</title><link>https://www.akitaonrails.com/en/2026/04/01/my-sim-racing-cockpit-formula-fx1/</link><guid isPermaLink="true">https://www.akitaonrails.com/en/2026/04/01/my-sim-racing-cockpit-formula-fx1/</guid><pubDate>Wed, 01 Apr 2026 17:00:00 GMT</pubDate><description>&lt;p&gt;I&amp;rsquo;ve loved cars for as long as I can remember. My first real contact with racing games was at the arcades of the 80s and 90s. And when I say &amp;ldquo;real,&amp;rdquo; I mean sitting at a cabinet with a wheel, pedals, a hard plastic seat and the screen wrapping in front of you. &lt;a href="https://en.wikipedia.org/wiki/Out_Run"target="_blank" rel="noopener"&gt;OutRun&lt;/a&gt; (1986), &lt;a href="https://en.wikipedia.org/wiki/Rad_Mobile"target="_blank" rel="noopener"&gt;Rad Mobile&lt;/a&gt; (1991), &lt;a href="https://en.wikipedia.org/wiki/Virtua_Racing"target="_blank" rel="noopener"&gt;Virtua Racing&lt;/a&gt; (1992), &lt;a href="https://en.wikipedia.org/wiki/Ridge_Racer"target="_blank" rel="noopener"&gt;Ridge Racer&lt;/a&gt; (1993), &lt;a href="https://en.wikipedia.org/wiki/Daytona_USA_%28video_game%29"target="_blank" rel="noopener"&gt;Daytona USA&lt;/a&gt; (1994), &lt;a href="https://en.wikipedia.org/wiki/Scud_Race"target="_blank" rel="noopener"&gt;Scud Race&lt;/a&gt; (1996). Every one of those games left a mark on me. But Daytona USA stuck in a different way. That twin cabinet, two machines side by side, the &amp;ldquo;DAYTONAAA, let&amp;rsquo;s go away&amp;rdquo; track blasting through the arcade, the wheel rumbling in your hand. I still remember it.&lt;/p&gt;</description><content:encoded><![CDATA[<p>I&rsquo;ve loved cars for as long as I can remember. My first real contact with racing games was at the arcades of the 80s and 90s. And when I say &ldquo;real,&rdquo; I mean sitting at a cabinet with a wheel, pedals, a hard plastic seat and the screen wrapping in front of you. <a href="https://en.wikipedia.org/wiki/Out_Run"target="_blank" rel="noopener">OutRun</a> (1986), <a href="https://en.wikipedia.org/wiki/Rad_Mobile"target="_blank" rel="noopener">Rad Mobile</a> (1991), <a href="https://en.wikipedia.org/wiki/Virtua_Racing"target="_blank" rel="noopener">Virtua Racing</a> (1992), <a href="https://en.wikipedia.org/wiki/Ridge_Racer"target="_blank" rel="noopener">Ridge Racer</a> (1993), <a href="https://en.wikipedia.org/wiki/Daytona_USA_%28video_game%29"target="_blank" rel="noopener">Daytona USA</a> (1994), <a href="https://en.wikipedia.org/wiki/Scud_Race"target="_blank" rel="noopener">Scud Race</a> (1996). Every one of those games left a mark on me. But Daytona USA stuck in a different way. That twin cabinet, two machines side by side, the &ldquo;DAYTONAAA, let&rsquo;s go away&rdquo; track blasting through the arcade, the wheel rumbling in your hand. I still remember it.</p>
<p><img src="https://upload.wikimedia.org/wikipedia/commons/2/24/DaytonaUSA_arcade_SaoPaulo.jpg" alt="Daytona USA machines at a São Paulo mall — exactly the kind of twin cabinet that’s burned into my memory"  loading="lazy" /></p>
<p>But the game that really got me hooked on the simcade genre was the original <a href="https://en.wikipedia.org/wiki/Gran_Turismo_%28video_game%29"target="_blank" rel="noopener">Gran Turismo</a>, in 1997, on the PlayStation 1. &ldquo;The Real Driving Simulator&rdquo; on the cover. I played that game obsessively. Around the same time I was watching the <a href="https://en.wikipedia.org/wiki/Initial_D"target="_blank" rel="noopener">Initial D</a> anime, which premiered in 1998 in Japan. I bought every manga volume and read all of it from start to finish. The story of Takumi Fujiwara going down Mount Akina at dawn delivering tofu in his father&rsquo;s AE86 is, to me, one of the best motorsport stories ever told in any medium.</p>
<p>I still follow Shuichi Shigeno&rsquo;s work today. After Initial D came <a href="https://kodansha.us/series/mf-ghost/"target="_blank" rel="noopener">MF Ghost</a> (2017-2025), set in the same universe but in a near future where combustion cars have become museum pieces. And now, since July 2025, I&rsquo;m reading <a href="https://kmanga.kodansha.com/title/10664/episode/360584"target="_blank" rel="noopener">Subaru and Subaru</a>, the direct sequel that ties the Initial D and MF Ghost universes together with two protagonists named Subaru — one from Gunma, one from Kanagawa — competing in a new racing series. It&rsquo;s Shigeno at his best.</p>
<p><a href="https://kmanga.kodansha.com/title/10664/episode/360584"target="_blank" rel="noopener"><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/01/cockpit/subaru-x-subaru-cover.png" alt="Subaru and Subaru — Shigeno’s new manga, the direct sequel to Initial D and MF Ghost"  loading="lazy" /></a></p>
<p>Two years ago I traveled to Japan with my girlfriend and made a point of going to <a href="https://en.wikipedia.org/wiki/Daikoku_Parking_Area"target="_blank" rel="noopener">Daikoku PA</a>, the famous parking area on the Shuto Expressway in Yokohama where the JDM culture concentrates. As an old fan of <a href="https://store.steampowered.com/app/2634950/Tokyo_Xtreme_Racer/"target="_blank" rel="noopener">Tokyo Xtreme Racer</a>, by Genki, I needed to see Daikoku with my own eyes at least once. And it didn&rsquo;t disappoint. Instead of renting a car, we booked a tour with a local guide in his prepped Nissan GT-R. Better that way. On the drive there he explained the history of the Wangan, how the scene works, what&rsquo;s YouTube exaggeration and what&rsquo;s real. When we got there on a Friday night and I saw the whole thing in person — Skyline R34, RX-7, Supra, GT-R, tuned kei trucks, insane bosozoku — the feeling was strange in the best possible way. It looked like Tokyo Xtreme Racer, except with the smell of fuel in the air and the sound of real exhaust pipes.</p>
<p>And there&rsquo;s another detail: I&rsquo;m playing the new <a href="https://store.steampowered.com/app/2634950/Tokyo_Xtreme_Racer/"target="_blank" rel="noopener">Tokyo Xtreme Racer</a> reboot on PC, and it&rsquo;s exactly the kind of game that understands its own audience. Strong single-player campaign, addictive progression, the right vibe, and none of the loot box nonsense. I&rsquo;d recommend it without hesitation. For the same reason, I&rsquo;m also really looking forward to <a href="https://forza.net/forzahorizon6"target="_blank" rel="noopener">Forza Horizon 6</a>, which this time is going to be set in Japan. I&rsquo;ve already pre-ordered it and I can&rsquo;t wait to play it on the new cockpit.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/01/cockpit/daikoku---nissan.jpg" alt="At Daikoku PA with a modified GT-R"  loading="lazy" /></p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/01/cockpit/daikoku---trueno.jpg" alt="With an AE86 Trueno at Daikoku PA — Takumi’s car"  loading="lazy" /></p>
<h2>Driving for real<span class="hx:absolute hx:-mt-20" id="driving-for-real"></span>
    <a href="#driving-for-real" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Now that I&rsquo;m semi-retired, I&rsquo;ve had the chance to take my Mercedes to track days. I&rsquo;ve driven at <a href="https://en.wikipedia.org/wiki/Aut%C3%B3dromo_Jos%C3%A9_Carlos_Pace"target="_blank" rel="noopener">Autódromo de Interlagos</a> (the Autódromo José Carlos Pace), the 4.309 km circuit in São Paulo that&rsquo;s been hosting the Brazilian F1 GP since 1973, famous for the S do Senna corner complex and the circuit&rsquo;s wild elevation changes. I&rsquo;ve also driven at <a href="https://www.velocittapark.com.br/"target="_blank" rel="noopener">Autódromo Velocitta</a>, a modern 3.443 km circuit opened in 2014 in Mogi Guaçu in the interior of São Paulo, which hosts Stock Car Brasil and Porsche Cup.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/01/cockpit/velocitta.jpg" alt="At Velocitta with my Mercedes during an AMG track day"  loading="lazy" /></p>
<p>In Las Vegas I&rsquo;ve driven supercars on those track-day experiences. And when I traveled with my girlfriend to Gramado, in Rio Grande do Sul, we went to <a href="https://supercarros.cc/"target="_blank" rel="noopener">Super Carros</a>, which is on Av. das Hortênsias 4635. They have a 2,400 m² hangar with more than 50 cars — Ferraris, Lamborghinis, Porsches, GT-Rs, Corvettes, American muscle cars. You pick a car, head out with an instructor, and drive a roughly 17 km route between Gramado and Canela. I took out a Nissan GT-R and a Ferrari California.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/01/cockpit/gramado---gtr.jpg" alt="With a GT-R in Gramado"  loading="lazy" /></p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/01/cockpit/gramado---ferrari.jpg" alt="With a Ferrari California in Gramado"  loading="lazy" /></p>
<p>Three years ago I also went to Abu Dhabi with my girlfriend and we went to Ferrari World, which has some of the best racing simulators I&rsquo;ve ever tried. Hydraulic platform with 6 degrees of freedom, F1 cockpit, the works. I&rsquo;ve always loved testing simulators wherever I go.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/01/cockpit/ferrari-park.jpg" alt="F1 simulator with a hydraulic platform at Ferrari World in Abu Dhabi"  loading="lazy" /></p>
<p>But driving real cars on real tracks is a very expensive hobby. Tires, fuel, insurance, maintenance, registration. And more important: I&rsquo;m an introvert. I prefer being alone. My simulator cockpit is perfect for when I want to drive without having to deal with anyone. That&rsquo;s why I love rally so much — it&rsquo;s me, the virtual co-driver, and the road. Nothing else.</p>
<h2>The games I play<span class="hx:absolute hx:-mt-20" id="the-games-i-play"></span>
    <a href="#the-games-i-play" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>I know most people who build a cockpit like this do it to play serious sims — iRacing, Assetto Corsa Competizione, Automobilista 2. I respect that, but it isn&rsquo;t my thing. I don&rsquo;t like playing online with other people. I have zero intention of starting a live streaming career. This is purely for my own enjoyment.</p>
<p>These days I play Gran Turismo 7 on the PS5, <a href="https://store.steampowered.com/app/2440510/Forza_Motorsport/"target="_blank" rel="noopener">Forza Motorsport</a> (the 8, from 2023) on PC, but where I have the most fun is in rally games: <a href="https://store.steampowered.com/app/1849250/EA_SPORTS_WRC/"target="_blank" rel="noopener">EA SPORTS WRC</a>, <a href="https://store.steampowered.com/app/1462810/WRC_10_FIA_World_Rally_Championship/"target="_blank" rel="noopener">WRC 10</a> and <a href="https://store.steampowered.com/app/690790/DiRT_Rally_20/"target="_blank" rel="noopener">DiRT Rally 2.0</a>. My first experience with Forza was on the Xbox One with Forza Motorsport 5 and then Forza Horizon 4, which kept me hooked for hundreds of hours.</p>
<p>And I have a huge soft spot for retro games. The original Colin McRae Rally from 1998 on the PS1 was my first rally game. But my favorite of all time is Colin McRae Rally 2.0 (2000), also on the PS1. I recently played through the entire campaign again on the PC version — you can find repacks that run in high resolution and widescreen, much better than the original PlayStation versions. I&rsquo;d recommend that for any of the titles in the series.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/01/cockpit/colin-mcrae-rally-2-gameplay.jpg" alt="Colin McRae Rally 2.0 running in widescreen on PC — snow in Sweden with the Ford Focus"  loading="lazy" /></p>
<p>After that came Colin McRae 3 (2002), Colin McRae Rally 04 (2003) and Colin McRae 2005 (2004). Other arcades I revisit often: OutRun 2 SP (2004) and OutRun 2006: Coast 2 Coast (2006) — the best OutRun ever made, in my opinion.</p>
<p>But my game of the year, by a long shot, is <a href="https://store.steampowered.com/app/3218630/Super_Woden_Rally_Edge/"target="_blank" rel="noopener">Super Woden: Rally Edge</a>. An indie made by a solo developer (ViJuDa, from Spain) that launched in January 2026 for less than R$ 60. Eight countries, more than 80 cars, a career mode, local split-screen multiplayer for up to 4 players, online leaderboards. The behind-the-car camera instead of the top-down view of the previous Super Woden GP made all the difference. 96% positive reviews on Steam with more than 1,300 ratings. It&rsquo;s the kind of game that proves you don&rsquo;t need a million-dollar budget to make something amazing.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/01/cockpit/super-woden-rally-edge.jpg" alt="Super Woden: Rally Edge — indie made by a solo dev that competes with the big ones"  loading="lazy" /></p>
<h2>The evolution of my wheel setup<span class="hx:absolute hx:-mt-20" id="the-evolution-of-my-wheel-setup"></span>
    <a href="#the-evolution-of-my-wheel-setup" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><h3>Logitech G29 era (~2015-2021)<span class="hx:absolute hx:-mt-20" id="logitech-g29-era-2015-2021"></span>
    <a href="#logitech-g29-era-2015-2021" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>I always wanted a racing wheel. I started over 15 years ago with something equivalent to Logitech&rsquo;s entry-level wheel, a G29. The G29 is a fine wheel to start with — gear-driven force feedback, pedals with a clutch, 900 degrees of rotation. But its force feedback is noisy and a bit crude. You can feel the gears turning.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/01/cockpit/logitech-g29-and-myself.jpeg" alt="Me playing with the Logitech G29 on the couch — the beginning of it all"  loading="lazy" /></p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/01/cockpit/logitech-g29-with-support.jpg" alt="The G29 with a simple stand in front of the couch"  loading="lazy" /></p>
<h3>Thrustmaster T300RS + SXT V2 stand era (~2021-2024)<span class="hx:absolute hx:-mt-20" id="thrustmaster-t300rs--sxt-v2-stand-era-2021-2024"></span>
    <a href="#thrustmaster-t300rs--sxt-v2-stand-era-2021-2024" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Around 2021 I upgraded to the <a href="https://www.thrustmaster.com/products/t300rs-gt-edition/"target="_blank" rel="noopener">Thrustmaster T300RS</a>, a belt-driven wheel that&rsquo;s a huge jump up from the Logitech. The force feedback is much smoother and more precise. And I bought the <a href="https://loja.cockpitextremeracing.com.br/products/suporte-para-volantes-sxt-v2?variant=51111119454489"target="_blank" rel="noopener">Extreme Sim Racing SXT V2</a> stand, which is much sturdier than those generic desk clamps.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/01/cockpit/thrustmaster-with-support.jpg" alt="The Thrustmaster T300RS mounted on the SXT V2 stand"  loading="lazy" /></p>
<p>First I set it up in front of my desktop PC, which at the time had an RTX 3090. It worked, but it was a hassle to keep mounting and unmounting the stand and the cables every time I wanted to play.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/01/cockpit/thrustmaster-with-pc.jpg" alt="The Thrustmaster in front of the desktop PC — functional but annoying"  loading="lazy" /></p>
<p>Then I built a setup with a long fiber-optic HDMI cable to connect my 60&quot; TV to the PC at the back of the room. I moved the stand in front of the couch. Less of a hassle, but I still had to take it down whenever I wanted to watch a movie with my girlfriend.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/01/cockpit/thrustmaster-with-big-tv.jpg" alt="Setup with the big TV — better, but still a hassle"  loading="lazy" /></p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/01/cockpit/thrustmaster-in-the-living-room.jpg" alt="The stand in the living room — always in the way"  loading="lazy" /></p>
<p>Around 2024 or 2025 I swapped my couch for one of those VIP cinema couches from <a href="https://www.starseat.com.br/sofa-cinema-sofa-cinema"target="_blank" rel="noopener">Star Seat</a>, which reclines and the whole nine yards. The problem: it was way taller than the previous couch. I had to do all kinds of workarounds to make the stand work at that height. I even 3D-printed mounts and sent them to PCBWay to machine steel plates so I could attach big wheels under the stand and gain a few centimeters of height. But that left the setup way too wobbly to drive comfortably.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/01/cockpit/fanatec-support-sofa-too-high.jpg" alt="The problem: VIP couch too tall for the stand — total kludge"  loading="lazy" /></p>
<h3>Fanatec CSL DD + Direct Drive era (~2024-2025)<span class="hx:absolute hx:-mt-20" id="fanatec-csl-dd--direct-drive-era-2024-2025"></span>
    <a href="#fanatec-csl-dd--direct-drive-era-2024-2025" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>In the meantime I gave the T300 to my brother and upgraded to a <a href="https://fanatec.com/eu-en/racing-wheels-wheel-bases/racing-wheels/gran-turismo-dd-pro-5-nm-wheel-base"target="_blank" rel="noopener">Fanatec CSL DD Gran Turismo Edition</a>. The CSL DD&rsquo;s direct drive motor delivers 5 Nm of torque at the base, but the Gran Turismo DD Pro kit comes with the Boost Kit 180 that brings it up to a sustained 8 Nm with no active cooling. Direct drive means there&rsquo;s no gear or belt between the motor and the wheel — the motor&rsquo;s shaft IS the wheel&rsquo;s shaft. The difference is absurd. The T300 was already great, much better than the Logitech. But the Fanatec is on another level. You feel every asphalt texture, every bump, every incipient slide. There&rsquo;s no going back once you try it.</p>
<p>I bought it together with the <a href="https://fanatec.com/eu-en/pedals/csl-pedals"target="_blank" rel="noopener">CSL Pedals with Load Cell</a> kit, which measures pressure on the brake instead of displacement. Makes all the difference in braking — you learn to modulate by foot pressure, not by how far the pedal moves. Way more natural.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/01/cockpit/fanatec-direct-drive-close-up.jpg" alt="Close-up of the Fanatec CSL DD direct drive motor"  loading="lazy" /></p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/01/cockpit/fanatec-csl-pedals.jpg" alt="Fanatec CSL Pedals with Load Cell Kit"  loading="lazy" /></p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/01/cockpit/fanatec---shifter.jpg" alt="The Fanatec shifter — manual H-pattern with chrome knob"  loading="lazy" /></p>
<p>I also wanted to try the H-pattern manual shifter with the <a href="https://fanatec.com/us/en/p/add-ons/crd-9040002-ww/clubsport-shifter-sq"target="_blank" rel="noopener">ClubSport Shifter SQ V1.5</a> from Fanatec and a separate handbrake. It&rsquo;s fun to try out old cars with a clutch and an H-pattern, but in practice I never adapted. The SXT V2 stand was already shaking a lot with the direct drive, and using the shifter on that unstable setup was frustrating. And I know there are people who want to do heel-toe, but in the simulator I prefer to keep my left foot on the brake and my right on the gas and modulate both at the same time. Works better for me. Now that I have the McLaren wheel with the analog handbrake and clutch paddles right on the wheel, the H-pattern shifter and the external handbrake have been retired. For rally, an analog handbrake on the wheel is much more natural.</p>
<p>I also bought the PS5 with Gran Turismo 7 around this time. I put on the <a href="https://dbrand.com/shop/ps5"target="_blank" rel="noopener">dbrand Darkplates</a> matte black faceplates to replace the original white plates — it looks much better and more discreet.</p>
<p>But the setup was still the SXT V2 stand in front of the VIP couch. The same kludge. The same wobble. I obviously wasn&rsquo;t going to give up the cinema couch. The situation became unsustainable.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/01/cockpit/fanatec-with-support.jpg" alt="The Fanatec CSL DD on the SXT V2 stand — too powerful for the stand to handle"  loading="lazy" /></p>
<h2>The computer and the hardware setup<span class="hx:absolute hx:-mt-20" id="the-computer-and-the-hardware-setup"></span>
    <a href="#the-computer-and-the-hardware-setup" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>A note on my gaming hardware. I bought a <a href="https://www.minisforum.com/products/UX790-Pro.html"target="_blank" rel="noopener">Minisforum UX790 Pro</a> to be my dedicated Steam machine. It&rsquo;s a mini-PC with an Intel Core Ultra 9 285H processor, fits in the palm of your hand. Together I bought the <a href="https://www.minisforum.com/products/minisforum-deg1-egpu-dock"target="_blank" rel="noopener">Minisforum DEG1</a>, an external GPU dock that connects via OCuLink (PCIe 4.0 x4, 64 GT/s). It&rsquo;s an open design — basically a board with a PCIe x16 slot and room for an ATX or SFX power supply. There&rsquo;s no card size limit, so an RTX 4090 fits comfortably. The performance loss compared to a native PCIe slot is minimal. I put the RTX 4090 in it. The 4090 came from my desktop — at the start of 2025 I went to Miami and took the chance to buy an RTX 5090 because I was using more and more local AI and LLMs. I gave my old 3090 to my girlfriend to use for video editing. The 4090 went into the mini-PC.</p>
<p>So my gaming setup today is: Minisforum UX790 Pro + eGPU with RTX 4090 for Steam and PC games, and a PlayStation 5 with matte black Darkplates for Gran Turismo 7 and exclusives.</p>
<h2>The cockpit: Formula FX1<span class="hx:absolute hx:-mt-20" id="the-cockpit-formula-fx1"></span>
    <a href="#the-cockpit-formula-fx1" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>To round out the setup I also needed a decent monitor. I was already used to my 80&quot; Samsung OLED TV in the living room and didn&rsquo;t want to downgrade picture quality. So I invested in the <a href="https://www.samsung.com/br/monitors/gaming/odyssey-oled-g8-g81sf-32-inch-240hz-oled-uhd-ls32fg810snxzd/"target="_blank" rel="noopener">Samsung Odyssey OLED G8 32&quot;</a>. It&rsquo;s a 4K (3840x2160) OLED monitor with a 240 Hz refresh rate, 0.03 ms response time (GTG), HDR True Black 400, HDR10+, 99% DCI-P3 coverage, 1,000,000:1 contrast, and FreeSync Premium Pro. It has 2 HDMI 2.1 inputs and 1 DisplayPort 1.4.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/01/cockpit/closed-delivery-box.jpg" alt="The box of the Samsung Odyssey OLED G8 32&#34; when it arrived"  loading="lazy" /></p>
<p>In practice: the colors pop, black is real black (it&rsquo;s OLED, no backlight), and with the RTX 4090 I run most games at 4K and 120 fps with no problem. On lighter titles like Super Woden: Rally Edge, it easily hits 240 Hz. The smoothness is absurd. For a cockpit where you&rsquo;re 60-70 cm from the screen, 32&quot; OLED in 4K is the sweet spot. Bigger and you start to see pixels. Smaller and you lose the immersion.</p>
<p>In January 2026, after years of kludges, I finally ordered a dedicated cockpit. I researched a lot. I considered the <a href="https://loja.cockpitextremeracing.com.br/products/cockpit-ax160-horizontal?variant=52008751431961"target="_blank" rel="noopener">Cockpit AX160</a>, made of aluminum profile and very modular, and the <a href="https://loja.cockpitextremeracing.com.br/products/cockpit-4-0-horizontal?variant=52004294852889"target="_blank" rel="noopener">Cockpit 4.0</a>, which is the more traditional tubular steel kind. But neither was available at the time of purchase. And then I found the <a href="https://loja.cockpitextremeracing.com.br/products/cockpit-formula-fx1-preto-e-verde?variant=51700876509465"target="_blank" rel="noopener">Formula FX1 in black and green</a> from Extreme Racing — Petronas colors, F1-styled.</p>
<p>The FX1 is very different from traditional cockpits. The whole structure is welded thick steel tubing. When I say it doesn&rsquo;t shake, I mean it doesn&rsquo;t shake at all. Zero wobble. It&rsquo;s a brutal difference compared to a stand in front of the couch. The driving position is reclined, F1-style — your feet are at the same height or higher than your hips. You&rsquo;d think it would be uncomfortable, but it isn&rsquo;t. You can sit there for hours without complaining. It comes with a padded adjustable seat, an articulating monitor mount, a tilt-adjustable pedal mount, and a height-adjustable wheel mount.</p>
<p>I had to wait about a month for delivery. In the meantime, as anyone who follows my blog knows, I dove into a 16-hour-a-day marathon testing the new AI agents from Anthropic and OpenAI — check the <a href="/en/tags/vibe-coding/">#vibecoding</a> and <a href="/en/tags/ai/">#agents</a> tags to see everything I built. After about 30 days of that insane marathon, my lower back gave out and I started developing what looks like a herniated disc. I had to see a doctor and take heavy anti-inflammatories.</p>
<p>And right that week, the cockpit decided to arrive.</p>
<h3>The build<span class="hx:absolute hx:-mt-20" id="the-build"></span>
    <a href="#the-build" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>I was in absurd pain, but I built the cockpit anyway. It took an entire day to unbox and assemble the heavy steel pieces with my back screaming, but I did it.</p>
<p>The official assembly video I followed:</p>
              

<div class="embed-container">
  <iframe
    src="https://www.youtube.com/embed/WnocqqhmZas"
    title="YouTube video player"
    frameborder="0"
    allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
    referrerpolicy="strict-origin-when-cross-origin"
    allowfullscreen>
  </iframe>
</div>

<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/01/cockpit/first-pieces-mounted.jpg" alt="First pieces mounted — the base and the seat structure"  loading="lazy" /></p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/01/cockpit/mostly-mounted.jpg" alt="Almost complete structure — seat, wheel mount, pedals"  loading="lazy" /></p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/01/cockpit/mostly-mounted-from-front.jpg" alt="Front view of the almost-finished structure"  loading="lazy" /></p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/01/cockpit/almost-mounted,-with-monitor.jpg" alt="Almost complete assembly with the monitor mount"  loading="lazy" /></p>
<h3>The McLaren GT3 V2 wheel<span class="hx:absolute hx:-mt-20" id="the-mclaren-gt3-v2-wheel"></span>
    <a href="#the-mclaren-gt3-v2-wheel" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>After assembling the cockpit, I decided that the standard wheel that comes with the CSL GT kit wasn&rsquo;t enough. I upgraded to the <a href="https://www.racingwheelbrasil.com.br/produtos/volante-fanatec-csl-elite-mclaren-gt3-v2-pc-xbox-ps4-ps5-ready/"target="_blank" rel="noopener">Fanatec CSL Elite McLaren GT3 V2</a> (~R$ 4,990). It&rsquo;s a 1:1 scale replica of the McLaren GT3 wheel, with carbon fiber, an OLED display, and compatibility with PC, Xbox, PS4 and PS5.</p>
<p>What I like most about it: it has the normal shift paddles behind it (shift up/down), but it also has two additional analog paddles that can be configured in four different modes. In mode B, which is what I use, the left paddle works as an analog handbrake and the right one as an analog clutch. That&rsquo;s perfect for rally — I can pull the handbrake mid-corner without taking my hand off the wheel. It also has two 2-position toggles, two 12-position rotaries, 7 standard buttons with interchangeable caps, and Fanatec&rsquo;s 7-direction FunkySwitch. It&rsquo;s a complete racing controller.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/01/cockpit/mclaren-wheel.jpg" alt="The McLaren GT3 V2 wheel mounted on the cockpit — carbon fiber and OLED display"  loading="lazy" /></p>
<h2>The final setup<span class="hx:absolute hx:-mt-20" id="the-final-setup"></span>
    <a href="#the-final-setup" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The cockpit ended up in a corner of my bedroom, between the manga shelves (you can spot Akira, Initial D, and 500-something other volumes in the background). I mounted the mini-PC and the PS5 on the cockpit&rsquo;s side structure, together with the eGPU and the RTX 4090. Everything stays permanently connected. That&rsquo;s what makes the difference: I no longer have to set anything up or take it down. I sit, turn it on, and I&rsquo;m driving in 30 seconds.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/01/cockpit/fully-mounted-from-side.jpg" alt="Side view of the complete cockpit — between the manga shelves"  loading="lazy" /></p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/01/cockpit/all-consoles-from-front.jpg" alt="The consoles mounted on the structure: PS5 with Darkplates, Minisforum UX790 Pro, eGPU with RTX 4090"  loading="lazy" /></p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/01/cockpit/close-up-of-the-consoles-from-front.jpg" alt="Close-up of the consoles and cabling"  loading="lazy" /></p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/01/cockpit/sitting-angle.jpg" alt="The driver’s perspective — this is what I see when I sit down"  loading="lazy" /></p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/01/cockpit/monitor-on-view-from-front.jpg" alt="PS5 showing the game list — Gran Turismo 7 at the top"  loading="lazy" /></p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/01/cockpit/another-fully-mounted-shot.jpg" alt="The complete cockpit seen from behind"  loading="lazy" /></p>
<div style="max-width: 100%; margin: 1em 0;">
  <video controls playsinline style="width: 100%; border-radius: 8px;">
    <source src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/01/cockpit/from-front-gran-turismo-h264.mp4" type="video/mp4">
  </video>
  <em>Gran Turismo 7 running on the final cockpit</em>
</div>
<h2>The audio system<span class="hx:absolute hx:-mt-20" id="the-audio-system"></span>
    <a href="#the-audio-system" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>To round out the setup I needed dedicated audio. I didn&rsquo;t want to use the monitor&rsquo;s audio (terrible) and I didn&rsquo;t want to be on a headset all the time. The fix was to build a separate audio system with HDMI audio extraction.</p>
<p>The centerpiece is an <a href="https://www.amazon.com.br/dp/B00MNGIP2Y"target="_blank" rel="noopener">HDMI 2.1 switcher from OREI</a> with audio extraction. It has 2 inputs and 1 HDMI output, supports 4K at 120Hz (48 Gbps of bandwidth), and extracts the audio through optical TOSLINK and 3.5mm. I connect the HDMI output of the RTX 4090 to one input and the PS5 to the other. Video goes to the monitor. Audio goes out through the optical port.</p>
<p>The optical audio goes to an <a href="https://www.mercadolivre.com.br/amplificador-de-potncia-aiyima-d03-bluetooth-50-150-watts-cor-preto/p/MLB46172770"target="_blank" rel="noopener">Aiyima D03 amplifier</a>, a compact 2.1 channel amp with 150W per channel, integrated DAC (PCM1808 chip), and Bluetooth 5.0 with aptX HD. It has optical, coaxial, USB, RCA and Bluetooth inputs. It even has a dedicated subwoofer output for when I get around to adding one. It uses Texas Instruments&rsquo; TAS5624 amplifier chip and has bass and treble control through the remote. For a cockpit setup where you&rsquo;re 1 meter from the speakers, 150W is more than enough.</p>
<p>In practice, I keep the amp at 50% and Windows volume at 50%, and that&rsquo;s already loud as hell. Which is to say: this isn&rsquo;t a &ldquo;good enough&rdquo; little system. It&rsquo;s set up to actually go loud if I want.</p>
<p>The speakers are <a href="https://edifier.com.br/caixa-de-som-passiva-p12-madeira-edifier.html"target="_blank" rel="noopener">Edifier P12</a>, passive, with a 4-inch woofer and a 19mm tweeter. Frequency response from 55Hz to 20kHz, 6 ohm impedance, 20W RMS each. The MDF cabinet with wood finish has a rear bass-reflex port that helps the lows. For passive speakers this size, they deliver well. The mid-range is clean and the highs don&rsquo;t distort even at high volume.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/01/cockpit/audio-equipment.jpg" alt="The audio gear — Aiyima D03 box and Edifier P12"  loading="lazy" /></p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/01/cockpit/audio-speakers-next-to-other-stuff.jpg" alt="The Edifier P12s positioned on the shelves next to the cockpit, alongside the Akira collection and car miniatures"  loading="lazy" /></p>
<p>The setup logic is: HDMI switcher handles the switching between PS5 and PC, extracts audio to optical, the amp converts and amplifies, and the passive speakers deliver the sound. All without having to touch the monitor or swap cables. I press a button on the switcher and switch consoles.</p>
<p>When I want to play without bothering anyone, I plug my <a href="https://www.mercadolivre.com.br/fones-de-ouvido-meze-audio-109-pro-com-fio-de-madeira-com-en/p/MLB42456685"target="_blank" rel="noopener">Meze 109 Pro</a> directly into the 3.5mm output of the HDMI switcher. The Meze 109 Pro is an open-back headphone with 50mm dynamic drivers, 40 ohm impedance, 112 dB SPL/1mW sensitivity, and 5Hz to 30kHz response. The ear cups are walnut wood with handcrafted finish. It&rsquo;s an audiophile headphone that works perfectly without a dedicated amplifier thanks to the low impedance. The sound is warm, with full bass and rich mids. You can hear every detail of the engines.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/01/cockpit/meze-headset.jpg" alt="Meze 109 Pro — walnut wood, audiophile sound"  loading="lazy" /></p>
<p>I haven&rsquo;t decided about a subwoofer yet, but it&rsquo;ll be my next upgrade. A dedicated sub is going to add that low-end weight that makes you feel the engine in your chest.</p>
<h2>The verdict<span class="hx:absolute hx:-mt-20" id="the-verdict"></span>
    <a href="#the-verdict" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The couch with a stand works. The PC desk with a stand works. But neither comes anywhere close to a dedicated cockpit. The FX1&rsquo;s steel structure doesn&rsquo;t move a millimeter, even with the Fanatec direct drive at max torque. The reclined F1 position is comfortable for sessions of hours. The load cell pedals stay firm on the base. The monitor is exactly at the right height and distance. And best of all: it&rsquo;s always ready. I don&rsquo;t need to assemble anything, take anything down, run cables, none of it. I sit and I drive.</p>
<p>For anyone who&rsquo;s wondering whether it&rsquo;s worth investing in a dedicated cockpit instead of staying with a desk or couch stand: it is. If you already have a direct drive wheel, the cockpit is the missing piece. I spent years thinking &ldquo;this is fine&rdquo; with the stand on the couch. It wasn&rsquo;t fine. The difference in driveability is something else entirely. And for my case — introvert, single-player only, simcade — I couldn&rsquo;t have built it any sooner. To be honest, I think I&rsquo;ve finally landed on the simulator setup that&rsquo;s perfect for my taste.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/01/cockpit/monitor-on-view.jpg" alt="The monitor on with the driver’s view"  loading="lazy" /></p>
<div style="max-width: 100%; margin: 1em 0;">
  <video controls playsinline style="width: 100%; border-radius: 8px;">
    <source src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/01/cockpit/video-testing-h264.mp4" type="video/mp4">
  </video>
  <em>Me driving on the final cockpit</em>
</div>
<h2>Shopping list: how much it all cost<span class="hx:absolute hx:-mt-20" id="shopping-list-how-much-it-all-cost"></span>
    <a href="#shopping-list-how-much-it-all-cost" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Here&rsquo;s the consolidated list of everything in my current setup, with approximate prices (some were bought in dollars and converted to reais at the time&rsquo;s exchange rate):</p>
<table>
  <thead>
      <tr>
          <th>Item</th>
          <th style="text-align: right">Estimated Price (R$)</th>
          <th>Link</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="https://loja.cockpitextremeracing.com.br/products/cockpit-formula-fx1-preto-e-verde?variant=51700876509465"target="_blank" rel="noopener">Cockpit Formula FX1 Black and Green</a></td>
          <td style="text-align: right">~6,290</td>
          <td>Extreme Racing</td>
      </tr>
      <tr>
          <td><a href="https://fanatec.com/us/en/p/sim-racing-bundles/crd-9020007-8nm-us/gran-turismo-dd-pro-8nm-qr2l-us"target="_blank" rel="noopener">Fanatec Gran Turismo DD Pro 8Nm (motor + wheel + pedals + Boost Kit)</a></td>
          <td style="text-align: right">~9,590</td>
          <td>Fanatec / Racing Wheel Brasil</td>
      </tr>
      <tr>
          <td><a href="https://fanatec.com/us/en/p/pedals/csl_p_lc/csl_pedals_lc"target="_blank" rel="noopener">Fanatec CSL Pedals LC (with Load Cell)</a></td>
          <td style="text-align: right">~1,500</td>
          <td>Fanatec</td>
      </tr>
      <tr>
          <td><a href="https://www.racingwheelbrasil.com.br/produtos/volante-fanatec-csl-elite-mclaren-gt3-v2-pc-xbox-ps4-ps5-ready/"target="_blank" rel="noopener">Fanatec CSL Elite McLaren GT3 V2</a></td>
          <td style="text-align: right">~4,990</td>
          <td>Racing Wheel Brasil</td>
      </tr>
      <tr>
          <td><a href="https://fanatec.com/us/en/p/add-ons/crd-9040002-ww/clubsport-shifter-sq"target="_blank" rel="noopener">Fanatec ClubSport Shifter SQ V1.5</a></td>
          <td style="text-align: right">~2,500</td>
          <td>Fanatec</td>
      </tr>
      <tr>
          <td><a href="https://www.minisforum.com/products/UX790-Pro.html"target="_blank" rel="noopener">Minisforum UX790 Pro</a></td>
          <td style="text-align: right">~5,000</td>
          <td>Minisforum</td>
      </tr>
      <tr>
          <td><a href="https://www.minisforum.com/products/minisforum-deg1-egpu-dock"target="_blank" rel="noopener">Minisforum DEG1 eGPU Dock</a> + RTX 4090</td>
          <td style="text-align: right">~12,000</td>
          <td>Minisforum / bought separately</td>
      </tr>
      <tr>
          <td>PlayStation 5 + <a href="https://dbrand.com/shop/limited-edition/ps5"target="_blank" rel="noopener">dbrand Darkplates</a></td>
          <td style="text-align: right">~4,500</td>
          <td>Sony / dbrand</td>
      </tr>
      <tr>
          <td><a href="https://www.samsung.com/br/monitors/gaming/odyssey-oled-g8-g81sf-32-inch-240hz-oled-uhd-ls32fg810snxzd/"target="_blank" rel="noopener">Samsung Odyssey OLED G8 32&quot;</a></td>
          <td style="text-align: right">~2,500</td>
          <td>Samsung</td>
      </tr>
      <tr>
          <td><a href="https://www.amazon.com.br/dp/B00MNGIP2Y"target="_blank" rel="noopener">OREI BK-21A HDMI 2.1 Switcher 2x1 with audio extraction</a></td>
          <td style="text-align: right">~450</td>
          <td>Amazon</td>
      </tr>
      <tr>
          <td><a href="https://www.mercadolivre.com.br/amplificador-de-potncia-aiyima-d03-bluetooth-50-150-watts-cor-preto/p/MLB46172770"target="_blank" rel="noopener">Aiyima D03 Amplifier</a></td>
          <td style="text-align: right">~900</td>
          <td>Mercado Livre</td>
      </tr>
      <tr>
          <td><a href="https://edifier.com.br/caixa-de-som-passiva-p12-madeira-edifier.html"target="_blank" rel="noopener">Edifier P12 (pair)</a></td>
          <td style="text-align: right">~799</td>
          <td>Edifier</td>
      </tr>
      <tr>
          <td><a href="https://www.mercadolivre.com.br/fones-de-ouvido-meze-audio-109-pro-com-fio-de-madeira-com-en/p/MLB42456685"target="_blank" rel="noopener">Meze 109 Pro</a></td>
          <td style="text-align: right">~5,390</td>
          <td>Mercado Livre / Heinrich Audio</td>
      </tr>
      <tr>
          <td>Cables (HDMI 2.1, optical, 3.5mm, power)</td>
          <td style="text-align: right">~300</td>
          <td>Various</td>
      </tr>
      <tr>
          <td><strong>TOTAL ESTIMATED</strong></td>
          <td style="text-align: right"><strong>~56,709</strong></td>
          <td></td>
      </tr>
  </tbody>
</table>
<p>Yes, almost R$ 57k is a lot of money. I worked like a dog for decades. Now that I&rsquo;ve managed to retire honestly, my family is well taken care of, I have no debts, and I can finally give myself something I always wanted as a kid but couldn&rsquo;t afford. When I sat at those OutRun and Daytona USA cabinets at the arcade, I dreamed of having something like this at home. It took 30-something years, but I got there.</p>
<p>And if you add up the years of kludges, stands that didn&rsquo;t work, 15-meter HDMI cables, 3D prints, machined steel plates, and the frustration of mounting and unmounting everything — a dedicated cockpit saves your sanity. Unlike a PC that depreciates fast, a steel cockpit lasts decades.</p>
]]></content:encoded><category>gaming</category><category>sim-racing</category><category>cockpit</category><category>fanatec</category><category>gran-turismo</category><category>initial-d</category></item><item><title>Claude Code's Source Code Leaked. Here's What We Found Inside.</title><link>https://www.akitaonrails.com/en/2026/03/31/claude-code-source-code-leaked-what-we-found-inside/</link><guid isPermaLink="true">https://www.akitaonrails.com/en/2026/03/31/claude-code-source-code-leaked-what-we-found-inside/</guid><pubDate>Tue, 31 Mar 2026 17:00:00 GMT</pubDate><description>&lt;blockquote&gt;
&lt;p&gt;Updated April 2, 2026: if you already read this yesterday, jump straight to the &lt;a href="#update-2026-04-02"&gt;new update section&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;This morning (March 31, 2026), security researcher &lt;a href="https://x.com/Fried_rice"target="_blank" rel="noopener"&gt;Chaofan Shou&lt;/a&gt; discovered that the entire source code for Claude Code, Anthropic&amp;rsquo;s official CLI for AI coding, was sitting there for anyone to grab on the public npm registry. 512,000 lines of TypeScript. 1,900 files. All of it exposed through a 59.8 MB source map file accidentally bundled into version 2.1.88 of the &lt;code&gt;@anthropic-ai/claude-code&lt;/code&gt; package.&lt;/p&gt;</description><content:encoded><![CDATA[<blockquote>
  <p>Updated April 2, 2026: if you already read this yesterday, jump straight to the <a href="#update-2026-04-02">new update section</a>.</p>

</blockquote>
<p>This morning (March 31, 2026), security researcher <a href="https://x.com/Fried_rice"target="_blank" rel="noopener">Chaofan Shou</a> discovered that the entire source code for Claude Code, Anthropic&rsquo;s official CLI for AI coding, was sitting there for anyone to grab on the public npm registry. 512,000 lines of TypeScript. 1,900 files. All of it exposed through a 59.8 MB source map file accidentally bundled into version 2.1.88 of the <code>@anthropic-ai/claude-code</code> package.</p>
<p>Within hours the code had been mirrored on GitHub, picked apart by thousands of developers, and Anthropic had put out a statement calling it &ldquo;human error in release packaging, not a security breach.&rdquo; Which is technically true and ignores that the result is the same.</p>
<p><img src="https://raw.githubusercontent.com/kuberwastaken/claude-code/main/public/leak-tweet.png" alt="Tweet announcing the leak"  loading="lazy" /></p>
<p>I use Claude Code every day. Some of the articles you read here, I wrote with it. So I figured I&rsquo;d take a look at what&rsquo;s inside. I actually started writing this piece in Claude Code itself, but my Max plan ran out before I finished. I closed the rest in Codex.</p>
<h2>How the leak happened<span class="hx:absolute hx:-mt-20" id="how-the-leak-happened"></span>
    <a href="#how-the-leak-happened" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Claude Code is bundled with <a href="https://bun.sh/"target="_blank" rel="noopener">Bun</a>, the JavaScript runtime Anthropic acquired in late 2024. When you build with Bun, source maps are generated by default. Those <code>.map</code> files contain the full original source code, not just mappings. Every file, every comment, every internal constant, every system prompt.</p>
<p>The initial theory was that a <a href="https://github.com/oven-sh/bun/issues/28001"target="_blank" rel="noopener">known Bun bug</a> had caused the leak: even with <code>development: false</code>, source maps were still being served and bundled in. But <a href="https://github.com/oven-sh/bun/issues/28001#issuecomment-4164447815"target="_blank" rel="noopener">Jarred Sumner</a>, Bun&rsquo;s creator, shut that down: &ldquo;This has nothing to do with claude code. This is with Bun&rsquo;s frontend development server. Claude Code is not a frontend app. It is a TUI. It doesn&rsquo;t use Bun.serve() to compile a single-file executable.&rdquo; In other words, the Bun bug affects the frontend dev server, not the build process that generated the Claude Code npm package.</p>
<p>What actually happened is simpler: somebody at Anthropic forgot to add <code>*.map</code> to <code>.npmignore</code> or didn&rsquo;t configure the bundler to skip source map generation in production builds. And worse: according to <a href="https://www.theregister.com/2026/03/31/anthropic_claude_code_source_code/"target="_blank" rel="noopener">The Register</a>, the source map didn&rsquo;t just point to the original files, it referenced a ZIP hosted on Anthropic&rsquo;s own Cloudflare R2 bucket. npm happily served it to anyone running <code>npm pack</code>, and the rest was mirroring work.</p>
<p><img src="https://raw.githubusercontent.com/kuberwastaken/claude-code/main/public/claude-files.png" alt="Source files exposed in the npm package"  loading="lazy" /></p>
<p>The irony is that the code contains a whole system called &ldquo;Undercover Mode&rdquo; built specifically to prevent Anthropic&rsquo;s internal information from leaking in commits and PRs. They built a subsystem to stop the AI from revealing internal codenames, and then a source map exposed everything.</p>
<h2>What&rsquo;s inside: the hidden features<span class="hx:absolute hx:-mt-20" id="whats-inside-the-hidden-features"></span>
    <a href="#whats-inside-the-hidden-features" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The source code reveals 44 feature flags covering functionality that&rsquo;s ready but not yet shipped. This isn&rsquo;t vaporware. It&rsquo;s real code hiding behind flags that compile to <code>false</code> in external builds. Let me highlight the most interesting ones.</p>
<h3>KAIROS: a Claude that never stops<span class="hx:absolute hx:-mt-20" id="kairos-a-claude-that-never-stops"></span>
    <a href="#kairos-a-claude-that-never-stops" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Inside the <code>assistant/</code> directory, there&rsquo;s a mode called KAIROS, a persistent assistant that doesn&rsquo;t wait for you to type. It observes, logs, and acts proactively on things it notices. Maintains append-only daily log files, receives <code>&lt;tick&gt;</code> prompts at regular intervals to decide whether to act or stay quiet, and has a 15-second budget: any proactive action that would block the user&rsquo;s workflow for more than 15 seconds gets deferred.</p>
<p>KAIROS-exclusive tools: <code>SendUserFile</code> (sends files to the user), <code>PushNotification</code> (push notifications), <code>SubscribePR</code> (monitors pull requests). None of this exists in the public build.</p>
<h3>BUDDY: a Tamagotchi in the terminal<span class="hx:absolute hx:-mt-20" id="buddy-a-tamagotchi-in-the-terminal"></span>
    <a href="#buddy-a-tamagotchi-in-the-terminal" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>I&rsquo;m not making this up. Claude Code has a complete pet companion system in the Tamagotchi style called &ldquo;Buddy.&rdquo; A deterministic gacha system with 18 species, rarity, shiny variants, procedurally generated stats, and a &ldquo;soul&rdquo; written by Claude on the first hatch.</p>
<p>The species is determined by a Mulberry32 PRNG seeded by the hash of the userId. Same user always gets the same buddy. There are 5 stats (DEBUGGING, PATIENCE, CHAOS, WISDOM, SNARK), 6 eye styles, 8 hat options, and sprites rendered as 5-line ASCII art with animations. The code references April 1-7, 2026 as the teaser window, with full launch slated for May 2026.</p>
<h3>ULTRAPLAN: 30 minutes of remote planning<span class="hx:absolute hx:-mt-20" id="ultraplan-30-minutes-of-remote-planning"></span>
    <a href="#ultraplan-30-minutes-of-remote-planning" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>ULTRAPLAN offloads complex planning tasks to a remote session running Opus 4.6, gives it up to 30 minutes to think, and lets you approve the result through the browser. The terminal shows polling every 3 seconds, and once approved, a sentinel value <code>__ULTRAPLAN_TELEPORT_LOCAL__</code> &ldquo;teleports&rdquo; the result back into the local terminal.</p>
<h3>Multi-Agent: &ldquo;Coordinator Mode&rdquo;<span class="hx:absolute hx:-mt-20" id="multi-agent-coordinator-mode"></span>
    <a href="#multi-agent-coordinator-mode" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>The multi-agent orchestration system in the <code>coordinator/</code> directory turns Claude Code from a single agent into a coordinator that spawns, directs and manages multiple workers in parallel. Parallel research, synthesis by the coordinator, implementation by workers, verification by workers. The prompt teaches parallelism explicitly and forbids lazy delegation: &ldquo;Do NOT say &lsquo;based on your findings&rsquo; - read the actual findings and specify exactly what to do.&rdquo;</p>
<p>And there&rsquo;s more. The leak also shows in-process teammates with <code>AsyncLocalStorage</code> to isolate context, workers in separate processes via tmux/iTerm2 panes, memory synchronization between agents, and flags ready for <code>BRIDGE_MODE</code>, <code>VOICE_MODE</code>, <code>WORKFLOW_SCRIPTS</code>, <code>AFK mode</code>, <code>advisor-tool</code> and <code>history snipping</code>. None of that guarantees a launch, but it suggests a roadmap that&rsquo;s a lot further along than the public version lets on.</p>
<h2>The memory architecture<span class="hx:absolute hx:-mt-20" id="the-memory-architecture"></span>
    <a href="#the-memory-architecture" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The memory system caught my eye. It&rsquo;s not a &ldquo;store everything and retrieve&rdquo; approach. It&rsquo;s a three-layer architecture:</p>
<p><a href="https://x.com/himanshustwts/status/2038924027411222533"target="_blank" rel="noopener"><img src="https://new-uploads-akitaonrails.s3.amazonaws.com/2026/03/31/claude-code-memory-architecture.jpg" alt="Summary of Claude Code’s memory architecture"  loading="lazy" /></a></p>
<p><code>MEMORY.md</code> is a lightweight pointer index (~150 characters per line) that stays permanently loaded in the context. It doesn&rsquo;t store data, it stores locations. The actual knowledge lives distributed across &ldquo;topic files&rdquo; fetched on demand. Raw transcripts are never reloaded into the context whole, only searched with grep for specific identifiers.</p>
<p>And it comes with an important discipline: the system writes to the topic file first and only then updates the index. <code>MEMORY.md</code> doesn&rsquo;t become a fact dump. It stays a map. If you let the index turn into storage, it pollutes the permanent context and degrades the whole system.</p>
<p>The &ldquo;Dream&rdquo; system (<code>services/autoDream/</code>) is a memory consolidation engine that runs as a background subagent. The name is intentional. It&rsquo;s Claude dreaming.</p>
<p>The dream has a three-gate trigger: 24 hours since the last dream, at least 5 sessions since the last dream, and acquiring a consolidation lock (preventing concurrent dreams). All three have to pass.</p>
<p>When it runs, it follows four phases: Orient (ls in the memory directory, read the index), Gather (look for new signals in logs, stale memories, transcripts), Consolidate (write or update topic files, convert relative dates to absolute, delete contradicted facts), and Prune (keep the index under 200 lines and ~25KB).</p>
<p>There are four memory types: <code>user</code> (user profile), <code>feedback</code> (corrections and confirmations), <code>project</code> (context about ongoing work), <code>reference</code> (pointers to external systems). The taxonomy explicitly excludes things derivable from the code (patterns, architecture, git history, file structure).</p>
<p>The dream subagent gets read-only bash. It can look at the project but can&rsquo;t modify anything. It&rsquo;s purely a consolidation pass.</p>
<p>And there&rsquo;s another detail I found elegant: memory isn&rsquo;t treated as truth. It&rsquo;s treated as a hint. The system assumes that memory may be stale, wrong or contradicted, so the model still has to verify before trusting it. That&rsquo;s the opposite of the fantasy of &ldquo;throw everything in a vector database and let the magic happen.&rdquo;</p>
<h2>&ldquo;Undercover Mode&rdquo;<span class="hx:absolute hx:-mt-20" id="undercover-mode"></span>
    <a href="#undercover-mode" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Anthropic employees (identified by <code>USER_TYPE === 'ant'</code>) use Claude Code on public and open source repositories. Undercover Mode (<code>utils/undercover.ts</code>) prevents the AI from accidentally revealing internal information in commits and PRs.</p>
<p>When active, it injects into the system prompt:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>## UNDERCOVER MODE - CRITICAL

You are operating UNDERCOVER in a PUBLIC/OPEN-SOURCE repository. Your commit
messages, PR titles, and PR bodies MUST NOT contain ANY Anthropic-internal
information. Do not blow your cover.

NEVER include in commit messages or PR descriptions:
- Internal model codenames (animal names like Capybara, Tengu, etc.)
- Unreleased model version numbers (e.g., opus-4-7, sonnet-4-8)
- Internal repo or project names
- Internal tooling, Slack channels, or short links
- The phrase &#34;Claude Code&#34; or any mention that you are an AI
- Co-Authored-By lines or any other attribution</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>There&rsquo;s no way to turn it off. If the system isn&rsquo;t sure it&rsquo;s in an internal repository, it stays in undercover mode. That confirms something kind of uncomfortable: Anthropic uses Claude Code to contribute to open source, and the agent is instructed to hide that it&rsquo;s an AI.</p>
<p>The internal codenames are animal names: Tengu (codename for the Claude Code project), Fennec (Opus), Capybara, Numbat (in testing). &ldquo;Fast Mode&rdquo; is internally called &ldquo;Penguin Mode&rdquo; with endpoint <code>claude_code_penguin_mode</code> and kill-switch <code>tengu_penguins_off</code>.</p>
<h2>The most paranoid parts<span class="hx:absolute hx:-mt-20" id="the-most-paranoid-parts"></span>
    <a href="#the-most-paranoid-parts" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>There&rsquo;s a piece of the analysis I almost missed because I was looking more at the hidden features. But maybe the most revealing thing about Anthropic&rsquo;s mindset is in the defense mechanisms against copying and abuse.</p>
<p>According to <a href="https://alex000kim.com/posts/2026-03-31-claude-code-source-leak/"target="_blank" rel="noopener">Alex Kim&rsquo;s analysis</a>, there&rsquo;s an anti-distillation mode that can ask the server to inject fake tools into the system prompt. The idea is to poison traffic recorded by anyone trying to distill Claude Code&rsquo;s behavior to train a competitor. There&rsquo;s also a second mechanism for summarizing connector text, cryptographically signed, so that part of the observable traffic doesn&rsquo;t match the original raw reasoning. It&rsquo;s not perfect protection. It&rsquo;s another layer of friction. But it shows the company is explicitly thinking about copy-by-observation, not just traditional security.</p>
<p>And there&rsquo;s the more aggressive part: client attestation. Every request includes a billing header with a placeholder <code>cch=00000</code>, and the Bun native runtime replaces that with a hash computed below the JavaScript layer. In other words, looking like Claude Code isn&rsquo;t enough. The binary tries to prove that it is Claude Code. That helps explain why the fight with third-party tools like OpenCode got so sensitive: it wasn&rsquo;t just a commercial or legal issue. There was technical enforcement built into the transport.</p>
<h3>Update: the DRM died in less than 24 hours<span class="hx:absolute hx:-mt-20" id="update-the-drm-died-in-less-than-24-hours"></span>
    <a href="#update-the-drm-died-in-less-than-24-hours" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Remember when I mentioned client attestation as &ldquo;the most aggressive part&rdquo;? Yeah. It lasted less than a day.</p>
<p>For context: Anthropic had been waging a war against third-party tools since January 2026. First came the server-side blocking of OAuth tokens from non-official clients. Then in March, the <a href="https://github.com/anomalyco/opencode"target="_blank" rel="noopener">OpenCode</a> maintainer merged a <a href="https://github.com/anomalyco/opencode/issues/7456"target="_blank" rel="noopener">PR</a> removing all Claude authentication from the project. The commit message was two words: &ldquo;anthropic legal requests.&rdquo; <a href="https://www.theregister.com/2026/02/20/anthropic_clarifies_ban_third_party_claude_access/"target="_blank" rel="noopener">The Register reported</a> that Anthropic updated its terms of service to make it explicit that OAuth tokens from Pro/Max subscriptions can only be used in the official Claude Code and on Claude.ai. People paying $100-200/month for Max who wanted to use the tool of their choice were left holding the bag.</p>
<p>The technical mechanism behind the block was the <code>cch=</code> header. With the leaked code you can see the system has two parts. The first is a version suffix: the <code>cc_version</code> field includes 3 hex characters derived from the user&rsquo;s first message via SHA-256, using a 12-character salt embedded in the JavaScript. The second is the body hash itself: the entire body of the request (messages, tools, metadata, model, thinking config, everything) is serialized as compact JSON with the <code>cch=00000</code> placeholder, then hashed with <a href="https://github.com/Cyan4973/xxHash"target="_blank" rel="noopener">xxHash64</a> using a fixed seed. The result is masked with <code>0xFFFFF</code> (20 bits) and formatted as 5 lowercase hex characters. The placeholder is replaced with the computed hash before the request leaves the process.</p>
<p>The detail that makes the difference: that substitution happens inside the Bun native runtime, written in Zig, below the JavaScript layer. Bun literally mutates the JavaScript string in-place, overwriting the <code>00000</code> bytes in the string buffer with the computed hash. If you ran the same bundle in Node or in a stock Bun, the placeholder would go to the server as-is and the request would be rejected.</p>
<p>And then the leak happened. With the source code exposed, <a href="https://x.com/StraughterG/status/2039344027556798476"target="_blank" rel="noopener">@StraughterG</a> (Jay Guthrie) announced that same night: &ldquo;Yesterday I said Anthropic&rsquo;s compiled Zig cch= hash was banning 3rd-party Claude clients. Tonight, the DRM is dead. We extracted the algorithm from the binary. It&rsquo;s not advanced cryptography. It&rsquo;s a static xxHash64 seed.&rdquo;</p>
<p>The seed is <code>0x6E52736AC806831E</code>. The <a href="https://a10k.co/b/reverse-engineering-claude-code-cch.html"target="_blank" rel="noopener">full algorithm</a>, as he explained in a thread of tweets, fits in a few lines of TypeScript:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-typescript" data-lang="typescript"><span class="line"><span class="cl"><span class="kr">import</span> <span class="nx">xxhash</span> <span class="kr">from</span> <span class="s2">&#34;xxhash-wasm&#34;</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="p">{</span> <span class="nx">h64Raw</span> <span class="p">}</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">xxhash</span><span class="p">();</span>
</span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">body</span> <span class="o">=</span> <span class="nx">JSON</span><span class="p">.</span><span class="nx">stringify</span><span class="p">(</span><span class="nx">request</span><span class="p">);</span> <span class="c1">// com cch=00000 no placeholder
</span></span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">hash</span> <span class="o">=</span> <span class="nx">h64Raw</span><span class="p">(</span><span class="k">new</span> <span class="nx">TextEncoder</span><span class="p">().</span><span class="nx">encode</span><span class="p">(</span><span class="nx">body</span><span class="p">),</span> <span class="mh">0x6E52736AC</span><span class="nx">n</span> <span class="o">|</span> <span class="p">(</span><span class="mh">0x806831E</span><span class="nx">n</span> <span class="o">&lt;&lt;</span> <span class="mi">32</span><span class="nx">n</span><span class="p">));</span>
</span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">cch</span> <span class="o">=</span> <span class="p">(</span><span class="nx">hash</span> <span class="o">&amp;</span> <span class="mh">0xFFFFF</span><span class="nx">n</span><span class="p">).</span><span class="nx">toString</span><span class="p">(</span><span class="mi">16</span><span class="p">).</span><span class="nx">padStart</span><span class="p">(</span><span class="mi">5</span><span class="p">,</span> <span class="s2">&#34;0&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="c1">// substituir cch=00000 por cch={valor calculado}
</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p><a href="https://x.com/paoloanzn/status/2039348588741087341"target="_blank" rel="noopener">@paoloanzn</a> celebrated: &ldquo;we cracked it. the cch= signing system in claude code is fully reverse engineered.&rdquo; And he immediately put the bypass in <a href="https://github.com/paoloanzn/free-code"target="_blank" rel="noopener">free-code</a>, a fork of Claude Code with telemetry removed, system prompt guardrails stripped, and all 54 experimental feature flags unlocked.</p>
<p><a href="https://github.com/paoloanzn/free-code"target="_blank" rel="noopener"><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/01/free-code-screenshot.png" alt="Screenshot of free-code running with experimental features"  loading="lazy" /></a></p>
<p>The technical point that matters: xxHash64 is not cryptography. It&rsquo;s a checksum hash designed for speed, not security. The seed is static, embedded in the binary. It changes with each version of Claude Code, but within a version it&rsquo;s the same for everyone. The &ldquo;security&rdquo; depended entirely on nobody being able to extract the seed from the compiled Zig binary. With the source code leaked, that obscurity evaporated in hours.</p>
<p>Now any third-party client — OpenCode, Claw-Code, whatever — can intercept the <code>fetch()</code>, hash the body with the correct seed, and pass the server&rsquo;s validation as if it were the official Claude Code. The barrier Anthropic built to protect its $2.5 billion ARR business model was, in the end, security by obscurity over a non-cryptographic hash.</p>
<p>The third detail is small but says a lot about real product in production: the system detects user frustration with regex. Yes, regex. Profanity, insults, &ldquo;this sucks,&rdquo; that kind of thing. It&rsquo;s funny to see an LLM company doing sentiment analysis with <code>wtf|ffs|shit</code>, but it&rsquo;s also the kind of pragmatic solution you reach for when you need a cheap immediate answer, not conceptual elegance.</p>
<h2>What the code reveals about how you use Claude Code<span class="hx:absolute hx:-mt-20" id="what-the-code-reveals-about-how-you-use-claude-code"></span>
    <a href="#what-the-code-reveals-about-how-you-use-claude-code" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p><a href="https://x.com/iamfakeguru/status/2038965567269249484"target="_blank" rel="noopener">@iamfakeguru</a> compiled a thread with seven technical findings from the code that any user should know:</p>
<p>Claude Code has a 2,000-line cap per file read. When you ask it to read a larger file, it silently truncates. Tool results are cut off at 50,000 characters. The context window compression system drops old messages to fit more new context. And there&rsquo;s a difference between the access level of Anthropic employees (<code>USER_TYPE === 'ant'</code>) and public access: internal tools like <code>ConfigTool</code> and <code>TungstenTool</code> are invisible in the external build.</p>
<p>The most useful finding from the thread is how Anthropic employees work around the limitations external users face. The code reveals that <code>USER_TYPE === 'ant'</code> unlocks internal tools, exclusive beta headers (<code>cli-internal-2026-02-09</code>), access to staging (<code>claude-ai.staging.ant.dev</code>), and a <code>ConfigTool</code> that lets you change configurations at runtime. External builds compile all of this to <code>false</code> via dead code elimination.</p>
<p>But the point that matters is: the CLAUDE.md you put at the root of your project gets read in full by Claude Code and injected into the system prompt. It&rsquo;s literally the place where you control how the agent behaves. <a href="https://x.com/iamfakeguru/status/2038965567269249484"target="_blank" rel="noopener">@iamfakeguru</a> published a complete override with 10 mechanical rules, and then put the whole file in a separate repository: <a href="https://github.com/iamfakeguru/claude-md"target="_blank" rel="noopener">iamfakeguru/claude-md</a>.</p>
<p><a href="https://github.com/iamfakeguru/claude-md/blob/main/CLAUDE.md"target="_blank" rel="noopener"><img src="https://new-uploads-akitaonrails.s3.amazonaws.com/2026/03/31/claude-md-production-grade-agent-directives.png" alt="Screenshot of the CLAUDE.md published by fakeguru"  loading="lazy" /></a></p>
<p>I&rsquo;m not going to paste the whole block here. What matters is the content: it forces post-edit verification (<code>tsc</code> and <code>eslint</code> before declaring success), it imposes re-reading files before editing, it requires reading large files in chunks, it assumes silent truncation of long results, and it tells you to break larger work into phases or parallel subagents. In other words: it turns into explicit rules everything that external users were having to figure out empirically.</p>
<p>These aren&rsquo;t magic instructions. They&rsquo;re guardrails. The difference is that now we know which limits the system actually has and we can write a CLAUDE.md that works with them, not against them.</p>
<h2>Cache bugs that cost real money<span class="hx:absolute hx:-mt-20" id="cache-bugs-that-cost-real-money"></span>
    <a href="#cache-bugs-that-cost-real-money" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p><a href="https://x.com/altryne/status/2038676458026189225"target="_blank" rel="noopener">@altryne</a> (Alex Volkov) reported cache invalidation bugs that make uncached tokens cost 10-20x more than cached ones. There are two bugs: a string substitution bug in Bun that affects the standalone CLI (workaround: use <code>npx @anthropic-ai/claude-code</code> instead of the installed binary), and another in the <code>--resume</code> flag that breaks the cache with no known workaround. Over 500 users reported similar quota exhaustion issues. If you&rsquo;ve been feeling like Claude Code was burning tokens faster than expected over the past few days, it probably wasn&rsquo;t your imagination.</p>
<h2>&ldquo;Staff engineer spaghetti&rdquo;<span class="hx:absolute hx:-mt-20" id="staff-engineer-spaghetti"></span>
    <a href="#staff-engineer-spaghetti" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The code analysis revealed real problems. A comment in the source itself admits: &ldquo;1,279 sessions had 50+ consecutive failures (up to 3,272) in a single session, wasting ~250K API calls per day globally.&rdquo; The fix was three lines: limit consecutive failures to three before disabling compaction.</p>
<p>The <code>print.ts</code> file has 5,594 lines with a single 3,167-line function containing twelve levels of nesting. <code>main.tsx</code> is 803,924 bytes in a single file. <code>interactiveHelpers.tsx</code> is 57,424 bytes. These are files no human can review with confidence.</p>
<p>The most viral reaction came from <a href="https://x.com/thekitze/status/2038956521942577557"target="_blank" rel="noopener">@thekitze</a>: he asked GPT-5.4 to evaluate the codebase and the score came in at 6.5/10. The description: &ldquo;This is not junior spaghetti. This is staff-engineer spaghetti: performance-aware, feature-flagged, telemetry-instrumented, surgically optimized spaghetti.&rdquo; In other words, it isn&rsquo;t bad code from inexperience. It&rsquo;s bad code from pressure to ship fast without paying the cost of cleaning up afterwards.</p>
<p><a href="https://x.com/thekitze/status/2038986445839622405"target="_blank" rel="noopener">@thekitze</a> also elaborated in another thread on how the code shows a lack of basic engineering practices. And this is where I feel vindicated.</p>
<p>I&rsquo;ve been repeating in several posts on <a href="/en/tags/vibe-coding/">vibe coding</a> that speed without discipline produces exactly this. The principles I defend, small increments, tests at every step, review before committing, continuous refactoring, CI that rejects high cyclomatic complexity, are the same Extreme Programming principles that have worked since the early 2000s. Anthropic apparently didn&rsquo;t follow any of them on their own product.</p>
<p>A 3,167-line function with 12 levels of nesting isn&rsquo;t something that appears overnight. It&rsquo;s accumulation. It&rsquo;s the result of dozens of additions where nobody stopped to refactor because &ldquo;it works, don&rsquo;t touch it.&rdquo; It&rsquo;s the classic anti-pattern of vibe coding without discipline: generate code with AI, see that it compiles, commit, repeat. Without rigorous review. Without complexity limits in CI. Without the basic rule that if a function passes 50 lines, it needs to be broken up.</p>
<p>The irony is that Anthropic sells the most popular vibe coding tool on the market and doesn&rsquo;t practice what I call responsible vibe coding. Claude Code is worth $2.5 billion in ARR. The code that generates that revenue is rated 6.5/10.</p>
<h2>The &ldquo;clean room&rdquo; question<span class="hx:absolute hx:-mt-20" id="the-clean-room-question"></span>
    <a href="#the-clean-room-question" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>With the entire source code public, there&rsquo;s a serious legal and competitive implication. And here I think a lot of people started using the term &ldquo;clean room&rdquo; with a lightness that doesn&rsquo;t fit the subject.</p>
<p>A real clean room isn&rsquo;t just &ldquo;I rewrote it in another language&rdquo; or &ldquo;I didn&rsquo;t copy and paste.&rdquo; The classic model is much more annoying: one group studies the original and produces a functional spec; another group, isolated, implements from that spec without ever seeing the original code. The whole point is to reduce contamination risk.</p>
<p><a href="https://x.com/braelyn_ai/status/2039025584626397491"target="_blank" rel="noopener">@braelyn_ai</a> raised another interesting point: with generative tools, somebody could try a &ldquo;clean room rebuild&rdquo; using tests, observable behavior and documentation, without reusing the original implementation. In theory, that makes sense. In practice, what shows up in the heat of a leak usually lands in a much grayer zone.</p>
<p>The <a href="https://github.com/ultraworkers/claw-code"target="_blank" rel="noopener">Claw-Code</a> case illustrates this well. The project presents itself as an independent rewrite and has already shifted focus to Python and Rust, but the README itself admits direct study of the exposed code and even mentions a parity audit against a local archive. So I wouldn&rsquo;t call that a clean room in the strictest classic sense. I&rsquo;d call it an inspired reimplementation, with a deliberate attempt to move away from the leaked snapshot.</p>
<p>That doesn&rsquo;t mean every reimplementation is doomed. Software copyright doesn&rsquo;t protect abstract ideas, generic tool flow, high-level architecture or &ldquo;a CLI that does X.&rdquo; It protects concrete expression. But that&rsquo;s exactly why discipline matters. The more a project wants to maintain independence, the less it should rely on the leaked material as a direct benchmark.</p>
<p>There&rsquo;s a more pragmatic detail in there: the literal copies of the leaked source will probably disappear quickly when the first DMCAs start arriving. Mirrors fall easily. That&rsquo;s why a reimplementation matters more than a raw mirror. It doesn&rsquo;t erase the legal discussion, but it changes the type of fight quite a bit and the chances of staying online.</p>
<p>That&rsquo;s more or less what I did myself when I <a href="/en/2026/03/16/rewrote-openclaw-in-rust-frankclaw/">rewrote OpenClaw in Rust</a>. The point wasn&rsquo;t to copy line by line. It was to understand the behavior and rewrite the whole piece in my own code.</p>
<p>The satirical site <a href="https://malus.sh/"target="_blank" rel="noopener">malus.sh</a> appeared today offering &ldquo;Clean Room as a Service&rdquo; with the tagline &ldquo;Robot-Reconstructed, Zero Attribution.&rdquo; The joke: AI robots recreate open source projects eliminating attribution obligations, with guarantees like &ldquo;This has never happened because it legally cannot happen. Trust us.&rdquo; and indemnification via an offshore subsidiary in a jurisdiction that doesn&rsquo;t recognize software copyright. It&rsquo;s satire, but it&rsquo;s satire that describes what someone is going to actually try to do.</p>
<p><a id="update-2026-04-02"></a></p>
<h2>Update on April 2, 2026<span class="hx:absolute hx:-mt-20" id="update-on-april-2-2026"></span>
    <a href="#update-on-april-2-2026" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Since the text above opens in the heat of March 31, it&rsquo;s worth recording what happened right after. I decided to add this update after reading <a href="https://x.com/k1rallik/status/2039686500619534818"target="_blank" rel="noopener">this tweet from @k1rallik</a>, which captures the post-leak mood well but mixes verifiable facts with a touch too much epic.</p>
<p>First: the DMCA part got messier than it looked. The notice itself published in the <code>github/dmca</code> repository says GitHub processed the takedown against the entire network of <strong>8,100 repositories</strong>, because the notification claimed that &ldquo;all or most of the forks&rdquo; were infringing to the same extent as the main repository. The next day, Anthropic published a <strong>partial retraction</strong>: it asked for the reinstatement of all the removed repositories, except <code>nirholas/claude-code</code> and <strong>96 forks listed individually</strong>. So the thesis that the initial attempt was too broad is correct. The final picture, however, isn&rsquo;t &ldquo;8,100 repositories were taken down.&rdquo; What happened was a formal walk-back after the mass removal.</p>
<p>Second: the <a href="https://github.com/ultraworkers/claw-code"target="_blank" rel="noopener">Claw-Code</a> project really did blow up. By the time I was updating this post, GitHub was already showing <strong>142,829 stars</strong> and <strong>101,510 forks</strong>. That alone is enough to say the story has moved out of the &ldquo;curious fork of the leak&rdquo; category and into the &ldquo;real competitive side effect&rdquo; category. The viral tweet that went around today is right about the size of the damage but exaggerates some details. The project&rsquo;s own README describes itself as &ldquo;the fastest repo in history to surpass 50K stars&rdquo; and says the milestone came in two hours. I couldn&rsquo;t independently confirm that historical record, so I&rsquo;d rather treat that as the project&rsquo;s own claim, not as a settled fact.</p>
<p>Third: the Rust part also needs nuance. Yes, there&rsquo;s already a Rust workspace on the main branch and the <code>Cargo.toml</code> is at version <code>0.1.0</code>. But I couldn&rsquo;t find a public release on GitHub to support the line &ldquo;release 0.1.0 already shipped&rdquo; as a formal launch. What I can say with confidence is something else: the project already has a Python base, already has a Rust workspace, and has already drawn enough attention to keep existing even without the literal mirror of the leaked code.</p>
<h2>What Anthropic should have done<span class="hx:absolute hx:-mt-20" id="what-anthropic-should-have-done"></span>
    <a href="#what-anthropic-should-have-done" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Anthropic responded fast. They pulled the compromised package, put out a public statement, and cleaned up what they could. But the damage was done. The code was mirrored before the takedown. Mirrors on GitHub, analyses in blogs, threads on X/Twitter. There&rsquo;s no way to un-publish something on the internet.</p>
<p>What bothers me isn&rsquo;t the leak itself. Bugs happen. What bothers me is that this was avoidable with basic engineering practices:</p>
<ol>
<li>Add <code>*.map</code> to <code>.npmignore</code>. One line.</li>
<li>Configure the bundler to not generate source maps in production builds. One flag.</li>
<li>Have a CI check that rejects publication if the package contains <code>.map</code>. A 5-line script.</li>
<li>Have a release pipeline with manual review before publishing to npm. Process, not code.</li>
</ol>
<p>None of these are hard. They&rsquo;re all the kind of thing that gets dropped when you&rsquo;re moving too fast and don&rsquo;t have discipline in the release process. It&rsquo;s exactly what I preach as <a href="/en/tags/vibe-coding/">disciplined vibe coding</a>: moving fast doesn&rsquo;t mean skipping the guardrails.</p>
<p>And the second failure: the quality of the code itself. 512,000 lines with 3,000-line functions and 12 levels of nesting isn&rsquo;t engineering. It&rsquo;s accumulation. It&rsquo;s what happens when you generate code with AI without rigorous review, without continuous refactoring, without CI that rejects high cyclomatic complexity. The irony of being precisely the company that sells the most popular vibe coding tool in the world doesn&rsquo;t go unnoticed.</p>
<h2>Sources<span class="hx:absolute hx:-mt-20" id="sources"></span>
    <a href="#sources" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><ul>
<li><a href="https://github.com/Kuberwastaken/claude-code"target="_blank" rel="noopener">Kuberwastaken/claude-code - Complete breakdown of the leaked code</a></li>
<li><a href="https://alex000kim.com/posts/2026-03-31-claude-code-source-leak/"target="_blank" rel="noopener">Alex Kim - Claude Code Source Leak: fake tools, frustration regexes, undercover mode</a></li>
<li><a href="https://venturebeat.com/technology/claude-codes-source-code-appears-to-have-leaked-heres-what-we-know/"target="_blank" rel="noopener">VentureBeat - Claude Code&rsquo;s source code appears to have leaked</a></li>
<li><a href="https://www.theregister.com/2026/03/31/anthropic_claude_code_source_code/"target="_blank" rel="noopener">The Register - Anthropic accidentally exposes Claude Code source code</a></li>
<li><a href="https://fortune.com/2026/03/31/anthropic-source-code-claude-code-data-leak-second-security-lapse-days-after-accidentally-revealing-mythos/"target="_blank" rel="noopener">Fortune - Anthropic leaks its own AI coding tool&rsquo;s source code</a></li>
<li><a href="https://cybernews.com/security/anthropic-claude-code-source-leak/"target="_blank" rel="noopener">Cybernews - Full source code for Anthropic&rsquo;s Claude Code leaks</a></li>
<li><a href="https://gizmodo.com/source-code-for-anthropics-claude-code-leaks-at-the-exact-wrong-time-2000740379"target="_blank" rel="noopener">Gizmodo - Source Code for Anthropic&rsquo;s Claude Code Leaks at the Exact Wrong Time</a></li>
<li><a href="https://www.anthropic.com/news/anthropic-acquires-bun-as-claude-code-reaches-usd1b-milestone?s=33"target="_blank" rel="noopener">Anthropic - Anthropic acquires Bun as Claude Code reaches $1B milestone</a></li>
<li><a href="https://github.com/oven-sh/bun/issues/28001"target="_blank" rel="noopener">Bun Issue #28001 - Source maps incorrectly served in production</a></li>
<li><a href="https://news.ycombinator.com/item?id=43909409"target="_blank" rel="noopener">Hacker News - Claude&rsquo;s system prompt is over 24k tokens with tools</a></li>
<li><a href="https://malus.sh/"target="_blank" rel="noopener">malus.sh - Clean Room as a Service (satire)</a></li>
<li><a href="https://x.com/iamfakeguru/status/2038965567269249484"target="_blank" rel="noopener">@iamfakeguru - Thread with 7 technical findings from the code</a></li>
<li><a href="https://x.com/altryne/status/2038676458026189225"target="_blank" rel="noopener">@altryne - Cache bugs that cost 10-20x more</a></li>
<li><a href="https://x.com/thekitze/status/2038956521942577557"target="_blank" rel="noopener">@thekitze - &ldquo;Staff-engineer spaghetti&rdquo; 6.5/10</a></li>
<li><a href="https://x.com/braelyn_ai/status/2039025584626397491"target="_blank" rel="noopener">@braelyn_ai - Clean room and legal implications</a></li>
<li><a href="https://github.com/github/dmca/blob/master/2026/03/2026-03-31-anthropic.md"target="_blank" rel="noopener">GitHub DMCA - Anthropic takedown notice processed against the network of 8.1K repositories</a></li>
<li><a href="https://github.com/github/dmca/blob/master/2026/04/2026-04-01-anthropic-retraction.md"target="_blank" rel="noopener">GitHub DMCA - Anthropic&rsquo;s partial retraction the next day</a></li>
<li><a href="https://github.com/ultraworkers/claw-code"target="_blank" rel="noopener">ultraworkers/claw-code - Python and Rust reimplementation that became the main post-leak project</a></li>
<li><a href="https://x.com/mem0ai/status/2039041449854124229"target="_blank" rel="noopener">@mem0ai - Analysis of the memory architecture</a></li>
<li><a href="https://x.com/himanshustwts/status/2038924027411222533"target="_blank" rel="noopener">@himanshustwts - Memory architecture summary</a></li>
<li><a href="https://github.com/iamfakeguru/claude-md"target="_blank" rel="noopener">iamfakeguru/claude-md - Override published with the complete CLAUDE.md</a></li>
<li><a href="https://x.com/StraughterG/status/2039344027556798476"target="_blank" rel="noopener">@StraughterG - &ldquo;the DRM is dead&rdquo; - reverse engineering of the cch= hash</a></li>
<li><a href="https://x.com/StraughterG/status/2039344035555344550"target="_blank" rel="noopener">@StraughterG - xxHash64 seed and TypeScript bypass code</a></li>
<li><a href="https://x.com/paoloanzn/status/2039348588741087341"target="_blank" rel="noopener">@paoloanzn - &ldquo;we cracked it&rdquo; - confirmation of the reverse engineering</a></li>
<li><a href="https://github.com/paoloanzn/free-code"target="_blank" rel="noopener">paoloanzn/free-code - Fork of Claude Code with telemetry removed and features unlocked</a></li>
<li><a href="https://a10k.co/b/reverse-engineering-claude-code-cch.html"target="_blank" rel="noopener">a10k.co - What&rsquo;s cch? Reverse Engineering Claude Code&rsquo;s Request Signing</a></li>
<li><a href="https://www.theregister.com/2026/02/20/anthropic_clarifies_ban_third_party_claude_access/"target="_blank" rel="noopener">The Register - Anthropic clarifies ban on third-party tool access to Claude</a></li>
</ul>
]]></content:encoded><category>ai</category><category>security</category><category>claude-code</category><category>vibe-coding</category><category>open-source</category></item><item><title>Migrating my Home Server with Claude Code | openSUSE MicroOS</title><link>https://www.akitaonrails.com/en/2026/03/31/migrating-my-home-server-with-claude-code/</link><guid isPermaLink="true">https://www.akitaonrails.com/en/2026/03/31/migrating-my-home-server-with-claude-code/</guid><pubDate>Tue, 31 Mar 2026 16:00:00 GMT</pubDate><description>&lt;p&gt;My old home server was a mess. An Intel NUC with Ubuntu Server that I&amp;rsquo;d been patching together for two years. Containers with hardcoded paths, volumes mounted in random places (&lt;code&gt;/home/akitaonrails/docker/&lt;/code&gt;, &lt;code&gt;/home/akitaonrails/sonarr/&lt;/code&gt;, &lt;code&gt;/mnt/terachad/&lt;/code&gt;), docker-compose files scattered with no consistent layout. It worked, but if I lost the disk it would take days to rebuild everything from memory.&lt;/p&gt;
&lt;p&gt;With the &lt;a href="https://www.akitaonrails.com/en/2026/03/31/minisforum-ms-s1-max-amd-ai-max-395-review/"&gt;new Minisforum MS-S1 Max&lt;/a&gt; that I bought, I decided to do the migration properly. And I decided to use Claude Code from the start to speed up the process. It&amp;rsquo;s a home server, only I use it, so the risk of making a mistake is low. But if this were a real production server, I would never do this without rigorous human review at every step.&lt;/p&gt;</description><content:encoded><![CDATA[<p>My old home server was a mess. An Intel NUC with Ubuntu Server that I&rsquo;d been patching together for two years. Containers with hardcoded paths, volumes mounted in random places (<code>/home/akitaonrails/docker/</code>, <code>/home/akitaonrails/sonarr/</code>, <code>/mnt/terachad/</code>), docker-compose files scattered with no consistent layout. It worked, but if I lost the disk it would take days to rebuild everything from memory.</p>
<p>With the <a href="/en/2026/03/31/minisforum-ms-s1-max-amd-ai-max-395-review/">new Minisforum MS-S1 Max</a> that I bought, I decided to do the migration properly. And I decided to use Claude Code from the start to speed up the process. It&rsquo;s a home server, only I use it, so the risk of making a mistake is low. But if this were a real production server, I would never do this without rigorous human review at every step.</p>
<p>What follows is the migration writeup and a guide for anyone who wants to replicate it. If I ever have to rebuild from scratch, this post is the documentation.</p>
<h2>Choosing the operating system<span class="hx:absolute hx:-mt-20" id="choosing-the-operating-system"></span>
    <a href="#choosing-the-operating-system" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><h3>Why not Ubuntu Server again<span class="hx:absolute hx:-mt-20" id="why-not-ubuntu-server-again"></span>
    <a href="#why-not-ubuntu-server-again" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>I used Ubuntu Server on the NUC for convenience. But <code>do-release-upgrade</code> is Russian roulette. Every time Ubuntu releases a new version, the upgrade is a real risk of breaking things. Packages change, configs get overwritten, dependencies clash. For a server that has to be running constantly, that&rsquo;s unacceptable.</p>
<h3>Why not Arch Linux<span class="hx:absolute hx:-mt-20" id="why-not-arch-linux"></span>
    <a href="#why-not-arch-linux" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>I use Arch on the desktop and I like it. But Arch is a rolling release with no stability guarantees at all. For a desktop where I can stop and fix problems, fine. For a headless server running 49 Docker containers that has to work after every reboot, no.</p>
<h3>Fedora CoreOS vs openSUSE MicroOS<span class="hx:absolute hx:-mt-20" id="fedora-coreos-vs-opensuse-microos"></span>
    <a href="#fedora-coreos-vs-opensuse-microos" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>The two modern options for a container server are Fedora CoreOS and openSUSE MicroOS. Both are immutable systems: the root filesystem is read-only, updates are atomic (they either apply in full or don&rsquo;t apply at all), and rollback is instant.</p>
<p>The difference: Fedora CoreOS uses Ignition (declarative configuration before the first boot) and is designed to be provisioned automatically. MicroOS uses <code>transactional-update</code> and allows normal interactive use. For a home server where I want SSH and the ability to poke around manually, MicroOS fits better.</p>
<h3>What makes MicroOS different<span class="hx:absolute hx:-mt-20" id="what-makes-microos-different"></span>
    <a href="#what-makes-microos-different" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>The immutable system concept changes how you operate the server:</p>
<p>Every package install or <code>/etc</code> edit goes through <code>transactional-update</code>, which creates a new btrfs snapshot, applies the change in that snapshot, and on the next reboot the system boots into the updated snapshot. If the change breaks something, you do <code>transactional-update rollback</code> and you&rsquo;re back on the previous snapshot in seconds.</p>
<p>Updates are automatic and daily. The <code>transactional-update.timer</code> downloads patches, creates a snapshot, and <code>rebootmgr</code> reboots in a configured window (in my case, between 4am and 5:30am). If the update breaks the boot, GRUB automatically falls back to the previous snapshot.</p>
<p>SELinux is enforcing by default. That caused 90% of the problems during the migration, but it&rsquo;s the right setting for security.</p>
<h2>Initial setup<span class="hx:absolute hx:-mt-20" id="initial-setup"></span>
    <a href="#initial-setup" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><h3>Hardware<span class="hx:absolute hx:-mt-20" id="hardware"></span>
    <a href="#hardware" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><ul>
<li>AMD Ryzen AI Max+ 395, 128GB LPDDR5X</li>
<li>96GB allocated as VRAM via BIOS (UMA Frame Buffer Size)</li>
<li>2TB NVMe (system + Docker)</li>
<li>Wired 2.5Gbps network</li>
<li>Synology DS1821+ NAS at 192.168.0.21 (NFS)</li>
</ul>
<h3>First steps<span class="hx:absolute hx:-mt-20" id="first-steps"></span>
    <a href="#first-steps" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>MicroOS install is standard. Then:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># Create user with a UID that matches the NAS (so NFS works without permission issues)</span>
</span></span><span class="line"><span class="cl">useradd -u <span class="m">1026</span> -m akitaonrails
</span></span><span class="line"><span class="cl">passwd akitaonrails
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Configure sudo (inside transactional-update shell)</span>
</span></span><span class="line"><span class="cl">sudo transactional-update shell
</span></span><span class="line"><span class="cl"><span class="c1"># inside: add akitaonrails to sudoers</span>
</span></span><span class="line"><span class="cl"><span class="nb">exit</span>
</span></span><span class="line"><span class="cl">sudo reboot</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<h3>Synology NFS<span class="hx:absolute hx:-mt-20" id="synology-nfs"></span>
    <a href="#synology-nfs" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>The Synology NAS exports <code>/volume1/TERACHAD</code> over NFS. The mount point on MicroOS is <code>/var/mnt/terachad</code> (not <code>/mnt/</code>, which lives on the immutable root).</p>
<p>In <code>/etc/fstab</code> (applied through transactional-update):</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>192.168.0.21:/volume1/TERACHAD /var/mnt/terachad nfs4 nfsvers=4.1,rsize=262144,wsize=262144,hard,_netdev 0 0</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Details that matter: <code>nfsvers=4.1</code> because 4.2 didn&rsquo;t work with the Synology. <code>rsize=262144,wsize=262144</code> (256KB buffers) was the biggest NFS performance improvement. <code>hard</code> instead of <code>nofail</code> so the mount keeps retrying indefinitely if the NAS disconnects temporarily.</p>
<h3>GPU / ROCm<span class="hx:absolute hx:-mt-20" id="gpu--rocm"></span>
    <a href="#gpu--rocm" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>This step was a pain. The Radeon 8060S in the AI Max+ 395 is gfx1151, which ROCm doesn&rsquo;t officially support. Three steps were needed, and all three are mandatory:</p>
<ol>
<li>BIOS: set UMA Frame Buffer Size to 96GB</li>
<li>Kernel: add <code>amdttm.pages_limit=25165824 amdttm.page_pool_size=25165824</code> to <code>/etc/kernel/cmdline</code></li>
<li>Docker: use <code>HSA_OVERRIDE_GFX_VERSION=11.5.1</code> in every ROCm container</li>
</ol>
<p>Without step 2, ROCm only sees 15.5GB even with the BIOS allocation in place. The numbers are 96GB / 4KB (page size) = 25,165,824 pages.</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo transactional-update shell
</span></span><span class="line"><span class="cl"><span class="nb">echo</span> <span class="s2">&#34;amdttm.pages_limit=25165824 amdttm.page_pool_size=25165824&#34;</span> &gt;&gt; /etc/kernel/cmdline
</span></span><span class="line"><span class="cl"><span class="nb">exit</span>
</span></span><span class="line"><span class="cl">sudo sdbootutil update-all-entries  <span class="c1"># OUTSIDE the transactional shell</span>
</span></span><span class="line"><span class="cl">sudo reboot</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Verification:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">cat /sys/class/drm/card1/device/mem_info_vram_total
</span></span><span class="line"><span class="cl"><span class="c1"># 103079215104 (96 * 1024^3)</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<h2>Docker on MicroOS<span class="hx:absolute hx:-mt-20" id="docker-on-microos"></span>
    <a href="#docker-on-microos" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo transactional-update --non-interactive pkg install docker
</span></span><span class="line"><span class="cl">sudo reboot
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">sudo systemctl <span class="nb">enable</span> --now docker
</span></span><span class="line"><span class="cl">sudo usermod -aG docker akitaonrails
</span></span><span class="line"><span class="cl"><span class="c1"># logout and login for the group to take effect</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Install standalone docker-compose (the openSUSE package doesn&#39;t include it)</span>
</span></span><span class="line"><span class="cl">sudo curl -L <span class="s2">&#34;https://github.com/docker/compose/releases/latest/download/docker-compose-linux-x86_64&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  -o /usr/local/bin/docker-compose
</span></span><span class="line"><span class="cl">sudo chmod +x /usr/local/bin/docker-compose
</span></span><span class="line"><span class="cl">mkdir -p ~/.docker/cli-plugins
</span></span><span class="line"><span class="cl">ln -s /usr/local/bin/docker-compose ~/.docker/cli-plugins/docker-compose</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<h3>daemon.json<span class="hx:absolute hx:-mt-20" id="daemonjson"></span>
    <a href="#daemonjson" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;log-level&#34;</span><span class="p">:</span> <span class="s2">&#34;warn&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;log-driver&#34;</span><span class="p">:</span> <span class="s2">&#34;local&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;log-opts&#34;</span><span class="p">:</span> <span class="p">{</span><span class="nt">&#34;max-size&#34;</span><span class="p">:</span> <span class="s2">&#34;10m&#34;</span><span class="p">,</span> <span class="nt">&#34;max-file&#34;</span><span class="p">:</span> <span class="s2">&#34;5&#34;</span><span class="p">},</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;selinux-enabled&#34;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;live-restore&#34;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;userland-proxy&#34;</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;exec-opts&#34;</span><span class="p">:</span> <span class="p">[</span><span class="s2">&#34;native.cgroupdriver=systemd&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p><code>live-restore: true</code> keeps containers alive across a Docker daemon restart. <code>userland-proxy: false</code> uses iptables directly instead of proxy processes (less overhead). <code>selinux-enabled: true</code> is mandatory on MicroOS.</p>
<h2>SELinux and Docker: the biggest source of problems<span class="hx:absolute hx:-mt-20" id="selinux-and-docker-the-biggest-source-of-problems"></span>
    <a href="#selinux-and-docker-the-biggest-source-of-problems" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>This deserves an entire section because it was responsible for 90% of the bugs during the migration.</p>
<p>On MicroOS with SELinux enforcing, every container that writes to a host bind-mounted directory needs special handling. There are two approaches: the <code>:Z</code> suffix on volumes and the <code>security_opt: label:disable</code> option.</p>
<h3>NEVER use <code>:Z</code>. Use <code>security_opt: label:disable</code>.<span class="hx:absolute hx:-mt-20" id="never-use-z-use-security_opt-labeldisable"></span>
    <a href="#never-use-z-use-security_opt-labeldisable" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p><code>:Z</code> tells Docker to relabel the host directory with the container&rsquo;s SELinux context. Sounds like the right thing. In practice:</p>
<ul>
<li>SQLite databases break. The relabeling changes the file&rsquo;s context and SQLite may refuse to open the WAL journal.</li>
<li>NFS mounts silently ignore <code>:Z</code>. NFS doesn&rsquo;t support SELinux xattrs. The kernel ignores the flag without error, but the container still doesn&rsquo;t have permission.</li>
<li><code>:ro,Z</code> mounts try to relabel even when read-only, which fails on NFS and can corrupt the context on local paths.</li>
</ul>
<p>The right solution for every container on this system:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">services</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">myservice</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">security_opt</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">label:disable    </span><span class="w"> </span><span class="c"># disables SELinux enforcement for this container</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">./data:/data     </span><span class="w"> </span><span class="c"># NO :Z</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">./config.yml:/etc/config.yml:ro </span><span class="w"> </span><span class="c"># NO :Z even on :ro</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p><code>label:disable</code> disables SELinux label enforcement for that container only, not for the entire system. Combined with Docker&rsquo;s network and process isolation, it&rsquo;s safe for a home server.</p>
<h2>Migrating the stacks<span class="hx:absolute hx:-mt-20" id="migrating-the-stacks"></span>
    <a href="#migrating-the-stacks" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>All Docker stacks were reorganized into <code>/var/opt/docker/&lt;stack&gt;/docker-compose.yml</code>. On the old server, they were scattered across <code>/home/akitaonrails/docker/</code>, <code>/home/akitaonrails/&lt;service&gt;/</code>, with no pattern.</p>
<h3>Substitutions applied across every compose file<span class="hx:absolute hx:-mt-20" id="substitutions-applied-across-every-compose-file"></span>
    <a href="#substitutions-applied-across-every-compose-file" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><table>
  <thead>
      <tr>
          <th>Before</th>
          <th>After</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>/mnt/terachad/</code></td>
          <td><code>/var/mnt/terachad/</code></td>
      </tr>
      <tr>
          <td><code>192.168.0.145</code></td>
          <td><code>192.168.0.90</code></td>
      </tr>
      <tr>
          <td><code>/home/akitaonrails/&lt;service&gt;/</code></td>
          <td><code>/var/opt/docker/&lt;stack&gt;/&lt;service&gt;/</code></td>
      </tr>
      <tr>
          <td><code>OLLAMA_BASE_URL=http://192.168.0.14:11434</code></td>
          <td><code>OLLAMA_BASE_URL=http://192.168.0.90:11434</code></td>
      </tr>
  </tbody>
</table>
<h3>Media stack (Plex, Radarr, Sonarr, etc.)<span class="hx:absolute hx:-mt-20" id="media-stack-plex-radarr-sonarr-etc"></span>
    <a href="#media-stack-plex-radarr-sonarr-etc" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>The media stack is the most complex. Plex needs its own LAN IP (macvlan) for direct streaming to work. The setup:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker network create -d macvlan <span class="se">\
</span></span></span><span class="line"><span class="cl">  --subnet<span class="o">=</span>192.168.0.0/24 <span class="se">\
</span></span></span><span class="line"><span class="cl">  --gateway<span class="o">=</span>192.168.0.1 <span class="se">\
</span></span></span><span class="line"><span class="cl">  -o <span class="nv">parent</span><span class="o">=</span>enp97s0 <span class="se">\
</span></span></span><span class="line"><span class="cl">  plex_macvlan</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>In compose, Plex needs to be on two networks: the macvlan (for the IP 192.168.0.6) and the default bridge (so other containers like Seerr can talk to it):</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">plex</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">networks</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">plex_macvlan</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">ipv4_address</span><span class="p">:</span><span class="w"> </span><span class="m">192.168.0.6</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">mac_address</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;02:42:c0:a8:00:06&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">default</span><span class="p">:</span><span class="w"> </span>{}<span class="w">    </span><span class="c"># mandatory — without this, Seerr can&#39;t see Plex</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>A detail that almost broke me: Plex stores absolute paths in its database. If the container&rsquo;s internal volume changed from <code>/media</code> to <code>/data</code>, Plex no longer finds anything. You have to use exactly the same mount target as the old compose.</p>
<h3>Ollama with ROCm<span class="hx:absolute hx:-mt-20" id="ollama-with-rocm"></span>
    <a href="#ollama-with-rocm" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>New stack, didn&rsquo;t exist on the previous server:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">ollama</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">ollama/ollama:rocm</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">container_name</span><span class="p">:</span><span class="w"> </span><span class="l">ollama</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">devices</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="l">/dev/kfd:/dev/kfd</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="l">/dev/dri:/dev/dri</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">security_opt</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="l">seccomp:unconfined</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="l">label:disable</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">group_add</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="s2">&#34;485&#34;</span><span class="w">   </span><span class="c"># render group GID</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="s2">&#34;488&#34;</span><span class="w">   </span><span class="c"># video group GID</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="l">HSA_OVERRIDE_GFX_VERSION=11.5.1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="l">PYTORCH_ROCM_ARCH=gfx1151</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="l">OLLAMA_KEEP_ALIVE=30m</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="l">OLLAMA_NUM_PARALLEL=4</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="l">OLLAMA_FLASH_ATTENTION=1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="l">OLLAMA_KV_CACHE_TYPE=q8_0</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="l">/var/lib/ollama:/root/.ollama</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">ports</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="s2">&#34;11434:11434&#34;</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p><code>OLLAMA_FLASH_ATTENTION=1</code> activates flash attention. <code>OLLAMA_KV_CACHE_TYPE=q8_0</code> uses 8-bit KV cache, cutting the bandwidth needed per token in half. Free performance optimizations.</p>
<h3>Monitoring (Grafana + Prometheus)<span class="hx:absolute hx:-mt-20" id="monitoring-grafana--prometheus"></span>
    <a href="#monitoring-grafana--prometheus" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Grafana uses a named volume (<code>grafana_data</code>) which is NOT included in normal filesystem backups. That&rsquo;s the reason I lost all my dashboards on the first attempt. The fix is an explicit backup of the named volume:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># On the old server:</span>
</span></span><span class="line"><span class="cl">docker run --rm -v grafana_data:/data:ro -v /tmp:/backup alpine <span class="se">\
</span></span></span><span class="line"><span class="cl">  tar czf /backup/grafana_data.tar.gz -C /data .
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Transfer and restore on the new one:</span>
</span></span><span class="line"><span class="cl">docker run --rm -v grafana_data:/data -v /tmp:/backup alpine <span class="se">\
</span></span></span><span class="line"><span class="cl">  sh -c <span class="s2">&#34;cd /data &amp;&amp; tar xzf /backup/grafana_data.tar.gz&#34;</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Same thing for Portainer (<code>portainer_data</code>). Any volume defined in the <code>volumes:</code> block of a compose without a host path needs this treatment.</p>
<h3>Cloudflare Tunnel<span class="hx:absolute hx:-mt-20" id="cloudflare-tunnel"></span>
    <a href="#cloudflare-tunnel" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>I use <a href="https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/"target="_blank" rel="noopener">Cloudflare Tunnel</a> to access all the services from outside the house without opening ports on the router. The migration was the easiest part: copy the tunnel&rsquo;s JSON credentials file and the <code>config.yml</code>, update the IPs from <code>.145</code> to <code>.90</code>, and bring the container up. The tunnel keeps the same ID, no need to recreate DNS.</p>
<p>The hostnames live in <code>config.yml</code>: portainer, grafana, plex, seerr, qbittorrent, syncthing, radarr, sonarr, bazarr, prowlarr, vault, gitea, kavita, and others. Everything accessible via <code>https://&lt;service&gt;.example.com</code> from anywhere.</p>
<h3>Gitea (image registry)<span class="hx:absolute hx:-mt-20" id="gitea-image-registry"></span>
    <a href="#gitea-image-registry" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p><a href="https://github.com/go-gitea/gitea"target="_blank" rel="noopener">Gitea</a> acts as a private Docker registry on port 3007. The Frank FBI, Frank Mega, Frank Yomik and Mila projects have Docker images that are built and pushed to Gitea. For it to work, Docker&rsquo;s <code>daemon.json</code> needs:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;insecure-registries&#34;</span><span class="p">:</span> <span class="p">[</span><span class="s2">&#34;192.168.0.90:3007&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Gitea SSH gave me a problem during the migration: the old app.ini had <code>SSH_LISTEN_PORT=22</code>, but the container&rsquo;s entrypoint also starts sshd on port 22. Conflict. Fix: <code>GITEA__server__SSH_LISTEN_PORT=2222</code> as an environment variable in compose.</p>
<h3>All 49 containers running<span class="hx:absolute hx:-mt-20" id="all-49-containers-running"></span>
    <a href="#all-49-containers-running" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>The migrated server runs 49 containers across 15 stacks. The media stack alone has 10 containers (<a href="https://www.plex.tv/"target="_blank" rel="noopener">Plex</a>, <a href="https://github.com/Radarr/Radarr"target="_blank" rel="noopener">Radarr</a>, <a href="https://github.com/Sonarr/Sonarr"target="_blank" rel="noopener">Sonarr</a>, <a href="https://github.com/morpheus65535/bazarr"target="_blank" rel="noopener">Bazarr</a>, <a href="https://github.com/Prowlarr/Prowlarr"target="_blank" rel="noopener">Prowlarr</a>, <a href="https://github.com/qbittorrent/qBittorrent"target="_blank" rel="noopener">qBittorrent</a>, <a href="https://github.com/sabnzbd/sabnzbd"target="_blank" rel="noopener">SABnzbd</a>, <a href="https://github.com/Jackett/Jackett"target="_blank" rel="noopener">Jackett</a>, <a href="https://github.com/FlareSolverr/FlareSolverr"target="_blank" rel="noopener">FlareSolverr</a>, <a href="https://github.com/seerr/seerr"target="_blank" rel="noopener">Seerr</a>). The personal projects (<a href="https://github.com/akitaonrails/frank_fbi"target="_blank" rel="noopener">Frank FBI</a>, <a href="/en/2026/02/21/vibe-code-built-a-mega-clone-in-rails-in-1-day-frankmega/">Frank Mega</a>, <a href="https://github.com/akitaonrails/FrankYomik"target="_blank" rel="noopener">Frank Yomik</a>, Mila) add another 11. Monitoring with <a href="https://github.com/grafana/grafana"target="_blank" rel="noopener">Grafana</a>, <a href="https://github.com/prometheus/prometheus"target="_blank" rel="noopener">Prometheus</a>, node-exporter and <a href="https://github.com/google/cadvisor"target="_blank" rel="noopener">cAdvisor</a>. Utilities like <a href="https://github.com/portainer/portainer"target="_blank" rel="noopener">Portainer</a>, <a href="https://github.com/dani-garcia/vaultwarden"target="_blank" rel="noopener">Vaultwarden</a>, <a href="https://github.com/syncthing/syncthing"target="_blank" rel="noopener">Syncthing</a>, <a href="https://github.com/causefx/Organizr"target="_blank" rel="noopener">Organizr</a>, <a href="https://github.com/containrrr/watchtower"target="_blank" rel="noopener">Watchtower</a>. <a href="https://github.com/go-gitea/gitea"target="_blank" rel="noopener">Gitea</a> as private Docker registry. <a href="https://github.com/immich-app/immich"target="_blank" rel="noopener">Immich</a> as self-hosted Google Photos. <a href="https://github.com/oae/kaizoku"target="_blank" rel="noopener">Kaizoku</a> for manga with <a href="https://github.com/Kareadita/Kavita"target="_blank" rel="noopener">Kavita</a> as the reader. <a href="https://github.com/ollama/ollama"target="_blank" rel="noopener">Ollama</a> with ROCm. And <a href="https://github.com/bitcoin/bitcoin"target="_blank" rel="noopener">Bitcoin Core</a>/<a href="https://github.com/cculianu/Fulcrum"target="_blank" rel="noopener">Fulcrum</a> indexing the blockchain off the NAS.</p>
<h2>Backups: two layers<span class="hx:absolute hx:-mt-20" id="backups-two-layers"></span>
    <a href="#backups-two-layers" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><h3>Layer 1: local btrfs snapshots (snapper)<span class="hx:absolute hx:-mt-20" id="layer-1-local-btrfs-snapshots-snapper"></span>
    <a href="#layer-1-local-btrfs-snapshots-snapper" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p><code>/var</code> lives on a 3.7TB btrfs partition. snapper creates automatic snapshots: 7 daily + 1 weekly. They&rsquo;re crash-consistent, not application-consistent (postgres can be slightly inconsistent if there&rsquo;s heavy writing during the snapshot).</p>
<p>To recover an accidentally deleted file:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo snapper -c var list
</span></span><span class="line"><span class="cl">sudo cp /var/.snapshots/5/snapshot/opt/docker/media/radarr/appdata/config/radarr.db <span class="se">\
</span></span></span><span class="line"><span class="cl">        /var/opt/docker/media/radarr/appdata/config/radarr.db</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>For a full stack rollback:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo docker compose -p media down
</span></span><span class="line"><span class="cl">sudo snapper -c var undochange 7..0 /var/opt/docker/media
</span></span><span class="line"><span class="cl">sudo docker compose -p media up -d</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<h3>Layer 2: restic to the NAS (off-machine)<span class="hx:absolute hx:-mt-20" id="layer-2-restic-to-the-nas-off-machine"></span>
    <a href="#layer-2-restic-to-the-nas-off-machine" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p><a href="https://github.com/restic/restic"target="_blank" rel="noopener">restic</a> runs every night at 3am, doing incremental backups to <code>/var/mnt/terachad/homelab-backups/</code>. Retention: 7 daily + 4 weekly. Content-based deduplication, so Plex config (19GB) and Gitea repos (12GB) only transfer deltas.</p>
<p>Before restic runs, a <code>pg_dump</code> exports the postgres databases (Immich, Kaizoku). The dumps go to <code>/tmp/homelab-db-dumps/</code> and are included in the backup.</p>
<p>What&rsquo;s NOT included in the backup (re-downloadable): Bitcoin blockchain (785GB on the NAS), Docker images (re-pullable), Ollama models (re-downloadable), HuggingFace/EasyOCR caches, Plex transcoding scratch.</p>
<p>Large re-downloadable directories were converted to btrfs subvolumes so snapper ignores them: <code>/var/lib/ollama</code> and <code>/var/opt/docker/bitcoin/fulcrum/fulc2_db</code>.</p>
<h2>Performance tuning<span class="hx:absolute hx:-mt-20" id="performance-tuning"></span>
    <a href="#performance-tuning" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><h3>btrfs with zstd compression<span class="hx:absolute hx:-mt-20" id="btrfs-with-zstd-compression"></span>
    <a href="#btrfs-with-zstd-compression" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Added <code>compress=zstd:1</code> to the fstab for the <code>/var</code> partition. zstd level 1 has near-zero CPU overhead on NVMe and compresses Docker metadata, JSON configs and logs nicely. Incompressible data (SQLite, postgres) is automatically skipped by btrfs.</p>
<h3>zram swap<span class="hx:absolute hx:-mt-20" id="zram-swap"></span>
    <a href="#zram-swap" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>With ~30GB of RAM available for the system (96GB go to VRAM), in-memory compressed swap helps. zram creates a ~15GB swap device (ram/2) with zstd compression, much faster than disk swap.</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="cl"><span class="c1"># /etc/systemd/zram-generator.conf</span>
</span></span><span class="line"><span class="cl"><span class="k">[zram0]</span>
</span></span><span class="line"><span class="cl"><span class="na">zram-size</span> <span class="o">=</span> <span class="s">ram / 2</span>
</span></span><span class="line"><span class="cl"><span class="na">compression-algorithm</span> <span class="o">=</span> <span class="s">zstd</span>
</span></span><span class="line"><span class="cl"><span class="na">swap-priority</span> <span class="o">=</span> <span class="s">100</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<h3>btrfs nodatacow on database directories<span class="hx:absolute hx:-mt-20" id="btrfs-nodatacow-on-database-directories"></span>
    <a href="#btrfs-nodatacow-on-database-directories" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Copy-on-write + random database writes = write amplification. I disabled CoW on the directories that hold SQLite and postgres:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo chattr +C /var/opt/docker/gitea/data/gitea.db
</span></span><span class="line"><span class="cl">sudo chattr +C /var/opt/docker/immich/db/
</span></span><span class="line"><span class="cl">sudo chattr +C /var/opt/docker/media/radarr/appdata/config/</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<h3>CPU in performance mode<span class="hx:absolute hx:-mt-20" id="cpu-in-performance-mode"></span>
    <a href="#cpu-in-performance-mode" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>On a headless server, there&rsquo;s no point saving energy:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nb">echo</span> performance <span class="p">|</span> sudo tee /sys/devices/system/cpu/cpu*/cpufreq/energy_performance_preference</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Persisted via systemd service <code>cpu-epp.service</code>.</p>
<h3>Docker shutdown fix<span class="hx:absolute hx:-mt-20" id="docker-shutdown-fix"></span>
    <a href="#docker-shutdown-fix" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>A problem I discovered: Docker ships with <code>KillMode=process</code>, which means at system shutdown, systemd kills only dockerd and leaves all the <code>containerd-shim</code> processes (one per container, ~49 in my case) orphaned. systemd-shutdown then has to hunt them down one by one after the journal has already stopped, causing a silent multi-minute hang.</p>
<p>Fix:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="cl"><span class="c1"># /etc/systemd/system/docker.service.d/shutdown.conf</span>
</span></span><span class="line"><span class="cl"><span class="k">[Service]</span>
</span></span><span class="line"><span class="cl"><span class="na">KillMode</span><span class="o">=</span><span class="s">control-group</span>
</span></span><span class="line"><span class="cl"><span class="na">TimeoutStopSec</span><span class="o">=</span><span class="s">30</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<h2>The problems we ran into<span class="hx:absolute hx:-mt-20" id="the-problems-we-ran-into"></span>
    <a href="#the-problems-we-ran-into" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>This is the table of actual problems we hit during the migration. If you&rsquo;re planning something similar, read it before starting:</p>
<table>
  <thead>
      <tr>
          <th>Problem</th>
          <th>Cause</th>
          <th>Fix</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>ROCm only sees 15.5GB of VRAM</td>
          <td>Kernel TTM caps pages even with BIOS at 96GB</td>
          <td>Add <code>amdttm.pages_limit=25165824</code> to kernel cmdline</td>
      </tr>
      <tr>
          <td>Every container: permission denied on volumes</td>
          <td>SELinux <code>container_t</code> can&rsquo;t write to unlabeled paths</td>
          <td><code>security_opt: label:disable</code> on every service</td>
      </tr>
      <tr>
          <td>NFS with <code>:Z</code> silently fails</td>
          <td>NFS doesn&rsquo;t support SELinux xattrs</td>
          <td>Never use <code>:Z</code> on NFS paths</td>
      </tr>
      <tr>
          <td>SQLite breaks with <code>:Z</code></td>
          <td>Relabeling changes the context, WAL mode fails</td>
          <td>Drop <code>:Z</code>, use <code>label:disable</code></td>
      </tr>
      <tr>
          <td>Radarr/Sonarr showed the setup screen</td>
          <td>Backup at <code>appdata/config/</code> but compose mounted <code>appdata/</code></td>
          <td>Fix to: <code>appdata/config:/config</code></td>
      </tr>
      <tr>
          <td>Grafana lost dashboards</td>
          <td>Named volume not included in filesystem backup</td>
          <td>Explicit named volume backup</td>
      </tr>
      <tr>
          <td>Plex can&rsquo;t find media</td>
          <td>Internal path changed from <code>/media</code> to <code>/data</code></td>
          <td>Restore the original path in compose</td>
      </tr>
      <tr>
          <td>Seerr can&rsquo;t connect to Plex</td>
          <td>macvlan isolated from the bridge network</td>
          <td>Add <code>default: {}</code> to Plex networks</td>
      </tr>
      <tr>
          <td>Fulcrum crash: &ldquo;option -b missing&rdquo;</td>
          <td>Env vars not supported by the image</td>
          <td>Use CLI flags in <code>command:</code></td>
      </tr>
      <tr>
          <td>bitcoind rejects RPC</td>
          <td>Binds on <code>::1</code> by default</td>
          <td>Add <code>-rpcbind=0.0.0.0 -rpcallowip=172.0.0.0/8</code></td>
      </tr>
      <tr>
          <td>sdbootutil warning in transactional shell</td>
          <td>Has to run outside the transaction</td>
          <td>Run <code>sdbootutil update-all-entries</code> in the normal shell</td>
      </tr>
      <tr>
          <td>Watchtower permission denied on docker.sock</td>
          <td>SELinux blocks socket access</td>
          <td><code>label:disable</code></td>
      </tr>
      <tr>
          <td>Gitea SSH crash</td>
          <td>Conflict: entrypoint sshd on port 22 + app on port 22</td>
          <td><code>GITEA__server__SSH_LISTEN_PORT=2222</code></td>
      </tr>
      <tr>
          <td>docker-compose not installed with Docker</td>
          <td>The openSUSE package only installs the daemon</td>
          <td>Install standalone binary manually</td>
      </tr>
  </tbody>
</table>
<h2>What to tell Claude Code before you start<span class="hx:absolute hx:-mt-20" id="what-to-tell-claude-code-before-you-start"></span>
    <a href="#what-to-tell-claude-code-before-you-start" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>If I were redoing the migration from scratch, I&rsquo;d give Claude Code these instructions on the very first message. In order of importance:</p>
<p>Tell it that SELinux is enforcing and that it should NEVER use <code>:Z</code> on any Docker volume, but rather <code>security_opt: label:disable</code> on every service. Tell it that <code>/var/mnt/terachad/</code> is an NFS mount and that <code>:Z</code> should never appear on NFS paths. Tell it to always look at the original compose before rewriting and only change IPs, paths and container names, without inventing new volume layouts. Warn that named volumes need explicit backup (Grafana, Portainer). Explain that Plex runs on macvlan and needs <code>default: {}</code> in the networks. Inform that the GPU is gfx1151, not officially supported, and that it needs UMA 96GB in the BIOS + kernel TTM params + <code>HSA_OVERRIDE_GFX_VERSION=11.5.1</code>. And tell it that Bitcoin/Fulcrum don&rsquo;t process environment variables, everything goes as an argument in <code>command:</code>.</p>
<p>Those instructions would have prevented 80% of the problems we hit.</p>
<h2>Final server layout<span class="hx:absolute hx:-mt-20" id="final-server-layout"></span>
    <a href="#final-server-layout" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>/var/opt/docker/
├── bitcoin/          (bitcoind &#43; fulcrum)
├── cloudflared/      (Cloudflare tunnel)
├── frank_fbi/        (email fraud analysis)
├── frank_mega/       (Mega clone)
├── frank_yomik/      (manga translation)
├── gitea/            (Docker registry)
├── immich/           (self-hosted Google Photos)
├── kaizoku/          (manga downloader &#43; reader)
├── media/            (Plex &#43; *arr stack)
├── mila/             (Discord bot)
├── monitor/          (Grafana &#43; Prometheus)
├── ollama/           (local LLM with ROCm)
├── rip/              (HandBrake)
└── utils/            (Portainer, Vaultwarden, Syncthing, etc.)

/var/mnt/terachad/    (Synology NFS)
├── Bitcoin/data/     (blockchain, 785GB)
├── Downloads/        (torrents &#43; nzbget)
├── Videos/           (Radarr movies &#43; Sonarr series)
└── Ollama/models/    (model overflow if local disk fills up)</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<h2>A warning about using AI to administer servers<span class="hx:absolute hx:-mt-20" id="a-warning-about-using-ai-to-administer-servers"></span>
    <a href="#a-warning-about-using-ai-to-administer-servers" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>I used Claude Code to speed up the migration. It created compose files, wrote backup scripts, configured the firewall, diagnosed SELinux problems. It worked well for my case: home server, only I use it, and I was reviewing every step.</p>
<p>But there are traps. Claude doesn&rsquo;t know that <code>:Z</code> breaks SQLite unless you tell it. It doesn&rsquo;t know that Fulcrum doesn&rsquo;t accept env vars unless it&rsquo;s already seen the Dockerfile. It will invent &ldquo;better&rdquo; volume layouts that break Plex because Plex stores absolute paths in its database.</p>
<p>If it were real production: don&rsquo;t do this without review. Every compose file Claude generates, read it in full before applying. Every destructive command (rollback, delete, recreate), confirm manually. And have tested backups before you start. Claude is great for generating the first version and diagnosing errors, but the architecture decisions and the safety validations are yours.</p>
<p>The previous home server posts that may give additional context:</p>
<ul>
<li><a href="/2024/04/03/meu-netflix-pessoal-com-docker-compose/">My &ldquo;Personal Netflix&rdquo; with Docker Compose</a></li>
<li><a href="/en/2025/09/09/accessing-my-home-server-with-a-real-domain/">Accessing my Home Server with a real domain</a></li>
<li><a href="/en/2025/09/10/protecting-your-home-server-with-cloudflare-zero-trust/">Protecting your Home Server with Cloudflare Zero Trust</a></li>
<li><a href="/en/2025/09/10/installing-grafana-on-my-home-server/">Installing Grafana on my Home Server</a></li>
<li><a href="/en/2025/09/10/omarchy-2-0-bitwarden-self-hosted-vaultwarden/">Self-hosted Vaultwarden</a></li>
</ul>
]]></content:encoded><category>homeserver</category><category>docker</category><category>opensuse</category><category>microos</category><category>claude-code</category><category>vibe-coding</category></item><item><title>Review: Minisforum MS-S1 Max | AMD AI Max+ 395 with 96GB of VRAM</title><link>https://www.akitaonrails.com/en/2026/03/31/minisforum-ms-s1-max-amd-ai-max-395-review/</link><guid isPermaLink="true">https://www.akitaonrails.com/en/2026/03/31/minisforum-ms-s1-max-amd-ai-max-395-review/</guid><pubDate>Tue, 31 Mar 2026 15:00:00 GMT</pubDate><description>&lt;p&gt;If you&amp;rsquo;ve been following my &lt;a href="https://www.akitaonrails.com/2024/04/03/meu-netflix-pessoal-com-docker-compose/"&gt;home server posts&lt;/a&gt;, you know I used to run everything on an Intel NUC Core i7 with 32GB of RAM. It worked. But as open source AI models grew, the NUC became the bottleneck. Without a dedicated GPU, any LLM inference would fall back to the CPU and turn unusable.&lt;/p&gt;
&lt;p&gt;I bought a Minisforum MS-S1 Max with the new AMD Ryzen AI Max+ 395 chip for one specific reason: this chip supports up to 128GB of unified RAM, and I can allocate 96GB of it as VRAM for the iGPU. That gives me more VRAM than any consumer gaming card, including the RTX 5090 (32GB). And that changes what I can run locally.&lt;/p&gt;</description><content:encoded><![CDATA[<p>If you&rsquo;ve been following my <a href="/2024/04/03/meu-netflix-pessoal-com-docker-compose/">home server posts</a>, you know I used to run everything on an Intel NUC Core i7 with 32GB of RAM. It worked. But as open source AI models grew, the NUC became the bottleneck. Without a dedicated GPU, any LLM inference would fall back to the CPU and turn unusable.</p>
<p>I bought a Minisforum MS-S1 Max with the new AMD Ryzen AI Max+ 395 chip for one specific reason: this chip supports up to 128GB of unified RAM, and I can allocate 96GB of it as VRAM for the iGPU. That gives me more VRAM than any consumer gaming card, including the RTX 5090 (32GB). And that changes what I can run locally.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/blog/2026/03/31/minisforum-desk.jpg" alt="Minisforum MS-S1 Max on the desk"  loading="lazy" /></p>
<h2>Why ditch the Intel NUC<span class="hx:absolute hx:-mt-20" id="why-ditch-the-intel-nuc"></span>
    <a href="#why-ditch-the-intel-nuc" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The NUC was a fine Docker server for two years. But the limitation was clear: without a GPU with enough VRAM, I couldn&rsquo;t run LLMs locally in any usable way. <a href="https://github.com/akitaonrails/FrankYomik"target="_blank" rel="noopener">Frank Yomik</a>, my automatic manga translation system, needed CPU-based OCR (slow) and would connect remotely to the Ollama running on my desktop (AMD 7950X3D + RTX 5090) for translation. It worked, but it meant my desktop had to be on for the server to do its job.</p>
<p><img src="https://raw.githubusercontent.com/akitaonrails/FrankYomik/master/docs/sample_translate.png" alt="Frank Yomik - automatic manga translation"  loading="lazy" /></p>
<p>With the Minisforum, Frank Yomik now runs entirely on the server. The worker uses ROCm for OCR on the iGPU, and Ollama runs locally with 96GB of VRAM. Zero dependency on the desktop.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/blog/2026/03/31/minisforum-nuc-compare.jpg" alt="Comparison: Intel NUC (left) vs Minisforum MS-S1 Max (right)"  loading="lazy" /></p>
<p>You can get a sense of the size from the photo. The NUC is the tiny cube on the left. The Minisforum is bigger but it&rsquo;s still a mini-PC. It fits on the rack shelf under my Synology NAS without any trouble.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/blog/2026/03/31/minisforum-shelf.jpg" alt="Minisforum installed on the shelf, next to the NAS"  loading="lazy" /></p>
<h2>The specs<span class="hx:absolute hx:-mt-20" id="the-specs"></span>
    <a href="#the-specs" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/blog/2026/03/31/minisforum-fastfetch.png" alt="fastfetch on the Minisforum"  loading="lazy" /></p>
<p>The chip is the AMD Ryzen AI Max+ 395: 16 cores / 32 threads Zen 5, with an integrated Radeon 8060S iGPU and 128GB of unified LPDDR5X. In the BIOS, I set the UMA Frame Buffer Size to 96GB, which leaves ~30GB of RAM for the operating system and containers. Plus the kernel parameters for TTM (without them, ROCm only sees 15.5GB even with the BIOS allocation in place).</p>
<p>The operating system is openSUSE MicroOS (more on that in the <a href="/en/2026/03/31/migrating-my-home-server-with-claude-code/">next post</a>). The whole machine pulls under 100W, which is absurd if you&rsquo;re used to dedicated GPUs that draw 450W+ on their own.</p>
<h2>Minisforum vs my desktop: benchmarks<span class="hx:absolute hx:-mt-20" id="minisforum-vs-my-desktop-benchmarks"></span>
    <a href="#minisforum-vs-my-desktop-benchmarks" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>I ran a <a href="https://github.com/akitaonrails/homelab-docs/tree/master/benchmarks"target="_blank" rel="noopener">set of benchmarks</a> comparing the Minisforum against my desktop (AMD 7950X3D, 96GB DDR5, RTX 5090 32GB GDDR7). The results are clear.</p>
<h3>CPU<span class="hx:absolute hx:-mt-20" id="cpu"></span>
    <a href="#cpu" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><table>
  <thead>
      <tr>
          <th>Test</th>
          <th>7950X3D</th>
          <th>AI Max+ 395</th>
          <th>Winner</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Prime sieve (single-core)</td>
          <td>0.021s</td>
          <td>0.018s</td>
          <td>Strix Halo +14%</td>
      </tr>
      <tr>
          <td>Float pi (single-core)</td>
          <td>1.335s</td>
          <td>1.706s</td>
          <td>7950X3D +28%</td>
      </tr>
      <tr>
          <td>Multi-core sieve (32 threads)</td>
          <td>0.181s</td>
          <td>0.118s</td>
          <td>Strix Halo +53%</td>
      </tr>
      <tr>
          <td>SHA-256 throughput</td>
          <td>2.714 MB/s</td>
          <td>2.488 MB/s</td>
          <td>7950X3D +9%</td>
      </tr>
      <tr>
          <td>AES-256-CBC throughput</td>
          <td>1.613 MB/s</td>
          <td>1.410 MB/s</td>
          <td>7950X3D +14%</td>
      </tr>
  </tbody>
</table>
<p>Mixed results. The AI Max+ 395 is better at pure parallelism (multi-core sieve), probably thanks to lower latency in the unified memory architecture. The 7950X3D wins at float and crypto because of its higher clocks and the 3D V-Cache.</p>
<h3>LLM inference (models that fit on both)<span class="hx:absolute hx:-mt-20" id="llm-inference-models-that-fit-on-both"></span>
    <a href="#llm-inference-models-that-fit-on-both" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>This is where it gets interesting. For models that fit in the 32GB of the RTX 5090, the comparison is purely about memory bandwidth:</p>
<table>
  <thead>
      <tr>
          <th>Model</th>
          <th>Size</th>
          <th>RTX 5090 (tok/s)</th>
          <th>Strix Halo (tok/s)</th>
          <th>5090 advantage</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>phi4</td>
          <td>9.1 GB</td>
          <td>155.1</td>
          <td>23.2</td>
          <td>6.7x</td>
      </tr>
      <tr>
          <td>qwen3:14b</td>
          <td>9.3 GB</td>
          <td>138.9</td>
          <td>22.6</td>
          <td>6.1x</td>
      </tr>
      <tr>
          <td>phi4-reasoning</td>
          <td>11.1 GB</td>
          <td>130.2</td>
          <td>19.1</td>
          <td>6.8x</td>
      </tr>
      <tr>
          <td>qwen3:32b</td>
          <td>20.2 GB</td>
          <td>66.9</td>
          <td>10.0</td>
          <td>6.7x</td>
      </tr>
  </tbody>
</table>
<p>The RTX 5090 is ~7x faster. The explanation is simple: GDDR7 has ~1,792 GB/s of bandwidth. LPDDR5X has ~256 GB/s. The ratio (7x) lines up almost exactly with the measured speed difference (6.7x). LLM inference is a problem dominated by memory bandwidth. Whoever reads weights faster, generates tokens faster.</p>
<h3>And what about prompt processing?<span class="hx:absolute hx:-mt-20" id="and-what-about-prompt-processing"></span>
    <a href="#and-what-about-prompt-processing" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><table>
  <thead>
      <tr>
          <th>Model</th>
          <th>RTX 5090 (tok/s)</th>
          <th>Strix Halo (tok/s)</th>
          <th>5090 advantage</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>phi4</td>
          <td>~1,933</td>
          <td>~212</td>
          <td>9.1x</td>
      </tr>
      <tr>
          <td>qwen3:14b</td>
          <td>~1,474</td>
          <td>~155</td>
          <td>9.5x</td>
      </tr>
      <tr>
          <td>qwen3:32b</td>
          <td>~767</td>
          <td>~68</td>
          <td>11.3x</td>
      </tr>
  </tbody>
</table>
<p>Prompt processing is even worse: 7-11x slower. That makes sense, because the prompt has to be processed in full before generating the first token, and it&rsquo;s an even more bandwidth-intensive operation.</p>
<h3>Where the Strix Halo wins: large models<span class="hx:absolute hx:-mt-20" id="where-the-strix-halo-wins-large-models"></span>
    <a href="#where-the-strix-halo-wins-large-models" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Now we get to the reason I bought this PC. Models that don&rsquo;t fit in the RTX 5090:</p>
<table>
  <thead>
      <tr>
          <th>Model</th>
          <th>Size</th>
          <th>Strix Halo (tok/s)</th>
          <th>Notes</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>gpt-oss:20b</td>
          <td>13.8 GB MXFP4</td>
          <td>48.9</td>
          <td>MoE, faster than expected</td>
      </tr>
      <tr>
          <td>qwen3.5:35b</td>
          <td>23.9 GB</td>
          <td>43.2</td>
          <td>MoE, only ~4B active params</td>
      </tr>
      <tr>
          <td>qwen3-coder-next</td>
          <td>51.7 GB</td>
          <td>29.5</td>
          <td>MoE, 50GB+</td>
      </tr>
      <tr>
          <td>qwen3.5:122b</td>
          <td>81.4 GB Q4_K_M</td>
          <td>19.2</td>
          <td>122B params, MoE</td>
      </tr>
      <tr>
          <td>glm-4.7-flash:bf16</td>
          <td>59.9 GB</td>
          <td>17.9</td>
          <td>Full precision bf16</td>
      </tr>
      <tr>
          <td>qwen2.5:72b</td>
          <td>47.4 GB Q4_K_M</td>
          <td>4.5</td>
          <td>Dense 72B, bandwidth-limited</td>
      </tr>
  </tbody>
</table>
<p>qwen3.5:122b with 81GB of weights running at 19 tok/s. On a mini-PC. That&rsquo;s simply not possible on an RTX 5090. On the NVIDIA card, that model would have to offload layers to system RAM, dropping to 2-3 tok/s. In practice, unusable.</p>
<p>The difference between MoE and dense models is brutal. qwen3.5:35b runs at 43 tok/s because, despite having 35B total parameters, only ~4B are active per token. A dense 72B model like qwen2.5:72b has to read 40GB+ of weights per token, and at 256 GB/s of bandwidth, the theoretical maximum is ~6.7 tok/s. The 4.5 measured represent ~67% efficiency, which is what you&rsquo;d expect for an iGPU (overhead from shared bus and drivers).</p>
<h3>Summary: when to use which machine<span class="hx:absolute hx:-mt-20" id="summary-when-to-use-which-machine"></span>
    <a href="#summary-when-to-use-which-machine" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><table>
  <thead>
      <tr>
          <th>Use case</th>
          <th>Best machine</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Interactive chat/coding (models &lt;32GB)</td>
          <td>RTX 5090 (6-7x faster)</td>
      </tr>
      <tr>
          <td>Large models (50GB+)</td>
          <td>Strix Halo (only option)</td>
      </tr>
      <tr>
          <td>Dense 70B+ models</td>
          <td>Strix Halo (only option)</td>
      </tr>
      <tr>
          <td>Full-precision bf16</td>
          <td>Strix Halo (only option)</td>
      </tr>
      <tr>
          <td>Batch processing with long context</td>
          <td>Strix Halo (more VRAM for KV cache)</td>
      </tr>
      <tr>
          <td>API serving with low latency</td>
          <td>RTX 5090 (sub-150ms TTFT)</td>
      </tr>
  </tbody>
</table>
<h3>A ROCm bug that&rsquo;s still around<span class="hx:absolute hx:-mt-20" id="a-rocm-bug-thats-still-around"></span>
    <a href="#a-rocm-bug-thats-still-around" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Not everything works. Models like deepseek-r1:70b, llama3.3:70b and llama4:scout crash with a ggml bug (<code>GGML_ASSERT(ggml_nbytes(src0) &lt;= INT_MAX) failed</code>). The embedding tensor of these models exceeds 2GB and the ROCm copy kernel uses a 32-bit integer for the size. On CUDA (NVIDIA) it&rsquo;s already been fixed, but on ROCm it hasn&rsquo;t. Waiting for the fix in Ollama 0.20.0+.</p>
<h2>LPDDR5X vs GDDR7: why this difference exists<span class="hx:absolute hx:-mt-20" id="lpddr5x-vs-gddr7-why-this-difference-exists"></span>
    <a href="#lpddr5x-vs-gddr7-why-this-difference-exists" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The next question is: why is LPDDR5X so much slower?</p>
<p>GDDR7 is dedicated GPU memory. It&rsquo;s soldered onto the graphics card, connected by a wide bus (384 or 512 bits on the RTX 5090) at high clocks. Its only job is to feed data to the GPU. LPDDR5X is unified memory that serves everything: the operating system, applications, and the GPU all at once. The bus is narrower and shared.</p>
<p>In practice: GDDR7 delivers ~1,792 GB/s dedicated to the GPU. LPDDR5X delivers ~256 GB/s that still need to be split between CPU and GPU. LLM inference is basically &ldquo;read all the model weights from memory, multiply by the current token, generate the next token, repeat.&rdquo; Whoever reads faster, generates faster. There&rsquo;s no shortcut.</p>
<p>The Strix Halo&rsquo;s advantage isn&rsquo;t speed. It&rsquo;s capacity. 96GB of VRAM in a 100W chip that costs a fraction of a professional GPU. The RTX 5090 is 7x faster, but it&rsquo;s stuck at 32GB. Models that don&rsquo;t fit, don&rsquo;t run.</p>
<h2>The alternatives: who else does this?<span class="hx:absolute hx:-mt-20" id="the-alternatives-who-else-does-this"></span>
    <a href="#the-alternatives-who-else-does-this" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>If 96GB isn&rsquo;t enough or you want more speed, the options are limited.</p>
<p>The Framework Desktop uses the same AI Max+ 395 chip with up to 128GB of RAM. Same platform, same performance, but with the differential of being modular and repairable (it&rsquo;s Framework, after all). In practice it&rsquo;s equivalent to the Minisforum on specs and price.</p>
<p>Above that, the alternative is a Mac Studio with M3 Ultra. The M3 Ultra chip supports up to 512GB of unified memory, with ~819 GB/s of bandwidth (more than 3x the Strix Halo). Apple manufactures the memory chips on the package, so latency and bandwidth are superior. You could potentially allocate ~400GB as VRAM and run models that don&rsquo;t fit anywhere outside of professional GPU servers.</p>
<p>Apple&rsquo;s internal NVMe is also another level: ~7.4 GB/s of sequential read on the M3 Ultra, compared with ~14 GB/s on the Crucial T700 (PCIe 5.0). The T700 is faster on raw throughput, but Apple&rsquo;s NVMe latency tends to be lower on random I/O thanks to the SoC integration.</p>
<table>
  <thead>
      <tr>
          <th>Spec</th>
          <th>Minisforum MS-S1 Max</th>
          <th>Mac Studio M3 Ultra (max)</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Max RAM</td>
          <td>128 GB LPDDR5X</td>
          <td>512 GB unified</td>
      </tr>
      <tr>
          <td>Allocatable VRAM</td>
          <td>~96 GB</td>
          <td>~400 GB</td>
      </tr>
      <tr>
          <td>Memory bandwidth</td>
          <td>~256 GB/s</td>
          <td>~819 GB/s</td>
      </tr>
      <tr>
          <td>CPU</td>
          <td>Zen 5, 16C/32T</td>
          <td>Apple M3 Ultra, 32C</td>
      </tr>
      <tr>
          <td>GPU compute</td>
          <td>ROCm (gfx1151, experimental)</td>
          <td>Metal (mlx, mature)</td>
      </tr>
      <tr>
          <td>Power draw</td>
          <td>~100W</td>
          <td>~135W</td>
      </tr>
      <tr>
          <td>NVMe</td>
          <td>PCIe 5.0 (standard slot)</td>
          <td>Custom Apple (~7.4 GB/s)</td>
      </tr>
      <tr>
          <td>Price (US)</td>
          <td>~$1,500-2,000</td>
          <td>~$9,999 (512GB config)</td>
      </tr>
      <tr>
          <td>Estimated price (Brazil)</td>
          <td>~R$ 12,000-15,000</td>
          <td>~R$ 110,000+ (imported)</td>
      </tr>
  </tbody>
</table>
<p>The Brazilian price is the elephant in the room. The Mac Studio&rsquo;s max config costs $9,999 in the US. With import taxes (~60% + state ICMS), it goes past R$ 110,000. The Minisforum with 128GB lands at R$ 12,000-15,000. A nearly 8x price gap buys you a lot.</p>
<p>If you need more than 96GB of VRAM for truly enormous models (DeepSeek-V3 with 671B parameters fits in ~400GB Q4, for example), the Mac Studio with 512GB is the only consumer option. The alternative would be professional NVIDIA A6000 GPUs (48GB VRAM, ~$6,000 each, and you&rsquo;d need several in NVLink). For everything that fits in 96GB, the Minisforum gets the job done at a fraction of the cost.</p>
<h2>And projects that promise to run big LLMs on small GPUs?<span class="hx:absolute hx:-mt-20" id="and-projects-that-promise-to-run-big-llms-on-small-gpus"></span>
    <a href="#and-projects-that-promise-to-run-big-llms-on-small-gpus" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>There&rsquo;s a concept called &ldquo;layer offloading&rdquo; that projects like llama.cpp already support. The idea: if the model doesn&rsquo;t fit fully in VRAM, keep some layers on the GPU and the rest in system RAM. The GPU processes the layers it has, hands off to the CPU to process the rest, and back.</p>
<p>In practice, it doesn&rsquo;t work well. The bottleneck is PCIe: transfer speed between system RAM and GPU VRAM is ~32 GB/s (PCIe 5.0 x16). Each generated token needs to transfer data back and forth. The result is that you drop from 150 tok/s (everything in VRAM) to 2-8 tok/s (partial offload). It&rsquo;s too slow for interactive use.</p>
<p>VRAM is the fundamental limitation because LLM inference is memory-bandwidth-bound, not compute-bound. The GPU has compute to spare. What&rsquo;s missing is the ability to read the model weights fast enough. When part of the weights live in system RAM through PCIe, the entire pipeline waits on the transfer.</p>
<p>That&rsquo;s why unified memory (like in the Strix Halo or Apple Silicon) makes a difference. There&rsquo;s no PCIe in the middle. CPU and GPU access the same physical memory. The Strix Halo&rsquo;s 256 GB/s is slow compared to GDDR7, but it&rsquo;s 8x faster than offloading through PCIe.</p>
<h2>Advances in LLM optimization (up to 2026)<span class="hx:absolute hx:-mt-20" id="advances-in-llm-optimization-up-to-2026"></span>
    <a href="#advances-in-llm-optimization-up-to-2026" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>To understand why some models run so much better than others on the Strix Halo, you have to understand what&rsquo;s changed in the ecosystem over the last two years.</p>
<h3>Mixture of Experts (MoE)<span class="hx:absolute hx:-mt-20" id="mixture-of-experts-moe"></span>
    <a href="#mixture-of-experts-moe" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>If you run local models, MoE is the advance that matters most. An MoE model has high total parameters (e.g. 122B in qwen3.5:122b), but only activates a fraction of them per token (e.g. ~4B). The inactive weights stay in VRAM but aren&rsquo;t read on every token, which drastically reduces the bandwidth needed.</p>
<p>In the Strix Halo benchmarks, MoE models run 3-10x faster than dense models of the same size. qwen3.5:35b (MoE, ~4B active) runs at 43 tok/s while qwen2.5:72b (dense, 72B active) runs at 4.5 tok/s.</p>
<h3>DeepSeek and training optimization<span class="hx:absolute hx:-mt-20" id="deepseek-and-training-optimization"></span>
    <a href="#deepseek-and-training-optimization" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>DeepSeek V3 (December 2024) showed that it was possible to train 671B parameter models at a cost an order of magnitude lower than predicted. They combined MoE with FP8 quantization during training (not just inference), multi-stage training with curriculum learning, and several inter-GPU communication optimizations. The impact: everyone copied. Qwen, GLM, MiniMax, all of them adopted variations of the technique.</p>
<h3>Quantization: from FP16 to Q4 without losing much<span class="hx:absolute hx:-mt-20" id="quantization-from-fp16-to-q4-without-losing-much"></span>
    <a href="#quantization-from-fp16-to-q4-without-losing-much" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Quantization compresses model weights from 16 bits (FP16) to smaller formats: 8 bits (Q8), 4 bits (Q4), or even 2 bits. A 70B model that would take ~140GB in FP16 fits in ~40GB at Q4_K_M. Quality loss exists, but in modern formats (GGUF Q4_K_M, AWQ, EXL2) it&rsquo;s small enough for practical use.</p>
<p>GGUF (the llama.cpp format) became the standard for local inference. AWQ and GPTQ are alternatives with more sophisticated calibration, but the ecosystem converged on GGUF because it works on CPU, CUDA and ROCm without recompilation.</p>
<h3>Distillation: smaller models that know more<span class="hx:absolute hx:-mt-20" id="distillation-smaller-models-that-know-more"></span>
    <a href="#distillation-smaller-models-that-know-more" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Distillation is training a small model using the responses of a large model as the teacher. Microsoft&rsquo;s Phi-4 (14B) was trained with distillation from GPT-4 and competes with 70B models on several benchmarks. Qwen3 did the same: qwen3:14b is surprisingly capable for its size.</p>
<h3>Flash Attention and optimized KV Cache<span class="hx:absolute hx:-mt-20" id="flash-attention-and-optimized-kv-cache"></span>
    <a href="#flash-attention-and-optimized-kv-cache" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Flash Attention (Tri Dao, 2022) changed how attention is computed: instead of materializing the full attention matrix in memory, it processes in blocks keeping the data in the GPU&rsquo;s on-chip SRAM, reducing memory consumption from O(n²) to O(n). Without that, contexts of 128K+ tokens would be impractical. It already went through versions 2 and 3, with optimizations for FP8 and async operations on H100. PagedAttention (vLLM, UC Berkeley) did the same for the KV cache during serving: applies virtual memory concepts to the cache, eliminating fragmentation and improving throughput by 2-4x.</p>
<p>In Ollama, I set <code>OLLAMA_FLASH_ATTENTION=1</code> and <code>OLLAMA_KV_CACHE_TYPE=q8_0</code> on the server. The first activates flash attention, the second uses 8-bit KV cache instead of fp16, cutting the bandwidth needed per token in half. These are zero-hardware-cost optimizations that improve throughput measurably.</p>
<h3>What Qwen, Kimi, MiniMax and GLM are doing<span class="hx:absolute hx:-mt-20" id="what-qwen-kimi-minimax-and-glm-are-doing"></span>
    <a href="#what-qwen-kimi-minimax-and-glm-are-doing" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Qwen (Alibaba) has consistently been the best price/performance in open source models. Qwen3:14b is dense and strong; Qwen3.5:122b is MoE and runs surprisingly well in 96GB. GLM-4.7 (Zhipu AI) is notable for offering bf16 full precision versions that fit in 96GB. MiniMax experimented with long contexts (up to 4M tokens). Kimi (Moonshot AI) focused on large context windows with linear architectures.</p>
<h3>What runs well in 96GB of VRAM<span class="hx:absolute hx:-mt-20" id="what-runs-well-in-96gb-of-vram"></span>
    <a href="#what-runs-well-in-96gb-of-vram" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>With 96GB on the Strix Halo, the models that work well for daily use:</p>
<table>
  <thead>
      <tr>
          <th>Model</th>
          <th>Size</th>
          <th>tok/s</th>
          <th>Use</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>qwen3.5:35b</td>
          <td>24 GB</td>
          <td>43.2</td>
          <td>General purpose, excellent</td>
      </tr>
      <tr>
          <td>qwen3-coder-next</td>
          <td>52 GB</td>
          <td>29.5</td>
          <td>Code, MoE</td>
      </tr>
      <tr>
          <td>qwen3.5:122b</td>
          <td>81 GB</td>
          <td>19.2</td>
          <td>Heavy but usable</td>
      </tr>
      <tr>
          <td>glm-4.7-flash:bf16</td>
          <td>60 GB</td>
          <td>17.9</td>
          <td>Full precision</td>
      </tr>
      <tr>
          <td>qwen2.5-coder:32b</td>
          <td>20 GB</td>
          <td>10.2</td>
          <td>Code, dense</td>
      </tr>
      <tr>
          <td>deepseek-r1:32b</td>
          <td>20 GB</td>
          <td>7.4</td>
          <td>Reasoning</td>
      </tr>
  </tbody>
</table>
<p>Dense 70B+ models (deepseek-r1:70b, llama3.3:70b) are still blocked by the ROCm bug I mentioned. When it gets fixed, they should run at ~4-6 tok/s, usable for batch but not for interactive chat.</p>
<h2>Conclusion<span class="hx:absolute hx:-mt-20" id="conclusion"></span>
    <a href="#conclusion" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>I bought the Minisforum to run models that don&rsquo;t fit in any gaming GPU. For that, it works. It&rsquo;s not fast. 19 tok/s on a 122B model isn&rsquo;t the experience you get with Claude or ChatGPT. But it&rsquo;s local, it&rsquo;s private, and it runs on my shelf consuming less power than an old lightbulb.</p>
<p>For people asking about the Mac Studio: if you have the budget, it&rsquo;s the best machine for running local LLMs. 512GB of unified memory, 819 GB/s of bandwidth, mature Metal/mlx ecosystem. You can run DeepSeek-V3 in full Q4. But in Brazil, with import duties, it crosses R$ 110k. The Minisforum with 128GB at R$ 12-15k is the realistic option.</p>
<p>And for people who think you can work around the VRAM limitation with layer offloading: you can&rsquo;t. PCIe is too slow. The model has to fit fully in VRAM for inference to be usable. It&rsquo;s the reason gaming GPUs with 32GB of ultra-fast GDDR7 are still capped on model size, and why the unified memory of the Strix Halo and Apple Silicon changed the equation.</p>
<p>In the <a href="/en/2026/03/31/migrating-my-home-server-with-claude-code/">next post</a> I tell the story of how I migrated the entire home server to the Minisforum using Claude Code, the problems I ran into, and how openSUSE MicroOS behaves as a Docker server operating system.</p>
]]></content:encoded><category>hardware</category><category>llm</category><category>homeserver</category><category>amd</category><category>review</category></item><item><title>Teaching People to Question the News | Frank Investigator</title><link>https://www.akitaonrails.com/en/2026/03/27/teaching-people-to-question-the-news-frank-investigator/</link><guid isPermaLink="true">https://www.akitaonrails.com/en/2026/03/27/teaching-people-to-question-the-news-frank-investigator/</guid><pubDate>Fri, 27 Mar 2026 10:00:00 GMT</pubDate><description>&lt;p&gt;Heads up: &lt;a href="https://github.com/akitaonrails/frank_investigator"target="_blank" rel="noopener"&gt;Frank Investigator&lt;/a&gt; is an experimental project, in active development, and isn&amp;rsquo;t meant to be the final word on any article it analyzes. It doesn&amp;rsquo;t tell you what&amp;rsquo;s true or false. What it does is ask the questions the article refused to ask, identify known rhetorical patterns, and search for external sources the author left out. If you want to help, contribute on &lt;a href="https://github.com/akitaonrails/frank_investigator"target="_blank" rel="noopener"&gt;GitHub&lt;/a&gt; or send feedback. If you want to follow the results, &lt;a href="https://themakitachronicles.com/"target="_blank" rel="noopener"&gt;The Makita Chronicles&lt;/a&gt; newsletter is going to have a new section called &amp;ldquo;Notícias Duvidosas&amp;rdquo; (Dubious News) where I&amp;rsquo;ll publish the investigator&amp;rsquo;s summary and a link to the full report.&lt;/p&gt;</description><content:encoded><![CDATA[<p>Heads up: <a href="https://github.com/akitaonrails/frank_investigator"target="_blank" rel="noopener">Frank Investigator</a> is an experimental project, in active development, and isn&rsquo;t meant to be the final word on any article it analyzes. It doesn&rsquo;t tell you what&rsquo;s true or false. What it does is ask the questions the article refused to ask, identify known rhetorical patterns, and search for external sources the author left out. If you want to help, contribute on <a href="https://github.com/akitaonrails/frank_investigator"target="_blank" rel="noopener">GitHub</a> or send feedback. If you want to follow the results, <a href="https://themakitachronicles.com/"target="_blank" rel="noopener">The Makita Chronicles</a> newsletter is going to have a new section called &ldquo;Notícias Duvidosas&rdquo; (Dubious News) where I&rsquo;ll publish the investigator&rsquo;s summary and a link to the full report.</p>
<p>With that out of the way, let me explain why I built this.</p>
<h2>The problem with the Brazilian press<span class="hx:absolute hx:-mt-20" id="the-problem-with-the-brazilian-press"></span>
    <a href="#the-problem-with-the-brazilian-press" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>I&rsquo;m fed up.</p>
<p>Fed up of opening the newspaper and having to do mental gymnastics to separate information from narrative. Fed up of outlets like Folha de São Paulo, UOL, Carta Capital, Brasil 247, O Globo and several others that use misleading headlines, omit context on purpose, transpose evidence from other countries with no caveat, and create the appearance of consensus among outlets that are all saying the same thing because they&rsquo;re following the same coordinated agenda.</p>
<p>This isn&rsquo;t conspiracy theory. It&rsquo;s a verifiable editorial pattern. And the worst part: most readers don&rsquo;t have the time or the tools to notice. You read the headline, read the first two paragraphs, and walk away with the impression the article planted in your head.</p>
<h2>What Frank Investigator does<span class="hx:absolute hx:-mt-20" id="what-frank-investigator-does"></span>
    <a href="#what-frank-investigator-does" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>You give it a news article URL. The system fetches the article with a headless Chromium (to get past paywalls and cookie walls), extracts the content while filtering out ads and sidebars, and decomposes the text into verifiable claims. Then the real analysis starts: divergence between headline and body, rhetorical fallacies (false causality, appeal to authority, strawman, bait-and-pivot), source distortion, temporal manipulation, selective citation, authority laundering. It expands the links cited in the article to verify whether the sources actually say what the author claims. It evaluates each claim with consensus from 3 AI models (Claude Sonnet 4.6, GPT-5.4, Gemini 3.1 Pro) through OpenRouter. It detects contextual gaps, coordinated campaigns across outlets, and measures the ratio between passion and evidence. 15 stages in total.</p>
<p>The central principle is &ldquo;Truth Above Consensus&rdquo;: a primary source (official data, government document, original academic study) vetoes any number of secondary sources repeating the same information. Ten newspapers repeating the same thing without a primary source still adds up to zero.</p>
<p>Let me show you five real examples.</p>
<h2>Example 1: The Noelia Castillo case (BBC)<span class="hx:absolute hx:-mt-20" id="example-1-the-noelia-castillo-case-bbc"></span>
    <a href="#example-1-the-noelia-castillo-case-bbc" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/blog/2026/03/27/frank-investigator/noelia-original.png" alt="Original article on the BBC"  loading="lazy" /></p>
<p>The <a href="https://www.bbc.com/portuguese/articles/clyxedlekleo"target="_blank" rel="noopener">BBC published</a> a story with the headline &ldquo;Noelia Castillo dies: a 25-year-old&rsquo;s fight in the Spanish courts against her father to receive euthanasia.&rdquo; At first glance it looks like a story about the right to euthanasia and a family dispute. A young quadriplegic woman who fought against her religious father&rsquo;s opposition to exercise her right to die.</p>
<p>Except when you compare it with other outlets, like <a href="https://veja.abril.com.br/comportamento/a-decisao-extrema-tomada-por-espabhola-que-ficou-paraplegica-apos-agressao-sexual/"target="_blank" rel="noopener">Veja</a>, facts come up that the BBC almost completely omitted. And those facts change everything.</p>
<p>Noelia was taken from her family by the Spanish government at age 13 and placed under state custody. While she was under that custody, she suffered multiple gang rapes. The sexual violence resulted in serious psychiatric damage and a mental health history that already added up to 67% disability rating before the events of 2022. When she attempted suicide in October 2022 by jumping from the fifth floor of a building, she was left paraplegic. Her disability rating rose to 74%.</p>
<p>The euthanasia request was approved by Catalonia&rsquo;s Guarantee and Evaluation Commission. The procedure was scheduled for August 2, 2024, but was suspended for over 600 days because of the father&rsquo;s appeals. Five judicial instances ruled on it. The Constitutional Court dismissed any violation of fundamental rights. Spain&rsquo;s Supreme Court denied the appeal. The European Court of Human Rights rejected the suspension request. On Friday, March 26, 2026, Noelia underwent euthanasia at the Sant Camil Residential Hospital, in Catalonia&rsquo;s Garraf region.</p>
<p>But there&rsquo;s a detail that Veja mentions that&rsquo;s disturbing: Noelia reportedly expressed doubts before the procedure. And the hospital allegedly accelerated the process because her organs were already committed for donation.</p>
<p>The <a href="https://investigator.themakitachronicles.com/investigations/e5a27e016c"target="_blank" rel="noopener">Frank Investigator report</a> compared the coverage from several outlets.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/blog/2026/03/27/frank-investigator/noelia-contexto.png" alt="Event context and omitted facts"  loading="lazy" /></p>
<p>The cross-analysis of the articles showed that some outlets, like the BBC, omitted facts that change the interpretation of the entire case. Others, like Veja, included the full context. The episode of gang sexual violence in October 2022, the criminal investigation, the psychiatric history since age 13, all of that appears in some coverage but is completely absent in others. And among the outlets that omitted these facts, none touched on the ethical question of whether the physical basis for the euthanasia request derives from a suicide attempt.</p>
<p>The convergent framing across outlets is one of &ldquo;judicial battle,&rdquo; &ldquo;death she asked for,&rdquo; &ldquo;to stop suffering,&rdquo; &ldquo;to leave in peace,&rdquo; softening the definitive nature of the procedure and positioning Noelia as the heroic protagonist and the father as the obstructive antagonist. The father, Gerônimo Castillo, and his Christian Lawyers are labeled &ldquo;ultra-Catholics&rdquo; or &ldquo;ultra-conservatives&rdquo; without any independent source backing that editorial classification.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/blog/2026/03/27/frank-investigator/noelia-coordenada.png" alt="Coordinated narrative analysis - Noelia"  loading="lazy" /></p>
<p>Narrative coordination came in at 55%. It doesn&rsquo;t seem to be active coordination between newsrooms, but thematic editorial alignment: everyone bought the autonomy-of-the-individual narrative without questioning it. What raises the score is the convergence of omissions. No outlet explains the legal distinction between the ECHR &ldquo;actively authorizing&rdquo; the euthanasia and simply refusing the provisional protective measures the father requested, which is what actually happened. The medical and bioethical implications of approving euthanasia in a case stemming from a suicide attempt show up nowhere. And any voice critical of the procedure is automatically framed as religious or ideological, never as medical or legal.</p>
<p>It&rsquo;s the kind of case where the omission is the manipulation. The outlets that omitted these facts didn&rsquo;t lie at any point. But by framing it as a &ldquo;family dispute over euthanasia rights&rdquo; and omitting the causal chain (state custody → gang rapes → psychiatric damage → suicide attempt → paraplegia → euthanasia), the reader walks away with an impression that&rsquo;s radically different from reality. Comparing coverage is exactly the kind of thing the investigator does well: exposing what each outlet chose to show and what it chose to hide.</p>
<h2>Example 2: &ldquo;Government cuts taxes on nearly a thousand imports&rdquo; (UOL)<span class="hx:absolute hx:-mt-20" id="example-2-government-cuts-taxes-on-nearly-a-thousand-imports-uol"></span>
    <a href="#example-2-government-cuts-taxes-on-nearly-a-thousand-imports-uol" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/blog/2026/03/27/frank-investigator/corte-original.png" alt="Original article on UOL"  loading="lazy" /></p>
<p><a href="https://economia.uol.com.br/noticias/redacao/2026/03/26/de-remedios-a-lupulo-governo-corta-imposto-de-mil-importados.ghtm"target="_blank" rel="noopener">UOL published</a> that the government cut import taxes on nearly a thousand products, from medicine to hops. Positive headline, framed as a benefit to the consumer. Several other outlets ran the same thing with similar framing: the government did something good, prices are going down.</p>
<p>Except <a href="https://www.gazetadopovo.com.br/economia/governo-aumenta-imposto-importacao-recua-fake-news/"target="_blank" rel="noopener">Gazeta do Povo</a> tells the other half of the story. The government didn&rsquo;t cut old taxes. What actually happened was: at some point before February 2025, the government raised import tariffs on more than 1,200 items, a measure that would have generated an estimated R$ 14 billion in revenue. Then, under social-media and public pressure, it partially backed down. Tariffs on around 970 capital goods, computing and telecommunications items were dropped to zero. Taxes on 120 IT products were reduced. And now they&rsquo;re calling that a &ldquo;tax cut.&rdquo;</p>
<p>In other words: they raised taxes, took public pressure, partially walked it back, and rebranded it as a generous concession. The majority of the 1,200+ items that had tariffs raised still have higher tariffs than before. No final consumer prices went down. Prices went back to where they were for some products, and remain higher for most.</p>
<p>The <a href="https://investigator.themakitachronicles.com/investigations/f35bfe0176"target="_blank" rel="noopener">Frank Investigator report</a> cross-checked the articles and exposed what was omitted.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/blog/2026/03/27/frank-investigator/corte-contexto.png" alt="Context and omitted facts - tax cut"  loading="lazy" /></p>
<p>What the comparison between articles exposes: none of the outlets with the positive framing mentions that most of the 1,200+ items with raised tariffs still have higher tariffs after the two rounds of &ldquo;cuts.&rdquo; The fiscal impact on the R$ 14 billion revenue target from the original increases — nobody calculates it. Voices from the domestic industry that may be hurt by the tariff reduction on imported competitors — none. The Gecex&rsquo;s objective criteria for defining &ldquo;insufficient supply in the internal market&rdquo; — they don&rsquo;t show up.</p>
<p>The two articles analyzed build the same positive framing for the government. The paradox that the &ldquo;cuts&rdquo; are a partial reversal of increases made by the same government the previous year stays buried or simply absent.</p>
<p>It&rsquo;s the classic kind of manipulation through reframing. Nobody lied. But &ldquo;government cuts taxes&rdquo; and &ldquo;government backs down on tax hike after public pressure&rdquo; describe the same event with opposite impressions. The editorial choice of which version to publish IS the manipulation.</p>
<h2>Example 3: &ldquo;Globo apologizes for PowerPoint&rdquo; (Brasil 247)<span class="hx:absolute hx:-mt-20" id="example-3-globo-apologizes-for-powerpoint-brasil-247"></span>
    <a href="#example-3-globo-apologizes-for-powerpoint-brasil-247" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/blog/2026/03/27/frank-investigator/globo-original.png" alt="Original article on Brasil 247"  loading="lazy" /></p>
<p>This case blew up over the past few days. GloboNews showed a diagram on the Estúdio I program connecting President Lula, his ministers and Daniel Vorcaro, owner of Banco Master, who&rsquo;s at the center of documented fraud. Globo later issued a public retraction, called the material &ldquo;erroneous and incomplete,&rdquo; and fired an editor.</p>
<p><a href="https://www.brasil247.com/brasil/globo-se-desculpa-por-powerpoint-que-tentou-jogar-o-caso-master-no-colo-de-lula"target="_blank" rel="noopener">Brasil 247</a> published a story with the headline &ldquo;Globo apologizes for PowerPoint that tried to dump the Master case on Lula&rsquo;s lap.&rdquo;</p>
<p>The <a href="https://investigator.themakitachronicles.com/investigations/752d80653a"target="_blank" rel="noopener">Frank Investigator report</a> exposed what&rsquo;s going on under the hood:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/blog/2026/03/27/frank-investigator/globo-resumo.png" alt="Investigation summary - Globo"  loading="lazy" /></p>
<p>The central fact is real: Globo apologized and fired someone. But Brasil 247&rsquo;s framing goes way beyond what the facts support. The headline saying &ldquo;tried to dump it on Lula&rdquo; attributes deliberate intent where the documents point to an editorial mistake. Globo&rsquo;s retraction described the material as &ldquo;erroneous and incomplete,&rdquo; not as an attempt to incriminate anyone.</p>
<p>What stands out in this case is the coordinated campaign. The investigator gave it 62% narrative coordination.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/blog/2026/03/27/frank-investigator/globo-coordenada.png" alt="Coordinated narrative analysis - Globo"  loading="lazy" /></p>
<p>Several outlets editorially aligned with the government used the phrase &ldquo;without evidence&rdquo; in a convergent way to describe the association between Lula and the Master case. All of them focused on Globo&rsquo;s mistake as the central narrative point, instead of investigating the actual connections. No outlet mentioned which other political names were excluded from the original PowerPoint. None investigated Vorcaro&rsquo;s documented connections with different spheres of power. The focus is meta-journalistic: they criticize the broadcaster instead of covering the scandal.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/blog/2026/03/27/frank-investigator/globo-retorica.png" alt="Rhetorical analysis - Globo"  loading="lazy" /></p>
<p>The fallacies detected: loaded language (&ldquo;tried to dump,&rdquo; &ldquo;without evidence&rdquo; used to frame an editorial mistake as a deliberate political attack), false causality (Globo&rsquo;s retraction doesn&rsquo;t prove the connections are false), cherry-picking (highlights the omission of names tied to the Lula government without contextualizing which other names were omitted), and bait-and-pivot (uses Globo&rsquo;s apology as a hook to minimize the Banco Master scandal).</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/blog/2026/03/27/frank-investigator/globo-lacunas.png" alt="Contextual gaps - Globo"  loading="lazy" /></p>
<p>And the questions none of these outlets asked: which Supreme Court justices and politicians from other parties were also excluded from the PowerPoint? Does the Master case have documented connections to Lula government figures, or are they limited to previous administrations? Does Brasil 247, which is publishing this article, have a declared editorial alignment with the Lula government? What was the journalism Ethics Council&rsquo;s reaction?</p>
<p>Overall confidence: 13%. The article doesn&rsquo;t fabricate facts. But it selects, frames and omits in a way that builds a narrative the data doesn&rsquo;t support.</p>
<h2>Example 4: &ldquo;Why expensive fuel is good&rdquo; (Folha)<span class="hx:absolute hx:-mt-20" id="example-4-why-expensive-fuel-is-good-folha"></span>
    <a href="#example-4-why-expensive-fuel-is-good-folha" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/blog/2026/03/27/frank-investigator/combustivel-original.png" alt="Original article on Folha"  loading="lazy" /></p>
<p>The economist Bernardo Guimarães published a <a href="https://www1.folha.uol.com.br/colunas/bernardo-guimaraes/2026/03/por-que-combustivel-caro-e-bom.shtml"target="_blank" rel="noopener">column on Folha</a> arguing that expensive fuel is good for society because it stimulates innovation in clean energy. He cites real academic articles (Popp 2002, NBER) and has verifiable academic credentials (PhD from Yale, professor at FGV EESP). It looks solid.</p>
<p>The <a href="https://investigator.themakitachronicles.com/investigations/e6cd2ac867"target="_blank" rel="noopener">full Frank Investigator report</a> shows another picture.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/blog/2026/03/27/frank-investigator/combustivel-summary.png" alt="Investigation summary - fuel"  loading="lazy" /></p>
<p>The article is right to cite real studies. But it omits who pays the bill. The column completely ignores the distributive impact: low-income populations, residents of peripheral and rural areas, who depend more on personal vehicles and have less access to clean alternatives. For an economist, ignoring distributive effects is either incompetence or editorial choice.</p>
<p>There&rsquo;s a worse problem. The empirical evidence he cites (US patents, electric vehicle data in California between 2014-2017) is transposed to Brazil with no caveat at all. Brazil has an ethanol matrix and flex-fuel infrastructure that completely changes the causal mechanism. The article treats it as if the Brazilian consumer were in the same situation as the Californian one, which is false.</p>
<p>And there&rsquo;s the context the article mentions in passing but doesn&rsquo;t develop: the war in Iran is making fuel prices rise around the world. Brazil should be in a privileged position thanks to the pre-salt and to ethanol. But decades of mismanagement and corruption at Petrobras mean we&rsquo;re paying the same price as the rest of the world. Instead of questioning that, the column sells the idea that &ldquo;at least it&rsquo;ll stimulate clean energy.&rdquo; It&rsquo;s a rationalization of a problem that shouldn&rsquo;t exist.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/blog/2026/03/27/frank-investigator/combustivel-lacunas.png" alt="Contextual gaps - fuel"  loading="lazy" /></p>
<p>The investigator identified 5 questions the article refused to address, with 35% contextual completeness. The fallacies detected include false dilemma (presents expensive fuel as the only viable climate policy), cherry-picking (acknowledges short-term inelasticity but emphasizes only long-term effects), and loaded language (describes alternatives as &ldquo;playing at planting a little sapling&rdquo;).</p>
<p>Overall confidence: 25%. It isn&rsquo;t fabricated disinformation. It&rsquo;s opinion with cherry-picked evidence in favor of the thesis and omission of relevant counterpoints.</p>
<h2>Example 5: &ldquo;There&rsquo;s no strong cinema without streaming regulation&rdquo; (O Globo)<span class="hx:absolute hx:-mt-20" id="example-5-theres-no-strong-cinema-without-streaming-regulation-o-globo"></span>
    <a href="#example-5-theres-no-strong-cinema-without-streaming-regulation-o-globo" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/blog/2026/03/27/frank-investigator/cinema-original.png" alt="Original article on O Globo"  loading="lazy" /></p>
<p>Renata Magalhães, president of the Brazilian Academy of Cinema, gave <a href="https://oglobo.globo.com/blogs/miriam-leitao/post/2026/03/renata-magalhaes-nao-existe-cinema-forte-sem-regulamentacao-do-streaming.ghtml"target="_blank" rel="noopener">an interview in Miriam Leitão&rsquo;s column on O Globo</a> arguing that regulating streaming is a necessary condition for strengthening Brazilian cinema.</p>
<p>The <a href="https://investigator.themakitachronicles.com/investigations/7e4f5605c5"target="_blank" rel="noopener">Frank Investigator report</a> found serious problems.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/blog/2026/03/27/frank-investigator/cinema-resumo.png" alt="Investigation summary - cinema"  loading="lazy" /></p>
<p>First: the central claim that &ldquo;many films have low audiences&rdquo; appears with no empirical data. No numbers, no historical series. It&rsquo;s a pure authority claim with no analytical basis.</p>
<p>Second: there&rsquo;s an internal contradiction the article doesn&rsquo;t resolve. The text opens by saying that Brazilian cinema is &ldquo;in international spotlight&rdquo; (awards, festivals). And then immediately argues that the absence of regulation prevents the industry from being strengthened. But if Brazilian cinema is already winning international awards without that regulation, the argument that the regulation is a necessary condition collapses. The article doesn&rsquo;t address that contradiction.</p>
<p>And here&rsquo;s the elephant in the room that none of these articles mentions: people simply don&rsquo;t want to watch most of these films. Internationally awarded Brazilian cinema is made to compete in Cannes and at the Oscars, not to fill movie theaters in Brazil. Instead of asking why the Brazilian audience isn&rsquo;t interested, the industry would rather ask for streaming regulation to force platforms to fund and screen content that has no spontaneous audience. It&rsquo;s the classic playbook: use public money and regulation to keep alive an industry that doesn&rsquo;t sustain itself in the market.</p>
<p>The interviewee is the president of the Brazilian Academy of Cinema. She has a direct institutional interest in the regulation. The article doesn&rsquo;t present any opposing voice and doesn&rsquo;t discuss the costs to consumers: subscription price increases, catalog reduction. A single source, with a declared conflict of interest, with no counterpoint.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/blog/2026/03/27/frank-investigator/cinema-coordenada.png" alt="Coordinated narrative analysis - cinema"  loading="lazy" /></p>
<p>And here comes the coordinated campaign, with 55% narrative coordination. Multiple outlets reproduce the same emotional framing: cinema &ldquo;at risk,&rdquo; regulation as &ldquo;essential.&rdquo; None of the identified sources discusses comparable empirical international evidence on the effectiveness of content quotas (Europe has experiences with contradictory results). None mentions the Brazilian Academy of Cinema&rsquo;s conflicts of interest. The fact that the internationally awarded Brazilian productions were made without the proposed regulation is omitted convergently across all outlets. Only one isolated site (targethd.net) mentioned negative impacts on consumers.</p>
<p>Overall confidence: 9%. The article is legitimate editorial advocacy, but with analytical flaws that limit its informational value to nearly zero.</p>
<h2>What the investigator analyzes<span class="hx:absolute hx:-mt-20" id="what-the-investigator-analyzes"></span>
    <a href="#what-the-investigator-analyzes" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The 5 examples above show different patterns, but the analysis criteria are the same.</p>
<p>The strongest signal of manipulation is omission. It isn&rsquo;t what the article says that misleads, it&rsquo;s what it leaves out. The contextual gap analysis identifies the questions the article should have answered and didn&rsquo;t, and searches for counter-evidence on each one. In the examples above, articles that omit most of the relevant context aren&rsquo;t informing anyone. And when cross-comparison between outlets shows that some covered the facts and others didn&rsquo;t, like in the Noelia case or the tax cut, it&rsquo;s hard to argue that the omission was accidental.</p>
<p>Then comes the detection of coordinated campaigns. Several newspapers covering the same subject is normal. All of them using the same loaded language, focusing on the same points and omitting the same counterpoints at the same time isn&rsquo;t. The strongest signal of coordination isn&rsquo;t what outlets say in common, but what they omit in common.</p>
<p>There&rsquo;s also reframing, which is more subtle. In the tax case, the government raised tariffs, backed down under pressure, and the outlets called it a &ldquo;cut.&rdquo; Nobody lied technically, but the choice of framing completely changes the interpretation. This kind of manipulation is harder to detect because each individual statement is defensible.</p>
<p>The rhetorical fallacies catch specific constructions: false dilemma (&ldquo;either you regulate or cinema dies&rdquo;), bait-and-pivot (open with a positive fact and pivot to a crisis narrative), loaded language (&ldquo;without evidence&rdquo; used to attribute intent). Each detected fallacy comes with the exact citation of the passage and the explanation of why that construction is problematic.</p>
<p>And there&rsquo;s the principle that ties it all together: if 10 outlets repeat the same claim citing each other, the LLM consensus has to reflect that the chain of evidence is circular, not that the claim is well supported. Volume of coverage is not a proxy for truth.</p>
<h2>Why you can&rsquo;t &ldquo;just ask ChatGPT&rdquo;<span class="hx:absolute hx:-mt-20" id="why-you-cant-just-ask-chatgpt"></span>
    <a href="#why-you-cant-just-ask-chatgpt" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The first reaction many people will have is: &ldquo;why do I need this if I can just paste the article into ChatGPT and ask it to analyze?&rdquo;</p>
<p>Try it. Take a political article and ask ChatGPT to criticize it. It&rsquo;ll criticize. Take the same article and ask it to confirm. It&rsquo;ll find arguments to confirm. The LLM isn&rsquo;t searching for truth. It&rsquo;s predicting which response you most likely want to hear given the framing of your question. If you ask &ldquo;analyze the problems with this article,&rdquo; the model will find problems. If you ask &ldquo;is this article correct?&rdquo;, it&rsquo;ll find merits. It&rsquo;s automated confirmation bias.</p>
<p>There&rsquo;s another problem. General-purpose LLMs are trained to be agreeable. The sycophantic tendency (agreeing with the user) is documented in every large model. If your conversation history indicates you&rsquo;re left-leaning, the model tends to frame answers in a way that pleases that profile. If you&rsquo;re right-leaning, same thing in the other direction. It&rsquo;s not lying on purpose. It&rsquo;s optimizing for user satisfaction, which is literally the metric it was trained for via RLHF.</p>
<p>And worse: LLMs hallucinate. If they don&rsquo;t have enough evidence to support the answer they think you want to hear, they invent it. They fabricate plausible citations and data, attribute statements to people who never made them. If you ask it to criticize an article about fuel, it might invent a fictional study that &ldquo;proves&rdquo; the opposite of the article. It sounds convincing. But it doesn&rsquo;t exist.</p>
<p>Frank Investigator was built precisely to avoid these problems. The first design decision is that no human asks the LLM a question. There&rsquo;s no open-ended prompt like &ldquo;analyze this article.&rdquo; Each step in the pipeline has structured prompts that ask for specific analyses: &ldquo;list the rhetorical fallacies in this passage,&rdquo; &ldquo;identify which contextual information is missing,&rdquo; &ldquo;compare the headline with the body.&rdquo; The model doesn&rsquo;t know whether the operator agrees or disagrees with the article, because the operator never expresses an opinion. That eliminates confirmation bias at the root.</p>
<p>To deal with hallucination, every analyzer that uses an LLM includes the instruction &ldquo;CRITICAL — NO HALLUCINATION: Only reference URLs, sources, claims, quotes, and data that are EXPLICITLY present in the input provided to you. Do not invent, guess, or fabricate any URL, source name, statistic, quote, or claim. If you cannot verify something from the provided text, mark it as unverifiable — never fill in details.&rdquo; It doesn&rsquo;t eliminate hallucination completely, but it cuts it down a lot. And since 3 models from different companies (Anthropic, OpenAI, Google) answer the same questions, when one hallucinates the other two usually disagree. The consensus is weighted by confidence, not by simple majority. If two models say &ldquo;supported&rdquo; with 70% confidence and one says &ldquo;mixed&rdquo; with 95%, the &ldquo;mixed&rdquo; weighs more. The further apart the disagreement, the bigger the penalty on final confidence. If one model starts giving inconsistent answers, it gets put in quarantine and the other two carry on.</p>
<p>But the safeguard I consider most important is the primary source veto. If a primary source (IBGE data, court ruling, original study) contradicts a claim, confidence is capped at 60% and the verdict is forced to &ldquo;mixed,&rdquo; even if all 3 LLMs say &ldquo;supported.&rdquo; Ten newspaper articles repeating a claim don&rsquo;t override one official datum that contradicts it. Along the same lines, if 5 stories &ldquo;confirm&rdquo; a claim but they all come from the same editorial group (Folha/UOL, Globo/G1/Valor), the system knows they&rsquo;re the same voice and reduces the weight. Volume doesn&rsquo;t replace independence.</p>
<p>None of this makes the system perfect. But it&rsquo;s categorically different from pasting text into ChatGPT and asking &ldquo;what do you think?&rdquo;.</p>
<h2>The numbers<span class="hx:absolute hx:-mt-20" id="the-numbers"></span>
    <a href="#the-numbers" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><table>
  <thead>
      <tr>
          <th>Metric</th>
          <th>Value</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Commits</td>
          <td>129</td>
      </tr>
      <tr>
          <td>Active days of work</td>
          <td>~9</td>
      </tr>
      <tr>
          <td>Lines of Ruby (code)</td>
          <td>19,444</td>
      </tr>
      <tr>
          <td>Lines of test code</td>
          <td>9,190</td>
      </tr>
      <tr>
          <td>Test files</td>
          <td>108</td>
      </tr>
      <tr>
          <td>Total lines (code)</td>
          <td>24,301</td>
      </tr>
      <tr>
          <td>Services (analyzers + services)</td>
          <td>80</td>
      </tr>
      <tr>
          <td>Misinformation analyzers</td>
          <td>15</td>
      </tr>
      <tr>
          <td>ActiveRecord models</td>
          <td>14</td>
      </tr>
      <tr>
          <td>Background jobs</td>
          <td>19</td>
      </tr>
      <tr>
          <td>Database migrations</td>
          <td>31</td>
      </tr>
      <tr>
          <td>Pipeline stages</td>
          <td>15</td>
      </tr>
      <tr>
          <td>LLM models in consensus</td>
          <td>3</td>
      </tr>
      <tr>
          <td>Locales</td>
          <td>2 (en, pt-BR)</td>
      </tr>
  </tbody>
</table>
<p>Stack: Ruby 4.0.1, Rails 8.1.2, SQLite with WAL mode, Solid Queue (jobs inside Puma), Solid Cable (WebSockets), Tailwind CSS v4, headless Chromium via Ferrum CDP, deploy with Kamal to GitHub Container Registry. AGPL-3.0.</p>
<h2>The development process<span class="hx:absolute hx:-mt-20" id="the-development-process"></span>
    <a href="#the-development-process" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The project has 129 commits in 9 active days of work (March 11-16 and 25-27). The first day was the heaviest: more than 60 commits on March 11 alone, going from zero to a working system with content extraction, claim decomposition, LLM evaluation, and a web interface with live updates via Turbo Streams.</p>
<p>The commits tell the story. It started with the foundation:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>1e32d5a - Initial Frank Investigator foundation
564eb97 - Add recursive source crawling and RubyLLM scaffold
c8c5357 - Add Brazil source registry and authority connectors
3d30617 - Add U.S. authority profiles, source role modeling, and specialized connectors</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Then came the misinformation analyzers, one by one:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>a65fef7 - Add rhetorical fallacy analyzer for detecting narrative manipulation
5dcf99d - Add headline-body divergence detection and headline citation amplification
a113efd - Add smear campaign defense with circular citation and viral volume detection
56d501a - Add media ownership modeling, syndication detection, and independence analysis</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The hardening phase against noise and false positives is the one that took the most time:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>46e4bc5 - Add pre-fetch defenses: URL filtering, circuit breaker, fetch prioritization
168fea1 - Add post-fetch content gate, claim noise filter, and duplicate content skip
b2843d7 - Add paywall detection, pricing noise filter, and ofertas.* host rejection
b1b230d - Rewrite Chromium fetcher with Ferrum CDP for anti-bot evasion</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Then came the interface, deployment, and the more advanced analyzers:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>c5afcea - Replace custom CSS with Tailwind CSS v4 and rewrite all templates
95a8ec4 - Add Docker deployment with bin/deploy script
ccfacc9 - Add coordinated narrative detection across media outlets
ba3e2f4 - Add 6 new misinformation detection analyzers with cross-analysis
fda984b - Add LLM-generated investigation summary with quality assessment
a4ebe78 - Add contextual gap analysis to detect manipulation through omission</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>And in the most recent commits, simultaneous 3-model consensus and test optimization:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>5cc9c05 - Enable 3-model LLM consensus: Sonnet 4.6, GPT-4.1 Mini, Gemini 2.5 Pro
cdf5fb5 - Batch 5 content analyzers into single LLM call, add anti-hallucination
28c305e - Add WebMock stubs for LLM and web search — tests run in 1.4s (was 540s)</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The tests that used to run in 9 minutes now run in 1.4 seconds with WebMock stubbing of every LLM and web search call. That made a huge difference in iteration speed.</p>
<h2>Limitations<span class="hx:absolute hx:-mt-20" id="limitations"></span>
    <a href="#limitations" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>News fact-checking is a hard problem. A single article doesn&rsquo;t contain everything needed for a complete analysis. The author chose what to include and what to omit, and that choice is already the first level of manipulation. The sources cited inside the article were selected by the author to support their narrative, not to give a balanced view.</p>
<p>Frank Investigator doesn&rsquo;t tell you what&rsquo;s true and what&rsquo;s false. The result is a report with strong and weak points, not a &ldquo;true&rdquo; or &ldquo;false&rdquo; stamp. Even with all the safeguards I described above, the counter-evidence searched for automatically may not be the most relevant. The fallacies detected can have false positives. The coordinated campaign analysis depends on what web search returns at the moment of the lookup.</p>
<p>Use the reports as a starting point to form your own opinion, not as a final verdict.</p>
<p>The project is open source, AGPL-3.0. If you want to contribute, test, report bugs or suggest improvements: <a href="https://github.com/akitaonrails/frank_investigator"target="_blank" rel="noopener">GitHub</a>. If you want to follow the analyses, the <a href="https://themakitachronicles.com/"target="_blank" rel="noopener">The Makita Chronicles</a> newsletter is going to have a &ldquo;Notícias Duvidosas&rdquo; (Dubious News) section with the summary and a link to the full report of each investigation.</p>
]]></content:encoded><category>ruby</category><category>rails</category><category>ai</category><category>fact-checking</category><category>open-source</category><category>vibe-coding</category></item><item><title>I Rewrote OpenClaw in Rust. Did It Work? | FrankClaw</title><link>https://www.akitaonrails.com/en/2026/03/16/rewrote-openclaw-in-rust-frankclaw/</link><guid isPermaLink="true">https://www.akitaonrails.com/en/2026/03/16/rewrote-openclaw-in-rust-frankclaw/</guid><pubDate>Mon, 16 Mar 2026 08:00:00 GMT</pubDate><description>&lt;p&gt;Before anything else: FrankClaw is still in heavy alpha. It works for simple tasks, but I haven&amp;rsquo;t tested complex workflows. If you want to help, open Issues on &lt;a href="https://github.com/akitaonrails/frankclaw"target="_blank" rel="noopener"&gt;GitHub&lt;/a&gt; with whatever you find. There&amp;rsquo;s a lot to test.&lt;/p&gt;
&lt;p&gt;It &amp;ldquo;works,&amp;rdquo; but this project was more about the exercise. With that out of the way, let me tell you why I did it.&lt;/p&gt;
&lt;h2&gt;The problem with OpenClaw&lt;span class="hx:absolute hx:-mt-20" id="the-problem-with-openclaw"&gt;&lt;/span&gt;
&lt;a href="#the-problem-with-openclaw" class="subheading-anchor" aria-label="Permalink for this section"&gt;&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;&lt;a href="https://github.com/openclaw/openclaw"target="_blank" rel="noopener"&gt;OpenClaw&lt;/a&gt; is a gateway that connects messaging channels (Telegram, Discord, Slack, WhatsApp, etc.) to AI providers (OpenAI, Anthropic, Ollama). You configure it, bring up the server, and you can chat with LLMs straight from Telegram or any other channel. It&amp;rsquo;s a popular project with plenty of activity.&lt;/p&gt;</description><content:encoded><![CDATA[<p>Before anything else: FrankClaw is still in heavy alpha. It works for simple tasks, but I haven&rsquo;t tested complex workflows. If you want to help, open Issues on <a href="https://github.com/akitaonrails/frankclaw"target="_blank" rel="noopener">GitHub</a> with whatever you find. There&rsquo;s a lot to test.</p>
<p>It &ldquo;works,&rdquo; but this project was more about the exercise. With that out of the way, let me tell you why I did it.</p>
<h2>The problem with OpenClaw<span class="hx:absolute hx:-mt-20" id="the-problem-with-openclaw"></span>
    <a href="#the-problem-with-openclaw" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p><a href="https://github.com/openclaw/openclaw"target="_blank" rel="noopener">OpenClaw</a> is a gateway that connects messaging channels (Telegram, Discord, Slack, WhatsApp, etc.) to AI providers (OpenAI, Anthropic, Ollama). You configure it, bring up the server, and you can chat with LLMs straight from Telegram or any other channel. It&rsquo;s a popular project with plenty of activity.</p>
<p>Too much activity, actually.</p>
<p>I did a depth-1 clone of the repo and ran <code>tokei</code>:</p>
<table>
  <thead>
      <tr>
          <th>Metric</th>
          <th>Value</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>TypeScript files (without tests)</td>
          <td>3,794</td>
      </tr>
      <tr>
          <td>Lines of TypeScript</td>
          <td>~1,247,000</td>
      </tr>
      <tr>
          <td>Test files</td>
          <td>2,799</td>
      </tr>
      <tr>
          <td>Dependencies (root package.json)</td>
          <td>73</td>
      </tr>
  </tbody>
</table>
<p>Over a million lines of TypeScript. The 2,799 test files sound like a lot in absolute numbers, but proportional to the codebase size, the coverage is low. Most of the code lives in 29 packages of a monorepo with 21 channel extensions.</p>
<p>I went looking for more commits to understand the development pace. In the 100 commits I managed to pull, all of them landed in just 2 days (March 9 and 10). ~50 commits per day, from 42 different contributors. Vibe coding to the extreme.</p>
<p>The conclusion is the one you&rsquo;re imagining: enormous volumes of AI-generated code being dumped into a repository at a speed that makes serious human review impossible. And that bothered me enough to go investigate further.</p>
<h2>The security audit<span class="hx:absolute hx:-mt-20" id="the-security-audit"></span>
    <a href="#the-security-audit" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>I asked Claude to do a complete security audit of OpenClaw&rsquo;s code. The <a href="https://github.com/akitaonrails/frankclaw/blob/master/docs/OPENCLAW_SECURITY_AUDIT.md"target="_blank" rel="noopener">report</a> found:</p>
<table>
  <thead>
      <tr>
          <th>Severity</th>
          <th>Count</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>CRITICAL</td>
          <td>7</td>
      </tr>
      <tr>
          <td>HIGH</td>
          <td>9</td>
      </tr>
      <tr>
          <td>MEDIUM</td>
          <td>12</td>
      </tr>
      <tr>
          <td>LOW</td>
          <td>6</td>
      </tr>
      <tr>
          <td>INFO</td>
          <td>5</td>
      </tr>
  </tbody>
</table>
<p>Seven critical vulnerabilities. Let me list a few:</p>
<ul>
<li>Timing side-channel in token comparison — <code>safeEqualSecret()</code> does an early return on the type-check, letting an attacker distinguish malformed tokens from wrong tokens by measuring latency.</li>
<li><code>eval()</code> in the browser tool — arbitrary JavaScript execution with no sandbox.</li>
<li>Shell with no allowlist — any tool can run any command on the system.</li>
<li>Slack webhooks with no signature verification at all.</li>
<li>Transcripts and config in plaintext on disk, no encryption.</li>
<li>No effective rate limiting — IPs can be spoofed if the operator configures trusted proxies broadly.</li>
</ul>
<p>Those are just the ones Claude found in an automated scan. There are probably more.</p>
<p>I&rsquo;m not going to run that on my machine. Not even inside a Docker container. A gateway that receives webhooks from the internet, executes shell commands, connects to AI APIs with your keys, and stores conversation history, all of it with 7 known critical vulnerabilities? No, thanks.</p>
<p>So I did what any rational developer would do: I decided to build my own.</p>
<h2>The first attempt: Claude Code<span class="hx:absolute hx:-mt-20" id="the-first-attempt-claude-code"></span>
    <a href="#the-first-attempt-claude-code" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>I started the most direct way. Cloned OpenClaw, pointed Claude Code at the code, and asked: &ldquo;rewrite this in Rust.&rdquo;</p>
<p>Didn&rsquo;t work. The codebase is too big. Over a million lines of TypeScript spread across 29 packages. Claude can&rsquo;t keep all of it in context. The initial result was very incomplete: lots of types created but no implementation, <code>todo!()</code> everywhere, too much boilerplate and not enough functionality.</p>
<p>I switched to Codex 5.4 to test. Same thing: I asked it to analyze and rewrite. It improved a bit in certain aspects, but the fundamental problem is the same. No AI today takes a project of that size and rewrites it in one go. The context doesn&rsquo;t fit.</p>
<h2>The technique that actually works<span class="hx:absolute hx:-mt-20" id="the-technique-that-actually-works"></span>
    <a href="#the-technique-that-actually-works" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>What works is going slowly. One step at a time.</p>
<p>Ask Claude (or Codex) to analyze the original code in stages. Make a long plan detailing each feature. Then implement one feature at a time in Rust, with tests, commit, and repeat. It&rsquo;s tedious, but it produces code that compiles and actually works.</p>
<p>The reason is simple: the original code is so massive that no AI agent (and not even several in parallel) can keep all of it in context at the same time. You have to decide what matters, implement that, validate, and move on to the next thing.</p>
<p>And you have to decide what to cut. OpenClaw has 21 channel extensions: Google Chat, iMessage, IRC, Teams, Matrix, Mattermost, Nostr, Twitch&hellip; I don&rsquo;t need any of those. I kept the mainstream channels: Web, Telegram, Discord, Slack, Signal, WhatsApp and Email. TTS? Out. Polls? Out. WhatsApp Web through Baileys? Out, I use the official Cloud API. They&rsquo;re features that add complexity without proportional value.</p>
<h2>Discovering IronClaw<span class="hx:absolute hx:-mt-20" id="discovering-ironclaw"></span>
    <a href="#discovering-ironclaw" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>In the middle of development, I came across <a href="https://github.com/nichochar/iron-claw"target="_blank" rel="noopener">IronClaw</a>, which presents itself as &ldquo;OpenClaw in Rust.&rdquo; Great, I thought. Let me see what they did.</p>
<p>I cloned the repo and asked Claude to write a <a href="https://github.com/akitaonrails/frankclaw/blob/master/docs/IRONCLAW_COMPARISON.md"target="_blank" rel="noopener">comparison report</a>. IronClaw has good things. I adopted 12 features:</p>
<p>Circuit breaker with retry and exponential backoff for LLM provider resilience. Credential leak detection in the output. LLM response cache with SHA-256 of the prompt. Cost tracking with budget guards (warning at 80%, block at 100%). Extended thinking for Claude 3.7+ and o1. MCP client for external tool servers. Lifecycle hooks on inbound, tool calls and outbound. Smart model routing that sends simple queries to cheaper models. Tunnel support (cloudflared, ngrok, tailscale). Interactive REPL (<code>frankclaw chat</code>). Routines with event triggers beyond cron. Job state machine with auto-repair.</p>
<p>But IronClaw depends on PostgreSQL + pgvector, has a WASM sandbox (wasmtime adds ~10MB), and is part of the NEAR AI ecosystem. I want a single binary with embedded SQLite and zero external dependencies.</p>
<h2>What FrankClaw brings from OpenClaw<span class="hx:absolute hx:-mt-20" id="what-frankclaw-brings-from-openclaw"></span>
    <a href="#what-frankclaw-brings-from-openclaw" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The OpenClaw core is there: 7 messaging channels, multi-provider with failover, agent runtime, command system, skills, subagents, browser automation through CDP, bash tool with allowlist, cron jobs, interactive REPL. Plus the 12 IronClaw features I listed above.</p>
<p>But several pieces had to be rewritten in a way the original didn&rsquo;t. Context compaction, for example, uses a sliding window with token estimation, message pruning and automatic repair of tool pairs that get orphaned when the context is cut. Provider failover is now model-aware: if you ask for a Claude model and the current provider is OpenAI, it skips automatically instead of erroring out. The canvas renders SVG, HTML and Markdown with revision conflict detection. Things that in OpenClaw either didn&rsquo;t exist or were half-built.</p>
<p>Then came the phase of adding what was missing for parity. FrankClaw today has 30+ LLM tools: <code>web_fetch</code> (SSRF-safe with HTML-to-text), <code>web_search</code> (Brave API), <code>file_read</code>/<code>file_write</code>/<code>file_edit</code> (sandboxed in the workspace with path traversal protection), <code>pdf_read</code>, <code>image_describe</code> (through vision models), <code>audio_transcribe</code>, <code>sessions_list</code>/<code>sessions_history</code>/<code>sessions_delete</code>, <code>message_send</code>/<code>message_react</code>, <code>cron_list</code>/<code>cron_add</code>/<code>cron_remove</code>, <code>config_get</code> (auto-redacts secrets), <code>agents_list</code>, <code>memory_get</code>/<code>memory_search</code>, plus the browser tools (<code>browser_open</code>, <code>browser_extract</code>, <code>browser_snapshot</code>, <code>browser_click</code>, <code>browser_type</code>, <code>browser_wait</code>, <code>browser_press</code>, <code>browser_sessions</code>, <code>browser_close</code>, <code>browser_aria</code>).</p>
<p>Some additions that don&rsquo;t exist in the original OpenClaw: a memory/RAG system with SQLite FTS5 and embeddings (OpenAI, Gemini, Voyage) that automatically syncs workspace files. An OpenAI-compatible API (<code>/v1/chat/completions</code> and <code>/v1/models</code>), so any client that speaks that protocol (Cursor, Continue, Open WebUI) can use FrankClaw as a backend with no adaptation. A <code>ratatui</code> TUI for people who prefer the terminal. Interactive approval of destructive tools before execution.</p>
<p>Smaller things that make a difference in practice. You can configure multiple API keys per provider with round-robin and automatic backoff, so if one key hits the rate limit, the next one takes over. The model catalog already knows context windows and costs of OpenAI and Anthropic models without you having to configure them. URL extraction from messages has a private IP blocklist against SSRF. The command system accepts inline directives (<code>/think</code>, <code>/model</code>) in addition to aliases.</p>
<p>On the operational side: ACP (Agent Client Protocol) over JSON-RPC 2.0 on top of NDJSON for people who want to integrate programmatically. Plugin system with manifests and enable/disable lifecycle. i18n with 9 locales via <code>FRANKCLAW_LANG</code>. Workspace identity files (<code>SOUL.md</code>, <code>IDENTITY.md</code>) to define the bot&rsquo;s personality per project. Channel health monitor with auto-restart. WebSocket with ping keepalive that survives proxy and tunnel timeouts. <code>frankclaw start/stop/status</code> for people who want to run it as a daemon with PID tracking. And the entire configuration migrated from JSON to TOML.</p>
<h2>Hardening: where the real difference is<span class="hx:absolute hx:-mt-20" id="hardening-where-the-real-difference-is"></span>
    <a href="#hardening-where-the-real-difference-is" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The <a href="https://github.com/akitaonrails/frankclaw/blob/master/docs/OPENCLAW_SECURITY_AUDIT.md"target="_blank" rel="noopener">audit report</a> we ran on OpenClaw found 7 critical and 9 high vulnerabilities. FrankClaw fixes all of them:</p>
<table>
  <thead>
      <tr>
          <th>Area</th>
          <th>OpenClaw</th>
          <th>FrankClaw</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Token comparison</td>
          <td>SHA-256 + timingSafeEqual with early return that leaks timing</td>
          <td>Constant-time byte-by-byte comparison, no early returns</td>
      </tr>
      <tr>
          <td>Shell execution</td>
          <td>No mandatory allowlist</td>
          <td>Deny-all by default + binary allowlist + metacharacter rejection + optional ai-jail sandbox</td>
      </tr>
      <tr>
          <td>Browser tool</td>
          <td><code>eval()</code> with no sandbox</td>
          <td>CDP with 15s timeout, SSRF guard, crash recovery, ARIA inspection</td>
      </tr>
      <tr>
          <td>Slack webhook</td>
          <td>Zero signature verification</td>
          <td>HMAC-SHA256 with replay protection</td>
      </tr>
      <tr>
          <td>Discord webhook</td>
          <td>Hardcoded placeholder</td>
          <td>Ed25519 with timestamp validation</td>
      </tr>
      <tr>
          <td>Cryptography</td>
          <td>Plaintext on disk</td>
          <td>ChaCha20-Poly1305 on sessions and config</td>
      </tr>
      <tr>
          <td>Password hashing</td>
          <td>No password authentication at all</td>
          <td>Argon2id (t=3, m=64MB, p=4)</td>
      </tr>
      <tr>
          <td>File permissions</td>
          <td>0o644 (world-readable)</td>
          <td>0o600 (owner-only)</td>
      </tr>
      <tr>
          <td>Prompt injection</td>
          <td>Basic sanitization</td>
          <td>Unicode Cc/Cf stripping + boundary tags + 2MB limit</td>
      </tr>
      <tr>
          <td>Malware scanning</td>
          <td>None</td>
          <td>Optional VirusTotal on uploads</td>
      </tr>
      <tr>
          <td>Input validation</td>
          <td>No limits</td>
          <td>255 byte IDs, 800 byte session keys, configurable WS frames</td>
      </tr>
      <tr>
          <td>SSRF</td>
          <td>Partial protection</td>
          <td>Full blocklist (RFC 1918, loopback, CGNAT, link-local) + DNS rebinding defense</td>
      </tr>
      <tr>
          <td>Tool execution</td>
          <td>No user confirmation</td>
          <td>Interactive approval for mutating/destructive tools</td>
      </tr>
  </tbody>
</table>
<p>FrankClaw compiles with <code>#![forbid(unsafe_code)]</code> in all 13 crates. Zero unsafe blocks.</p>
<p>And the audit didn&rsquo;t stop at OpenClaw. We did a <a href="https://github.com/akitaonrails/frankclaw/blob/master/docs/AUDIT_PLAN.md"target="_blank" rel="noopener">per-component audit</a> in 14 phases comparing each part of FrankClaw against the original: channels, providers, runtime, tools, sessions, crypto, cron, webhooks. All documented.</p>
<h2>Deploy: how to install<span class="hx:absolute hx:-mt-20" id="deploy-how-to-install"></span>
    <a href="#deploy-how-to-install" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>FrankClaw runs with Docker Compose. Three containers: gateway, headless Chromium (for browser tools), and Cloudflare tunnel (to receive webhooks).</p>
<h3>1. Clone and configure<span class="hx:absolute hx:-mt-20" id="1-clone-and-configure"></span>
    <a href="#1-clone-and-configure" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">git clone https://github.com/akitaonrails/frankclaw.git
</span></span><span class="line"><span class="cl"><span class="nb">cd</span> frankclaw
</span></span><span class="line"><span class="cl">cp .env.docker.example .env.docker</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Edit <code>.env.docker</code> with your keys:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># Providers de IA (preencha os que usar)</span>
</span></span><span class="line"><span class="cl"><span class="nv">OPENAI_API_KEY</span><span class="o">=</span>
</span></span><span class="line"><span class="cl"><span class="nv">ANTHROPIC_API_KEY</span><span class="o">=</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Canais (preencha os que quiser usar)</span>
</span></span><span class="line"><span class="cl"><span class="nv">TELEGRAM_BOT_TOKEN</span><span class="o">=</span>         <span class="c1"># via @BotFather</span>
</span></span><span class="line"><span class="cl"><span class="nv">WHATSAPP_TOKEN</span><span class="o">=</span>             <span class="c1"># Meta Business Platform</span>
</span></span><span class="line"><span class="cl"><span class="nv">WHATSAPP_PHONE_ID</span><span class="o">=</span>
</span></span><span class="line"><span class="cl"><span class="nv">WHATSAPP_VERIFY_TOKEN</span><span class="o">=</span>
</span></span><span class="line"><span class="cl"><span class="nv">DISCORD_BOT_TOKEN</span><span class="o">=</span>
</span></span><span class="line"><span class="cl"><span class="nv">SLACK_BOT_TOKEN</span><span class="o">=</span>
</span></span><span class="line"><span class="cl"><span class="nv">SLACK_APP_TOKEN</span><span class="o">=</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Embedding providers (só se usar memória/RAG)</span>
</span></span><span class="line"><span class="cl"><span class="c1"># GEMINI_API_KEY=</span>
</span></span><span class="line"><span class="cl"><span class="c1"># VOYAGE_API_KEY=</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Opcional: criptografia de sessions (recomendado)</span>
</span></span><span class="line"><span class="cl"><span class="c1"># Gere com: openssl rand -base64 32</span>
</span></span><span class="line"><span class="cl"><span class="nv">FRANKCLAW_MASTER_KEY</span><span class="o">=</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Opcional: scan de malware em uploads</span>
</span></span><span class="line"><span class="cl"><span class="nv">VIRUSTOTAL_API_KEY</span><span class="o">=</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<h3>2. Configure the gateway<span class="hx:absolute hx:-mt-20" id="2-configure-the-gateway"></span>
    <a href="#2-configure-the-gateway" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>The <code>frankclaw.toml</code> file defines agents, models and channels. Use the wizard or the examples:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># Generate a base config with the web channel</span>
</span></span><span class="line"><span class="cl">cargo run -- onboard --channel web
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Or copy from the examples</span>
</span></span><span class="line"><span class="cl">ls examples/channels/</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>For each channel, the CLI has ready-made templates:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">cargo run -- config-example --channel telegram
</span></span><span class="line"><span class="cl">cargo run -- config-example --channel whatsapp</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<h3>3. Cloudflare Tunnel (to receive webhooks)<span class="hx:absolute hx:-mt-20" id="3-cloudflare-tunnel-to-receive-webhooks"></span>
    <a href="#3-cloudflare-tunnel-to-receive-webhooks" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>If you&rsquo;re going to use channels that need a webhook (Telegram, Discord, Slack, WhatsApp), you need a public tunnel. The Docker Compose already includes cloudflared:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># Copy your Cloudflare credentials</span>
</span></span><span class="line"><span class="cl">cp docker/cloudflared/config.yml.example docker/cloudflared/config.yml
</span></span><span class="line"><span class="cl">cp ~/.cloudflared/&lt;tunnel-id&gt;.json docker/cloudflared/credentials.json
</span></span><span class="line"><span class="cl">cp ~/.cloudflared/cert.pem docker/cloudflared/cert.pem
</span></span><span class="line"><span class="cl"><span class="c1"># Edit config.yml with your hostname</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<h3>4. Bring it up<span class="hx:absolute hx:-mt-20" id="4-bring-it-up"></span>
    <a href="#4-bring-it-up" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker compose up -d</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The gateway comes up on port 18789 (internal to Docker). cloudflared routes external traffic. Chromium stays on the internal network for browser tools.</p>
<p>To test locally without Docker:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">cargo run -- chat</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Opens the interactive REPL straight in the terminal (it now also has a <code>ratatui</code> TUI with dark mode and tabs). No gateway, no webhook. Good for validating that the AI provider is responding before configuring channels.</p>
<h3>Validation<span class="hx:absolute hx:-mt-20" id="validation"></span>
    <a href="#validation" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">cargo run -- check     <span class="c1"># validates config</span>
</span></span><span class="line"><span class="cl">cargo run -- doctor    <span class="c1"># full diagnostic</span>
</span></span><span class="line"><span class="cl">cargo run -- audit     <span class="c1"># security audit with severity ratings</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p><code>audit</code> is the one I like the most. It checks whether you have encryption enabled, whether file permissions are correct, whether webhooks have signature verification, whether the bash tool is in deny-all. It exits with a non-zero exit code when it finds critical issues, so you can drop it in CI.</p>
<h2>The development process<span class="hx:absolute hx:-mt-20" id="the-development-process"></span>
    <a href="#the-development-process" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The project has 178 commits in ~5 days of work (March 10-16). Almost 57 thousand lines of Rust in 120 files, organized in 13 crates.</p>
<p>The commits tell the story. The first few dozens were scaffolding: workspace structure, basic types, the HTTP/WebSocket gateway, first version of the channel adapters. Mass-generated code, lots of incomplete pieces.</p>
<p>Then the channel adapters started, one by one:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>236aa1c - Minimal Discord channel adapter
fd67017 - Minimal Slack channel adapter
9f51373 - Minimal Signal channel adapter
1052e47 - WhatsApp channel webhook adapter
035f86e - Email channel adapter (IMAP inbound, SMTP outbound)</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>When the basic channels were in place, the IronClaw integration came in one big commit:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>c87ab32 - IronClaw-derived features: circuit breaker, retry, leak detection, cache, cost tracking, extended thinking</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>And then came the hardening. That was the phase where I had to step in manually the most, because Claude generates functional code but doesn&rsquo;t think about attack vectors on its own:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>db34198 - Prompt injection sanitization, external content wrapping, prompt size limit
5719b34 - Optional VirusTotal malware scanning for file uploads
ccd2b2b - Harden input validation across all user-facing entry points
aa918ee - Optional ai-jail sandbox for bash tool
2d7b1df - Security audit command with severity-rated findings
d12cc97 - 3-tier ToolRiskLevel system replacing binary browser mutation flag
21e0c91 - Timing-safe token comparison in WhatsApp, crypto audit tests
e240c1b - Webhook replay prevention with timestamp verification
876a78c - Gateway &amp; media: SSRF redirect validation, filename hardening</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>22 security hardening commits. Plus 10 more component audits. Each finding became a commit with a fix.</p>
<p>Then came the per-channel audits, each one uncovering different edge cases:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>43b085f - Discord audit: HELLO timeout, fatal close codes, message chunking
12c7cff - Telegram audit: caption overflow, parse fallback, edit idempotency
f515062 - WhatsApp audit: message type filtering, send error classification
3c42aff - Slack audit: fatal auth errors, send error classification</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Browser tools needed extra attention. A headless Chrome that gets URLs from an LLM is an obvious attack vector:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>3217a96 - Browser automation: CDP timeout, SSRF guard, session limits, crash recovery
d98a803 - Gate mutating browser tools behind operator approval
014f56e - Browser screenshot/ARIA tools for accessibility tree inspection</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>In the more recent commits, the project started diverging from OpenClaw:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>5d73c4d - OpenAI-compatible HTTP API (/v1/chat/completions, /v1/models)
d832c36 - Memory/RAG system with SQLite FTS5, embeddings, and file sync
2b05f47 - Interactive tool approval for mutating/destructive tools
49034eb - Web console: dark mode, 8 tabs, focus mode, tool sidebar
9f51a18 - TUI, Gemini/Voyage embeddings, plugin management, ACP protocol
3c0703b - Config migration from JSON to TOML
eb130ad - Channel health monitor with auto-restart and rate limiting
2c3ea7e - Workspace bootstrap files (SOUL.md, IDENTITY.md) to system prompt
9df61bf - Model-aware failover routing, canvas SVG rendering
c7ba108 - WebSocket ping keepalive and auto-reconnect to web console</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The OpenAI-compatible API is the one I use the most day to day. Cursor, Continue, Open WebUI, anything that speaks the OpenAI protocol can use FrankClaw as a backend without touching anything on the client side.</p>
<h2>The numbers<span class="hx:absolute hx:-mt-20" id="the-numbers"></span>
    <a href="#the-numbers" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><table>
  <thead>
      <tr>
          <th>Metric</th>
          <th>Value</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Commits</td>
          <td>178</td>
      </tr>
      <tr>
          <td>Days of work</td>
          <td>~5</td>
      </tr>
      <tr>
          <td>Lines of Rust</td>
          <td>56,586</td>
      </tr>
      <tr>
          <td>Rust files</td>
          <td>120</td>
      </tr>
      <tr>
          <td>Crates</td>
          <td>13</td>
      </tr>
      <tr>
          <td>LLM tools</td>
          <td>30+</td>
      </tr>
      <tr>
          <td>Security hardening commits</td>
          <td>22</td>
      </tr>
      <tr>
          <td>Audit commits</td>
          <td>10</td>
      </tr>
      <tr>
          <td>Supported channels</td>
          <td>7</td>
      </tr>
      <tr>
          <td>AI providers</td>
          <td>9 (OpenAI, Anthropic, Ollama, Google, OpenRouter, Copilot, Groq, Together, DeepSeek)</td>
      </tr>
      <tr>
          <td>OpenClaw critical vulnerabilities fixed</td>
          <td>7/7</td>
      </tr>
      <tr>
          <td>OpenClaw high vulnerabilities fixed</td>
          <td>9/9</td>
      </tr>
      <tr>
          <td>Unsafe blocks in the code</td>
          <td>0</td>
      </tr>
  </tbody>
</table>
<p>Compared to OpenClaw:</p>
<table>
  <thead>
      <tr>
          <th>Metric</th>
          <th>OpenClaw (TS)</th>
          <th>FrankClaw (Rust)</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Lines of code</td>
          <td>~1,247,000</td>
          <td>56,586</td>
      </tr>
      <tr>
          <td>Source files</td>
          <td>3,794</td>
          <td>120</td>
      </tr>
      <tr>
          <td>Runtime dependencies</td>
          <td>73</td>
          <td>~40 crates</td>
      </tr>
      <tr>
          <td>Channels</td>
          <td>28</td>
          <td>7</td>
      </tr>
      <tr>
          <td>Critical vulnerabilities</td>
          <td>7 known</td>
          <td>0</td>
      </tr>
  </tbody>
</table>
<p>The numbers aren&rsquo;t directly comparable. OpenClaw has 21 channel extensions I cut, a more complete web UI, and niche features I didn&rsquo;t port. But the core (gateway, mainstream channels, providers, runtime, sessions, tools, memory) is there with 22x less code.</p>
<h2>How to help (beta testing)<span class="hx:absolute hx:-mt-20" id="how-to-help-beta-testing"></span>
    <a href="#how-to-help-beta-testing" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>FrankClaw works for basic conversation through Web and Telegram (I tested both). WhatsApp works for simple messages. Discord, Slack, Signal and Email are implemented but haven&rsquo;t had extensive testing. We haven&rsquo;t done any complex workflows yet.</p>
<p>If you want to test it: clone the repository, bring it up with Docker Compose, configure at least one channel (Telegram is the easiest) and try to use it normally. Send messages, test tool calls, try to break the system. Open Issues on GitHub with whatever you find.</p>
<p>What I know needs more eyes: workflows with tools (browser, bash, MCP), subagent orchestration, failover between providers, session persistence with encryption, smart routing between models, scheduled jobs, the memory/RAG system, and the OpenAI-compatible API. Basically everything that goes beyond &ldquo;send a message, get a reply.&rdquo;</p>
<p>You don&rsquo;t have to be a Rust developer. The bigger value is in using the system in ways I didn&rsquo;t think of and finding the edge cases that only show up under real use.</p>
<p>FrankClaw doesn&rsquo;t replace OpenClaw today. OpenClaw has more channels, more features, more people working on it. But it carries the weight of over a million lines of TypeScript generated at 50 commits per day by dozens of contributors, with documented critical vulnerabilities. FrankClaw is the alternative for anyone who looks at that and thinks &ldquo;I&rsquo;m not running this code on my machine.&rdquo;</p>
<p>But I&rsquo;ll be honest: as fun as it was to build, I personally don&rsquo;t know if I need this. FrankClaw is a generic gateway, designed to be flexible, to connect any channel to any provider, with an agent runtime, tools, subagents, jobs, hooks. It&rsquo;s a lot of infrastructure.</p>
<p>What I&rsquo;ve discovered over the last few months is that I can build custom bots for specific tasks much faster. In 1 day I have a bot working, focused on what I need, without carrying the weight of a generic framework. That&rsquo;s what I did with <a href="/en/2026/02/20/discord-as-an-admin-panel-behind-the-m-akita-chronicles/">Marvin</a> on the newsletter project, for instance. A custom-built Discord bot that does exactly what I need and nothing else.</p>
<p>A generic gateway like FrankClaw makes more sense for someone who wants a unified interface between several chat channels and several AI models without coding. If that&rsquo;s your case, give it a shot. If you&rsquo;re a developer and you know what you want, maybe a custom bot will serve you better. Up to you.</p>
<p>The <a href="https://github.com/akitaonrails/frankclaw"target="_blank" rel="noopener">repository is here</a>. AGPL-3.0.</p>
]]></content:encoded><category>rust</category><category>security</category><category>ai</category><category>open-source</category><category>vibe-coding</category></item><item><title>Going After Email Fraud | Frank FBI</title><link>https://www.akitaonrails.com/en/2026/03/09/going-after-email-fraud-frank-fbi/</link><guid isPermaLink="true">https://www.akitaonrails.com/en/2026/03/09/going-after-email-fraud-frank-fbi/</guid><pubDate>Mon, 09 Mar 2026 13:00:00 GMT</pubDate><description>&lt;p&gt;This past weekend I worked on 3 projects. Two of them I already published: &lt;a href="https://www.akitaonrails.com/en/2026/03/07/easy-ffmpeg-smart-wrapper-in-crystal/"&gt;easy-ffmpeg&lt;/a&gt;, a smart wrapper for FFmpeg in Crystal, and &lt;a href="https://www.akitaonrails.com/en/2026/03/07/porting-10k-lines-of-python-to-crystal-with-claude-easy-subtitle/"&gt;easy-subtitle&lt;/a&gt;, a port of 10 thousand lines of Python to Crystal in less than 40 minutes. I keep improving both, adding features and fixing edge cases as I use them day to day.&lt;/p&gt;
&lt;p&gt;But the third project is a different beast. It&amp;rsquo;s a security project. And the motivation comes from an old pain.&lt;/p&gt;</description><content:encoded><![CDATA[<p>This past weekend I worked on 3 projects. Two of them I already published: <a href="/en/2026/03/07/easy-ffmpeg-smart-wrapper-in-crystal/">easy-ffmpeg</a>, a smart wrapper for FFmpeg in Crystal, and <a href="/en/2026/03/07/porting-10k-lines-of-python-to-crystal-with-claude-easy-subtitle/">easy-subtitle</a>, a port of 10 thousand lines of Python to Crystal in less than 40 minutes. I keep improving both, adding features and fixing edge cases as I use them day to day.</p>
<p>But the third project is a different beast. It&rsquo;s a security project. And the motivation comes from an old pain.</p>
<h2>The problem: too many emails<span class="hx:absolute hx:-mt-20" id="the-problem-too-many-emails"></span>
    <a href="#the-problem-too-many-emails" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>As an ex-YouTuber and content creator, I get an absurd amount of email. Event invitations, collab proposals, sponsorship offers, requests to promote stuff. Every kind of pitch.</p>
<p>I delete 100% of them. I don&rsquo;t read them. Most of them I mark as SPAM without even opening. The easiest way to get reported by me is to send me an email — I don&rsquo;t care, because I don&rsquo;t need to. I also don&rsquo;t answer the phone. Ever. And I automatically block anyone who messages me directly on WhatsApp, regardless of the content. My time is too valuable to spend triaging messages from strangers. I delete everything and move on.</p>
<p>It works for me. But I know most people don&rsquo;t operate that way.</p>
<h2>The poison is VANITY<span class="hx:absolute hx:-mt-20" id="the-poison-is-vanity"></span>
    <a href="#the-poison-is-vanity" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Most people who fall for email phishing don&rsquo;t fall because of technical ignorance. They fall because of <strong>VANITY</strong>.</p>
<p>&ldquo;Hello, we&rsquo;d like to invite you to our exclusive event.&rdquo; &ldquo;Your brand has been selected for a special partnership.&rdquo; &ldquo;Congratulations, you&rsquo;ve been nominated as a reference in your sector.&rdquo;</p>
<p>The dopamine hits before reason has a chance to step in. Someone recognized your work, someone wants to give you money. That&rsquo;s exactly when the scammer gets you.</p>
<p>It isn&rsquo;t the multinational CEO who falls for the Nigerian prince scam. It&rsquo;s the micro-influencer who gets a sponsorship offer that&rsquo;s &ldquo;too good to be true.&rdquo; It&rsquo;s the small business owner who gets an invite to an event that looks legitimate. Vanity turns off critical thinking.</p>
<p>And the scams are getting more sophisticated by the day. With LLMs, any criminal can generate perfect emails in Portuguese, with no grammar errors, professional formatting, and domains that mimic real companies. The old &ldquo;look at the typos&rdquo; doesn&rsquo;t work as a filter anymore.</p>
<h2>The idea: Frank FBI<span class="hx:absolute hx:-mt-20" id="the-idea-frank-fbi"></span>
    <a href="#the-idea-frank-fbi" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Instead of trying to teach everyone how to spot phishing (which doesn&rsquo;t work, because vanity beats training), why not build a tool that does it automatically?</p>
<p>Got a suspicious email? Forward it to a dedicated address. In a few minutes, you get back a detailed report with everything the tool managed to find out about that email.</p>
<p>That&rsquo;s how <a href="https://github.com/akitaonrails/frank_fbi"target="_blank" rel="noopener">Frank FBI</a> was born — Fraud Bureau of Investigation.</p>
<h2>How it works<span class="hx:absolute hx:-mt-20" id="how-it-works"></span>
    <a href="#how-it-works" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>You set up a dedicated Gmail account (any Gmail with an App Password works). You register the email addresses authorized to use the service. From there, the flow is:</p>
<ol>
<li>You receive a suspicious email in your personal inbox</li>
<li>You forward it to the Frank FBI address</li>
<li>The system analyzes it automatically</li>
<li>You receive the response in the same thread, with the full report</li>
</ol>
<p>To give you a sense of the result, here&rsquo;s a report for a legitimate email (cold outreach from a real company):</p>
<p><img src="https://raw.githubusercontent.com/akitaonrails/frank_fbi/master/docs/ok-email.png" alt="Report for a legitimate email — score 80/100"  loading="lazy" /></p>
<p>And here&rsquo;s a suspicious one, where the sender&rsquo;s domain tries to impersonate another company:</p>
<p><img src="https://raw.githubusercontent.com/akitaonrails/frank_fbi/master/docs/suspect-email.png" alt="Report for a suspicious email — score 40/100"  loading="lazy" /></p>
<p>Frank FBI runs 6 layers of analysis, each with a specific weight in the final score:</p>
<h3>Layer 1 — Header authentication (15% weight)<span class="hx:absolute hx:-mt-20" id="layer-1--header-authentication-15-weight"></span>
    <a href="#layer-1--header-authentication-15-weight" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>SPF, DKIM, DMARC. Does the Reply-To match the From? Are there suspicious anti-spam headers? This layer answers the most basic question: did the email actually come from who it claims to?</p>
<h3>Layer 2 — Sender reputation (15% weight)<span class="hx:absolute hx:-mt-20" id="layer-2--sender-reputation-15-weight"></span>
    <a href="#layer-2--sender-reputation-15-weight" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Looks up the domain&rsquo;s age via WHOIS (a domain registered last week is already a signal), checks whether the IP is on DNS blacklists (DNSBL), and maintains a local reputation database that improves as more emails are analyzed. If the sender pretends to be a company but emails from a Gmail, that weighs in.</p>
<h3>Layer 3 — Content analysis (15% weight)<span class="hx:absolute hx:-mt-20" id="layer-3--content-analysis-15-weight"></span>
    <a href="#layer-3--content-analysis-15-weight" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>This is where pattern matching comes in: artificial urgency (&ldquo;your account will be locked in 24 hours&rdquo;), requests for personal data, authority impersonation, financial offers. It also detects URL shorteners and links where the displayed text doesn&rsquo;t match the real href — that classic &ldquo;click here&rdquo; pointing at a completely different domain. Checks for dangerous attachments (.exe, .scr, Office macros).</p>
<h3>Layer 4 — External APIs (15% weight)<span class="hx:absolute hx:-mt-20" id="layer-4--external-apis-15-weight"></span>
    <a href="#layer-4--external-apis-15-weight" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>URLhaus (abuse.ch) maintains a known-malicious URL database. VirusTotal aggregates results from dozens of antivirus engines. If any URL in the email has already been flagged as malware or phishing by these databases, the score goes up. Results are cached with a TTL so we don&rsquo;t blow through the rate limits of the free APIs.</p>
<h3>Layer 5 — Entity verification (10% weight)<span class="hx:absolute hx:-mt-20" id="layer-5--entity-verification-10-weight"></span>
    <a href="#layer-5--entity-verification-10-weight" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>This is the layer I find the most interesting. Frank FBI does OSINT — Open Source Intelligence. It uses Brave Search to verify whether the sender or company actually exists. Does WHOIS on the domain directly. Captures a screenshot of the site with headless Chrome. Cross-references all of it to try to answer: &ldquo;is this entity real and is it who it claims to be?&rdquo;</p>
<p>Here&rsquo;s a real example of this layer in action, analyzing an email that tried to impersonate a legitimate company:</p>
<p><img src="https://raw.githubusercontent.com/akitaonrails/frank_fbi/master/docs/verificacao-identidade.png" alt="Identity verification — detailed OSINT"  loading="lazy" /></p>
<p>The domain was 83 days old, registered through Namecheap, with no verifiable online presence. The system found discrepancies between the name in the email and the public records, and identified that the legitimate company&rsquo;s real domain was a different one.</p>
<h3>Layer 6 — LLM analysis (30% weight)<span class="hx:absolute hx:-mt-20" id="layer-6--llm-analysis-30-weight"></span>
    <a href="#layer-6--llm-analysis-30-weight" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Queries 3 AI models in parallel: Claude Sonnet (Anthropic), GPT-4o (OpenAI) and Grok 4 (xAI), via OpenRouter. Each model analyzes the email independently. A confidence-weighted consensus system combines the results. If all 3 agree it&rsquo;s fraud, confidence is high. If they disagree, the system weighs them. If every LLM fails, it falls back to a neutral score of 50 instead of guessing — better to admit ignorance than to hallucinate.</p>
<p>The final score is a confidence-adjusted weighted average. Possible verdicts: Legitimate (0-25), Suspicious OK (26-50), Suspicious Fraud (51-75) or Fraudulent (76-100). There&rsquo;s also a risk escalation policy that imposes minimum floors: if critical indicators were detected (confirmed malicious URLs, DKIM failure), the score can&rsquo;t fall below certain thresholds, even if the other layers found nothing.</p>
<h2>Self-hosted: your data stays with you<span class="hx:absolute hx:-mt-20" id="self-hosted-your-data-stays-with-you"></span>
    <a href="#self-hosted-your-data-stays-with-you" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Frank FBI is self-hosted. You run it on your own server. Your emails don&rsquo;t pass through any third-party service (except the verification APIs like VirusTotal, which only receive URLs, not the email&rsquo;s content).</p>
<p>You can install it on your home server, like I did, or inside your company&rsquo;s infrastructure for your employees to use. The deploy is via Docker Compose with 4 containers: the Rails app, a background job worker, an IMAP poller, and an initial database setup. Bring everything up with a <code>docker compose up -d</code> and it&rsquo;s running.</p>
<h2>Reporting fraud to the community<span class="hx:absolute hx:-mt-20" id="reporting-fraud-to-the-community"></span>
    <a href="#reporting-fraud-to-the-community" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Beyond analyzing emails for you, Frank FBI can optionally report confirmed fraud to community databases. This only happens when the score is &gt;= 85 and the verdict is &ldquo;fraudulent.&rdquo; It&rsquo;s opt-in.</p>
<p>ThreatFox (abuse.ch) is an open database of indicators of compromise (IOCs) maintained by the security community. When you report a malicious URL or domain there, firewalls, email filters and SIEMs around the world can consume that information to block the threat.</p>
<p>AbuseIPDB is the same idea, but for IPs. If the IP of the sender of the fraudulent email gets reported there, email providers and network admins can block malicious traffic before it reaches users.</p>
<p>And SpamCop is one of the oldest spam reporting services. Frank FBI forwards the full email to SpamCop, which analyzes the headers and reports to the responsible providers. It&rsquo;s reporting directly to whoever can act.</p>
<p>Each report is a contribution so other people don&rsquo;t fall for the same scam. And it&rsquo;s automated: if the email is clearly fraudulent, the report goes out without manual intervention.</p>
<p>But reporting wrong things does damage. That&rsquo;s why the system has anti-poisoning protections: a list of ~40 known domains (Microsoft, Apple, Google, Amazon, PayPal, governments) that are never reported; domains with clean scans are excluded; cloud infrastructure IPs (Google, Microsoft, Cloudflare) are filtered; free email domains are ignored. Only genuinely malicious IOCs reach the community databases.</p>
<h2>Deployment: how to get it running<span class="hx:absolute hx:-mt-20" id="deployment-how-to-get-it-running"></span>
    <a href="#deployment-how-to-get-it-running" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Let me explain how I deployed it on my home server. If you have a VPS, NAS or any Linux machine with Docker, the process is the same.</p>
<h3>1. Clone and build the image<span class="hx:absolute hx:-mt-20" id="1-clone-and-build-the-image"></span>
    <a href="#1-clone-and-build-the-image" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>You need a private registry to store the Docker image. I use <a href="https://gitea.io/"target="_blank" rel="noopener">Gitea</a> on my home server — it&rsquo;s a lightweight self-hosted GitHub that includes a container registry. If you already have a private GitHub, you can use the GitHub Container Registry (ghcr.io) instead.</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># Clone the repository</span>
</span></span><span class="line"><span class="cl">git clone https://github.com/akitaonrails/frank_fbi.git
</span></span><span class="line"><span class="cl"><span class="nb">cd</span> frank_fbi
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Build the Docker image</span>
</span></span><span class="line"><span class="cl"><span class="c1"># If using Gitea (replace with your registry&#39;s IP/port):</span>
</span></span><span class="line"><span class="cl">docker build -t seu-servidor:3007/frank_fbi:latest .
</span></span><span class="line"><span class="cl">docker push seu-servidor:3007/frank_fbi:latest
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># If using GitHub Container Registry:</span>
</span></span><span class="line"><span class="cl">docker build -t ghcr.io/seu-usuario/frank_fbi:latest .
</span></span><span class="line"><span class="cl">docker push ghcr.io/seu-usuario/frank_fbi:latest</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<h3>2. Configure the .env<span class="hx:absolute hx:-mt-20" id="2-configure-the-env"></span>
    <a href="#2-configure-the-env" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Copy <code>.env.example</code> and fill it in. The required variables:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># Gere com: ruby -rsecurerandom -e &#39;puts SecureRandom.hex(64)&#39;</span>
</span></span><span class="line"><span class="cl"><span class="nv">SECRET_KEY_BASE</span><span class="o">=</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Gere com: bin/rails db:encryption:init (roda local, copia os 3 valores)</span>
</span></span><span class="line"><span class="cl"><span class="nv">ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY</span><span class="o">=</span>
</span></span><span class="line"><span class="cl"><span class="nv">ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY</span><span class="o">=</span>
</span></span><span class="line"><span class="cl"><span class="nv">ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT</span><span class="o">=</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Gmail dedicado pro Frank FBI (crie uma conta só pra isso)</span>
</span></span><span class="line"><span class="cl"><span class="c1"># Ative 2FA e gere um App Password em https://myaccount.google.com/apppasswords</span>
</span></span><span class="line"><span class="cl"><span class="nv">GMAIL_USERNAME</span><span class="o">=</span>seu-frank-fbi@gmail.com
</span></span><span class="line"><span class="cl"><span class="nv">GMAIL_PASSWORD</span><span class="o">=</span>xxxx-xxxx-xxxx-xxxx
</span></span><span class="line"><span class="cl"><span class="nv">GMAIL_IMAP_HOST</span><span class="o">=</span>imap.gmail.com
</span></span><span class="line"><span class="cl"><span class="nv">GMAIL_SMTP_HOST</span><span class="o">=</span>smtp.gmail.com
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Senha do ingress do Action Mailbox (qualquer string aleatória)</span>
</span></span><span class="line"><span class="cl"><span class="nv">RAILS_INBOUND_EMAIL_PASSWORD</span><span class="o">=</span>uma-senha-qualquer-longa
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># LLM via OpenRouter (obrigatório pra Camada 6)</span>
</span></span><span class="line"><span class="cl"><span class="nv">OPENROUTER_API_KEY</span><span class="o">=</span>sua-chave-openrouter
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Seu email de admin (pra gerenciar remetentes autorizados)</span>
</span></span><span class="line"><span class="cl"><span class="nv">ADMIN_EMAIL</span><span class="o">=</span>seu-email@pessoal.com</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The external APIs are optional but recommended. Without them, the corresponding layers simply don&rsquo;t run:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># VirusTotal (grátis, 4 requests/min) - https://virustotal.com</span>
</span></span><span class="line"><span class="cl"><span class="nv">VIRUSTOTAL_API_KEY</span><span class="o">=</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># WhoisXML (grátis, 500 requests/mês) - https://whoisxmlapi.com</span>
</span></span><span class="line"><span class="cl"><span class="nv">WHOISXML_API_KEY</span><span class="o">=</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Brave Search (grátis, 1 req/s) - https://brave.com/search/api/</span>
</span></span><span class="line"><span class="cl"><span class="nv">BRAVE_SEARCH_API_KEY</span><span class="o">=</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>And the community reporting, which is fully opt-in. Leave it blank if you don&rsquo;t want to report:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nv">THREATFOX_AUTH_KEY</span><span class="o">=</span>
</span></span><span class="line"><span class="cl"><span class="nv">ABUSEIPDB_API_KEY</span><span class="o">=</span>
</span></span><span class="line"><span class="cl"><span class="nv">SPAMCOP_SUBMISSION_ADDRESS</span><span class="o">=</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<h3>3. Docker Compose on the server<span class="hx:absolute hx:-mt-20" id="3-docker-compose-on-the-server"></span>
    <a href="#3-docker-compose-on-the-server" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Create the <code>docker-compose.yml</code> on your server. Replace the image with your registry:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">services</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">setup</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">seu-servidor:3007/frank_fbi:latest </span><span class="w"> </span><span class="c"># ou ghcr.io/seu-usuario/frank_fbi:latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">command</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&#34;./bin/rails&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;db:prepare&#34;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">env_file</span><span class="p">:</span><span class="w"> </span><span class="l">.env</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">RAILS_ENV=production</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">./storage:/rails/storage</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">app</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">seu-servidor:3007/frank_fbi:latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">env_file</span><span class="p">:</span><span class="w"> </span><span class="l">.env</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">RAILS_ENV=production</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">./storage:/rails/storage</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">./emails:/rails/emails</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">depends_on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">setup</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">condition</span><span class="p">:</span><span class="w"> </span><span class="l">service_completed_successfully</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">healthcheck</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">test</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&#34;CMD&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;curl&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;-f&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;http://localhost:3000/up&#34;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">interval</span><span class="p">:</span><span class="w"> </span><span class="l">10s</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">timeout</span><span class="p">:</span><span class="w"> </span><span class="l">5s</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">retries</span><span class="p">:</span><span class="w"> </span><span class="m">3</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">start_period</span><span class="p">:</span><span class="w"> </span><span class="l">15s</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">unless-stopped</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">worker</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">seu-servidor:3007/frank_fbi:latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">command</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&#34;./bin/jobs&#34;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">env_file</span><span class="p">:</span><span class="w"> </span><span class="l">.env</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">RAILS_ENV=production</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">./storage:/rails/storage</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">depends_on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">setup</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">condition</span><span class="p">:</span><span class="w"> </span><span class="l">service_completed_successfully</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">unless-stopped</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">mail_fetcher</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">seu-servidor:3007/frank_fbi:latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">command</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&#34;./bin/rails&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;frank_fbi:fetch_mail&#34;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">env_file</span><span class="p">:</span><span class="w"> </span><span class="l">.env</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">RAILS_ENV=production</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">APP_HOST=http://app:3000</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">./storage:/rails/storage</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">depends_on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">app</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">condition</span><span class="p">:</span><span class="w"> </span><span class="l">service_healthy</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">unless-stopped</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<h3>4. Bring it up and register the first user<span class="hx:absolute hx:-mt-20" id="4-bring-it-up-and-register-the-first-user"></span>
    <a href="#4-bring-it-up-and-register-the-first-user" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker compose up -d</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p><code>setup</code> runs the migrations and exits. <code>app</code> brings up Rails, <code>worker</code> processes background jobs, and <code>mail_fetcher</code> polls IMAP every 30 seconds.</p>
<p>To register authorized senders, send an email from your ADMIN_EMAIL to the Frank FBI address with the subject <code>add email1@exemplo.com, email2@exemplo.com</code>. To list who&rsquo;s registered, send with subject <code>list</code>. To see statistics, <code>stats</code>.</p>
<p>From there, any registered sender can forward suspicious emails and receive the analysis reports.</p>
<h2>The development process<span class="hx:absolute hx:-mt-20" id="the-development-process"></span>
    <a href="#the-development-process" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>This project started as a simple idea: &ldquo;what if I could forward suspicious emails somewhere and get an analysis back?&rdquo; But security projects aren&rsquo;t like normal projects.</p>
<p>In a regular project, a bug is an inconvenience. In a security project, a bug can be a vulnerability. If the system says a fraudulent email is legitimate, someone might lose money. If it incorrectly reports a legitimate domain as malicious, it can hurt an innocent company. If a malicious email manages to exploit the analyzer itself, the attacker gets access to the server.</p>
<p>That changes the development mindset. Every decision needs to consider: &ldquo;how could a bad actor abuse this?&rdquo;</p>
<h3>Evolution through commits<span class="hx:absolute hx:-mt-20" id="evolution-through-commits"></span>
    <a href="#evolution-through-commits" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Let me show how the project evolved by looking at the most relevant commits.</p>
<p>The project started with the basic email analysis structure and quickly needed access control:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>eb59474 - Add admin access control and allowed senders whitelist
036e9f1 - Harden access control against email spoofing and admin impersonation</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The first one adds the authorized sender list. The second solves the obvious problem: what if someone spoofs an authorized sender&rsquo;s email? The system started verifying SPF/DKIM before accepting any submission. Per-sender rate limiting came along with that, to prevent abuse.</p>
<p>Then came concerns about scoring quality:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>e891270 - Fix zero-dilution scoring bug and add critical alert UX
2cbb272 - Harden fraud scoring and reporting
50d96a1 - Move text pattern detection from regex to LLM consensus layer</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The zero-dilution bug is subtle: when a layer fails and returns score 0 with low confidence, it would dilute the overall average downward, making fraudulent emails look safer than they were. The fix implemented dampening that discards low-quality layers instead of letting them drag the score down.</p>
<p>The shift from regex to LLM in pattern detection was pragmatic. Fraud patterns in natural language are hard to capture with regex. Too many false positives. LLMs understand context and intent in a way regex can&rsquo;t.</p>
<p>Race conditions showed up when the pipeline started running layers in parallel:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>2e4276d - Fix race conditions across pipeline and add Brave Search rate limiting
ac433d9 - Fix WHOIS race condition and Brave Search gzip error logging</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Concurrent jobs trying to write to the same email record, WHOIS queries stepping on each other, external API rate limits getting blown. The classic &ldquo;works in sequential tests, breaks in production.&rdquo;</p>
<p>Preventing LLM hallucination got its own dedicated commit:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>e08da9e - Prevent LLM hallucination in fraud reports</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>LLMs make things up. If the model says &ldquo;this domain was registered yesterday&rdquo; without evidence in the data, the report is compromised. This commit implemented cross-validation: claims from the LLMs are verified against the concrete data from the deterministic layers. If the LLM asserts something that contradicts the facts, the information is dropped or flagged.</p>
<p>And when community reporting was implemented:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>a01d769 - Add community threat intelligence reporting
1b64eee - Harden community reporting against poisoning and add rate limiting</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The first commit implements the feature. The second adds the anti-poisoning protections. If an attacker realizes Frank FBI reports automatically, they can try to send emails that contain IOCs from legitimate companies as &ldquo;fraud,&rdquo; making the system report innocent domains to community databases. That&rsquo;s IOC poisoning, and it&rsquo;s a real attack against threat intelligence systems.</p>
<h3>Continuous hardening<span class="hx:absolute hx:-mt-20" id="continuous-hardening"></span>
    <a href="#continuous-hardening" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>9430c63 - Harden risky attachment analysis and warnings
bdc503d - Harden screenshot capture with failure recovery and pipeline timeout
125e73a - Separate suspect content from submitter signature in forwarded emails
6f7a522 - Handle forwarded message fidelity</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Malicious attachments that could exploit the parser. Site screenshots that could lock up headless Chrome. Sender signatures that were being confused with the content of the suspicious email. Forwarded emails that lost fidelity in the forward. Each of these fixes a different attack vector or edge case.</p>
<p>Security isn&rsquo;t a feature you implement once. It&rsquo;s a continuous process of &ldquo;what didn&rsquo;t I think could go wrong?&rdquo;</p>
<h2>The numbers<span class="hx:absolute hx:-mt-20" id="the-numbers"></span>
    <a href="#the-numbers" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><table>
  <thead>
      <tr>
          <th>Metric</th>
          <th>Value</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Commits</td>
          <td>38</td>
      </tr>
      <tr>
          <td>Hours of work (~)</td>
          <td>17</td>
      </tr>
      <tr>
          <td>Lines of Ruby</td>
          <td>14,616</td>
      </tr>
      <tr>
          <td>Ruby files</td>
          <td>161</td>
      </tr>
      <tr>
          <td>Application code (app/)</td>
          <td>8,217 lines</td>
      </tr>
      <tr>
          <td>Test code (test/)</td>
          <td>5,312 lines</td>
      </tr>
      <tr>
          <td>Test/code ratio</td>
          <td>0.65</td>
      </tr>
      <tr>
          <td>Analysis layers</td>
          <td>6</td>
      </tr>
      <tr>
          <td>Async jobs</td>
          <td>20 classes</td>
      </tr>
      <tr>
          <td>Data models</td>
          <td>9 tables</td>
      </tr>
      <tr>
          <td>Commits/hour</td>
          <td>~2.2</td>
      </tr>
      <tr>
          <td>Lines/hour</td>
          <td>~860</td>
      </tr>
  </tbody>
</table>
<p>860 lines per hour. Obviously AI-assisted development. But look at the hardening commits: none of them came from the LLM suggesting &ldquo;hey, let&rsquo;s protect against spoofing.&rdquo; That was me stopping and thinking &ldquo;wait, what if someone spoofs the authorized sender?&rdquo;, &ldquo;what if an attacker uses IOC poisoning against my reporting system?&rdquo;. The LLM doesn&rsquo;t ask those questions on its own. It implements them when you ask, but the one who has to spot the hole is you.</p>
<h2>A serious warning: DO NOT offer this as a service<span class="hx:absolute hx:-mt-20" id="a-serious-warning-do-not-offer-this-as-a-service"></span>
    <a href="#a-serious-warning-do-not-offer-this-as-a-service" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>If you looked at Frank FBI and thought &ldquo;cool, I&rsquo;m going to offer this as a SaaS to other people,&rdquo; I have one piece of advice: <strong>DON&rsquo;T do that</strong>.</p>
<p>I can think of several ways to exploit a service like this offered to the public. The operator would have access to every email forwarded by users — personal information, corporate data, sensitive correspondence. A centralized service becomes a high-value target: compromise the server and you have access to a continuous stream of confidential emails from people who are already in a vulnerable situation.</p>
<p>I know enough people to know that whoever would deploy this as a service wouldn&rsquo;t worry about encryption at rest or about destroying the emails after analysis. And who guarantees that the operator won&rsquo;t read users&rsquo; emails? Nobody. It&rsquo;s the kind of thing that looks like a useful service but in practice creates a honeypot of sensitive data managed by someone with no incentive to protect it.</p>
<p>Frank FBI was built to be self-hosted. To run on your server, under your control, with your data staying with you. Or in your company&rsquo;s infrastructure, managed by your IT team.</p>
<p>And the project is licensed under AGPL-3.0. If you use my code and offer it as a network service, you&rsquo;re legally required to release all the derived code. No exceptions. I picked AGPL for that reason — to make sure nobody takes the project, adds tracking and telemetry, and offers it as a &ldquo;free email verification service&rdquo; while collecting user data behind the scenes.</p>
<p>The <a href="https://github.com/akitaonrails/frank_fbi"target="_blank" rel="noopener">repository is here</a>. AGPL-3.0.</p>
]]></content:encoded><category>ruby</category><category>rails</category><category>security</category><category>email</category><category>fraud-detection</category><category>open-source</category><category>vibe-coding</category></item><item><title>Porting 10K Lines of Python to Crystal with Claude: easy-subtitle</title><link>https://www.akitaonrails.com/en/2026/03/07/porting-10k-lines-of-python-to-crystal-with-claude-easy-subtitle/</link><guid isPermaLink="true">https://www.akitaonrails.com/en/2026/03/07/porting-10k-lines-of-python-to-crystal-with-claude-easy-subtitle/</guid><pubDate>Sat, 07 Mar 2026 22:00:00 GMT</pubDate><description>&lt;p&gt;In the &lt;a href="https://www.akitaonrails.com/en/2026/03/07/easy-ffmpeg-smart-wrapper-in-crystal/"&gt;previous article&lt;/a&gt; I showed why I picked Crystal for command-line CLIs. In this one I want to show a more ambitious case. It isn&amp;rsquo;t a tool from scratch anymore — it&amp;rsquo;s a feature-parity port of a 10,000-line Python open source project.&lt;/p&gt;
&lt;h2&gt;The problem: subtitles&lt;span class="hx:absolute hx:-mt-20" id="the-problem-subtitles"&gt;&lt;/span&gt;
&lt;a href="#the-problem-subtitles" class="subheading-anchor" aria-label="Permalink for this section"&gt;&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;Anyone who maintains a movie and TV collection knows the pain. You download the MKV, but the embedded subtitle is out of sync. Or worse: there&amp;rsquo;s no subtitle at all in the language you want. So you go to OpenSubtitles, download a subtitle, and it&amp;rsquo;s 3 seconds ahead because it was made for a different release. The manual flow is:&lt;/p&gt;</description><content:encoded><![CDATA[<p>In the <a href="/en/2026/03/07/easy-ffmpeg-smart-wrapper-in-crystal/">previous article</a> I showed why I picked Crystal for command-line CLIs. In this one I want to show a more ambitious case. It isn&rsquo;t a tool from scratch anymore — it&rsquo;s a feature-parity port of a 10,000-line Python open source project.</p>
<h2>The problem: subtitles<span class="hx:absolute hx:-mt-20" id="the-problem-subtitles"></span>
    <a href="#the-problem-subtitles" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Anyone who maintains a movie and TV collection knows the pain. You download the MKV, but the embedded subtitle is out of sync. Or worse: there&rsquo;s no subtitle at all in the language you want. So you go to OpenSubtitles, download a subtitle, and it&rsquo;s 3 seconds ahead because it was made for a different release. The manual flow is:</p>
<ol>
<li>Extract subtitle tracks from the MKV with <code>mkvextract</code></li>
<li>Go to OpenSubtitles, look for a subtitle for the movie</li>
<li>Download, test, see it&rsquo;s out of sync</li>
<li>Run some sync tool (ffsubsync, alass)</li>
<li>Rename the file, move it to the right place</li>
<li>Repeat for every language, for every movie</li>
</ol>
<p>For someone with 10 movies, it&rsquo;s tolerable. For someone with hundreds, it&rsquo;s insanity. It&rsquo;s exactly the kind of thing that should be automated.</p>
<h2>Subservient: the Python solution<span class="hx:absolute hx:-mt-20" id="subservient-the-python-solution"></span>
    <a href="#subservient-the-python-solution" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Looking at what already existed, I found <a href="https://github.com/N3xigen/Subservient"target="_blank" rel="noopener">Subservient</a>. It&rsquo;s a Python project that automates exactly this flow: it extracts subtitles from MKVs, downloads them from OpenSubtitles via REST API, syncs them with ffsubsync, and cleans ads out of SRT files.</p>
<p>The project is complete. It has movie mode and series mode, smart sync (tests every candidate in parallel and picks the best one) and first-match (stops at the first one that works). It uses the OpenSubtitles hash for exact matching and cleans watermarks and ads with more than 30 regex patterns.</p>
<p>But it has the typical Python distribution problems:</p>
<ul>
<li><strong>7 pip dependencies</strong>: colorama, requests, langdetect, ffsubsync, platformdirs, pycountry, tqdm</li>
<li><strong>ffsubsync as the sync engine</strong>: which in turn depends on numpy, auditok, and a bunch more Python packages</li>
<li><strong>Interactive menu UI</strong>: good for manual use, terrible for scriptability</li>
<li><strong>Config in INI format</strong>: not the end of the world, but YAML is more ergonomic</li>
<li><strong>10,220 lines across 6 Python files</strong>: 2,700-line files with hundreds-of-lines functions each</li>
</ul>
<p>The point isn&rsquo;t that Python is bad for this. Subservient works. But installing and maintaining it in production is another story. If you want to run it on a headless server, you need Python 3.8+, pip, virtualenv (or you&rsquo;re going to pollute the system), and pray no dependency breaks with the next OS update.</p>
<h2>The experiment: porting it to Crystal with Claude<span class="hx:absolute hx:-mt-20" id="the-experiment-porting-it-to-crystal-with-claude"></span>
    <a href="#the-experiment-porting-it-to-crystal-with-claude" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Here&rsquo;s where it gets interesting. I wanted to test a hypothesis: can Claude take a large open source project, understand the architecture, and do a complete port to another language?</p>
<p>I&rsquo;m not talking about translating it file by file. I&rsquo;m talking about understanding what the project does, redesigning the architecture where it makes sense, and generating idiomatic code in Crystal.</p>
<p>What I did:</p>
<ol>
<li>Asked Claude to clone and analyze the Subservient repo</li>
<li>Explained the design decisions: use <a href="https://github.com/kaegi/alass"target="_blank" rel="noopener">alass</a> (a Rust binary, no Python dependencies) instead of ffsubsync, CLI subcommands instead of interactive menus, YAML instead of INI</li>
<li>Asked for a feature-parity port, with tests</li>
</ol>
<p>alass is an important detail. ffsubsync works fine, but it&rsquo;s a Python package that pulls in numpy and does audio analysis. alass does the same thing (subtitle synchronization through timing analysis), but it&rsquo;s a standalone Rust binary. Swapping one for the other eliminates the biggest Python dependency in the stack.</p>
<h2>The result: easy-subtitle<span class="hx:absolute hx:-mt-20" id="the-result-easy-subtitle"></span>
    <a href="#the-result-easy-subtitle" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Five commits. Less than 40 minutes from the first to the last.</p>
<table>
  <thead>
      <tr>
          <th>Commit</th>
          <th>Time</th>
          <th>What it did</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Initial implementation</td>
          <td>21:47</td>
          <td>Complete port: 42 src files, 16 test files, CI, install script</td>
      </tr>
      <tr>
          <td>Track shard.lock</td>
          <td>21:56</td>
          <td>Dependency lock for reproducible builds</td>
      </tr>
      <tr>
          <td>Prefer ~/.local/bin</td>
          <td>22:03</td>
          <td>Install script fix</td>
      </tr>
      <tr>
          <td>Add doctor command</td>
          <td>22:20</td>
          <td>New <code>doctor</code> command to validate the setup + bump v0.2.0</td>
      </tr>
      <tr>
          <td>Homebrew formula</td>
          <td>22:24</td>
          <td>Support for <code>brew install</code> and auto-update workflow</td>
      </tr>
  </tbody>
</table>
<p>The first commit already delivers a working project: 8 CLI commands, OpenSubtitles client with rate limiting, 76 passing tests, GitHub Actions with CI and release for Linux and macOS.</p>
<h3>Numbers<span class="hx:absolute hx:-mt-20" id="numbers"></span>
    <a href="#numbers" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><table>
  <thead>
      <tr>
          <th>Metric</th>
          <th>Subservient (Python)</th>
          <th>easy-subtitle (Crystal)</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Source code</td>
          <td>10,220 lines (6 files)</td>
          <td>2,516 lines (42 files)</td>
      </tr>
      <tr>
          <td>Tests</td>
          <td>0</td>
          <td>800 lines (76 specs)</td>
      </tr>
      <tr>
          <td>Runtime dependencies</td>
          <td>7 pip packages + ffsubsync</td>
          <td>0 (just webmock for tests)</td>
      </tr>
      <tr>
          <td>Binary</td>
          <td>n/a (needs Python + deps)</td>
          <td>~6MB static</td>
      </tr>
      <tr>
          <td>Config</td>
          <td>INI</td>
          <td>YAML</td>
      </tr>
      <tr>
          <td>Sync engine</td>
          <td>ffsubsync (Python)</td>
          <td>alass (Rust)</td>
      </tr>
      <tr>
          <td>UI</td>
          <td>Interactive menu</td>
          <td>CLI subcommands</td>
      </tr>
      <tr>
          <td>Concurrency</td>
          <td>ThreadPoolExecutor</td>
          <td>Crystal fibers + channels</td>
      </tr>
  </tbody>
</table>
<p>The LOC difference is loud: 10,220 vs 2,516. But that&rsquo;s not all Crystal&rsquo;s doing. The original Python has monolithic files of thousands of lines, with a lot of duplication and UI logic mixed with business logic. The port separates the responsibilities into small, focused modules.</p>
<h3>Architecture of the port<span class="hx:absolute hx:-mt-20" id="architecture-of-the-port"></span>
    <a href="#architecture-of-the-port" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>easy-subtitle/
  src/easy_subtitle/
    cli/           # Router &#43; 9 commands (init, extract, download, sync, run, clean, scan, hash, doctor)
    core/          # Language map, SRT parser/writer/cleaner, video scanner
    acquisition/   # OpenSubtitles API client, auth, search, download, movie hash
    extraction/    # MKV track parsing, extraction, remuxing
    synchronization/  # alass runner, offset computation, smart/first-match strategies
    models/        # VideoFile, SubtitleCandidate, CoverageEntry</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Every module has a clear responsibility. The biggest file is 144 lines (config). In the original Python, <code>acquisition.py</code> alone has 2,726 lines.</p>
<h3>What each command does<span class="hx:absolute hx:-mt-20" id="what-each-command-does"></span>
    <a href="#what-each-command-does" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># Generate the default config</span>
</span></span><span class="line"><span class="cl">easy-subtitle init
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Extract subtitles from inside MKVs</span>
</span></span><span class="line"><span class="cl">easy-subtitle extract /path/to/movies
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Download subtitles from OpenSubtitles</span>
</span></span><span class="line"><span class="cl">easy-subtitle download -l en,pt /path/to/movies
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Sync downloaded subtitles with the video</span>
</span></span><span class="line"><span class="cl">easy-subtitle sync /path/to/movies
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Full pipeline: extract → download → sync</span>
</span></span><span class="line"><span class="cl">easy-subtitle run /path/to/movies
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Clean ads/watermarks from SRTs</span>
</span></span><span class="line"><span class="cl">easy-subtitle clean /path/to/subtitles
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># See subtitle coverage by language</span>
</span></span><span class="line"><span class="cl">easy-subtitle scan --json /path/to/movies
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Compute the OpenSubtitles hash (debug)</span>
</span></span><span class="line"><span class="cl">easy-subtitle <span class="nb">hash</span> /path/to/movie.mkv
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Validate the setup: config, credentials, dependencies</span>
</span></span><span class="line"><span class="cl">easy-subtitle doctor</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p><code>doctor</code> is a command I added later. It checks whether the config exists, whether the API key is configured, tests login against the API, and checks whether <code>mkvmerge</code>, <code>mkvextract</code> and <code>alass</code> are on the PATH. It shows OS-specific install instructions when something is missing.</p>
<h3>Smart sync with fibers<span class="hx:absolute hx:-mt-20" id="smart-sync-with-fibers"></span>
    <a href="#smart-sync-with-fibers" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Smart sync is the part I most enjoyed seeing in the port. In the original Python, it uses <code>ThreadPoolExecutor</code> to run multiple candidates in parallel. In Crystal, the same logic is more natural with fibers and channels:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-crystal" data-lang="crystal"><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">execute</span><span class="p">(</span><span class="n">candidates</span> <span class="p">:</span> <span class="nb">Array</span><span class="p">(</span><span class="n">Path</span><span class="p">),</span> <span class="n">video</span> <span class="p">:</span> <span class="n">VideoFile</span><span class="p">)</span> <span class="p">:</span> <span class="n">SyncResult?</span>
</span></span><span class="line"><span class="cl">  <span class="n">channel</span> <span class="o">=</span> <span class="nb">Channel</span><span class="p">(</span><span class="n">SyncResult</span><span class="p">)</span><span class="o">.</span><span class="n">new</span><span class="p">(</span><span class="n">candidates</span><span class="o">.</span><span class="n">size</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="n">candidates</span><span class="o">.</span><span class="n">each</span> <span class="k">do</span> <span class="o">|</span><span class="n">candidate</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">    <span class="bp">spawn</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">      <span class="n">result</span> <span class="o">=</span> <span class="n">sync_one</span><span class="p">(</span><span class="n">candidate</span><span class="p">,</span> <span class="n">video</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="n">channel</span><span class="o">.</span><span class="n">send</span><span class="p">(</span><span class="n">result</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">end</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="n">results</span> <span class="o">=</span> <span class="nb">Array</span><span class="p">(</span><span class="n">SyncResult</span><span class="p">)</span><span class="o">.</span><span class="n">new</span><span class="p">(</span><span class="n">candidates</span><span class="o">.</span><span class="n">size</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">candidates</span><span class="o">.</span><span class="n">size</span><span class="o">.</span><span class="n">times</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">    <span class="n">results</span> <span class="o">&lt;&lt;</span> <span class="n">channel</span><span class="o">.</span><span class="n">receive</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="n">accepted</span> <span class="o">=</span> <span class="n">results</span><span class="o">.</span><span class="n">select</span><span class="p">(</span><span class="o">&amp;.</span><span class="n">accepted?</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">accepted</span><span class="o">.</span><span class="n">min_by</span><span class="p">(</span><span class="o">&amp;.</span><span class="n">offset</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Each subtitle candidate gets synced in a separate fiber (via <code>spawn</code>). The results come back through the <code>Channel</code>. At the end, it picks the accepted one with the smallest offset. No ThreadPoolExecutor, no futures, no callbacks.</p>
<h3>API rate limiting<span class="hx:absolute hx:-mt-20" id="api-rate-limiting"></span>
    <a href="#api-rate-limiting" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>OpenSubtitles requires throttling of 500ms between requests. The Crystal client implements that with a Mutex:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-crystal" data-lang="crystal"><span class="line"><span class="cl"><span class="no">RATE_LIMIT_MS</span> <span class="o">=</span> <span class="mi">500</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">private</span> <span class="k">def</span> <span class="nf">throttle!</span> <span class="p">:</span> <span class="nb">Nil</span>
</span></span><span class="line"><span class="cl">  <span class="vi">@mutex</span><span class="o">.</span><span class="n">synchronize</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">    <span class="n">elapsed</span> <span class="o">=</span> <span class="nb">Time</span><span class="o">.</span><span class="n">utc</span> <span class="o">-</span> <span class="vi">@last_request_at</span>
</span></span><span class="line"><span class="cl">    <span class="n">remaining</span> <span class="o">=</span> <span class="no">RATE_LIMIT_MS</span> <span class="o">-</span> <span class="n">elapsed</span><span class="o">.</span><span class="n">total_milliseconds</span>
</span></span><span class="line"><span class="cl">    <span class="nb">sleep</span><span class="p">(</span><span class="n">remaining</span><span class="o">.</span><span class="n">milliseconds</span><span class="p">)</span> <span class="k">if</span> <span class="n">remaining</span> <span class="o">&gt;</span> <span class="mi">0</span>
</span></span><span class="line"><span class="cl">    <span class="vi">@last_request_at</span> <span class="o">=</span> <span class="nb">Time</span><span class="o">.</span><span class="n">utc</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Simple, thread-safe, no external library.</p>
<h3>Installation<span class="hx:absolute hx:-mt-20" id="installation"></span>
    <a href="#installation" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>The static binary comes out of GitHub Actions and can be installed three ways:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># Homebrew (macOS / Linux)</span>
</span></span><span class="line"><span class="cl">brew install akitaonrails/tap/easy-subtitle
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Install script</span>
</span></span><span class="line"><span class="cl">curl -fsSL https://raw.githubusercontent.com/akitaonrails/easy-subtitle/master/install.sh <span class="p">|</span> bash
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Or grab the binary directly from Releases</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>One binary. No Python, no pip, nothing.</p>
<h2>On porting things &ldquo;just because&rdquo;<span class="hx:absolute hx:-mt-20" id="on-porting-things-just-because"></span>
    <a href="#on-porting-things-just-because" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>I&rsquo;ve always argued that porting software from one language to another just for the language fetish is a waste of time. How many projects have been rewritten in Rust &ldquo;just because&rdquo;? How much effort spent on rewrites that delivered no new value?</p>
<p>But I have to admit this experiment made me reconsider.</p>
<p>When the cost of porting drops from weeks/months to less than 40 minutes, the equation changes. Porting Subservient to Crystal with Claude wasn&rsquo;t an exercise in linguistic vanity. I wanted a static binary I could drop on a server and forget. No managing a Python runtime, no pip install breaking on the next system update.</p>
<p>And the result isn&rsquo;t a mechanical port. It&rsquo;s 2,516 lines across 42 files, against 10,220 in 6 monolithic ones. The port came with 76 tests the original didn&rsquo;t have, CI with automatic release for Linux and macOS, a Homebrew formula and an install script with checksum verification.</p>
<p>The point isn&rsquo;t that Python is bad. It&rsquo;s that the bar for &ldquo;is it worth porting?&rdquo; got ridiculously low. Feature-parity port with tests in less than an hour. Hard to argue against that.</p>
<p>The <a href="https://github.com/akitaonrails/easy-subtitle"target="_blank" rel="noopener">repository is here</a>. GPL-3.0, like the original.</p>
]]></content:encoded><category>crystal</category><category>python</category><category>claude</category><category>vibe-coding</category><category>subtitle</category></item><item><title>Crystal and a Smart FFmpeg Wrapper Built in 3 Hours | easy-ffmpeg</title><link>https://www.akitaonrails.com/en/2026/03/07/easy-ffmpeg-smart-wrapper-in-crystal/</link><guid isPermaLink="true">https://www.akitaonrails.com/en/2026/03/07/easy-ffmpeg-smart-wrapper-in-crystal/</guid><pubDate>Sat, 07 Mar 2026 18:00:00 GMT</pubDate><description>&lt;p&gt;Anyone who&amp;rsquo;s ever needed to convert a video on the terminal knows the FFmpeg pain. It does everything. Absolutely everything. But to do anything at all, you need to remember flag combinations that look like incantations:&lt;/p&gt;
&lt;div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code"&gt;
&lt;div&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;ffmpeg -i input.mkv -c:v libx264 -crf &lt;span class="m"&gt;23&lt;/span&gt; -preset medium &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -profile:v high -level 4.1 -c:a aac -b:a 128k &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -movflags +faststart output.mp4&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0"&gt;
&lt;button
class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
title="Copy code"
&gt;
&lt;div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"&gt;&lt;/div&gt;
&lt;div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"&gt;&lt;/div&gt;
&lt;/button&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p&gt;What does that do? Convert to H.264 with reasonable quality for the web, using AAC for audio and moving the moov atom to the start of the file to allow progressive streaming. If you already knew that, congratulations. If you didn&amp;rsquo;t, welcome to the club of 99% of people who use FFmpeg by copying commands from Stack Overflow.&lt;/p&gt;</description><content:encoded><![CDATA[<p>Anyone who&rsquo;s ever needed to convert a video on the terminal knows the FFmpeg pain. It does everything. Absolutely everything. But to do anything at all, you need to remember flag combinations that look like incantations:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">ffmpeg -i input.mkv -c:v libx264 -crf <span class="m">23</span> -preset medium <span class="se">\
</span></span></span><span class="line"><span class="cl">  -profile:v high -level 4.1 -c:a aac -b:a 128k <span class="se">\
</span></span></span><span class="line"><span class="cl">  -movflags +faststart output.mp4</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>What does that do? Convert to H.264 with reasonable quality for the web, using AAC for audio and moving the moov atom to the start of the file to allow progressive streaming. If you already knew that, congratulations. If you didn&rsquo;t, welcome to the club of 99% of people who use FFmpeg by copying commands from Stack Overflow.</p>
<p>And that&rsquo;s the easy case. Want to make an animated GIF from a sequence of PNGs? You need a two-pass pipeline with palette generation:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">ffmpeg -framerate <span class="m">10</span> -i frame_%04d.png <span class="se">\
</span></span></span><span class="line"><span class="cl">  -vf <span class="s2">&#34;split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse&#34;</span> output.gif</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Want to cut a clip, resize to 720p and force a 16:9 aspect ratio? Good luck assembling the filter chain.</p>
<p>I got tired of memorizing flags. I wanted a CLI where I&rsquo;d say &ldquo;convert this to MP4 optimized for the web&rdquo; and it would figure it out. So I built <a href="https://github.com/akitaonrails/easy-ffmpeg"target="_blank" rel="noopener">easy-ffmpeg</a>.</p>
<h2>What easy-ffmpeg does<span class="hx:absolute hx:-mt-20" id="what-easy-ffmpeg-does"></span>
    <a href="#what-easy-ffmpeg-does" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>It&rsquo;s a smart wrapper. You give it the input file and the output format, and it analyzes the video with ffprobe, finds out which video, audio and subtitle streams exist, checks codec compatibility against the destination container, and decides on its own what can be copied directly (no re-encoding, instant) and what needs to be transcoded.</p>
<p>The most basic use:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># Convert MKV to MP4 (copies compatible streams, no unnecessary re-encoding)</span>
</span></span><span class="line"><span class="cl">easy-ffmpeg input.mkv mp4
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Optimized for the web (H.264 + AAC, faststart)</span>
</span></span><span class="line"><span class="cl">easy-ffmpeg input.mkv mp4 --web
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Optimized for mobile (720p, AAC stereo, smaller file)</span>
</span></span><span class="line"><span class="cl">easy-ffmpeg input.mkv mp4 --mobile
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Maximum compression (H.265, CRF 28)</span>
</span></span><span class="line"><span class="cl">easy-ffmpeg input.mkv mp4 --compress
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># High quality for streaming (H.265, CRF 18)</span>
</span></span><span class="line"><span class="cl">easy-ffmpeg input.mkv mp4 --streaming</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<h2>Examples of what you can do<span class="hx:absolute hx:-mt-20" id="examples-of-what-you-can-do"></span>
    <a href="#examples-of-what-you-can-do" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p><strong>Cut a clip from the video:</strong></p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># From minute 1:30 to 3:00</span>
</span></span><span class="line"><span class="cl">easy-ffmpeg video.mp4 mp4 --start 1:30 --end 3:00
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># The first 90 seconds</span>
</span></span><span class="line"><span class="cl">easy-ffmpeg video.mp4 mp4 --duration <span class="m">90</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Accepts several time formats: 90, 1:30, 01:30.5, 1:02:30</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p><strong>Resize:</strong></p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># To 720p</span>
</span></span><span class="line"><span class="cl">easy-ffmpeg video.mp4 mp4 --scale hd
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># To 1080p</span>
</span></span><span class="line"><span class="cl">easy-ffmpeg video.mp4 mp4 --scale fullhd
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Presets: 2k (1440p), fullhd (1080p), hd (720p), retro (480p), icon (240p)</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p><strong>Change aspect ratio:</strong></p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># Force 16:9 (adds black bars if needed)</span>
</span></span><span class="line"><span class="cl">easy-ffmpeg video.mp4 mp4 --aspect wide
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># TikTok/Stories format (9:16 vertical)</span>
</span></span><span class="line"><span class="cl">easy-ffmpeg video.mp4 mp4 --aspect tiktok
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Square for Instagram</span>
</span></span><span class="line"><span class="cl">easy-ffmpeg video.mp4 mp4 --aspect square
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Crop instead of adding bars</span>
</span></span><span class="line"><span class="cl">easy-ffmpeg video.mp4 mp4 --aspect wide --crop</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p><strong>Image sequence to video:</strong></p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># Auto-detects the numbering pattern (frame_0001.png, frame_0002.png...)</span>
</span></span><span class="line"><span class="cl">easy-ffmpeg /folder/with/frames/ mp4
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Animated GIF with optimized palette</span>
</span></span><span class="line"><span class="cl">easy-ffmpeg /folder/with/frames/ gif
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Image sequence at 720p, 30fps</span>
</span></span><span class="line"><span class="cl">easy-ffmpeg /folder/with/frames/ mp4 --fps <span class="m">30</span> --scale hd</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p><strong>See what it&rsquo;ll do without running it:</strong></p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">easy-ffmpeg video.mkv mp4 --web --dry-run
</span></span><span class="line"><span class="cl"><span class="c1"># Shows the exact ffmpeg command that would be executed</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p><strong>Combine everything:</strong></p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># Cut, resize and compress to send over WhatsApp</span>
</span></span><span class="line"><span class="cl">easy-ffmpeg video.mkv mp4 --start 0:30 --end 2:00 --scale hd --compress</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>And if you run <code>easy-ffmpeg</code> with no arguments in an interactive terminal, it opens a TUI mode with file selection through fuzzy search, preset choice through a menu, and time input with validation — all without having to remember any flags.</p>
<h2>Why Crystal<span class="hx:absolute hx:-mt-20" id="why-crystal"></span>
    <a href="#why-crystal" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>I hadn&rsquo;t touched Crystal in years. The last time was for fun, before version 1.0. And I wanted to revisit it.</p>
<p>Crystal occupies an interesting niche. Go and Crystal compete for the same space: compiled languages for applications, generating static binaries, with garbage collection and no runtime dependency. But the approach is very different.</p>
<p>Go is famously, deliberately simple. No generics for years (they only landed in 1.18), no exceptions (error returns), no expressiveness. The argument is that this makes it easier to read and maintain in big teams. In practice, it produces verbose, repetitive code, with <code>if err != nil</code> on every line.</p>
<p>Crystal has static typing with inference, compile-time macros, blocks as closures (just like Ruby), exceptions, generics from day one, and a syntax any Rubyist will recognize:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-crystal" data-lang="crystal"><span class="line"><span class="cl"><span class="c1"># Crystal: read a JSON and extract data</span>
</span></span><span class="line"><span class="cl"><span class="n">streams</span> <span class="o">=</span> <span class="n">json</span><span class="o">[</span><span class="s2">&#34;streams&#34;</span><span class="o">].</span><span class="n">as_a</span><span class="o">.</span><span class="n">map</span> <span class="k">do</span> <span class="o">|</span><span class="n">s</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">  <span class="n">StreamInfo</span><span class="o">.</span><span class="n">new</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">    <span class="ss">codec</span><span class="p">:</span> <span class="n">s</span><span class="o">[</span><span class="s2">&#34;codec_name&#34;</span><span class="o">]?.</span><span class="n">try</span><span class="p">(</span><span class="o">&amp;.</span><span class="n">as_s</span><span class="p">)</span> <span class="o">||</span> <span class="s2">&#34;unknown&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="ss">width</span><span class="p">:</span>  <span class="n">s</span><span class="o">[</span><span class="s2">&#34;width&#34;</span><span class="o">]?.</span><span class="n">try</span><span class="p">(</span><span class="o">&amp;.</span><span class="n">as_i</span><span class="p">)</span> <span class="o">||</span> <span class="mi">0</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="ss">height</span><span class="p">:</span> <span class="n">s</span><span class="o">[</span><span class="s2">&#34;height&#34;</span><span class="o">]?.</span><span class="n">try</span><span class="p">(</span><span class="o">&amp;.</span><span class="n">as_i</span><span class="p">)</span> <span class="o">||</span> <span class="mi">0</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Go equivalent: would be 3x more lines with type assertions and error checks</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Crystal&rsquo;s stdlib has an HTTP server, JSON parsing, YAML, regex, fibers (green threads with a cooperative scheduler), channels (just like Go), and even IO::FileDescriptor with raw mode for the terminal — which I used for the interactive mode. Concurrency works with <code>spawn</code> (the equivalent of Go&rsquo;s <code>go</code>) and <code>Channel</code> (identical to Go&rsquo;s chan). The difference is that everything comes with Ruby&rsquo;s ergonomics.</p>
<p>For a CLI that needs to compile into a static binary with no dependencies, run on Linux and macOS, and be distributed as a direct download — Crystal is perfect. The compiler generates native binaries, and with Docker Alpine you can do static linking with musl for Linux. The final easy-ffmpeg binary is around 6MB.</p>
<p>I think Rust is better for systems code (kernels, drivers, databases, things where ownership and lifetime matter). But for a video conversion CLI? Rust would be overkill. Crystal gives you the same end result (fast static binary) with a third of the code and without fighting the borrow checker.</p>
<h2>The numbers<span class="hx:absolute hx:-mt-20" id="the-numbers"></span>
    <a href="#the-numbers" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The whole project was built in one afternoon. 10 commits between 18:32 and 21:33 on March 7, 2026. Three hours.</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>Language     Files       Lines     Code
Crystal      13          2,823     2,342
Shell        1           109       91
Markdown     1           266       210
YAML         2           46        34
─────────────────────────────────────────
Total        20          3,326     2,419</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>2,342 lines of Crystal do: a CLI with 5 presets, media analysis through ffprobe, smart conversion planning with a codec compatibility matrix, a progress bar with ETA, an interactive mode with fuzzy search, support for image sequences and GIFs, and trimming/scaling/aspect ratio with validation.</p>
<p>The tool already has CI/CD on GitHub Actions, compiling static binaries for Linux (x86_64 and arm64) and macOS (arm64), with installation through a one-line curl:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">curl -fsSL https://raw.githubusercontent.com/akitaonrails/easy-ffmpeg/master/install.sh <span class="p">|</span> sh</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Three hours of one afternoon, from the first line of code to a release on GitHub with binaries for three platforms. FFmpeg keeps doing the heavy lifting — I just put a decent interface in front of it.</p>
<p>The <a href="https://github.com/akitaonrails/easy-ffmpeg"target="_blank" rel="noopener">repository is here</a>. MIT license.</p>
]]></content:encoded><category>crystal</category><category>ffmpeg</category><category>cli</category><category>vibe-coding</category><category>open-source</category></item><item><title>37 Days of Vibe Coding Immersion: Conclusions on Business Models</title><link>https://www.akitaonrails.com/en/2026/03/05/37-days-of-vibe-coding-immersion-conclusions-on-business-models/</link><guid isPermaLink="true">https://www.akitaonrails.com/en/2026/03/05/37-days-of-vibe-coding-immersion-conclusions-on-business-models/</guid><pubDate>Thu, 05 Mar 2026 14:00:00 GMT</pubDate><description>&lt;p&gt;For the last 37 days I locked myself into a vibe coding immersion. The goal was simple: actually understand what the current generation of LLMs and coding agents can do. Not on a weekend toy project, but building real things, with production deploys, users, and the maintenance pain that comes along.&lt;/p&gt;
&lt;p&gt;The result was 653 commits, ~144K lines of code across 8 projects published on GitHub, and a series of articles documenting each one. If you&amp;rsquo;ve been following along, you&amp;rsquo;ve already read the post-mortems. If you haven&amp;rsquo;t, here&amp;rsquo;s the index:&lt;/p&gt;</description><content:encoded><![CDATA[<p>For the last 37 days I locked myself into a vibe coding immersion. The goal was simple: actually understand what the current generation of LLMs and coding agents can do. Not on a weekend toy project, but building real things, with production deploys, users, and the maintenance pain that comes along.</p>
<p>The result was 653 commits, ~144K lines of code across 8 projects published on GitHub, and a series of articles documenting each one. If you&rsquo;ve been following along, you&rsquo;ve already read the post-mortems. If you haven&rsquo;t, here&rsquo;s the index:</p>
<h2>The articles<span class="hx:absolute hx:-mt-20" id="the-articles"></span>
    <a href="#the-articles" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><ul>
<li><a href="/en/2026/01/28/vibe-code-built-a-little-app-fully-with-glm-4-7-tv-clipboard/">Vibe Code: I built a small app 100% with GLM 4.7 (TV Clipboard)</a></li>
<li><a href="/en/2026/01/29/vibe-code-which-llm-is-best-lets-be-real/">Vibe Code: Which LLM is the BEST?? Let&rsquo;s get real</a></li>
<li><a href="/en/2026/02/01/frankmd-markdown-editor-vibe-code-part-1/">Vibe Code: I built a Markdown editor from scratch with Claude Code (FrankMD) PART 1</a></li>
<li><a href="/en/2026/02/01/frankmd-markdown-editor-vibe-code-part-2/">Vibe Code: I built a Markdown editor from scratch with Claude Code (FrankMD) PART 2</a></li>
<li><a href="/en/2026/02/08/rant-ai-killed-programmers/">RANT: Did AI kill programmers?</a></li>
<li><a href="/en/2026/02/16/vibe-code-zero-to-production-in-6-days-the-m-akita-chronicles/">Vibe Code: From Zero to Production in 6 DAYS - The M.Akita Chronicles</a></li>
<li><a href="/en/2026/02/20/zero-to-post-production-in-1-week-using-ai-on-real-projects-behind-the-m-akita-chronicles/">From Zero to Post-Production in 1 Week - How to Use AI on Real Projects</a></li>
<li><a href="/en/2026/02/21/vibe-code-built-a-mega-clone-in-rails-in-1-day-frankmega/">Vibe Code: I built a Mega clone in Rails in 1 day for my Home Server</a></li>
<li><a href="/en/2026/02/23/vibe-code-built-a-smart-image-indexer-with-ai-in-2-days-frank-sherlock/">Vibe Code: I built an Intelligent Image Indexer with AI in 2 days - Frank Sherlock</a></li>
<li><a href="/en/2026/02/24/rant-akita-caved-to-ai/">RANT: Did Akita open his legs to AI??</a></li>
<li><a href="/en/2026/03/01/ai-jail-sandbox-for-ai-agents-from-shell-script-to-real-tool/">ai-jail: Sandbox for AI Agents</a></li>
<li><a href="/en/2026/03/01/software-is-never-done-4-projects-life-after-deploy-one-shot-prompt-myth/">Software Is Never &lsquo;Done&rsquo; - 4 Projects, the Life After Deploy</a></li>
<li><a href="/en/2026/03/04/data-mining-system-for-my-influencer-girlfriend/">I Built a Data Mining System for My Influencer Girlfriend</a></li>
<li><a href="/en/2026/03/05/my-first-vibe-code-failure-frank-yomik/">My First Vibe Code Failure and How I Fixed It - Frank Yomik</a></li>
</ul>
<h2>The projects<span class="hx:absolute hx:-mt-20" id="the-projects"></span>
    <a href="#the-projects" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><table>
  <thead>
      <tr>
          <th>Project</th>
          <th>Commits</th>
          <th>LOC</th>
          <th>Time</th>
          <th>What it does</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="https://github.com/akitaonrails/FrankMD"target="_blank" rel="noopener">FrankMD</a></td>
          <td>234</td>
          <td>38K</td>
          <td>~5 days</td>
          <td>Markdown editor in Rust/Tauri</td>
      </tr>
      <tr>
          <td><a href="https://github.com/akitaonrails/FrankYomik"target="_blank" rel="noopener">FrankYomik</a></td>
          <td>131</td>
          <td>21K</td>
          <td>~9 days</td>
          <td>Manga/webtoon translator (Go + Python + Flutter)</td>
      </tr>
      <tr>
          <td><a href="https://github.com/akitaonrails/FrankSherlock"target="_blank" rel="noopener">FrankSherlock</a></td>
          <td>103</td>
          <td>37K</td>
          <td>~6 days</td>
          <td>Intelligent image indexer (Rails + Python)</td>
      </tr>
      <tr>
          <td>mila-bot (private)</td>
          <td>60</td>
          <td>30K</td>
          <td>~3 days</td>
          <td>Data mining system (Rails + Discord)</td>
      </tr>
      <tr>
          <td><a href="https://github.com/akitaonrails/tvclipboard"target="_blank" rel="noopener">TVClipboard</a></td>
          <td>49</td>
          <td>5K</td>
          <td>~2 days</td>
          <td>Cross-device clipboard app with GLM 4.7</td>
      </tr>
      <tr>
          <td><a href="https://github.com/akitaonrails/ai-jail"target="_blank" rel="noopener">ai-jail</a></td>
          <td>46</td>
          <td>6K</td>
          <td>~4 days</td>
          <td>Security sandbox for AI agents (Rust)</td>
      </tr>
      <tr>
          <td><a href="https://github.com/akitaonrails/FrankMega"target="_blank" rel="noopener">FrankMega</a></td>
          <td>29</td>
          <td>7K</td>
          <td>~1 day</td>
          <td>Mega clone for the home server (Rails)</td>
      </tr>
      <tr>
          <td>The M.Akita Chronicles</td>
          <td>1+</td>
          <td>many</td>
          <td>~6 days</td>
          <td>Full blog/newsletter in production</td>
      </tr>
  </tbody>
</table>
<p>All of this happened between January 27 and March 5, 2026.</p>
<p>I&rsquo;m not going to repeat the technical details of every project. Each post-mortem above already covered that. What I want to discuss here is the consequence.</p>
<h2>What these 37 days showed me<span class="hx:absolute hx:-mt-20" id="what-these-37-days-showed-me"></span>
    <a href="#what-these-37-days-showed-me" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Projects that used to take weeks or months for an experienced developer now take days. A complete Markdown editor in 5 days. A Mega clone in 1 day. A data mining system with 40+ bot tools in 3 days. An image indexer with vision AI in 2 days.</p>
<p>And I&rsquo;m not talking about throwaway prototypes. These projects are in production. They have tests. They have automated deployments. It&rsquo;s real software built with real engineering practices, just at a speed that was unthinkable before.</p>
<p>This speed isn&rsquo;t unique to me. Cloudflare just demonstrated something similar. <a href="https://blog.cloudflare.com/vinext/"target="_blank" rel="noopener">In a recent post</a>, they describe how an engineer reimplemented the Next.js API surface on top of Vite in a week, using Claude as the main tool. US$ 1,100 in API tokens, more than 800 sessions, 1,700 unit tests, 380 E2E tests, builds 4.4x faster than the original Next.js. Controversies aside about whether it&rsquo;s a &ldquo;complete&rdquo; reimplementation or not, the central point is real: software that used to require teams and months can now be built by one person in days.</p>
<p>And that changes everything for anyone wanting to start a company.</p>
<h2>The death of the easy startup<span class="hx:absolute hx:-mt-20" id="the-death-of-the-easy-startup"></span>
    <a href="#the-death-of-the-easy-startup" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>For years, the classic startup model worked like this: someone has an idea, puts together a small team, builds an MVP in 3-6 months, raises seed money, and tries to scale. The barrier to entry was development cost. Programmers are expensive, development is slow, and the first to market has the advantage.</p>
<p>That model is breaking.</p>
<p>If I can build a working Mega clone in 1 day, what&rsquo;s a &ldquo;cloud storage&rdquo; startup worth? If Cloudflare reimplements the Next.js core in 1 week with one engineer and US$ 1,100 in tokens, what&rsquo;s the real moat of a platform like Vercel? The &ldquo;social listening&rdquo; SaaS that charges R$ 500/month is competing with something I built in 3 days as a side project.</p>
<p>Every entrepreneur who used to show up describing their idea as &ldquo;it&rsquo;s like Uber, but for&hellip;&rdquo; or &ldquo;it&rsquo;s like Airbnb, except&hellip;&rdquo; or &ldquo;another social network, but with&hellip;&rdquo; — those people need to stop and rethink. When any competent developer with access to Claude Code or GPT Codex can replicate your MVP in a week, your idea isn&rsquo;t worth anything anymore. Execution got too cheap.</p>
<p>I&rsquo;m not exaggerating. In my 37 days, I built things I wouldn&rsquo;t even attempt in 6 months before. Small CRMs, ecommerces, content managers, productivity tools, data mining apps, processing pipelines — all of that became commodity. The code itself is no longer the differentiator.</p>
<h2>So what is the differentiator?<span class="hx:absolute hx:-mt-20" id="so-what-is-the-differentiator"></span>
    <a href="#so-what-is-the-differentiator" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>This is where most vibe coding enthusiasts miss the point. To illustrate, let me use my own project as an example.</p>
<p><a href="https://github.com/akitaonrails/FrankYomik"target="_blank" rel="noopener">Frank Yomik</a> translates manga pages from Japanese to English in near real-time. The full pipeline (balloon detection, OCR, translation, rendering) works. But the most important component of the system isn&rsquo;t any code I wrote. It&rsquo;s the <code>ogkalu/comic-text-and-bubble-detector</code> model, an RT-DETR-v2 trained on ~11,000 labeled comic images.</p>
<p>I didn&rsquo;t train that model. I couldn&rsquo;t easily train that model. Collecting 11,000 diverse comic images, manually labeling the speech balloons in each one (or generating labels with some semi-automated pipeline, which isn&rsquo;t trivial either), configuring the training, and running it on the necessary hardware — that&rsquo;s work of a different nature. It&rsquo;s work that vibe coding doesn&rsquo;t solve.</p>
<p>And that&rsquo;s the case of a relatively small model. An object detector can be trained with a few hundred to a few thousand labeled images on a single GPU in hours. Studies show useful results are possible with 100-350 images for specific domains, but robust real-world detectors usually need thousands. The cost is low, in the hundreds of dollars range.</p>
<p>Now look at what happens when we scale up to bigger models.</p>
<h2>The numbers that matter<span class="hx:absolute hx:-mt-20" id="the-numbers-that-matter"></span>
    <a href="#the-numbers-that-matter" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>GPT-4 cost more than US$ 100 million to train, according to Sam Altman himself. Stanford estimated the compute cost of Google&rsquo;s Gemini Ultra at US$ 191 million. Meta&rsquo;s Llama 3 consumed 39.3 million GPU-hours on H100s, and Meta built two clusters of 24,576 GPUs each to make it possible — and by the end of 2024 it planned to have 350,000 H100s in its infrastructure.</p>
<p>These costs are accelerating. According to Epoch AI, the cost of hardware and energy to train frontier models grows by 2.4x per year since 2016. If that trend continues, the largest training runs will cost more than US$ 1 billion before the end of 2027. Dario Amodei, Anthropic&rsquo;s CEO, has said frontier developers are probably spending close to a billion per training run now, with US$ 10 billion training runs expected within two years.</p>
<p>And the hardware? An H100 GPU costs US$ 25,000-40,000 per unit. A server with 8 GPUs runs between US$ 200,000 and US$ 400,000. The HBM memory those GPUs use is at capacity — SK Hynix, Samsung and Micron have already announced ~20% price increases for 2026. NVIDIA consumes more than 60% of the global HBM production. It&rsquo;s a structural bottleneck, not a temporary one.</p>
<p>In terms of energy: global data centers consumed ~415 TWh of electricity in 2024, according to the IEA, about 1.5% of the world&rsquo;s electricity. The projection is ~945 TWh by 2030. New data centers are being built with capacities of 100 MW to 1 GW each.</p>
<p>And the big tech investments reflect that. In 2025, the aggregate capex of Amazon (<del>$125B), Google (</del>$91B), Microsoft (<del>$80B) and Meta (</del>$71B) crossed US$ 400 billion, a 62% increase over 2024. Goldman Sachs projects more than US$ 500 billion in 2026.</p>
<p>These numbers aren&rsquo;t meant to scare. They&rsquo;re meant to put the real entry barrier in context.</p>
<h2>The new barrier: exclusive data and training capacity<span class="hx:absolute hx:-mt-20" id="the-new-barrier-exclusive-data-and-training-capacity"></span>
    <a href="#the-new-barrier-exclusive-data-and-training-capacity" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>If software got cheap to produce, the competitive differentiator migrated. The question stopped being &ldquo;who writes code the fastest?&rdquo; and became &ldquo;who has access to data nobody else has, and knows how to turn that data into useful models?&rdquo;</p>
<p>DeepSeek-V3 announced that its training cost US$ 5.5 million in compute. The press celebrated the &ldquo;cheap Chinese model.&rdquo; But The Register reported that the acquisition cost of the 256 GPU servers used was over US$ 51 million — and that excludes R&amp;D, data acquisition, data cleaning, and all the failed training runs before the final successful one. The real cost of developing the capability is one or two orders of magnitude above the marginal cost of the final successful training run.</p>
<p>That&rsquo;s why we only see large companies producing frontier models. Meta, Alibaba, Google, Amazon, Microsoft, Anthropic — companies investing tens of billions in hardware and energy. A garage startup can&rsquo;t compete on this dimension.</p>
<p>But the question goes beyond frontier models. Even smaller specialized models require something you can&rsquo;t buy: high-quality proprietary data.</p>
<p>Llama 3 was trained on 15 trillion tokens. Epoch AI has documented that we&rsquo;re approaching the limits of human-generated text data on the internet. Public data is being exhausted. Whoever has exclusive data — medical, financial, industrial, logistics, sensor, user behavior — has something that vibe coding can&rsquo;t replicate.</p>
<p>And even those who train specialized models with proprietary data face a problem: the advantage is temporary. Another competitor can collect similar data and train a competing model in months. The differentiator needs to be continuously fed: more data, better curation, more efficient training pipelines, access to hardware that&rsquo;s increasingly scarce and expensive.</p>
<h2>The picture<span class="hx:absolute hx:-mt-20" id="the-picture"></span>
    <a href="#the-picture" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>If you&rsquo;re thinking about starting a company, the question that matters has changed.</p>
<p>Before it was: &ldquo;can I build this software?&rdquo; Now the answer is almost always yes, fast and cheap.</p>
<p>The question now is: &ldquo;do I have exclusive data nobody else has, and do I know how to turn that data into something useful that&rsquo;s hard to replicate?&rdquo;</p>
<p>If the answer is no, any competitor with Claude Code replicates your product in days. And then another one shows up. And another. The race to the bottom on price is immediate when the cost of building is close to zero.</p>
<p>If the answer is yes, you have a real moat — but a temporary one. Competing models trained on similar data can show up in months. Your differentiator needs to be continuously fed.</p>
<p>The era of easy startups is over. Not because building software got harder — it got way easier. But precisely because of that: when everyone can build the same thing in a week, the competitive advantage has to come from somewhere else. And that &ldquo;somewhere else&rdquo; requires capital and infrastructure that are orders of magnitude more expensive than writing code.</p>
<p>The garage still works. But what comes out of it can no longer be &ldquo;an app.&rdquo;</p>
]]></content:encoded><category>ai</category><category>vibe-coding</category><category>startups</category><category>business</category><category>opinion</category></item><item><title>My First Vibe Code Failure and How I Fixed It | Frank Yomik</title><link>https://www.akitaonrails.com/en/2026/03/05/my-first-vibe-code-failure-frank-yomik/</link><guid isPermaLink="true">https://www.akitaonrails.com/en/2026/03/05/my-first-vibe-code-failure-frank-yomik/</guid><pubDate>Thu, 05 Mar 2026 08:00:00 GMT</pubDate><description>&lt;p&gt;Before anyone asks, all the code is in &lt;a href="https://github.com/akitaonrails/FrankYomik/"target="_blank" rel="noopener"&gt;this repository&lt;/a&gt;. And there are pre-built client binaries on the &lt;a href="https://github.com/akitaonrails/FrankYomik/releases"target="_blank" rel="noopener"&gt;releases page&lt;/a&gt;. But the app alone isn&amp;rsquo;t enough because it needs the server component, which runs on a machine of yours (local or cloud) with a GPU of at least 16GB of VRAM, and that you need to configure — I&amp;rsquo;m not going to maintain a public server, just a personal one for my own private use.&lt;/p&gt;</description><content:encoded><![CDATA[<p>Before anyone asks, all the code is in <a href="https://github.com/akitaonrails/FrankYomik/"target="_blank" rel="noopener">this repository</a>. And there are pre-built client binaries on the <a href="https://github.com/akitaonrails/FrankYomik/releases"target="_blank" rel="noopener">releases page</a>. But the app alone isn&rsquo;t enough because it needs the server component, which runs on a machine of yours (local or cloud) with a GPU of at least 16GB of VRAM, and that you need to configure — I&rsquo;m not going to maintain a public server, just a personal one for my own private use.</p>
<p>&ndash;</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/03/screenshot-2026-03-05_07-52-11.png" alt="kindle library"  loading="lazy" /></p>
<p>I have a huge collection of manga bought on Amazon.co.jp that I read through <a href="read.amazon.co.jp">Kindle web</a>. Shounen manga normally has furigana — that small text in hiragana next to the kanji — that helps me read, because, despite having studied Japanese, I was never formally trained. But manga aimed at adult audiences (seinen, not porn) usually comes without furigana. It&rsquo;s pure kanji and my reading speed crawls.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/03/screenshot-2026-03-05_07-53-26.png" alt="furigana"  loading="lazy" /></p>
<p>For years I&rsquo;ve wanted a tool that solves this. The idea is simple: detect the speech bubbles on a manga page, extract the text with OCR, and either add furigana to the kanji, or translate directly to English and render it back into the bubble. Sounds easy, right?</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/03/screenshot-2026-03-05_08-07-43.png" alt="no-furigana"  loading="lazy" />
<em>(kanji with no caption/furigana)</em></p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/03/screenshot-2026-03-05_08-11-21.png" alt="com furigana"  loading="lazy" />
<em>(with furigana injected in real time)</em></p>
<p>Yeah, it sounded easy. And because I knew it sounded easy but wouldn&rsquo;t be, I never had the patience to actually do it. I know how to build the first 80% of any project. The problem is the last 20% — that phase of experimentation, tweaking, fine-tuning, handling edge cases — that consumes more time than all the rest combined. And in a computer vision project, that 20% is especially treacherous.</p>
<p>But then the vibe coding era arrived. And I thought: maybe now the 20% is feasible. I started the project on February 24, 2026, at 23:10 at night. And it became an example of how easy it is to produce massive volumes of <strong>useless</strong> code.</p>
<h2>The original idea: OpenCV and heuristics<span class="hx:absolute hx:-mt-20" id="the-original-idea-opencv-and-heuristics"></span>
    <a href="#the-original-idea-opencv-and-heuristics" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>My original plan — the concept I&rsquo;d imagined for years — was: use <a href="https://opencv.org/"target="_blank" rel="noopener">OpenCV</a> — which is a famous and old computer vision library — to detect the speech bubbles. Manga bubbles are typically white areas with a black contour inside the panels. In theory, you just threshold the image to grab white regions, find contours, filter by size and shape, and you&rsquo;re done.</p>
<p>In 24 hours I already had a working proof of concept: bubble detection, OCR with manga-ocr (a model trained specifically on Japanese manga text), furigana with MeCab for morphological analysis, translation with Ollama running Qwen3:14b locally, and rendering the text back into the bubble. The initial commit (<code>9169d73</code>) on Feb 24 already did all of that.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/03/shounen6-debug.jpg" alt="shounen6 debug"  loading="lazy" /></p>
<p>The next day, Feb 25, I was already extending the pipeline with a Korean webtoon translation flow. 27 commits that day. Everything seemed to be flowing.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/03/057-debug.jpg" alt="webtoon debug"  loading="lazy" /></p>
<p>And then the hell started.</p>
<h2>The hell of false positives<span class="hx:absolute hx:-mt-20" id="the-hell-of-false-positives"></span>
    <a href="#the-hell-of-false-positives" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The problem with OpenCV bubble detection is that manga isn&rsquo;t a standardized document. Every artist has their own line, scan quality varies enormously between print eras, and color pages need completely different parameters from black-and-white pages. And the thing that looks the most like a white speech bubble on a manga page is&hellip; <strong>a face</strong>.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/03/screenshot-2026-03-05_09-15-17.png" alt="false positive face"  loading="lazy" /></p>
<p>Character faces are light areas, relatively rounded, with a dark contour. Exactly like speech bubbles. And no matter how many filters you stack, there&rsquo;s always a case where the face of a character with blue hair sails through every filter, or where a legitimate bubble gets rejected because it has an unusual shape.</p>
<p>Look at how my bubble detector ended up at peak complexity — 551 lines with 7 layers of false-positive filters (abridged version):</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="c1"># --- False positive filters ---</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 1. Edge density</span>
</span></span><span class="line"><span class="cl"><span class="n">edge_pixels</span> <span class="o">=</span> <span class="n">cv2</span><span class="o">.</span><span class="n">countNonZero</span><span class="p">(</span><span class="n">cv2</span><span class="o">.</span><span class="n">bitwise_and</span><span class="p">(</span><span class="n">edges</span><span class="p">,</span> <span class="n">edges</span><span class="p">,</span> <span class="n">mask</span><span class="o">=</span><span class="n">mask</span><span class="p">))</span>
</span></span><span class="line"><span class="cl"><span class="n">edge_density</span> <span class="o">=</span> <span class="n">edge_pixels</span> <span class="o">/</span> <span class="n">area</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="n">edge_density</span> <span class="o">&gt;</span> <span class="n">max_edge_density</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="k">continue</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 2. Bright pixel ratio</span>
</span></span><span class="line"><span class="cl"><span class="n">bright_pixels</span> <span class="o">=</span> <span class="n">cv2</span><span class="o">.</span><span class="n">countNonZero</span><span class="p">(</span><span class="n">cv2</span><span class="o">.</span><span class="n">bitwise_and</span><span class="p">(</span><span class="n">bright_thresh</span><span class="p">,</span> <span class="n">mask</span><span class="p">))</span>
</span></span><span class="line"><span class="cl"><span class="n">bright_ratio</span> <span class="o">=</span> <span class="n">bright_pixels</span> <span class="o">/</span> <span class="n">area</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="n">bright_ratio</span> <span class="o">&lt;</span> <span class="n">min_bright_ratio</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="k">continue</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 3. Mid-tone ratio</span>
</span></span><span class="line"><span class="cl"><span class="n">mid_mask</span> <span class="o">=</span> <span class="n">cv2</span><span class="o">.</span><span class="n">inRange</span><span class="p">(</span><span class="n">gray</span><span class="p">,</span> <span class="mi">80</span><span class="p">,</span> <span class="mi">220</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">mid_pixels</span> <span class="o">=</span> <span class="n">cv2</span><span class="o">.</span><span class="n">countNonZero</span><span class="p">(</span><span class="n">cv2</span><span class="o">.</span><span class="n">bitwise_and</span><span class="p">(</span><span class="n">mid_mask</span><span class="p">,</span> <span class="n">mask</span><span class="p">))</span>
</span></span><span class="line"><span class="cl"><span class="n">mid_ratio</span> <span class="o">=</span> <span class="n">mid_pixels</span> <span class="o">/</span> <span class="n">area</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="n">mid_ratio</span> <span class="o">&gt;</span> <span class="n">max_mid_ratio</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="k">continue</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 4. Contour circularity</span>
</span></span><span class="line"><span class="cl"><span class="n">circularity</span> <span class="o">=</span> <span class="mi">4</span> <span class="o">*</span> <span class="n">np</span><span class="o">.</span><span class="n">pi</span> <span class="o">*</span> <span class="n">area</span> <span class="o">/</span> <span class="p">(</span><span class="n">perimeter</span> <span class="o">*</span> <span class="n">perimeter</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="n">circularity</span> <span class="o">&lt;</span> <span class="mf">0.10</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="k">continue</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 5. Border darkness</span>
</span></span><span class="line"><span class="cl"><span class="n">border_mean</span> <span class="o">=</span> <span class="n">cv2</span><span class="o">.</span><span class="n">mean</span><span class="p">(</span><span class="n">gray</span><span class="p">,</span> <span class="n">mask</span><span class="o">=</span><span class="n">border_only</span><span class="p">)[</span><span class="mi">0</span><span class="p">]</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="n">border_mean</span> <span class="o">&gt;</span> <span class="mi">160</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="k">continue</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 6. Background uniformity (white_std)</span>
</span></span><span class="line"><span class="cl"><span class="n">white_pixels</span> <span class="o">=</span> <span class="n">gray</span><span class="p">[(</span><span class="n">mask</span> <span class="o">&gt;</span> <span class="mi">0</span><span class="p">)</span> <span class="o">&amp;</span> <span class="p">(</span><span class="n">gray</span> <span class="o">&gt;</span> <span class="mi">200</span><span class="p">)]</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="nb">float</span><span class="p">(</span><span class="n">np</span><span class="o">.</span><span class="n">std</span><span class="p">(</span><span class="n">white_pixels</span><span class="p">))</span> <span class="o">&gt;</span> <span class="mi">15</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="k">continue</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 7. Dark content analysis (text strokes)</span>
</span></span><span class="line"><span class="cl"><span class="n">very_dark</span> <span class="o">=</span> <span class="n">np</span><span class="o">.</span><span class="n">sum</span><span class="p">((</span><span class="n">inner_mask</span> <span class="o">&gt;</span> <span class="mi">0</span><span class="p">)</span> <span class="o">&amp;</span> <span class="p">(</span><span class="n">gray</span> <span class="o">&lt;</span> <span class="mi">60</span><span class="p">))</span>
</span></span><span class="line"><span class="cl"><span class="n">dark_ratio_60</span> <span class="o">=</span> <span class="n">very_dark</span> <span class="o">/</span> <span class="n">inner_area</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="n">dark_ratio_60</span> <span class="o">&lt;</span> <span class="n">min_dark_ratio</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="k">continue</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Each one of those filters was added in response to a specific false positive. Edge density separated bubbles (which have sparse text strokes) from faces (which have hair, eyes, nose creating dense edges). Bright pixel ratio checked whether the region was actually white. Circularity discarded shapes that were too irregular. And so on.</p>
<p>But the worst part is that those filters interacted with each other in unpredictable ways. Look at this commit from Feb 26 (<code>70c814a</code>):</p>
<blockquote>
  <p>&ldquo;Revert rect_dark, mid_ratio, and early-split changes that caused face FPs&rdquo;</p>

</blockquote>
<p>I had tried to relax two thresholds — rect_dark from 0.10 to 0.11, mid_ratio from 0.15 to 0.16 — to recover bubbles that were being missed. Result: faces and body regions started passing as false positives in Adachi manga. I had to revert everything.</p>
<p>That&rsquo;s the pattern that repeated for days: recovering a missed bubble meant opening the door for false positives. Fixing a false positive meant losing a legitimate bubble. It was infinite whack-a-mole.</p>
<h2>The band-aids: CLAHE, edge detection, watershed<span class="hx:absolute hx:-mt-20" id="the-band-aids-clahe-edge-detection-watershed"></span>
    <a href="#the-band-aids-clahe-edge-detection-watershed" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>When the 7 basic filters weren&rsquo;t enough, I started stacking additional passes.</p>
<p>Commit <code>294e785</code> (Feb 26): added CLAHE (<em>Contrast Limited Adaptive Histogram Equalization</em>) as a second detection pass. Bubbles with mid-range brightness, near the 200 threshold, were being missed. CLAHE equalized the contrast and revealed those borderline bubbles.</p>
<p>But CLAHE also made faces pass through the filters because it artificially inflated skin brightness. So I had to add an entire validation function against the original image:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">_validate_on_original</span><span class="p">(</span><span class="n">candidate</span><span class="p">,</span> <span class="n">gray_orig</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;&#34;&#34;Check if a CLAHE-detected candidate looks bubble-like on original.&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="n">roi</span> <span class="o">=</span> <span class="n">gray_orig</span><span class="p">[</span><span class="n">y1</span><span class="p">:</span><span class="n">y2</span><span class="p">,</span> <span class="n">x1</span><span class="p">:</span><span class="n">x2</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">    <span class="n">mean_brightness</span> <span class="o">=</span> <span class="n">roi</span><span class="o">.</span><span class="n">mean</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c1"># Already bright enough for pass 1 — rejected for good reason</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">mean_brightness</span> <span class="o">&gt;</span> <span class="mi">215</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="kc">False</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c1"># Must have text strokes (dark pixels)</span>
</span></span><span class="line"><span class="cl">    <span class="n">dark_ratio</span> <span class="o">=</span> <span class="n">np</span><span class="o">.</span><span class="n">sum</span><span class="p">(</span><span class="n">roi</span> <span class="o">&lt;</span> <span class="mi">60</span><span class="p">)</span> <span class="o">/</span> <span class="n">roi</span><span class="o">.</span><span class="n">size</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">dark_ratio</span> <span class="o">&lt;</span> <span class="mf">0.07</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="kc">False</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c1"># White pixel variance: text creates high std, face skin is uniform</span>
</span></span><span class="line"><span class="cl">    <span class="n">white_pixels</span> <span class="o">=</span> <span class="n">roi</span><span class="p">[</span><span class="n">roi</span> <span class="o">&gt;</span> <span class="mi">200</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="n">white_pixels</span><span class="p">)</span> <span class="o">&gt;</span> <span class="mi">50</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="nb">float</span><span class="p">(</span><span class="n">np</span><span class="o">.</span><span class="n">std</span><span class="p">(</span><span class="n">white_pixels</span><span class="p">))</span> <span class="o">&lt;</span> <span class="mi">9</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">            <span class="k">return</span> <span class="kc">False</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="kc">True</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Commit <code>5dddb31</code> (March 1): added an entire third detection pass based on edges (<em>edge-based segmentation</em>). To catch bubbles where the white interior blended into the white background of the page. Dilated the Canny edges, inverted, did an AND with bright regions, and looked for contours in the result.</p>
<p>Commit <code>b695295</code> (Feb 26): added recovery of small bubbles via morphological gradient + OCR validation. If OCR confirmed there was valid Japanese text in the region, it was probably a real bubble.</p>
<p>Each band-aid added 50-100 lines of code and another layer of complexity. And each one had its own edge cases and false positives.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/03/shounen-debug.jpg" alt="shounen debug"  loading="lazy" /></p>
<h2>The final tally for v0.1<span class="hx:absolute hx:-mt-20" id="the-final-tally-for-v01"></span>
    <a href="#the-final-tally-for-v01" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>On March 1, I tagged v0.1. At that point I had:</p>
<ul>
<li><strong>90 commits</strong> in 6 days of development (Feb 24 to March 1)</li>
<li><strong>551 lines</strong> in <code>bubble_detector.py</code> alone</li>
<li>7 layers of false-positive filters, each with empirical thresholds</li>
<li>Two complete detection passes (original + CLAHE)</li>
<li>A third edge-based pass</li>
<li>Cross-validation against the original image</li>
<li>Watershed separation for overlapping bubbles</li>
<li>Separate threshold profiles for color vs black-and-white pages</li>
<li>20+ magic numbers tuned empirically against a limited sample of pages</li>
<li>Regression tests pinning each specific false positive (face of a girl with blue hair, window frame, thin horizontal strip, concrete floor&hellip;)</li>
</ul>
<p>And even with all that, it still wasn&rsquo;t reliable. Every new manga I tested revealed some little case that broke the detector. It was an extremely brittle monolith.</p>
<blockquote>
  <p><strong>SYMPTOM:</strong> you&rsquo;re patching a fix that fixed another fix that was fixing yet another fix, and when you touch one piece, you accidentally break another: that means the code is <strong>brittle</strong>, a house of cards about to collapse. That&rsquo;s the moment to give up and rethink!</p>

</blockquote>
<h2>The decision: research alternatives<span class="hx:absolute hx:-mt-20" id="the-decision-research-alternatives"></span>
    <a href="#the-decision-research-alternatives" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>At v0.1 I stopped and asked the question I should have asked at the start: is there someone who already trained an ML model to do exactly this?</p>
<p>I asked Claude to research available models for comic bubble detection. The research produced the document <code>docs/yolo_bubble_detection_plan.md</code>, where we analyzed the alternatives.</p>
<p>The first one that came up was a <a href="https://medium.com/@beyzaakyildiz/what-is-yolov8-how-to-use-it-b3807d13c5ce"target="_blank" rel="noopener">YOLOv8 Medium from ogkalu</a> (<code>comic-speech-bubble-detector-yolov8m</code>), trained on ~8,000 images of manga, webtoon, manhua and Western comics. It detects only one class (speech bubble). But digging deeper, we found another model from the same author: <code>ogkalu/comic-text-and-bubble-detector</code>, an <a href="https://github.com/lyuwenyu/RT-DETR"target="_blank" rel="noopener">RT-DETR-v2</a> with a ResNet-50-vd backbone (42.9M parameters), trained on <strong>~11,000 images</strong>, with three classes: <code>bubble</code>, <code>text_bubble</code> and <code>text_free</code>. Both Apache 2.0.</p>
<p>We also evaluated the comic-text-detector (DBNet + YOLOv5, ~13,000 images from Manga109), but that one detected text regions and not bubbles. And as future training data, there was Roboflow with 4,492 already-labeled images, and the Manga109 dataset with 147,918 annotations across 21,142 pages.</p>
<p>The RT-DETR-v2 with 3 classes was the most promising because it distinguished speech bubbles, text inside bubbles and free text (narration, SFX). It could replace both <code>bubble_detector.py</code> and <code>text_detector.py</code> in a single inference pass.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/03/026-en.jpg" alt="026 en"  loading="lazy" /></p>
<p>The conclusion of the research document was direct:</p>
<blockquote>
  <p>&ldquo;Even without fine-tuning, these models were trained on 8 to 11 thousand images of diverse comics. They should handle the artistic style diversity that our manual filters struggle with. The 7-filter heuristic cascade and its magic numbers would be completely eliminated.&rdquo;</p>

</blockquote>
<p>And if it weren&rsquo;t enough, we included a fine-tuning plan using paired data (original Japanese pages vs English fan translations) that generated labels automatically through image diff. But first we wanted to test the baseline.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/03/shounen2-en.jpg" alt="shounen2 en"  loading="lazy" /></p>
<h2>Replacing everything: RT-DETR-v2<span class="hx:absolute hx:-mt-20" id="replacing-everything-rt-detr-v2"></span>
    <a href="#replacing-everything-rt-detr-v2" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>On March 4, I made commit <code>0df63f2</code>: &ldquo;Replace OpenCV heuristic detection with RT-DETR-v2, add bubble shape masking&rdquo;.</p>
<p>The diff speaks for itself:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>16 files changed, 732 insertions(&#43;), 1112 deletions(-)</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>1,112 lines deleted. More lines removed than added. The 551-line bubble detector was replaced by 262 lines — and most of those are shape mask extraction (contour mask) from the detected bbox, not the detection itself.</p>
<p>The detection core became this:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="n">MODEL_ID</span> <span class="o">=</span> <span class="s2">&#34;ogkalu/comic-text-and-bubble-detector&#34;</span>
</span></span><span class="line"><span class="cl"><span class="n">DEFAULT_CONFIDENCE</span> <span class="o">=</span> <span class="mf">0.35</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">detect_bubbles</span><span class="p">(</span><span class="n">img_cv</span><span class="p">,</span> <span class="n">confidence</span><span class="o">=</span><span class="n">DEFAULT_CONFIDENCE</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="n">model</span><span class="p">,</span> <span class="n">processor</span><span class="p">,</span> <span class="n">device</span> <span class="o">=</span> <span class="n">_get_model</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="n">img_pil</span> <span class="o">=</span> <span class="n">Image</span><span class="o">.</span><span class="n">fromarray</span><span class="p">(</span><span class="n">img_cv</span><span class="p">[:,</span> <span class="p">:,</span> <span class="p">::</span><span class="o">-</span><span class="mi">1</span><span class="p">])</span>  <span class="c1"># BGR→RGB</span>
</span></span><span class="line"><span class="cl">    <span class="n">inputs</span> <span class="o">=</span> <span class="n">processor</span><span class="p">(</span><span class="n">images</span><span class="o">=</span><span class="n">img_pil</span><span class="p">,</span> <span class="n">return_tensors</span><span class="o">=</span><span class="s2">&#34;pt&#34;</span><span class="p">)</span><span class="o">.</span><span class="n">to</span><span class="p">(</span><span class="n">device</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">with</span> <span class="n">torch</span><span class="o">.</span><span class="n">no_grad</span><span class="p">():</span>
</span></span><span class="line"><span class="cl">        <span class="n">outputs</span> <span class="o">=</span> <span class="n">model</span><span class="p">(</span><span class="o">**</span><span class="n">inputs</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">results</span> <span class="o">=</span> <span class="n">processor</span><span class="o">.</span><span class="n">post_process_object_detection</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="n">outputs</span><span class="p">,</span> <span class="n">target_sizes</span><span class="o">=</span><span class="n">target_sizes</span><span class="p">,</span> <span class="n">threshold</span><span class="o">=</span><span class="n">confidence</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># ... map classes, deduplicate, sort</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>No magic thresholds. No separate profiles for color vs B&amp;W. No CLAHE. No edge detection. No watershed. No 7 filter layers. A model trained on 11,000 diverse comic images already knows how to tell a bubble from a face better than my pile of &ldquo;ifs.&rdquo;</p>
<p>What also vanished alongside it:</p>
<ul>
<li>The entire <code>text_detector.py</code> (387 lines) — replaced by RT-DETR&rsquo;s <code>text_free</code> class</li>
<li>The false-positive feedback system — RT-DETR detections are reliable enough to not need manual marking</li>
<li>Hundreds of lines of regression tests pinning specific false positives</li>
</ul>
<p>In the end, I didn&rsquo;t even need to try fine-tuning my own model. The model that already existed solved more than 99% of the cases, which for me was already excellent.</p>
<p>People who don&rsquo;t understand statistics struggle with this. With my &ldquo;manual&rdquo; OpenCV procedure I was already getting 80%, maybe more. But that&rsquo;s very little. If on every page a face gets a bubble slapped on top of it, that&rsquo;s terrible.</p>
<p>Even if with a lot of effort (another five hundred different &ldquo;ifs&rdquo;) I could get to 95%, that&rsquo;s still not enough. Reaching 80% is easy. The last 20% costs exponentially more, and the last 1% can be impossible in many cases. That&rsquo;s how things work. Everyone stops at 80%.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/03/shounen10-debug.jpg" alt="shounen10 debug"  loading="lazy" /></p>
<h2>The other pains: Flutter on Linux<span class="hx:absolute hx:-mt-20" id="the-other-pains-flutter-on-linux"></span>
    <a href="#the-other-pains-flutter-on-linux" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>While the Python and Go backend was relatively stable, the Flutter client on Linux was a separate chapter of suffering.</p>
<p>Flutter uses WebKitGTK for the WebView on Linux, and that component has painful particularities. Commit <code>8e5d168</code> (Feb 28) tells the story: WebKitGTK can&rsquo;t resolve Promise return values from asynchronous JavaScript, generating a PlatformException. I had to rewrite all the overlays as synchronous IIFEs with <code>decode().then()</code> and an <code>opacity: 0.999</code> nudge to force a texture refresh in the GPU compositor.</p>
<p>On my NVIDIA + Wayland setup, the WebView was unusable at full resolution. Commit <code>a36e1eb</code> (Feb 27) tried to fix it by forcing CPU rendering with <code>WEBKIT_SKIA_ENABLE_CPU_RENDERING=1</code> and disabling accelerated compositing. Then I had to revert that (commit <code>8e5d168</code>) and force the AMD iGPU&rsquo;s Mesa via <code>__EGL_VENDOR_LIBRARY_FILENAMES</code> to stop WebKitGTK from grabbing the NVIDIA dGPU.</p>
<p>Each one of those discoveries cost hours of debugging things that simply had no documentation. In the end I got the Linux version to a reasonable state, but it&rsquo;s nothing spectacular. I don&rsquo;t know if I&rsquo;m missing something obvious, but Flutter on Linux I found to be a drag, especially right after building a native app with Rust/Tauri (much better). But since I wanted an app that worked on Linux and Android, there weren&rsquo;t that many options.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/03/078-en.jpg" alt="078 en"  loading="lazy" /></p>
<h2>The Kindle prefetch that died<span class="hx:absolute hx:-mt-20" id="the-kindle-prefetch-that-died"></span>
    <a href="#the-kindle-prefetch-that-died" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>An idea that seemed brilliant and went wrong: create a second hidden WebView in Flutter that shared cookies/session with the main WebView and kept navigating pages ahead to pre-process them.</p>
<p>The reason is that the Kindle website only loads one page at a time. It doesn&rsquo;t load everything at once or in chunks, just one page. So you can&rsquo;t process pages forward or backward. When the server was still slow at processing pages, I wanted to keep everything pre-cached, so when the user turned the page the translation was already there.</p>
<p>Commit <code>a36e1eb</code> (Feb 27) implemented the entire KindlePrefetchManager: 406 lines of Dart, with batched prefetch (3 pages ahead), trusted GDK events for turning the page (isTrusted=true), rate limiting with human pacing, a window with sensitive=FALSE so it wouldn&rsquo;t steal focus.</p>
<p>In the following days, the fixes piled up:</p>
<ul>
<li><code>cfe08eb</code>: improve prefetch reliability and overlay matching</li>
<li><code>4461b5f</code>: harden overlay selection and Kindle recovery</li>
<li><code>f141b2e</code>: stop destroying the background webview on every page turn</li>
</ul>
<p>And in the end (commit <code>2b93e99</code>, March 4), I deleted everything. 657 lines removed, replaced by a simple spinner in the toolbar.</p>
<p>With the move to the RT-DETR model, and parallelizing the bubble translation, the translation became &ldquo;almost real-time,&rdquo; loading in less than 10 seconds, so it&rsquo;s no longer such a big deal to wait for a page to come in, and you can ask one at a time. The prefetch added too much complexity for marginal gain, and the right way to solve it was simply to process the page on demand with proper visual feedback.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/03/adult2-furigana.jpg" alt="adult2 furigana"  loading="lazy" /></p>
<h2>The numbers<span class="hx:absolute hx:-mt-20" id="the-numbers"></span>
    <a href="#the-numbers" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>In total, the Frank Yomik project today has:</p>
<ul>
<li><strong>111 commits</strong> across 9 days of development (with a 2-day break in the middle)</li>
<li><strong>29,428 lines</strong> of code in 181 files</li>
<li><strong>~13K lines</strong> of Python (processing pipeline + worker)</li>
<li><strong>~6K lines</strong> of Dart (Flutter client)</li>
<li><strong>~3.4K lines</strong> of Go (API server)</li>
<li><strong>345 unit tests</strong> + 34 integration tests</li>
<li>Support for Japanese manga (Kindle) and Korean webtoons (Naver/Webtoons)</li>
</ul>
<p>The pipeline today works like this:</p>
<ol>
<li>The Flutter client (Android or Linux desktop) opens the Kindle or Webtoons site in a WebView</li>
<li>Captures the page image (capturing the image blob on the Kindle page, fetching the <code>&lt;img&gt;</code> for webtoons)</li>
<li>Sends it to the Go API which queues it on Redis Streams with SHA256-based dedup</li>
<li>Python worker processes: RT-DETR-v2 detects bubbles → manga-ocr or EasyOCR extracts text → Ollama qwen3:14b translates → text_renderer renders back into the image</li>
<li>Result comes back over WebSocket, the overlay replaces the original image</li>
</ol>
<p>For furigana: fugashi (a MeCab wrapper) does morphological analysis and generates the hiragana reading for each kanji. I switched from pykakasi to fugashi because pykakasi doesn&rsquo;t consider sentence context (「人」 became にん instead of ひと).</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/03/adult4-furigana.jpg" alt="adult4 furigana"  loading="lazy" /></p>
<p>If I knew then what I know now — that the RT-DETR-v2 model already existed and solved the detection problem with a confidence threshold — I would have eliminated the entire OpenCV phase. The OCR, translation, rendering and Flutter parts were already reasonably stable. Detection was the bottleneck, and it was exactly the part I could have saved myself, if I&rsquo;d given up sooner.</p>
<h2>What I learned by failing<span class="hx:absolute hx:-mt-20" id="what-i-learned-by-failing"></span>
    <a href="#what-i-learned-by-failing" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>I spent 5 days polishing an OpenCV bubble detector that had 20+ tuning commits, 7 filter layers, 3 detection passes, and that in the end was replaced by a 50-line wrapper around a pretrained model.</p>
<p>It was 1,112 lines deleted in a single commit. But it wasn&rsquo;t wasted time. Those 5 days taught me exactly why heuristics fail in computer vision. I understood the problem, the cascade where touching one threshold breaks another, and it was that understanding that made me recognize when to stop and research alternatives.</p>
<p>And here&rsquo;s where the real role of vibe coding in this story comes in.</p>
<blockquote>
  <p><strong>Prompting doesn&rsquo;t replace thinking.</strong></p>

</blockquote>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/03/shounen8-en.jpg" alt="shounen8 en"  loading="lazy" /></p>
<p>I generated code very fast with Claude, but the problem wasn&rsquo;t writing speed, it was the approach. No prompt in the world turns 7 layers of heuristic filters into a robust solution. The right solution was to change the approach completely.</p>
<p>But vibe coding made the failure cheap. In the pre-AI days, those 5 days of OpenCV would have been maybe 2-3 weeks. The cost of being wrong would have been high enough to make the decision to throw it out really hard. With vibe coding, 5 days were discarded without pain because I knew I could rebuild fast. And in fact, the RT-DETR-v2 integration and the restructuring of the entire project were done in <strong>a single day</strong> (March 4, 15 commits).</p>
<p>The question that remains: if I had done the <code>yolo_bubble_detection_plan.md</code> research on day 1 instead of day 5, I&rsquo;d probably have reached the current state in 2 days. The difference between weeks of work and a weekend was a HuggingFace search. Researching before implementing seems obvious in hindsight, but in the heat of the moment the temptation to solve it by hand is strong.</p>
<p>The project is now open source. I initially didn&rsquo;t know if I wanted to open the code, there was a lot of kludge and that 551-line detector that I was embarrassed about. But after the refactoring, the code became clean enough to share. It&rsquo;s the version I would have liked to have built from the start, but that I could only build because I screwed up first.</p>
<p>The bigger headache was the bubble detection and replacement model, but there are several other points I didn&rsquo;t detail: did you notice that webtoons are colored and the bubbles themselves have art? I had to use an image model to do <strong>in-painting</strong> and have the AI redraw the bubble before placing the text on top.</p>
<p>Another headache I don&rsquo;t think I&rsquo;ll solve: <strong>translation coherence</strong>. Today it translates each bubble in isolation, with no context of the story before or after. In Japanese, there&rsquo;s no gender distinction in words. So the captain was talking about Nami, but the bubble says &ldquo;He&rdquo; instead of &ldquo;She.&rdquo; There&rsquo;s no way to know without reading the previous text. For this to work better, like in a GPT chat, you have to add part of the previous text to know when to use the correct gender or, worse, when there are puns that appeared volumes ago and are referenced in the future (something an Oda loves to do). All those subtleties get lost if you only translate one bubble at a time.</p>
<p>I imagine that&rsquo;s why nobody has done something like this yet, translating in near real time, because for it to really be good, the translation work would be exponential for each chapter further along in the story.</p>
<p>Anyway, the name <strong>Frank Yomik</strong> comes from &ldquo;yomi&rdquo; (読み, reading in Japanese) and &ldquo;ik-da&rdquo; (읽다, to read in Korean). Frank is a nod to a frank, direct translation. The app reads in both languages.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/03/shounen9-en.jpg" alt="shounen9 en"  loading="lazy" /></p>
<p>For anyone who wants to try it: the <a href="https://github.com/akitaonrails/FrankYomik"target="_blank" rel="noopener">repository is on GitHub</a>. The server needs a GPU with at least 8GB of VRAM for the detection model + OCR + translation. The Flutter client runs on Android and Linux desktop. And if you, like me, have a stack of Japanese manga you&rsquo;d like to read more fluently — now you can.</p>
]]></content:encoded><category>ai</category><category>vibe-coding</category><category>manga</category><category>flutter</category><category>python</category><category>opencv</category><category>machine-learning</category><category>FrankYomik</category></item><item><title>I Built a Data Mining System for My Influencer Girlfriend — Tips and Tricks</title><link>https://www.akitaonrails.com/en/2026/03/04/data-mining-system-for-my-influencer-girlfriend/</link><guid isPermaLink="true">https://www.akitaonrails.com/en/2026/03/04/data-mining-system-for-my-influencer-girlfriend/</guid><pubDate>Wed, 04 Mar 2026 10:00:00 GMT</pubDate><description>&lt;p&gt;My girlfriend is an influencer in games, anime, cosplay and pop culture. She does interviews, covers conventions, produces content for several platforms, negotiates with sponsors. It&amp;rsquo;s a one-person professional operation with a lean team. And like every professional operation, she needs data.&lt;/p&gt;
&lt;p&gt;The problem is that gathering data from social networks is grunt work. Open Instagram, YouTube, X, look at numbers, compare to competitors, check the events calendar, monitor sponsors, read comments, calculate engagement, decide how much to charge for a sponsored post. All manual, all repetitive, all eating time that should go to creating content.&lt;/p&gt;</description><content:encoded><![CDATA[<p>My girlfriend is an influencer in games, anime, cosplay and pop culture. She does interviews, covers conventions, produces content for several platforms, negotiates with sponsors. It&rsquo;s a one-person professional operation with a lean team. And like every professional operation, she needs data.</p>
<p>The problem is that gathering data from social networks is grunt work. Open Instagram, YouTube, X, look at numbers, compare to competitors, check the events calendar, monitor sponsors, read comments, calculate engagement, decide how much to charge for a sponsored post. All manual, all repetitive, all eating time that should go to creating content.</p>
<p>I did what any programmer boyfriend would do: I built a full data mining system, with automated collection, LLM-driven analysis, and a Discord chatbot where she talks to the data in plain language.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/03/screenshot-2026-03-04_13-15-10.jpg" alt="mila-bot"  loading="lazy" /></p>
<p>This article isn&rsquo;t about the code itself (the project won&rsquo;t be open source). It&rsquo;s about the build process, the decisions that only show up after you start, the technical tricks that saved the project, and why a wishlist document from your user is worth more than any functional spec.</p>
<h2>Start with the Wish, Not the Architecture<span class="hx:absolute hx:-mt-20" id="start-with-the-wish-not-the-architecture"></span>
    <a href="#start-with-the-wish-not-the-architecture" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The first thing I did was sit down with her and ask her to talk freely about what she wanted. No technical jargon, no form, no user stories. Her own words. I recorded it in an IDEA.md file that became the north star for the whole project.</p>
<p>What came out of it:</p>
<blockquote>
  <p>&ldquo;My biggest difficulty today is figuring out what kind of content to make that can really take off and why the ones that worked actually worked, what made them get the result they got. I have to read the comments on the videos to get a general sense of why.&rdquo;</p>

</blockquote>
<blockquote>
  <p>&ldquo;How much I should/could charge for a sponsored post, based on engagement research, competition, sponsors, events.&rdquo;</p>

</blockquote>
<blockquote>
  <p>&ldquo;How to make a sponsored post that doesn&rsquo;t look like a sponsored post — paid content that brings engagement without looking like just a sales pitch.&rdquo;</p>

</blockquote>
<p>She didn&rsquo;t ask for a dashboard. She didn&rsquo;t ask for charts. She didn&rsquo;t ask for PDF reports. She asked for answers to concrete day-to-day problems. That completely changed how I approached the architecture.</p>
<p>If I had started from a technical spec, I&rsquo;d have built an analytics platform with pretty charts and CSV exports. The system she actually needed was something more like an assistant that knows the data and answers questions in Portuguese.</p>
<p>The IDEA.md also listed initial competitors and sponsors. Brands she could work with (themed restaurants, figure shops, Crunchyroll, Piticas, Fanlab). Reference profiles on Instagram. All of it became seed data. The document wasn&rsquo;t a spec — it was a conversation that turned into organic requirements.</p>
<h2>58 Commits in 3 Days<span class="hx:absolute hx:-mt-20" id="58-commits-in-3-days"></span>
    <a href="#58-commits-in-3-days" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The project was built in 3 days. Not 3 weeks, not 3 months. 58 commits, 3 distinct phases:</p>
<p><strong>Day 1 — The Data Engine.</strong> 12 commits. Rails 8 scaffold, models, collectors for YouTube/Instagram/X, LLM integration. Discovery pipeline to find new profiles automatically. Per-post performance scoring. Sentiment analysis of comments via Claude. By the end of the day, the system was collecting data from all 3 platforms and classifying every post as viral, above average, average, below or flop.</p>
<p><strong>Day 2 — The Brain and the Voice.</strong> 35 commits. The most intense day. Two big subsystems showed up: the &ldquo;Oracle&rdquo; (tracking events, conventions, game/movie/anime releases, news) and the Discord chatbot with tool calling via RubyLLM. Also production hardening, Docker, deploy guide. And a big pivot: I swapped the weekly email report for 5 daily Discord digests.</p>
<p><strong>Day 3 — Entertainment and Resilience.</strong> 11 commits. Steam games (Store API + SteamSpy), AniList for tracking seasonal anime, growth analytics, image generation via Gemini, an auto-healing system for broken URLs.</p>
<p>The final numbers:</p>
<table>
  <thead>
      <tr>
          <th>Metric</th>
          <th>Value</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Files</td>
          <td>430</td>
      </tr>
      <tr>
          <td>Total lines</td>
          <td>37,088</td>
      </tr>
      <tr>
          <td>Lines of Ruby</td>
          <td>25,562</td>
      </tr>
      <tr>
          <td>Tests</td>
          <td>916 (0 failures)</td>
      </tr>
      <tr>
          <td>Models</td>
          <td>17</td>
      </tr>
      <tr>
          <td>Scheduled jobs</td>
          <td>25+</td>
      </tr>
      <tr>
          <td>Bot tools</td>
          <td>40+</td>
      </tr>
      <tr>
          <td>YAML prompts</td>
          <td>23</td>
      </tr>
      <tr>
          <td>External integrations</td>
          <td>12+ APIs</td>
      </tr>
  </tbody>
</table>
<p>These numbers aren&rsquo;t to my credit. Claude wrote a good chunk of the code. But the direction, the architectural decisions, and especially the validation of every step were human. Claude doesn&rsquo;t know what a Brazilian cosplay influencer needs. Neither did I — but she told me.</p>
<h2>The Architecture That Emerged<span class="hx:absolute hx:-mt-20" id="the-architecture-that-emerged"></span>
    <a href="#the-architecture-that-emerged" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The final system is a headless Rails 8. Zero web interface. No views, no real controllers (just <code>/up</code> for the health check). All functionality is delivered via background jobs, rake tasks, and the Discord chatbot.</p>
<p>The stack:</p>
<ul>
<li><strong>Rails 8.1</strong> with Solid Queue (jobs) and Solid Cache</li>
<li><strong>SQLite3</strong> in production (WAL mode, bind mount between containers)</li>
<li><strong>RubyLLM</strong> for integration with Claude Sonnet via OpenRouter and Grok</li>
<li><strong>Ferrum</strong> for scraping with headless Chrome</li>
<li><strong>Discordrb</strong> for the chatbot</li>
<li><strong>Docker Compose</strong> with 4 services</li>
</ul>
<p>The <code>docker-compose.yml</code> ended up lean:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">x-app</span><span class="p">:</span><span class="w"> </span><span class="cp">&amp;app</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">build</span><span class="p">:</span><span class="w"> </span><span class="l">.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">env_file</span><span class="p">:</span><span class="w"> </span><span class="l">.env</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">unless-stopped</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="l">./data/storage:/rails/storage</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">services</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">app</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">&lt;&lt;</span><span class="p">:</span><span class="w"> </span><span class="cp">*app</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">ports</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;127.0.0.1:3000:80&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">healthcheck</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">test</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&#34;CMD&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;curl&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;-f&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;http://localhost:80/up&#34;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">deploy</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">resources</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">limits</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">memory</span><span class="p">:</span><span class="w"> </span><span class="l">512M</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">jobs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">&lt;&lt;</span><span class="p">:</span><span class="w"> </span><span class="cp">*app</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">command</span><span class="p">:</span><span class="w"> </span><span class="l">bundle exec rake solid_queue:start</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">depends_on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">app</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">condition</span><span class="p">:</span><span class="w"> </span><span class="l">service_healthy</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">deploy</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">resources</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">limits</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">memory</span><span class="p">:</span><span class="w"> </span><span class="l">1G</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">discord</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">&lt;&lt;</span><span class="p">:</span><span class="w"> </span><span class="cp">*app</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">command</span><span class="p">:</span><span class="w"> </span><span class="l">bundle exec rake discord:start</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">depends_on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">app</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">condition</span><span class="p">:</span><span class="w"> </span><span class="l">service_healthy</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">chrome</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">chromedp/headless-shell:stable</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">deploy</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">resources</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">limits</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">memory</span><span class="p">:</span><span class="w"> </span><span class="l">1G</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>4 containers: app (Puma, basically just the health check), jobs (Solid Queue running 25+ scheduled jobs), discord (the bot), and chrome (headless browser for scraping). All state lives in SQLite via a bind mount on the host. No Redis, no Postgres, no extra infrastructure.</p>
<h2>Idempotency Above All<span class="hx:absolute hx:-mt-20" id="idempotency-above-all"></span>
    <a href="#idempotency-above-all" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>If I had to pick one concept to summarize this project, it would be <strong>idempotency</strong>. Every job can run twice in a row without creating duplicates, without corrupting data, without side effects.</p>
<p>The base pattern:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">module</span> <span class="nn">Collection</span>
</span></span><span class="line"><span class="cl">  <span class="k">class</span> <span class="nc">BaseCollectorJob</span> <span class="o">&lt;</span> <span class="no">ApplicationJob</span>
</span></span><span class="line"><span class="cl">    <span class="no">SNAPSHOT_DEDUP_WINDOW</span> <span class="o">=</span> <span class="mi">1</span><span class="o">.</span><span class="n">hour</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">retry_on</span> <span class="no">StandardError</span><span class="p">,</span> <span class="ss">wait</span><span class="p">:</span> <span class="ss">:polynomially_longer</span><span class="p">,</span> <span class="ss">attempts</span><span class="p">:</span> <span class="mi">3</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">def</span> <span class="nf">perform</span><span class="p">(</span><span class="ss">profile_id</span><span class="p">:</span> <span class="kp">nil</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="n">scope</span> <span class="o">=</span> <span class="n">profile_id</span> <span class="p">?</span> <span class="no">SocialProfile</span><span class="o">.</span><span class="n">where</span><span class="p">(</span><span class="nb">id</span><span class="p">:</span> <span class="n">profile_id</span><span class="p">)</span> <span class="p">:</span> <span class="n">profiles_to_collect</span>
</span></span><span class="line"><span class="cl">      <span class="n">scope</span><span class="o">.</span><span class="n">find_each</span> <span class="k">do</span> <span class="o">|</span><span class="n">profile</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">        <span class="n">log</span> <span class="o">=</span> <span class="n">find_or_create_log</span><span class="p">(</span><span class="n">profile</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="k">begin</span>
</span></span><span class="line"><span class="cl">          <span class="n">items</span> <span class="o">=</span> <span class="n">collect_for_profile</span><span class="p">(</span><span class="n">profile</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">          <span class="n">profile</span><span class="o">.</span><span class="n">touch</span><span class="p">(</span><span class="ss">:last_collected_at</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">          <span class="n">log</span><span class="o">.</span><span class="n">update!</span><span class="p">(</span><span class="ss">status</span><span class="p">:</span> <span class="ss">:completed</span><span class="p">,</span> <span class="ss">items_collected</span><span class="p">:</span> <span class="n">items</span> <span class="o">||</span> <span class="mi">0</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="k">rescue</span> <span class="o">=&gt;</span> <span class="n">e</span>
</span></span><span class="line"><span class="cl">          <span class="n">log</span><span class="o">.</span><span class="n">update!</span><span class="p">(</span><span class="ss">status</span><span class="p">:</span> <span class="ss">:failed</span><span class="p">,</span> <span class="ss">error_message</span><span class="p">:</span> <span class="s2">&#34;</span><span class="si">#{</span><span class="n">e</span><span class="o">.</span><span class="n">class</span><span class="si">}</span><span class="s2">: </span><span class="si">#{</span><span class="n">e</span><span class="o">.</span><span class="n">message</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">          <span class="k">raise</span> <span class="k">if</span> <span class="n">should_raise?</span><span class="p">(</span><span class="n">e</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="k">end</span>
</span></span><span class="line"><span class="cl">      <span class="k">end</span>
</span></span><span class="line"><span class="cl">    <span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">def</span> <span class="nf">upsert_post</span><span class="p">(</span><span class="n">profile</span><span class="p">,</span> <span class="n">attrs</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="n">post</span> <span class="o">=</span> <span class="n">profile</span><span class="o">.</span><span class="n">social_posts</span><span class="o">.</span><span class="n">find_or_initialize_by</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="ss">platform_post_id</span><span class="p">:</span> <span class="n">attrs</span><span class="o">[</span><span class="ss">:platform_post_id</span><span class="o">]</span>
</span></span><span class="line"><span class="cl">      <span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="n">post</span><span class="o">.</span><span class="n">assign_attributes</span><span class="p">(</span><span class="n">attrs</span><span class="o">.</span><span class="n">except</span><span class="p">(</span><span class="ss">:platform_post_id</span><span class="p">))</span>
</span></span><span class="line"><span class="cl">      <span class="n">post</span><span class="o">.</span><span class="n">save!</span>
</span></span><span class="line"><span class="cl">      <span class="n">post</span>
</span></span><span class="line"><span class="cl">    <span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">def</span> <span class="nf">record_snapshot</span><span class="p">(</span><span class="n">profile</span><span class="p">,</span> <span class="n">metrics</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="k">return</span> <span class="k">if</span> <span class="n">profile</span><span class="o">.</span><span class="n">profile_snapshots</span>
</span></span><span class="line"><span class="cl">        <span class="o">.</span><span class="n">where</span><span class="p">(</span><span class="s2">&#34;captured_at &gt; ?&#34;</span><span class="p">,</span> <span class="no">SNAPSHOT_DEDUP_WINDOW</span><span class="o">.</span><span class="n">ago</span><span class="p">)</span><span class="o">.</span><span class="n">exists?</span>
</span></span><span class="line"><span class="cl">      <span class="n">profile</span><span class="o">.</span><span class="n">profile_snapshots</span><span class="o">.</span><span class="n">create!</span><span class="p">(</span><span class="ss">captured_at</span><span class="p">:</span> <span class="no">Time</span><span class="o">.</span><span class="n">current</span><span class="p">,</span> <span class="o">**</span><span class="n">metrics</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">end</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Two protection mechanisms: <code>find_or_initialize_by(platform_post_id)</code> for posts (upsert, not insert), and <code>SNAPSHOT_DEDUP_WINDOW</code> for snapshots (skip if we already collected one in the last hour). If the job crashes mid-run and Solid Queue requeues it, nothing duplicates.</p>
<p>Scraping errors (<code>BlockedError</code>, <code>RateLimitError</code>) get swallowed silently — retrying doesn&rsquo;t help, the rate limit needs to cool off, and getting blocked is expected. Real errors (database, network, bugs) bubble up to the retry with polynomial backoff.</p>
<p>This decision to &ldquo;swallow certain errors&rdquo; feels wrong until you run the system for a week and see that Instagram blocks one in every 5 collection runs. If every block triggered 3 retries with exponential backoff, the system would be perpetually behind.</p>
<h2>nil vs Zero<span class="hx:absolute hx:-mt-20" id="nil-vs-zero"></span>
    <a href="#nil-vs-zero" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>This is one of those conceptual bugs you only discover with real data. When Instagram doesn&rsquo;t return the share count for a post (because the API simply doesn&rsquo;t expose that field), should the value be <code>nil</code> or <code>0</code>?</p>
<p>The difference matters. <code>nil</code> means <em>&ldquo;we don&rsquo;t have this data&rdquo;</em>. Zero means <em>&ldquo;we have the data and it&rsquo;s zero&rdquo;</em>. If you treat <code>nil</code> as zero, the LLM analysis concludes that nobody shares posts on Instagram — which is false. The API just doesn&rsquo;t expose that metric.</p>
<p>I built a reusable prompt snippet just for this:</p>
<blockquote>
  <p>When a field is null: don&rsquo;t say &ldquo;0 likes&rdquo; — say &ldquo;data not available for this platform&rdquo;. When comparing platforms, warn that certain metrics aren&rsquo;t comparable. When it&rsquo;s zero: report it normally — the data is real and confirmed by the API.</p>

</blockquote>
<p>That snippet gets included in every prompt that handles numeric data. Without it, the LLM happily mixes &ldquo;not collected&rdquo; with &ldquo;actually zero&rdquo; and draws wrong conclusions.</p>
<h2>The Headless Chrome Trick: Host Header Bypass<span class="hx:absolute hx:-mt-20" id="the-headless-chrome-trick-host-header-bypass"></span>
    <a href="#the-headless-chrome-trick-host-header-bypass" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The project uses <code>chromedp/headless-shell</code> in a separate container for scraping. Works perfectly. Until you try to connect Ferrum (the Ruby Chrome automation gem) to it over Docker networking.</p>
<p>The problem: <code>chromedp/headless-shell</code> uses a socat proxy on port 9222 that rejects any request whose <code>Host</code> header isn&rsquo;t <code>localhost</code> or an IP. When Ferrum tries to connect to <code>http://chrome:9222</code>, the Host header goes out as <code>chrome:9222</code>, and socat refuses.</p>
<p>The fix was to discover the WebSocket URL manually, forging the header:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">discover_ws_url</span><span class="p">(</span><span class="n">chrome_url</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">uri</span> <span class="o">=</span> <span class="no">URI</span><span class="o">.</span><span class="n">parse</span><span class="p">(</span><span class="s2">&#34;</span><span class="si">#{</span><span class="n">chrome_url</span><span class="si">}</span><span class="s2">/json/version&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">req</span> <span class="o">=</span> <span class="no">Net</span><span class="o">::</span><span class="no">HTTP</span><span class="o">::</span><span class="no">Get</span><span class="o">.</span><span class="n">new</span><span class="p">(</span><span class="n">uri</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">req</span><span class="o">[</span><span class="s2">&#34;Host&#34;</span><span class="o">]</span> <span class="o">=</span> <span class="s2">&#34;localhost&#34;</span>  <span class="c1"># bypass</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="n">response</span> <span class="o">=</span> <span class="no">Net</span><span class="o">::</span><span class="no">HTTP</span><span class="o">.</span><span class="n">start</span><span class="p">(</span><span class="n">uri</span><span class="o">.</span><span class="n">hostname</span><span class="p">,</span> <span class="n">uri</span><span class="o">.</span><span class="n">port</span><span class="p">)</span> <span class="p">{</span> <span class="o">|</span><span class="n">http</span><span class="o">|</span> <span class="n">http</span><span class="o">.</span><span class="n">request</span><span class="p">(</span><span class="n">req</span><span class="p">)</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="n">data</span> <span class="o">=</span> <span class="no">JSON</span><span class="o">.</span><span class="n">parse</span><span class="p">(</span><span class="n">response</span><span class="o">.</span><span class="n">body</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="n">ws_url</span> <span class="o">=</span> <span class="n">data</span><span class="o">[</span><span class="s2">&#34;webSocketDebuggerUrl&#34;</span><span class="o">]</span>
</span></span><span class="line"><span class="cl">  <span class="k">return</span> <span class="kp">nil</span> <span class="k">unless</span> <span class="n">ws_url</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="c1"># WS URL points to 127.0.0.1 inside the container — swap it for the reachable hostname</span>
</span></span><span class="line"><span class="cl">  <span class="n">remote_uri</span> <span class="o">=</span> <span class="no">URI</span><span class="o">.</span><span class="n">parse</span><span class="p">(</span><span class="n">chrome_url</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">ws_url</span><span class="o">.</span><span class="n">sub</span><span class="p">(</span><span class="sr">%r{://[^/]+}</span><span class="p">,</span> <span class="s2">&#34;://</span><span class="si">#{</span><span class="n">remote_uri</span><span class="o">.</span><span class="n">host</span><span class="si">}</span><span class="s2">:</span><span class="si">#{</span><span class="n">remote_uri</span><span class="o">.</span><span class="n">port</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>I do the GET to <code>/json/version</code> with <code>Host: localhost</code> to slip past socat. I get the WebSocket URL back (which points at <code>127.0.0.1</code> — useless from outside the container). I rewrite the hostname to <code>chrome:9222</code> (the service name in Docker Compose). I hand the <code>ws_url</code> straight to Ferrum, which opens the WebSocket without going through socat&rsquo;s HTTP layer.</p>
<p>This kind of thing doesn&rsquo;t come to mind upfront. You discover it after 1 hour of <code>connection refused</code> and reading the source code of <code>chromedp/headless-shell</code>.</p>
<h2>Tool Calling: The Bot That Queries the Database on Its Own<span class="hx:absolute hx:-mt-20" id="tool-calling-the-bot-that-queries-the-database-on-its-own"></span>
    <a href="#tool-calling-the-bot-that-queries-the-database-on-its-own" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The coolest part of the project is the Discord chatbot. She types a question in Portuguese (&ldquo;how were my posts this week?&rdquo;) and the bot calls the right tools, queries the database, and answers with real data.</p>
<p>The secret is RubyLLM&rsquo;s tool calling. Every tool is a Ruby class with <code>description</code> and <code>params</code>:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">PostPerformanceTool</span> <span class="o">&lt;</span> <span class="no">RubyLLM</span><span class="o">::</span><span class="no">Tool</span>
</span></span><span class="line"><span class="cl">  <span class="n">description</span> <span class="s2">&#34;Returns post performance stats: baseline, &#34;</span> <span class="p">\</span>
</span></span><span class="line"><span class="cl">              <span class="s2">&#34;breakdown by content type, best times, best hashtags.&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="n">param</span> <span class="ss">:username</span><span class="p">,</span> <span class="ss">type</span><span class="p">:</span> <span class="ss">:string</span><span class="p">,</span> <span class="ss">desc</span><span class="p">:</span> <span class="s2">&#34;Profile username (e.g. milaoliveira.png)&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="n">param</span> <span class="ss">:analysis</span><span class="p">,</span> <span class="ss">type</span><span class="p">:</span> <span class="ss">:string</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="ss">desc</span><span class="p">:</span> <span class="s2">&#34;Type: baseline, content_types, timing, hashtags&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="ss">required</span><span class="p">:</span> <span class="kp">false</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">execute</span><span class="p">(</span><span class="ss">username</span><span class="p">:,</span> <span class="ss">analysis</span><span class="p">:</span> <span class="s2">&#34;baseline&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">profile</span> <span class="o">=</span> <span class="no">SocialProfile</span><span class="o">.</span><span class="n">find_by</span><span class="p">(</span><span class="ss">username</span><span class="p">:</span> <span class="n">username</span><span class="o">.</span><span class="n">to_s</span><span class="o">.</span><span class="n">strip</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="s2">&#34;Profile &#39;</span><span class="si">#{</span><span class="n">username</span><span class="si">}</span><span class="s2">&#39; not found.&#34;</span> <span class="k">unless</span> <span class="n">profile</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">case</span> <span class="n">analysis</span><span class="o">.</span><span class="n">to_s</span><span class="o">.</span><span class="n">strip</span><span class="o">.</span><span class="n">downcase</span>
</span></span><span class="line"><span class="cl">    <span class="k">when</span> <span class="s2">&#34;baseline&#34;</span>
</span></span><span class="line"><span class="cl">      <span class="n">baseline</span> <span class="o">=</span> <span class="no">PostPerformance</span><span class="o">.</span><span class="n">compute_baseline</span><span class="p">(</span><span class="n">profile</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="ss">username</span><span class="p">:</span> <span class="n">profile</span><span class="o">.</span><span class="n">username</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="ss">post_count</span><span class="p">:</span> <span class="n">baseline</span><span class="o">[</span><span class="ss">:post_count</span><span class="o">]</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="ss">mean_views</span><span class="p">:</span> <span class="n">baseline</span><span class="o">[</span><span class="ss">:mean_views</span><span class="o">].</span><span class="n">round</span><span class="p">(</span><span class="mi">0</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">        <span class="ss">stddev_views</span><span class="p">:</span> <span class="n">baseline</span><span class="o">[</span><span class="ss">:stddev_views</span><span class="o">].</span><span class="n">round</span><span class="p">(</span><span class="mi">0</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">        <span class="ss">mean_engagement</span><span class="p">:</span> <span class="n">baseline</span><span class="o">[</span><span class="ss">:mean_engagement</span><span class="o">].</span><span class="n">round</span><span class="p">(</span><span class="mi">2</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="k">when</span> <span class="s2">&#34;timing&#34;</span>
</span></span><span class="line"><span class="cl">      <span class="no">PostPerformance</span><span class="o">.</span><span class="n">timing_breakdown</span><span class="p">(</span><span class="n">profile</span><span class="p">)</span><span class="o">.</span><span class="n">first</span><span class="p">(</span><span class="mi">10</span><span class="p">)</span><span class="o">.</span><span class="n">map</span> <span class="k">do</span> <span class="o">|</span><span class="n">entry</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">        <span class="p">{</span> <span class="ss">day</span><span class="p">:</span> <span class="n">days</span><span class="o">[</span><span class="n">entry</span><span class="o">[</span><span class="ss">:day</span><span class="o">]]</span><span class="p">,</span> <span class="ss">hour</span><span class="p">:</span> <span class="s2">&#34;%02d:00&#34;</span> <span class="o">%</span> <span class="n">entry</span><span class="o">[</span><span class="ss">:hour</span><span class="o">]</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">          <span class="ss">avg_percentile</span><span class="p">:</span> <span class="n">entry</span><span class="o">[</span><span class="ss">:avg_percentile</span><span class="o">].</span><span class="n">round</span><span class="p">(</span><span class="mi">1</span><span class="p">)</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">      <span class="k">end</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># ...</span>
</span></span><span class="line"><span class="cl">    <span class="k">end</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The LLM gets the list of 40+ tools with their descriptions, decides which ones to call with which parameters, gets the results back, and formulates the answer. The Discord bot shows real-time status while it works: <em>&ldquo;Querying profile metrics&hellip; Checking growth&hellip; Analyzing post performance&hellip; (3 queries)&rdquo;</em>.</p>
<p>The trick was making each tool return structured data (Hashes and Arrays), not formatted text. The LLM is much better at formatting raw data into a conversational reply than at parsing semi-structured text.</p>
<p>Another detail that only showed up in real use: clamping parameters. When the LLM asks for <code>limit: 999</code> on a parameter that maxes out at 50, instead of returning an error, I do <code>[[val.to_i, 1].max, 50].min</code>. The LLM gets parameters wrong more than you&rsquo;d think. Every error generates a correction round-trip that costs tokens and time.</p>
<h2>Composable Prompts: YAML &gt; Hardcoded Strings<span class="hx:absolute hx:-mt-20" id="composable-prompts-yaml--hardcoded-strings"></span>
    <a href="#composable-prompts-yaml--hardcoded-strings" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Every prompt in the system lives in YAML under <code>config/prompts/</code>. Each one has a <code>system</code> (fixed instructions), a <code>template</code> (with data interpolation), and optionally <code>includes</code> (reusable snippets):</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">module</span> <span class="nn">Llm</span>
</span></span><span class="line"><span class="cl">  <span class="k">class</span> <span class="nc">PromptBuilder</span>
</span></span><span class="line"><span class="cl">    <span class="no">PROMPTS_DIR</span> <span class="o">=</span> <span class="no">Rails</span><span class="o">.</span><span class="n">root</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="s2">&#34;config&#34;</span><span class="p">,</span> <span class="s2">&#34;prompts&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">class</span> <span class="o">&lt;&lt;</span> <span class="nb">self</span>
</span></span><span class="line"><span class="cl">      <span class="k">def</span> <span class="nf">build</span><span class="p">(</span><span class="nb">name</span><span class="p">,</span> <span class="n">data</span> <span class="o">=</span> <span class="p">{})</span>
</span></span><span class="line"><span class="cl">        <span class="n">prompt</span> <span class="o">=</span> <span class="n">load_prompt</span><span class="p">(</span><span class="nb">name</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="n">base</span> <span class="o">=</span> <span class="n">load_prompt</span><span class="p">(</span><span class="ss">:_base_context</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">        <span class="n">includes</span> <span class="o">=</span> <span class="n">resolve_includes</span><span class="p">(</span><span class="n">prompt</span><span class="o">[</span><span class="s2">&#34;includes&#34;</span><span class="o">]</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="n">system_parts</span> <span class="o">=</span> <span class="o">[</span><span class="n">base</span><span class="o">[</span><span class="s2">&#34;system&#34;</span><span class="o">]</span><span class="p">,</span> <span class="n">includes</span><span class="p">,</span> <span class="n">prompt</span><span class="o">[</span><span class="s2">&#34;system&#34;</span><span class="o">]]</span>
</span></span><span class="line"><span class="cl">          <span class="o">.</span><span class="n">compact</span><span class="o">.</span><span class="n">reject</span><span class="p">(</span><span class="o">&amp;</span><span class="ss">:blank?</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="n">system_message</span> <span class="o">=</span> <span class="n">system_parts</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="s2">&#34;</span><span class="se">\n\n</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="n">user_message</span> <span class="o">=</span> <span class="n">interpolate</span><span class="p">(</span><span class="n">prompt</span><span class="o">[</span><span class="s2">&#34;template&#34;</span><span class="o">]</span><span class="p">,</span> <span class="n">data</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">        <span class="p">{</span> <span class="nb">system</span><span class="p">:</span> <span class="n">system_message</span><span class="p">,</span> <span class="ss">user</span><span class="p">:</span> <span class="n">user_message</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">      <span class="k">end</span>
</span></span><span class="line"><span class="cl">    <span class="k">end</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The <code>_base_context.yml</code> carries info every prompt needs (who the influencer is, niches, audience, general guidelines). The snippets in <code>config/prompts/snippets/</code> solve recurring problems:</p>
<ul>
<li><code>null_vs_zero.yml</code> — the nil/zero distinction explained above</li>
<li><code>never_invent.yml</code> — &ldquo;NEVER invent data, only analyze what&rsquo;s actually present&rdquo;</li>
<li><code>json_only.yml</code> — &ldquo;respond ONLY in valid JSON&rdquo;</li>
</ul>
<p>When a prompt needs any combination of these, you just list them in <code>includes</code>. No duplication, no risk of conflicting instructions across prompts.</p>
<p>This decision came out of a real bug: two different prompts gave contradictory instructions on how to handle null values. One said &ldquo;use 0&rdquo;, the other said &ldquo;use null&rdquo;. The LLM obeyed one or the other depending on the day. Centralizing in snippets killed off an entire class of inconsistency.</p>
<h2>Discovery Pipeline: Autonomous Profile Mining<span class="hx:absolute hx:-mt-20" id="discovery-pipeline-autonomous-profile-mining"></span>
    <a href="#discovery-pipeline-autonomous-profile-mining" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The initial system started from a manual list of competitors and sponsors. But the influencer lives in a dynamic ecosystem — new creators show up every week, brands appear and disappear. The discovery pipeline automates that.</p>
<p>It runs every Friday at 2 a.m. It starts by mining data we already have in the database: who&rsquo;s being @mentioned in posts, who comments often and with high engagement, which profiles show up in bio links, who uses brand hashtags (#publi, #parceria), which Linktree links point to other creators. All pure SQL, no API calls, no rate-limit risk.</p>
<p>The candidates that come out of that get validated against the platforms&rsquo; APIs (does the Instagram exist? Is the YouTube channel real?), then enriched with cross-platform connections (if we validate an Instagram, we look for the YouTube and X of the same creator).</p>
<p>A batch LLM call evaluates all the candidates, classifies each one as competitor, potential sponsor or irrelevant, and assigns relevance and niche-fit scores. The top 3 become actually tracked profiles — the system starts collecting data from them automatically.</p>
<p>After that, it groups everything into <code>Creator</code> entities. The influencer is depth 0. Direct competitors, depth 1. Mentions of competitors, depth 2. Maximum 3 degrees of separation, to avoid infinite cascade.</p>
<h2>From Weekly Report to Daily Digests: Listening to the User<span class="hx:absolute hx:-mt-20" id="from-weekly-report-to-daily-digests-listening-to-the-user"></span>
    <a href="#from-weekly-report-to-daily-digests-listening-to-the-user" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The original plan was a weekly email report with 9 sections. But midway through I decided it would be more dynamic to not even leave Discord and have that information always available.</p>
<p>I pivoted to 5 daily digests on Discord:</p>
<ul>
<li><strong>Monday</strong> — Performance Recap (followers, growth, top posts)</li>
<li><strong>Tuesday</strong> — Competitor Radar (snapshot, news, strategies)</li>
<li><strong>Wednesday</strong> — Content Playbook (schedule, trends, hashtags)</li>
<li><strong>Thursday</strong> — Opportunities and Pricing (brands, pricing, packages)</li>
<li><strong>Friday</strong> — Next Week (events, priority actions)</li>
</ul>
<p>Every digest has numbered items with feedback buttons. She marks what she found useful and what she didn&rsquo;t. That feedback feeds future analyses — the system learns which kinds of insight she values.</p>
<p>This change, which wasn&rsquo;t in any plan, is probably what improved her experience the most. Every morning at 9 a.m. she opens Discord and gets a fresh, digestible, actionable summary. The weekly email got demoted to backup, commented out in <code>recurring.yml</code>.</p>
<h2>The Oracle: Context the Algorithm Doesn&rsquo;t See<span class="hx:absolute hx:-mt-20" id="the-oracle-context-the-algorithm-doesnt-see"></span>
    <a href="#the-oracle-context-the-algorithm-doesnt-see" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/03/screenshot-2026-03-04_13-24-24.jpg" alt="playbook"  loading="lazy" /></p>
<p>Social media data without external context is kind of useless. <em>&ldquo;This post got 3x more views than average&rdquo;</em> is a fact. <em>&ldquo;This post got 3x more views because it dropped on the day the new Sonic trailer came out&rdquo;</em> is an insight.</p>
<p>The Oracle is the subsystem that provides that context.</p>
<p>The most critical part is convention tracking: CCXP, Anime Friends, BGS, and dozens of other geek calendar events. Dates, prices, venues, ticket links, the event&rsquo;s Instagram/X accounts. It monitors daily for changes (date moved, cancellation, guest announcement). The influencer plans her entire calendar around conventions. If CCXP changes its date, she needs to know that day, not the following week.</p>
<p>It also tracks movie/series releases via TMDB, games via IGDB (with Twitch OAuth), anime via AniList — all filtered for geek content, with a 90-day forward window. Franchise anniversaries (using TMDB, IGDB, AniList, Wikimedia) and Brazilian holidays with floating-date calculation. Yes, there&rsquo;s an Easter calculator in the code based on the computus algorithm. And news via RSS and X/Twitter scraping every 6 hours, classified by relevance to the niche.</p>
<p>The event tracker scrapes convention sites with headless Chrome and uses the LLM at temperature 0.1 to extract structured metadata (date, price, venue). It has a regex fallback for Brazilian-format dates (&ldquo;15 de março de 2026&rdquo;). Deduplication by fuzzy name matching + date proximity — because &ldquo;CCXP 2026&rdquo;, &ldquo;CCXP São Paulo 2026&rdquo; and &ldquo;CCXP SP&rdquo; are the same event.</p>
<h2>SQLite in Production: Yes, It Works<span class="hx:absolute hx:-mt-20" id="sqlite-in-production-yes-it-works"></span>
    <a href="#sqlite-in-production-yes-it-works" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>SQLite3 in production. With WAL mode, a single writer at a time is enough because the system has a predictable write pattern (sequential jobs, never concurrent in the same second). The data lives in a Docker bind mount (<code>./data/storage:/rails/storage</code>) and backup is <code>cp data/storage/*.db backups/</code>.</p>
<p>Rails 8 treats SQLite as a first-class citizen. Solid Queue and Solid Cache run on SQLite without issues. The overhead of a Postgres for a system that serves one person doesn&rsquo;t justify itself.</p>
<h2>25+ Scheduled Jobs<span class="hx:absolute hx:-mt-20" id="25-scheduled-jobs"></span>
    <a href="#25-scheduled-jobs" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The <code>config/recurring.yml</code> has 25+ jobs with staggered schedules so the machine doesn&rsquo;t get overloaded:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">production</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">youtube_collection</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">class</span><span class="p">:</span><span class="w"> </span><span class="l">Collection::YoutubeCollectorJob</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">schedule</span><span class="p">:</span><span class="w"> </span><span class="l">every day at 3am</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">instagram_collection</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">class</span><span class="p">:</span><span class="w"> </span><span class="l">Collection::InstagramCollectorJob</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">schedule</span><span class="p">:</span><span class="w"> </span><span class="l">every day at 4am</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">x_collection</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">class</span><span class="p">:</span><span class="w"> </span><span class="l">Collection::XCollectorJob</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">schedule</span><span class="p">:</span><span class="w"> </span><span class="l">every 2 days at 5am</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">oracle_events</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">class</span><span class="p">:</span><span class="w"> </span><span class="l">Oracle::EventTrackerJob</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">schedule</span><span class="p">:</span><span class="w"> </span><span class="l">every Monday at 2am</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">convention_monitor</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">class</span><span class="p">:</span><span class="w"> </span><span class="l">Oracle::ConventionMonitorJob</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">schedule</span><span class="p">:</span><span class="w"> </span><span class="l">every day at 5am</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">discovery_pipeline</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">class</span><span class="p">:</span><span class="w"> </span><span class="l">Discovery::OrchestratorJob</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">schedule</span><span class="p">:</span><span class="w"> </span><span class="l">every Friday at 2am</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">comment_sentiment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">class</span><span class="p">:</span><span class="w"> </span><span class="l">Analysis::CommentSentimentJob</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">schedule</span><span class="p">:</span><span class="w"> </span><span class="l">every Sunday at 5am</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">weekly_analysis</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">class</span><span class="p">:</span><span class="w"> </span><span class="l">Analysis::WeeklyAnalysisJob</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">schedule</span><span class="p">:</span><span class="w"> </span><span class="l">every Sunday at 6am</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Collection runs in the early morning (3am-5am). The Oracle intelligence kicks off Monday at 2am. Discovery on Friday at 2am. Heavy analysis (sentiment, strategy) on Sunday when there&rsquo;s no digest to deliver. Digests Monday through Friday at 9am, each day on a different theme. Maintenance (cleaning up finished jobs, URL health checks, aggregating old data) fills the gaps.</p>
<p>The pattern is deliberate: no heavy job competes with the morning digests. If the weekly analysis runs late, Monday&rsquo;s digest still goes out because it uses Saturday&rsquo;s collection data, not Sunday&rsquo;s analysis.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/03/screenshot-2026-03-04_13-29-08.jpg" alt="nano banana 2"  loading="lazy" /></p>
<h2>What You Can&rsquo;t Predict Without Running It<span class="hx:absolute hx:-mt-20" id="what-you-cant-predict-without-running-it"></span>
    <a href="#what-you-cant-predict-without-running-it" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Some things I only found out after the system was in production.</p>
<p>Instagram blocks scraping at random. It doesn&rsquo;t matter how stealth your browser is. The initial setup was scraping via Ferrum only. Right off the bat I added <strong>Apify</strong> as the primary source (paid API, more reliable) and the scraping became fallback.</p>
<p>LLMs hallucinate timestamps. I asked the bot to create a reminder for &ldquo;tomorrow at 3pm&rdquo; and it calculated a date in 2024. The fix was to inject <code>current_datetime</code> into every prompt that touches time and instruct it explicitly: <em>&ldquo;calculate based on current_datetime, convert to ISO 8601 with UTC-3&rdquo;</em>.</p>
<p>Discord has a 2000-character per-message limit, which seems obvious until your bot sends a 4000-char analysis and Discord just truncates it. I built an automatic split that breaks at the last <code>\n</code> before the limit.</p>
<p>Tool calls fail on parameters more often than I expected. The LLM asks for <code>limit: 100</code> when the max is 50, or sends the username without the @ when it should have. Silent clamping (<code>[[val.to_i, 1].max, 50].min</code>) and input normalization (<code>.strip</code>, <code>.delete(&quot;@&quot;)</code>) on every tool killed off an entire category of errors that were burning tokens in retry loops.</p>
<p>Conventions change dates more than I imagined. I added an announcement system that tracks every change (date, venue, price, cancellation) with a timestamp. The event tracker runs every Monday, but the convention monitor runs DAILY, because the last thing the influencer wants is to find out a convention moved its date after she already booked the hotel.</p>
<h2>The Bot as an Interface<span class="hx:absolute hx:-mt-20" id="the-bot-as-an-interface"></span>
    <a href="#the-bot-as-an-interface" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/03/screenshot-2026-03-04_13-28-05.jpg" alt="events"  loading="lazy" /></p>
<p>The smartest decision in the project was not building a web interface. Discord is already where the influencer spends her day. The bot lives there, available any time. She types <em>&ldquo;what should I post this week?&rdquo;</em> and gets a contextualized answer with data from her own posts, from competitors, from the events calendar, from hashtag trends, and from the season&rsquo;s anime/games.</p>
<p>The chatbot combines several tools into one answer. If she asks about content strategy, the LLM calls: content digest + events calendar + hashtag trends + seasonal anime + Steam games. Five database queries, all stitched into a conversational reply in Portuguese.</p>
<p>There&rsquo;s even a <strong>&ldquo;deep thinking&rdquo; mode</strong> that activates when it detects words like &ldquo;research&rdquo;, &ldquo;analyze&rdquo;, &ldquo;investigate&rdquo;. In that mode, the bot uses ALL relevant tools, runs multiple queries, cross-references data across sources.</p>
<h2>Conclusion<span class="hx:absolute hx:-mt-20" id="conclusion"></span>
    <a href="#conclusion" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The whole project shipped in 3 days, from zero to production, collecting real data, sending out digests every morning. No Jira, no sprint planning, no 6-month discovery phase. An IDEA.md with the user&rsquo;s wishes in her own words, iterative development commit by commit, and continuous validation with the person who&rsquo;s going to actually use it.</p>
<p>If I had started from the architecture, I&rsquo;d have spent weeks designing a dashboard she would never open. Starting from the wish, the system ended up being a Discord chatbot she uses every day. Async jobs that aren&rsquo;t idempotent are time bombs — the rate limit is going to blow, the scraping is going to fail, the container is going to restart, and if the job can&rsquo;t survive that without duplicating data, the system doesn&rsquo;t work.</p>
<p>In the end, the measure of success for the project isn&rsquo;t lines of code, isn&rsquo;t the number of passing tests, isn&rsquo;t elegant architecture. It&rsquo;s the influencer opening Discord on a Monday morning and having what she needs to plan the week.</p>
]]></content:encoded><category>Ruby on Rails</category><category>Data Mining</category><category>LLM</category><category>Discord</category><category>Docker</category><category>SQLite</category></item><item><title>ai-jail: Sandbox for AI Agents — From Shell Script to Real Tool</title><link>https://www.akitaonrails.com/en/2026/03/01/ai-jail-sandbox-for-ai-agents-from-shell-script-to-real-tool/</link><guid isPermaLink="true">https://www.akitaonrails.com/en/2026/03/01/ai-jail-sandbox-for-ai-agents-from-shell-script-to-real-tool/</guid><pubDate>Sun, 01 Mar 2026 14:00:00 GMT</pubDate><description>&lt;p&gt;This post is a direct follow-up to &lt;a href="https://www.akitaonrails.com/en/2026/01/10/ai-agents-locking-down-your-system/"&gt;AI Agents: Locking Down Your System&lt;/a&gt;, where I showed how to use bubblewrap to build a manual jail for your AI agents. If you haven&amp;rsquo;t read it, read it before continuing.&lt;/p&gt;
&lt;p&gt;&amp;ndash;&lt;/p&gt;
&lt;p&gt;In January I published a ~170-line shell script that built a sandbox with bubblewrap to run Claude Code, OpenCode, Crush and any other AI agent. It worked. It solved the problem. But it was a bash script dropped in &lt;code&gt;~/.local/bin/&lt;/code&gt; that you had to copy, paste, and pray you wouldn&amp;rsquo;t need to customize too much.&lt;/p&gt;</description><content:encoded><![CDATA[<p>This post is a direct follow-up to <a href="/en/2026/01/10/ai-agents-locking-down-your-system/">AI Agents: Locking Down Your System</a>, where I showed how to use bubblewrap to build a manual jail for your AI agents. If you haven&rsquo;t read it, read it before continuing.</p>
<p>&ndash;</p>
<p>In January I published a ~170-line shell script that built a sandbox with bubblewrap to run Claude Code, OpenCode, Crush and any other AI agent. It worked. It solved the problem. But it was a bash script dropped in <code>~/.local/bin/</code> that you had to copy, paste, and pray you wouldn&rsquo;t need to customize too much.</p>
<p>Two months of using that script every day showed me its limits. I wanted per-project configuration. I wanted macOS support for the devs on my team. I wanted to stop editing bash arrays every time I needed an extra directory. And I wanted something someone could install with <code>brew install</code> or <code>cargo install</code> in 10 seconds, without reading 170 lines of script.</p>
<p>Result: <a href="https://github.com/akitaonrails/ai-jail"target="_blank" rel="noopener">ai-jail</a>. A Rust tool, ~880KB, 4 dependencies, 124 tests, that does exactly what that script did and more. I&rsquo;ll explain what changed and why you should be using it.</p>
<h2>The Problem (again, for those who skipped the previous post)<span class="hx:absolute hx:-mt-20" id="the-problem-again-for-those-who-skipped-the-previous-post"></span>
    <a href="#the-problem-again-for-those-who-skipped-the-previous-post" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>AI coding agents need access to your filesystem. They need to run compilers, linters, grep, ls, make, cargo, npm. The minimum to be useful. The problem is that along with that access comes the ability to read <code>~/.aws/credentials</code>, exfiltrate your SSH keys, or run an <code>rm -rf</code> outside the project directory.</p>
<p>It isn&rsquo;t paranoia. Supply-chain attacks are real. Every other week some NPM, PyPI or RubyGems lib gets compromised. If the agent runs <code>npm install</code> and a malicious post-install script tries to exfiltrate your data, the only thing between the attacker and your credentials is whatever barrier you set up beforehand.</p>
<p>The answer is a sandbox. Specifically, one that lets the agent work in the project directory with the tools it needs, but makes the entire rest of the system invisible.</p>
<h2>From Script to Tool<span class="hx:absolute hx:-mt-20" id="from-script-to-tool"></span>
    <a href="#from-script-to-tool" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The shell script from the previous post already solved this with bubblewrap. ai-jail solves the same problem, but addresses the limitations that two months of daily use revealed:</p>
<table>
  <thead>
      <tr>
          <th>Shell script</th>
          <th>ai-jail</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Configuration by editing bash arrays</td>
          <td>Per-project <code>.ai-jail</code> TOML file</td>
      </tr>
      <tr>
          <td>Linux only</td>
          <td>Linux + macOS</td>
      </tr>
      <tr>
          <td>Hardcoded GPU/Docker/Display</td>
          <td>Auto-detection with flags to turn things off</td>
      </tr>
      <tr>
          <td>No dry-run</td>
          <td><code>--dry-run --verbose</code> shows everything</td>
      </tr>
      <tr>
          <td>No lockdown</td>
          <td><code>--lockdown</code> for paranoid mode</td>
      </tr>
      <tr>
          <td>Copy/paste to install</td>
          <td><code>brew install</code>, <code>cargo install</code>, <code>mise</code></td>
      </tr>
      <tr>
          <td>No bootstrap</td>
          <td><code>--bootstrap</code> generates permission configs for Claude/Codex/OpenCode</td>
      </tr>
  </tbody>
</table>
<p>The core logic is the same: bubblewrap creates isolated PID, UTS and IPC namespaces, mounts <code>$HOME</code> as an ephemeral tmpfs, and only mounts the project directory with write access. The difference is that all of that is now configurable without editing code.</p>
<h2>Installation<span class="hx:absolute hx:-mt-20" id="installation"></span>
    <a href="#installation" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Four ways:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># Homebrew (macOS and Linux)</span>
</span></span><span class="line"><span class="cl">brew tap akitaonrails/tap <span class="o">&amp;&amp;</span> brew install ai-jail
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Cargo</span>
</span></span><span class="line"><span class="cl">cargo install ai-jail
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Mise</span>
</span></span><span class="line"><span class="cl">mise use -g ubi:akitaonrails/ai-jail
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Direct binary from GitHub Releases</span>
</span></span><span class="line"><span class="cl">curl -fsSL https://github.com/akitaonrails/ai-jail/releases/latest/download/ai-jail-linux-x86_64.tar.gz <span class="p">|</span> tar xz
</span></span><span class="line"><span class="cl">sudo mv ai-jail /usr/local/bin/</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>On Linux, bubblewrap needs to be installed separately: <code>pacman -S bubblewrap</code> (Arch), <code>apt install bubblewrap</code> (Debian/Ubuntu), <code>dnf install bubblewrap</code> (Fedora). On macOS no extra dependency is needed.</p>
<h2>Basic Usage<span class="hx:absolute hx:-mt-20" id="basic-usage"></span>
    <a href="#basic-usage" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nb">cd</span> ~/Projects/my-app
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Run Claude Code inside the sandbox</span>
</span></span><span class="line"><span class="cl">ai-jail claude
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Run Codex</span>
</span></span><span class="line"><span class="cl">ai-jail codex
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Run OpenCode</span>
</span></span><span class="line"><span class="cl">ai-jail opencode
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Plain bash for debugging</span>
</span></span><span class="line"><span class="cl">ai-jail bash
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Any command</span>
</span></span><span class="line"><span class="cl">ai-jail -- python script.py</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>On the first run, it creates an <code>.ai-jail</code> file in the project directory:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-toml" data-lang="toml"><span class="line"><span class="cl"><span class="c"># ai-jail sandbox configuration</span>
</span></span><span class="line"><span class="cl"><span class="c"># Edit freely. Regenerate with: ai-jail --clean --init</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nx">command</span> <span class="p">=</span> <span class="p">[</span><span class="s2">&#34;claude&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="cl"><span class="nx">rw_maps</span> <span class="p">=</span> <span class="p">[]</span>
</span></span><span class="line"><span class="cl"><span class="nx">ro_maps</span> <span class="p">=</span> <span class="p">[]</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>This file is committable to the repo. When another dev on your team clones the project and runs <code>ai-jail</code>, the same configuration applies.</p>
<p>If you want to add extra directories, you can do it from the CLI or directly in the TOML:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># Extra directory with write access</span>
</span></span><span class="line"><span class="cl">ai-jail --rw-map ~/Projects/shared-lib claude
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Extra read-only directory</span>
</span></span><span class="line"><span class="cl">ai-jail --map /opt/datasets claude</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Want to see what the sandbox is going to do without running anything?</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">ai-jail --dry-run --verbose claude</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>It shows every mount point, every isolation flag, the full bubblewrap command. No surprises.</p>
<h2>Why Bubblewrap on Linux<span class="hx:absolute hx:-mt-20" id="why-bubblewrap-on-linux"></span>
    <a href="#why-bubblewrap-on-linux" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>I evaluated the alternatives before deciding. The <a href="https://github.com/akitaonrails/ai-jail/blob/master/docs/sandbox-alternatives.md"target="_blank" rel="noopener">full analysis document</a> is in the repository, but the short version is:</p>
<p>Bubblewrap (bwrap) is the sandbox Flatpak uses to isolate every desktop app. ~50KB binary, ~4000 lines of C, maintained by the GNOME team. It runs without root using <code>CLONE_NEWUSER</code> to create namespaces without elevated privileges. It&rsquo;s packaged in every relevant Linux distro and tested at scale by millions of Flatpak installations.</p>
<p>I considered and rejected the alternatives. Firejail requires setuid root, and trusting setuid to protect against agents already running on your system is contradictory. nsjail and minijail are designed for production environments (Google uses them internally), too complex for a dev workstation. systemd-nspawn requires root and is meant for system containers, not for isolating a single process.</p>
<p>Landlock is a different case. It doesn&rsquo;t replace bubblewrap — it has nothing to do with namespaces or mount isolation. But it complements. Landlock is a Linux Security Module that controls access at the VFS level, independent of mount namespaces. That closes vectors bwrap alone doesn&rsquo;t cover: escape paths through <code>/proc</code>, symlink tricks inside permitted mounts, and it serves as a safety net against bugs in the namespace machinery itself. As of v0.4.0, ai-jail applies Landlock automatically on kernels 5.13+ as defense-in-depth. It uses ABI V3 (Linux 6.2+) with graceful degradation to V1 on older kernels, and turns into a silent no-op if the kernel doesn&rsquo;t support it. If it causes problems with some specific tool, <code>--no-landlock</code> turns it off.</p>
<p>Bubblewrap hits the exact sweet spot: real isolation without root, on every distro, and simple enough to wrap in an 880KB binary.</p>
<h2>What the Sandbox Does on Linux<span class="hx:absolute hx:-mt-20" id="what-the-sandbox-does-on-linux"></span>
    <a href="#what-the-sandbox-does-on-linux" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>When you run <code>ai-jail claude</code>, this is what happens:</p>
<p>The agent runs in isolated PID, UTS and IPC namespaces, with hostname <code>ai-sandbox</code>, and dies automatically if the parent dies (<code>--die-with-parent</code>).</p>
<p>The filesystem is mounted in a specific sequence (bubblewrap is order-dependent). <code>/usr</code>, <code>/etc</code>, <code>/opt</code>, <code>/sys</code> come in read-only for system tools. <code>/dev</code> and <code>/proc</code> are mounted for device and process access. <code>/tmp</code> and <code>/run</code> come in as fresh tmpfs. GPUs auto-detected (<code>/dev/nvidia*</code>, <code>/dev/dri</code>). Docker socket, X11/Wayland, <code>/dev/shm</code>, all auto-detected and mounted if they exist.</p>
<p>The most important part is how the home directory is handled. <code>$HOME</code> is mounted as an empty tmpfs. Then, selectively, dotfiles get layered on top. <code>.gnupg</code>, <code>.aws</code>, <code>.ssh</code>, <code>.mozilla</code>, <code>.sparrow</code> are never mounted (sensitive data). <code>.claude</code>, <code>.crush</code>, <code>.codex</code>, <code>.aider</code>, <code>.config</code>, <code>.cargo</code>, <code>.cache</code>, <code>.docker</code> come in as read-write because the agents need to write here. Everything else comes in read-only. Inside <code>~/.config</code>, browser subdirectories are hidden behind tmpfs: <code>BraveSoftware</code>, <code>Bitwarden</code>. Same in <code>~/.cache</code>: <code>BraveSoftware</code>, <code>chromium</code>, <code>spotify</code>, <code>nvidia</code>. The agent can&rsquo;t even see those directories exist.</p>
<p>The current project directory is the only place with write permission (besides the tool dotdirs). The agent modifies the code, but doesn&rsquo;t touch anything else.</p>
<h2>macOS: sandbox-exec<span class="hx:absolute hx:-mt-20" id="macos-sandbox-exec"></span>
    <a href="#macos-sandbox-exec" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>On macOS, the backend is <code>sandbox-exec</code> with SBPL (Sandbox Profile Language) profiles. It&rsquo;s a legacy Apple API, officially deprecated but with no public replacement. It works today, but Apple may remove it in the future.</p>
<p>ai-jail generates an SBPL profile at runtime that mirrors the same logic as Linux:</p>
<ul>
<li>Default deny on everything</li>
<li>Allows process operations (exec, fork, signal)</li>
<li>Allows network (except in lockdown)</li>
<li>Allows global reads, denies sensitive paths (<code>.gnupg</code>, <code>.aws</code>, <code>.ssh</code>, <code>~/Library/Keychains</code>, <code>~/Library/Mail</code>)</li>
<li>Allows writes only in the project directory, tool dotfiles, and <code>/tmp</code></li>
</ul>
<p>The limitations are real. GPU (Metal) and Display (Cocoa) are system-level on macOS, sandbox-exec can&rsquo;t restrict them. The <code>--no-gpu</code> and <code>--no-display</code> flags simply have no effect on macOS. Cross-platform parity is approximate, not exact.</p>
<p>Even with those limitations, it&rsquo;s better than running the agent completely open. sandbox-exec protects against access to sensitive filesystem areas and, in lockdown, removes write and network permissions.</p>
<h2>Windows: Not Supported (And Probably Never Will Be)<span class="hx:absolute hx:-mt-20" id="windows-not-supported-and-probably-never-will-be"></span>
    <a href="#windows-not-supported-and-probably-never-will-be" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>It&rsquo;s not for lack of interest, it&rsquo;s lack of primitives. Windows has no userspace equivalent to Linux namespaces. No mount API like bubblewrap. AppContainers exist but use a completely different security model, require admin privileges, and mapping bwrap functionality to AppContainers would effectively mean writing another project from scratch.</p>
<p>The Windows answer is WSL 2:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># Inside WSL 2 (real Linux kernel)</span>
</span></span><span class="line"><span class="cl">sudo apt install bubblewrap
</span></span><span class="line"><span class="cl">cargo install ai-jail
</span></span><span class="line"><span class="cl"><span class="c1"># or: mise use -g ubi:akitaonrails/ai-jail</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nb">cd</span> /mnt/c/Users/you/Projects/my-app
</span></span><span class="line"><span class="cl">ai-jail claude</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>WSL 2 runs an actual Linux kernel. Bubblewrap works normally. Windows files are accessible at <code>/mnt/c/</code>. I/O performance is slower across the 9p mount, but it works. For large projects, cloning inside <code>~/Projects/</code> on the Linux side improves performance considerably.</p>
<h2>Lockdown Mode<span class="hx:absolute hx:-mt-20" id="lockdown-mode"></span>
    <a href="#lockdown-mode" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>For workloads you really don&rsquo;t trust, there&rsquo;s <code>--lockdown</code>:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">ai-jail --lockdown bash</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Lockdown does everything normal mode does, but goes further. The project gets mounted read-only (not read-write). GPU, Docker, Display and mise are disabled. <code>--rw-map</code> and <code>--map</code> flags are ignored. <code>$HOME</code> becomes pure tmpfs, no host dotfiles. On Linux, the network is cut with <code>--unshare-net</code> and the environment is wiped with <code>--clearenv</code>. On macOS, environment variables are wiped and write and network rules are removed from the SBPL.</p>
<p>It&rsquo;s the most restrictive sandbox you can build short of using a VM. Useful for auditing third-party code or running agents on projects you don&rsquo;t know.</p>
<h2>Bootstrap: Automatic Permission Configuration<span class="hx:absolute hx:-mt-20" id="bootstrap-automatic-permission-configuration"></span>
    <a href="#bootstrap-automatic-permission-configuration" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p><code>ai-jail --bootstrap</code> generates permission configurations for the tools you use:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">ai-jail --bootstrap</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>For <strong>Claude Code</strong>, it generates <code>~/.claude/settings.json</code> with allow/deny/ask lists. Allows git status, diff, log, ls, grep, cargo, npm, python, docker compose. Blocks rm -rf, sudo, chmod 777, git push &ndash;force. Asks before git push, rm, docker run.</p>
<p>For <strong>Codex</strong>, it generates <code>~/.codex/config.toml</code> with <code>approval_policy = &quot;on-request&quot;</code>.</p>
<p>For <strong>OpenCode</strong>, it generates <code>~/.config/opencode/opencode.json</code> with bash, edit, write permissions.</p>
<p>Before overwriting any file, it makes an automatic backup (<code>settings.json.bak</code>). And it rejects operations if the target is a symlink, to avoid path traversal.</p>
<p>It&rsquo;s exactly the content I put in manually in the <a href="/en/2026/01/10/ai-agents-locking-down-your-system/">previous post</a>, but automated and tested.</p>
<h2>But Claude Code Already Has Its Own Sandbox<span class="hx:absolute hx:-mt-20" id="but-claude-code-already-has-its-own-sandbox"></span>
    <a href="#but-claude-code-already-has-its-own-sandbox" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>It does. Since October 2025, Claude Code has offered a runtime sandbox via the <code>/sandbox</code> command. And guess what it uses underneath? Bubblewrap on Linux and sandbox-exec on macOS. The same stack.</p>
<p>But the differences matter.</p>
<p>ai-jail is tool-agnostic. It works with Claude, Codex, OpenCode, Crush, and any command. Claude&rsquo;s sandbox only protects Claude. If tomorrow you switch agents, ai-jail keeps working the same.</p>
<p>The thing that bothers me most is the escape hatch. When a command fails because of a sandbox restriction, Claude can retry with <code>dangerouslyDisableSandbox</code>, falling back to the normal permission flow. You can disable that (<code>&quot;allowUnsandboxedCommands&quot;: false</code>), but it&rsquo;s opt-out, not opt-in. In ai-jail, there is no escape hatch. The process runs inside bwrap or sandbox-exec, period. There&rsquo;s no way for the agent to decide on its own to leave the sandbox.</p>
<p>Another practical difference: <code>.ai-jail</code> lives in the project directory and can be committed to the repo. Any dev who clones the project inherits the same sandbox policy. Claude&rsquo;s sandbox depends on a global <code>settings.json</code>.</p>
<p>When run inside Docker, Claude&rsquo;s sandbox falls back to an <code>enableWeakerNestedSandbox</code> mode that, in the words of its own documentation, <em>&ldquo;considerably weakens security&rdquo;</em>. ai-jail wasn&rsquo;t designed to run inside Docker (it runs directly on the dev&rsquo;s workstation), so this problem doesn&rsquo;t exist.</p>
<p>About the network: Claude&rsquo;s sandbox routes traffic through a proxy and allows/blocks by domain. ai-jail in normal mode inherits the host network; in lockdown, it cuts the entire network with <code>--unshare-net</code>. Claude&rsquo;s approach is more granular; ai-jail&rsquo;s is simpler and harder to circumvent.</p>
<p>The two aren&rsquo;t mutually exclusive. You can run Claude&rsquo;s sandbox inside ai-jail. ai-jail handles filesystem isolation; Claude&rsquo;s sandbox adds per-domain network filtering. Security layers stack.</p>
<h2>Why Not Use &ndash;dangerously-skip-permissions Without a Jail<span class="hx:absolute hx:-mt-20" id="why-not-use-dangerously-skip-permissions-without-a-jail"></span>
    <a href="#why-not-use-dangerously-skip-permissions-without-a-jail" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>I&rsquo;ll be blunt: if you run Claude Code with <code>--dangerously-skip-permissions</code> without any sandbox, you&rsquo;re trusting blindly that the LLM will never execute anything destructive. And you&rsquo;re trusting that none of your project&rsquo;s dependencies has been compromised in a supply-chain attack.</p>
<p>Every <code>--dangerously</code> flag has that name for a reason. Claude Code is explicit: that mode exists for CI/CD and automation in environments that are already isolated (containers, throwaway VMs). Not for your personal workstation with <code>~/.aws/credentials</code>, <code>~/.gnupg/</code>, SSH keys, and your browser&rsquo;s password vault.</p>
<p>With ai-jail, the agent has total autonomy inside the sandbox. It does whatever it wants in the project directory, uses the tools it needs, and can&rsquo;t access anything outside what was explicitly permitted.</p>
<h2>ai-jail + Git: The Safety Net You Already Have<span class="hx:absolute hx:-mt-20" id="ai-jail--git-the-safety-net-you-already-have"></span>
    <a href="#ai-jail--git-the-safety-net-you-already-have" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>There&rsquo;s something I haven&rsquo;t mentioned yet that changes the risk calculus: if your project is in a Git repo, with a remote on GitHub/GitLab, and the agent doesn&rsquo;t have permission to <code>git push</code>, the damage it can cause is limited to the local directory.</p>
<p>Think about it. The worst-case scenario inside ai-jail is the agent corrupting every file in the project. Annoying? Sure. Catastrophic? No. You run <code>git checkout .</code> and you&rsquo;re back to the last commit. If it corrupts <code>.git</code> somehow (unlikely, but possible), you delete the directory and clone again. The remote was never touched.</p>
<p>That&rsquo;s why ai-jail&rsquo;s <code>--bootstrap</code> puts <code>git push</code> on the &ldquo;ask&rdquo; list (ask before running), not the &ldquo;allow&rdquo; list. And <code>git push --force</code> goes straight to &ldquo;deny&rdquo;. The agent can commit locally all it wants, can create branches, can rebase. None of that affects the remote. When it comes time to push, you review what it did and decide whether it goes live.</p>
<p>That combination, sandbox for the filesystem + Git for the code + manual push, already gives you a very reasonable security level for daily use. ai-jail protects your personal data and the system. Git protects your code. And the decision to publish stays yours.</p>
<p>If you want to go further, the next two sections cover additional layers.</p>
<h2>ai-jail vs Dev Containers<span class="hx:absolute hx:-mt-20" id="ai-jail-vs-dev-containers"></span>
    <a href="#ai-jail-vs-dev-containers" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Since I wrote ai-jail, the most frequent question is: <em>&ldquo;why not use Dev Containers?&rdquo;</em>. The short answer is that one doesn&rsquo;t replace the other. They solve different problems.</p>
<p>Dev Containers (the <a href="https://containers.dev"target="_blank" rel="noopener">containers.dev</a> spec) define a complete development environment in a <code>devcontainer.json</code>. You specify base image, tools, VS Code extensions, environment variables, and the editor mounts everything for you in a Docker container. Docker also recently launched Docker Sandboxes, which go further and run each agent in a microVM with Firecracker, with hardware isolation.</p>
<p>ai-jail does none of that. It doesn&rsquo;t define an environment. It doesn&rsquo;t install tools. It doesn&rsquo;t run a Docker image. It takes the environment that already exists on your machine and restricts what the process can access.</p>
<p>In practice, the difference is:</p>
<table>
  <thead>
      <tr>
          <th></th>
          <th>Dev Container</th>
          <th>ai-jail</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>What it does</td>
          <td>Defines and provisions a complete isolated environment</td>
          <td>Restricts process access to the existing filesystem</td>
      </tr>
      <tr>
          <td>Setup</td>
          <td><code>devcontainer.json</code> + Docker</td>
          <td><code>.ai-jail</code> TOML + bubblewrap</td>
      </tr>
      <tr>
          <td>Startup</td>
          <td>Seconds (image pull, container build)</td>
          <td>Milliseconds (fork + exec of bwrap)</td>
      </tr>
      <tr>
          <td>Tools</td>
          <td>Whatever you put in the image</td>
          <td>Whatever&rsquo;s already installed on your machine</td>
      </tr>
      <tr>
          <td>GPU</td>
          <td>Requires NVIDIA Container Toolkit configuration</td>
          <td>Auto-detects <code>/dev/nvidia*</code> and <code>/dev/dri</code></td>
      </tr>
      <tr>
          <td>Daemon</td>
          <td>Requires Docker daemon running</td>
          <td>Nothing besides bwrap</td>
      </tr>
      <tr>
          <td>Reproducibility</td>
          <td>High (fixed image)</td>
          <td>Depends on what&rsquo;s installed on the host</td>
      </tr>
      <tr>
          <td>Network isolation</td>
          <td>Docker Sandboxes: per-domain firewall</td>
          <td>Lockdown: cuts everything with <code>--unshare-net</code></td>
      </tr>
  </tbody>
</table>
<p>Dev Container makes more sense when you need the whole team to have exactly the same environment, or when the project has dependencies nobody wants to install on the host, or for running non-interactive agents in CI/CD. Docker Sandboxes with microVM are the strongest isolation that exists outside a dedicated VM.</p>
<p>ai-jail makes more sense when you already have the environment configured and want instant startup with no Docker daemon. Or when you use tools that are annoying to run inside a container (CUDA, Wayland, mise). Or simply when you want the same protection for any agent, not just the ones with devcontainer integration.</p>
<p>And you can combine them. I know people who run ai-jail inside a Dev Container to get environment reproducibility + filesystem restriction. Security layers stack.</p>
<h2>Immutable Operating Systems: The Last Layer<span class="hx:absolute hx:-mt-20" id="immutable-operating-systems-the-last-layer"></span>
    <a href="#immutable-operating-systems-the-last-layer" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>If you want to take security seriously, the third layer is the operating system itself.</p>
<p>Immutable systems like <a href="https://fedoraproject.org/atomic-desktops/silverblue/"target="_blank" rel="noopener">Fedora Silverblue</a>, <a href="https://nixos.org/"target="_blank" rel="noopener">NixOS</a>, and <a href="https://aeondesktop.github.io/"target="_blank" rel="noopener">openSUSE Aeon</a> have a read-only root filesystem. The base system can&rsquo;t be modified by any process, even with root. Updates are atomic: they either apply completely or not at all. And if something goes wrong, you roll back to the previous image in one reboot.</p>
<p>In practice, that means even if an AI agent escaped the sandbox (exploiting a kernel vulnerability, for example), it couldn&rsquo;t modify the system persistently. On the next reboot, the system returns to the declared state. On NixOS, the entire system is defined by a configuration file (<code>configuration.nix</code>). On Silverblue, the base is an OSTree image that gets atomic updates via <code>rpm-ostree</code>.</p>
<p>For developers, the catch is: your dev tools run in containers (Toolbox/Distrobox on Silverblue, <code>nix-shell</code> on NixOS). The base system stays untouched. Desktop apps come via Flatpak, which already runs in a sandbox. The result is that the host&rsquo;s attack surface is minimal.</p>
<p>Fedora Silverblue is probably the most accessible entry point. It&rsquo;s already Fedora underneath, with GNOME, works with hardware Fedora supports, and Toolbox gives you a containerized Fedora Server where you install whatever you want without touching the host. NixOS is more powerful (full reproducibility, declarative rollback), but the learning curve is real.</p>
<p>The full combination looks like this: the immutable OS handles the system (read-only filesystem, atomic updates, one-reboot rollback). ai-jail handles the session (isolated namespace, ephemeral home, sensitive data invisible). And Git handles the code (remote untouched as long as the agent doesn&rsquo;t have push).</p>
<p>None of those layers is perfect on its own. But the attack that punches through all three at the same time — escaping the namespace, persisting on a read-only filesystem, and corrupting a Git remote — is a scenario I&rsquo;d be comfortable calling unlikely.</p>
<p>The best part is that none of them requires changing how you work. ai-jail is one command before your agent. Git you already use. And an immutable OS is an installation, not a workflow change.</p>
<h2>Technical Details (For Those Who Care)<span class="hx:absolute hx:-mt-20" id="technical-details-for-those-who-care"></span>
    <a href="#technical-details-for-those-who-care" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Written in Rust with 124 tests and 4 dependencies: <code>lexopt</code> (CLI parsing without clap), <code>serde</code> + <code>toml</code> (config), <code>nix</code> (Unix syscalls). No async runtime, no color framework (raw ANSI), ~880KB binary with LTO and strip.</p>
<p>Signal handling is done correctly: SIGINT, SIGTERM, and SIGHUP are forwarded to the child process. The handler only calls <code>libc::kill</code>, which is async-signal-safe. Process reaping uses <code>waitpid</code> in a loop with retry on EINTR.</p>
<p>Temporary files (like the custom <code>/etc/hosts</code> the sandbox mounts) use RAII: a <code>SandboxGuard</code> that implements <code>Drop</code> in Rust. If the parent process dies for any reason, cleanup happens.</p>
<p>Configuration compatibility is guaranteed by development policy: never remove fields, never rename, new fields always with <code>#[serde(default)]</code>, unknown fields are silently ignored. Regression tests for old <code>.ai-jail</code> formats guarantee that updating the binary never breaks existing configs. There are 32 tests just for config.</p>
<h2>Roadmap<span class="hx:absolute hx:-mt-20" id="roadmap"></span>
    <a href="#roadmap" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>What&rsquo;s left:</p>
<ul>
<li>More tool backends in bootstrap: Aider, Cursor, Windsurf. As more agents standardize permission configuration files.</li>
<li>Profile sharing for monorepos, so you don&rsquo;t have to configure each service separately.</li>
</ul>
<h2>Installation and First Use (Quick Recap)<span class="hx:absolute hx:-mt-20" id="installation-and-first-use-quick-recap"></span>
    <a href="#installation-and-first-use-quick-recap" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># 1. Install</span>
</span></span><span class="line"><span class="cl">brew tap akitaonrails/tap <span class="o">&amp;&amp;</span> brew install ai-jail
</span></span><span class="line"><span class="cl"><span class="c1"># or: cargo install ai-jail</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 2. On Linux, install bubblewrap</span>
</span></span><span class="line"><span class="cl">sudo pacman -S bubblewrap  <span class="c1"># Arch</span>
</span></span><span class="line"><span class="cl"><span class="c1"># sudo apt install bubblewrap  # Debian/Ubuntu</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 3. Enter the project and run</span>
</span></span><span class="line"><span class="cl"><span class="nb">cd</span> ~/Projects/my-app
</span></span><span class="line"><span class="cl">ai-jail claude
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 4. (Optional) Generate permission configs</span>
</span></span><span class="line"><span class="cl">ai-jail --bootstrap
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 5. (Optional) See what the sandbox does</span>
</span></span><span class="line"><span class="cl">ai-jail --dry-run --verbose claude</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The <code>.ai-jail</code> file it creates can be committed to your repo. From then on, any dev who clones the project runs <code>ai-jail claude</code> and gets the same sandbox.</p>
<h2>Conclusion<span class="hx:absolute hx:-mt-20" id="conclusion"></span>
    <a href="#conclusion" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>January&rsquo;s shell script solved the problem. ai-jail solves the problem properly. Per-project config, macOS support, lockdown mode, permission bootstrap, dry-run for auditing, and an 880KB binary that installs in 10 seconds.</p>
<p>If you use AI agents to code, run them in a sandbox. The LLM&rsquo;s good intentions are no guarantee of anything, and supply-chain attacks don&rsquo;t pick their victims. Process isolation is the barrier that works.</p>
<p>The project is GPL-3.0 and is on GitHub: <a href="https://github.com/akitaonrails/ai-jail"target="_blank" rel="noopener">github.com/akitaonrails/ai-jail</a></p>
<p>Issues and PRs are welcome.</p>
]]></content:encoded><category>AI</category><category>Linux</category><category>Bubblewrap</category><category>Sandbox</category><category>Rust</category><category>Security</category></item><item><title>Software Is Never 'Done' — 4 Projects, Life After Deploy, and Why One-Shot Prompting Is a Myth</title><link>https://www.akitaonrails.com/en/2026/03/01/software-is-never-done-4-projects-life-after-deploy-one-shot-prompt-myth/</link><guid isPermaLink="true">https://www.akitaonrails.com/en/2026/03/01/software-is-never-done-4-projects-life-after-deploy-one-shot-prompt-myth/</guid><pubDate>Sun, 01 Mar 2026 12:00:00 GMT</pubDate><description>&lt;p&gt;And don&amp;rsquo;t forget to subscribe to my newsletter &lt;a href="https://themakitachronicles.com/"target="_blank" rel="noopener"&gt;The M.Akita Chronicles&lt;/a&gt;!&lt;/p&gt;
&lt;p&gt;&amp;ndash;&lt;/p&gt;
&lt;p&gt;I published the &amp;ldquo;final post&amp;rdquo; of the &lt;a href="https://www.akitaonrails.com/en/2026/02/20/zero-to-post-production-in-1-week-using-ai-on-real-projects-behind-the-m-akita-chronicles/"&gt;Behind The M.Akita Chronicles&lt;/a&gt; series 10 days ago. 274 commits, 1,323 tests, deployed to production. I wrote down the lessons, did the conclusion, dropped the quote at the end. Done.&lt;/p&gt;
&lt;p&gt;125 post-production commits later, I can confirm: &lt;strong&gt;software &lt;em&gt;&amp;ldquo;done&amp;rdquo;&lt;/em&gt; doesn&amp;rsquo;t exist.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Today the M.Akita Chronicles repo has 335 commits and 1,422 tests. Tomorrow, Monday, subscribers get the 3rd consecutive newsletter — generated, reviewed, and sent by a system that won&amp;rsquo;t stop evolving. Meanwhile, &lt;a href="https://www.akitaonrails.com/en/2026/02/23/vibe-code-built-a-smart-image-indexer-with-ai-in-2-days-frank-sherlock/"&gt;Frank Sherlock&lt;/a&gt; went from 50 commits and v0.1 to 103 commits and v0.7 with face detection. &lt;a href="https://www.akitaonrails.com/en/2026/02/01/frankmd-markdown-editor-vibe-code-part-1/"&gt;FrankMD&lt;/a&gt; got 3 external contributors and shipped v0.2.0. And even &lt;a href="https://www.akitaonrails.com/en/2026/02/21/vibe-code-built-a-mega-clone-in-rails-in-1-day-frankmega/"&gt;FrankMega&lt;/a&gt; — a project built in 1 day — needed fixes when real users showed up.&lt;/p&gt;</description><content:encoded><![CDATA[<p>And don&rsquo;t forget to subscribe to my newsletter <a href="https://themakitachronicles.com/"target="_blank" rel="noopener">The M.Akita Chronicles</a>!</p>
<p>&ndash;</p>
<p>I published the &ldquo;final post&rdquo; of the <a href="/en/2026/02/20/zero-to-post-production-in-1-week-using-ai-on-real-projects-behind-the-m-akita-chronicles/">Behind The M.Akita Chronicles</a> series 10 days ago. 274 commits, 1,323 tests, deployed to production. I wrote down the lessons, did the conclusion, dropped the quote at the end. Done.</p>
<p>125 post-production commits later, I can confirm: <strong>software <em>&ldquo;done&rdquo;</em> doesn&rsquo;t exist.</strong></p>
<p>Today the M.Akita Chronicles repo has 335 commits and 1,422 tests. Tomorrow, Monday, subscribers get the 3rd consecutive newsletter — generated, reviewed, and sent by a system that won&rsquo;t stop evolving. Meanwhile, <a href="/en/2026/02/23/vibe-code-built-a-smart-image-indexer-with-ai-in-2-days-frank-sherlock/">Frank Sherlock</a> went from 50 commits and v0.1 to 103 commits and v0.7 with face detection. <a href="/en/2026/02/01/frankmd-markdown-editor-vibe-code-part-1/">FrankMD</a> got 3 external contributors and shipped v0.2.0. And even <a href="/en/2026/02/21/vibe-code-built-a-mega-clone-in-rails-in-1-day-frankmega/">FrankMega</a> — a project built in 1 day — needed fixes when real users showed up.</p>
<p>Throughout February, I published more than a dozen posts detailing the build of each project. This post is different. It isn&rsquo;t about building from scratch, I covered that already. It&rsquo;s about what happens <strong>after</strong>. And what happens after destroys the one-shot prompt narrative. Software needs an experienced human at the wheel. Iterative development is the only thing that works. Anyone who disagrees hasn&rsquo;t put a system in production yet.</p>
<p>I&rsquo;ll prove it with <code>git log</code>.</p>
<h2>Life After Deploy<span class="hx:absolute hx:-mt-20" id="life-after-deploy"></span>
    <a href="#life-after-deploy" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><table>
  <thead>
      <tr>
          <th>Project</th>
          <th>Post-publication commits</th>
          <th>Highlight</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>The M.Akita Chronicles</td>
          <td>56</td>
          <td>3rd week in production, new features, 13 bug fixes, prompt tuning</td>
      </tr>
      <tr>
          <td>Frank Sherlock</td>
          <td>53</td>
          <td>From v0.3 to v0.7: video support, face detection, AUR publish</td>
      </tr>
      <tr>
          <td>FrankMD</td>
          <td>14</td>
          <td>3 external contributors, 4 PRs merged, v0.2.0</td>
      </tr>
      <tr>
          <td>FrankMega</td>
          <td>2</td>
          <td>MIME types nobody saw coming</td>
      </tr>
      <tr>
          <td><strong>Total</strong></td>
          <td><strong>125</strong></td>
          <td></td>
      </tr>
  </tbody>
</table>
<p>Let&rsquo;s get into the details.</p>
<h2>The M.Akita Chronicles: 3 Weeks in Production<span class="hx:absolute hx:-mt-20" id="the-makita-chronicles-3-weeks-in-production"></span>
    <a href="#the-makita-chronicles-3-weeks-in-production" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The newsletter system has been live since February 16. It&rsquo;s already sent 2 newsletters and tomorrow it sends the third. 56 commits have happened since the &ldquo;final post&rdquo;:</p>
<table>
  <thead>
      <tr>
          <th>Date</th>
          <th>Commits</th>
          <th>What happened</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Feb 20 (Thu)</td>
          <td>16</td>
          <td>Steam Gaming (whole new section), bug fixes in paywall detection</td>
      </tr>
      <tr>
          <td>Feb 21 (Fri)</td>
          <td>12</td>
          <td>/preview command, YouTube collage, /rerun notifications</td>
      </tr>
      <tr>
          <td>Feb 22 (Sat)</td>
          <td>1</td>
          <td>Fix: /preflight sending the result to the wrong channel</td>
      </tr>
      <tr>
          <td>Feb 23 (Sun)</td>
          <td>10</td>
          <td>2nd newsletter publication, Gmail clipping warning, TTS revert</td>
      </tr>
      <tr>
          <td>Feb 24 (Mon)</td>
          <td>1</td>
          <td>Podcast prompt tweak</td>
      </tr>
      <tr>
          <td>Feb 28 (Sat)</td>
          <td>7</td>
          <td>Marvin prompt tuning, rate limiting, /rerun comments</td>
      </tr>
      <tr>
          <td>Mar 1 (Sun)</td>
          <td>9</td>
          <td>Mood system, switch to Grok-4, QA pipeline, config centralization</td>
      </tr>
  </tbody>
</table>
<h3>Features Nobody Saw Coming<span class="hx:absolute hx:-mt-20" id="features-nobody-saw-coming"></span>
    <a href="#features-nobody-saw-coming" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p><strong>Steam Gaming.</strong> Wasn&rsquo;t in the original plan. It was born because the entertainment section only had anime — and gamer readers were left out. Result: 6 commits just on launch day. The first commit (<code>bf41e25</code>) added the whole section — Steam API service, generation job, tests, Hugo shortcode. The next 5 fixed things: Portuguese date parsing, ranking offset so the top 10 didn&rsquo;t repeat, wishlist filter for releases, dark theme. No spec in the world predicts that the Steam API returns dates with Portuguese abbreviations (<code>&quot;fev&quot;</code>, <code>&quot;mar&quot;</code>, <code>&quot;abr&quot;</code>) when you ask for <code>l=brazilian</code>. You find that out when the parser breaks in production.</p>
<p><strong>Preview system.</strong> <code>/preview</code> was born because I needed to see how the auto-generated content looked <strong>before</strong> publishing the entire newsletter. Seems obvious in retrospect, but in v1 the only way to validate was to generate everything and look at the final result. 5 commits to build a preview system with 9 sections, aliases (<code>hn</code> for hacker_news, <code>steam</code> for steam_gaming), separate comment-preview mode, and rescue when one section fails without taking the others down (<code>d23c725</code> — because a section with an error was killing the entire preview).</p>
<p><strong>Marvin&rsquo;s moods.</strong> Marvin (the bot&rsquo;s sarcastic persona) suffers from a chronic LLM problem: smoothing. Without intervention, every comment ends up the same lukewarm tone. The iterative fix was a mood system — 9 modes the operator picks per story:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="no">MARVIN_MOODS</span> <span class="o">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="s2">&#34;sarcastic&#34;</span>   <span class="o">=&gt;</span> <span class="s2">&#34;Be extra sarcastic and biting in your commentary.&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="s2">&#34;ironic&#34;</span>      <span class="o">=&gt;</span> <span class="s2">&#34;Use irony and dark humor to make your point.&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="s2">&#34;grounded&#34;</span>    <span class="o">=&gt;</span> <span class="s2">&#34;Be more neutral and journalist-like, factual and measured.&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="s2">&#34;provocative&#34;</span> <span class="o">=&gt;</span> <span class="s2">&#34;Be provocative and controversial, challenge assumptions.&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="s2">&#34;counter&#34;</span>     <span class="o">=&gt;</span> <span class="s2">&#34;Take the opposite position from Akita&#39;s comment.&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="s2">&#34;insightful&#34;</span>  <span class="o">=&gt;</span> <span class="s2">&#34;Find a non-obvious angle -- a historical parallel, a second-order consequence.&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="s2">&#34;positive&#34;</span>    <span class="o">=&gt;</span> <span class="s2">&#34;Find the genuinely positive angle.&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="s2">&#34;hopeful&#34;</span>     <span class="o">=&gt;</span> <span class="s2">&#34;Be cautiously optimistic. Acknowledge the good potential.&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="s2">&#34;negative&#34;</span>    <span class="o">=&gt;</span> <span class="s2">&#34;Be extra pessimistic and bleak.&#34;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span><span class="o">.</span><span class="n">freeze</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>None of this came from a spec. It came from 3 weeks of reading bland comments and thinking <em>&ldquo;this isn&rsquo;t good enough&rdquo;</em>. There are 7 commits just for prompt tuning across 10 days. I banned formulaic patterns (<code>d4bd3a6</code>). I killed off &ldquo;Ah,&rdquo; as an opener (<code>4171628</code>). I removed Marvin from entire podcast sections because he was contaminating the tone (<code>246cc60</code>). I pushed for substance instead of puns (<code>8bc47c4</code>). Each prompt commit is a micro-correction that only makes sense after reading the previous output.</p>
<h3>Bugs That Only Exist in Production<span class="hx:absolute hx:-mt-20" id="bugs-that-only-exist-in-production"></span>
    <a href="#bugs-that-only-exist-in-production" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Of the 56 commits, 13 are bug fixes. Some favorites:</p>
<ul>
<li>
<p><strong>Gmail clipping</strong> (<code>3258f5b</code>): Gmail silently cuts off emails larger than 102KB. I found out when the 2nd newsletter went out longer and Gmail readers didn&rsquo;t see the end. Fix: a size check in the content preflight that warns before publishing. I shrunk the history sections from 10 to 5 items (<code>51ce528</code>) to fit the limit.</p>
</li>
<li>
<p><strong>False paywall detection</strong> (<code>1fd8739</code>): the scraper marked sites as paywalled when it found accidental block text in a generic footer. Only showed up when the story list grew to dozens of real sources.</p>
</li>
<li>
<p><strong>t.co link mangling</strong> (<code>6bd056f</code>): Twitter shortens URLs with t.co, but it also auto-links file names that look like domains. <code>config.yml</code> becomes a link to the <code>config.yml</code> domain. 158 lines of fix to handle tweet text correctly.</p>
</li>
<li>
<p><strong>TTS language revert</strong> (<code>7803ad9</code> -&gt; <code>c1dd668</code>): I tried switching the TTS language from &ldquo;Portuguese&rdquo; to &ldquo;Auto&rdquo;. The model produced a mixed accent. Reverted the same day.</p>
</li>
<li>
<p><strong>Podcast section ordering</strong> (<code>5bef18c</code>): podcast sections came out in the wrong order. 3-line fix + untracking of 1,504 lines of generated content that had been committed by accident.</p>
</li>
<li>
<p><strong>Elementor sites with multiple <code>&lt;article&gt;</code></strong> (<code>e70b756</code>): sites built with Elementor use the <code>&lt;article&gt;</code> tag in a non-semantic way. The content extractor was grabbing the wrong block. 171 lines of fix + tests.</p>
</li>
</ul>
<p>None of these bugs could have been predicted in a spec. They only exist because the system is live, processing real data, hitting real APIs, being read by real people.</p>
<h3>From 1,323 to 1,422 Tests<span class="hx:absolute hx:-mt-20" id="from-1323-to-1422-tests"></span>
    <a href="#from-1323-to-1422-tests" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>The test suite kept growing along with it:</p>
<table>
  <thead>
      <tr>
          <th>App</th>
          <th>Tests (Feb 20)</th>
          <th>Tests (Mar 1)</th>
          <th>Growth</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>marvin-bot</td>
          <td>970</td>
          <td>1,060</td>
          <td>+90</td>
      </tr>
      <tr>
          <td>newsletter</td>
          <td>353</td>
          <td>362</td>
          <td>+9</td>
      </tr>
      <tr>
          <td><strong>Total</strong></td>
          <td><strong>1,323</strong></td>
          <td><strong>1,422</strong></td>
          <td><strong>+99</strong></td>
      </tr>
  </tbody>
</table>
<p>New feature? Comes with a test. Bug fix? Comes with a regression test. Without that, the 56 post-production commits would be 56 chances to break something that worked yesterday. TDD isn&rsquo;t a phase, it&rsquo;s a habit.</p>
<h3>The Model Changed: From Claude to Grok-4<span class="hx:absolute hx:-mt-20" id="the-model-changed-from-claude-to-grok-4"></span>
    <a href="#the-model-changed-from-claude-to-grok-4" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Commit <code>8f9d11c</code>: <code>Switch default model to x-ai/grok-4</code>. The architecture had isolated the LLM model into an environment variable from day one. But the model name was still hardcoded across 24 files. Result: commit <code>c8c688e</code> — <code>Centralize LLM model config</code> — 24 files touched to make swapping models a 1-line config change.</p>
<p>This kind of refactoring only shows up in operation. When you start the project, you don&rsquo;t know you&rsquo;re going to change models. When you change, you want it to be trivial. And the cleanup to make it trivial? Only happens when the pain shows up.</p>
<h2>Frank Sherlock: From v0.1 to v0.7 in 4 Days<span class="hx:absolute hx:-mt-20" id="frank-sherlock-from-v01-to-v07-in-4-days"></span>
    <a href="#frank-sherlock-from-v01-to-v07-in-4-days" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The <a href="/en/2026/02/23/vibe-code-built-a-smart-image-indexer-with-ai-in-2-days-frank-sherlock/">Frank Sherlock post</a> covered the first 2 days and ~50 commits: the benchmark research, the Tauri app scaffold (Rust + React), the classification pipeline with Ollama, and the v0.1.0 release with binaries for Linux, macOS and Windows.</p>
<p>What the post didn&rsquo;t cover: the next 53 commits, in 4 days, that took the project from v0.3 to v0.7:</p>
<table>
  <thead>
      <tr>
          <th>Version</th>
          <th>Date</th>
          <th>Highlight</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>v0.4.0</td>
          <td>Feb 24</td>
          <td>Duplicate detection (SHA-256 + dHash), PDF password manager</td>
      </tr>
      <tr>
          <td>v0.5.0</td>
          <td>Feb 24</td>
          <td>Auto-update, scan performance 2x, checkpoint resume</td>
      </tr>
      <tr>
          <td>v0.6.0</td>
          <td>Feb 25</td>
          <td>Video support (11 formats), directory tree, FTS stemmer</td>
      </tr>
      <tr>
          <td>v0.7.0</td>
          <td>Feb 27</td>
          <td><strong>Face detection</strong> with native ONNX, person management</td>
      </tr>
  </tbody>
</table>
<h3>Face Detection: A Feature Born from Iteration<span class="hx:absolute hx:-mt-20" id="face-detection-a-feature-born-from-iteration"></span>
    <a href="#face-detection-a-feature-born-from-iteration" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>v0.1 was an image indexer with LLM-based classification. v0.7 has face detection with 512-dimensional embeddings, cosine-similarity clustering, and per-person search (<code>face:alice</code>). None of that was in the original plan. It came up because I started using the app with real photos and thought <em>&ldquo;I want to find every photo of so-and-so&rdquo;</em>. The feature was born from use, not from a spec.</p>
<p>The path to get there was methodical:</p>
<ol>
<li><strong><code>f34132f</code></strong>: A/B testing framework for face detection — benchmark before implementing, not after</li>
<li><strong><code>ef3be82</code></strong>: A/B results (SCRFD + ArcFace won)</li>
<li><strong><code>6d9174a</code></strong>: Native implementation with ONNX Runtime — no Python, no external dependency</li>
<li><strong><code>3b25eaf</code></strong>: Clustering, person management, complete FacesView</li>
<li><strong><code>a02d67f</code></strong>: Refactoring — extract helpers, delete dead code, share CSS</li>
</ol>
<p>Benchmark -&gt; implement -&gt; refactor. The same cycle as always. And something no one-shot prompt produces.</p>
<h3>Video Support: Another Emergent Feature<span class="hx:absolute hx:-mt-20" id="video-support-another-emergent-feature"></span>
    <a href="#video-support-another-emergent-feature" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>The app was built for images. But real media folders have videos. Commit <code>a67a2f9</code> added full support: scanning of 11 formats (MP4, MKV, AVI, MOV, WebM&hellip;), keyframe extraction for LLM classification, black frame skipping, .srt subtitle parsing for the full-text index, and inline preview with HTTP Range streaming.</p>
<p>1,626 lines added in one commit. An entire feature born from real use, not from a spec.</p>
<h3>7 Releases, 3 Platforms, Automatic AUR<span class="hx:absolute hx:-mt-20" id="7-releases-3-platforms-automatic-aur"></span>
    <a href="#7-releases-3-platforms-automatic-aur" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>In 7 days, Frank Sherlock had 7 releases. Each one with binaries compiled for Linux (AppImage), macOS (DMG with notarization and code signing), and Windows (MSI). The CI/CD pipeline (<code>release.yml</code>) does everything automatically. Starting at v0.5.0, an additional workflow (<code>aur-publish.yml</code>) publishes automatically to the AUR — the Arch Linux package repository.</p>
<p>Final tally: 17,210 lines of Rust, 10,863 lines of TypeScript, 621 tests (322 Rust + 299 frontend). Cross-platform. Releases published and installable by any user. One week, one dev, one AI agent.</p>
<p>The CI/CD cross-platform part is where the <em>&ldquo;works on my machine&rdquo;</em> fantasy dies. There were 17 commits just for builds: Windows UNC paths, macOS signing with notarization, Linux AppImage, release workflow permissions, macOS Intel target removed. No LLM in the world knows that macOS requires specific entitlements for hardened runtime, or that paths on Windows start with <code>\\?\</code>. That kind of deploy work demands someone who&rsquo;s been through that pain before.</p>
<h2>FrankMD: Real Open Source<span class="hx:absolute hx:-mt-20" id="frankmd-real-open-source"></span>
    <a href="#frankmd-real-open-source" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>FrankMD was the first project in this saga, a self-hosted Markdown editor with Rails 8. The <a href="/en/2026/02/01/frankmd-markdown-editor-vibe-code-part-1/">February posts</a> covered the build. What happened after is more interesting: other people started contributing.</p>
<p>14 commits since February 20. 3 external contributors. 4 PRs merged. v0.2.0 release on the 28th:</p>
<table>
  <thead>
      <tr>
          <th>PR</th>
          <th>Author</th>
          <th>Type</th>
          <th>What it did</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>#34</td>
          <td>@murilo-develop</td>
          <td>Bug fix</td>
          <td>Ollama integration: ModelNotFoundError + API base URL</td>
      </tr>
      <tr>
          <td>#36</td>
          <td>@rafaself</td>
          <td>Bug fix</td>
          <td>Table hint behavior in the editor (3 iterative commits)</td>
      </tr>
      <tr>
          <td>#38</td>
          <td>@LuccaRomanelli</td>
          <td>Feature</td>
          <td>Auto-sync theme with the Omarchy desktop environment</td>
      </tr>
      <tr>
          <td>#39</td>
          <td>@LuccaRomanelli</td>
          <td>Feature</td>
          <td>&ldquo;New Folder&rdquo; in the explorer&rsquo;s context menu</td>
      </tr>
  </tbody>
</table>
<h3>The Maintainer&rsquo;s Pattern<span class="hx:absolute hx:-mt-20" id="the-maintainers-pattern"></span>
    <a href="#the-maintainers-pattern" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>What&rsquo;s most telling isn&rsquo;t the contributions themselves, but what I did after every merge.</p>
<p>When @LuccaRomanelli submitted the Omarchy theme sync PR (+308 lines), I merged it and immediately committed <code>fix: harden Omarchy theme integration and fix broken tests</code> — <strong>+163 lines</strong> of fixes and tests in the next commit. The contributor implemented the feature. The maintainer hardened it.</p>
<p>When @rafaself submitted the table hint fix, there were 3 iterative commits the same day — <code>enhance</code>, <code>improve</code>, <code>streamline</code> — showing the same progressive refinement pattern I do with the AI agent. The next day, he sent a separate commit updating faraday, nokogiri and rack for security advisories.</p>
<p>On the 28th, I sat down and did everything in one shot: merged the 4 PRs, committed the hardening, updated 5 gems (brakeman, bootsnap, selenium-webdriver, web-console, mocha), and published the v0.2.0 release notes. A classic <em>&ldquo;release day&rdquo;</em>.</p>
<p>FrankMD today has 226 commits, 1,804 tests (425 Ruby + 1,379 JavaScript), and active contributors. It isn&rsquo;t <em>&ldquo;my little personal project&rdquo;</em> anymore. It became software with a community. And community doesn&rsquo;t show up for a test-less prototype that <em>&ldquo;works on my machine&rdquo;</em>. It shows up for a project with green CI, documentation, versioned releases, and code you can actually read.</p>
<h2>FrankMega: Even the Smallest Project Needs Post-Production<span class="hx:absolute hx:-mt-20" id="frankmega-even-the-smallest-project-needs-post-production"></span>
    <a href="#frankmega-even-the-smallest-project-needs-post-production" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>FrankMega was built in <a href="/en/2026/02/21/vibe-code-built-a-mega-clone-in-rails-in-1-day-frankmega/">1 day</a>. 26 commits, secure file sharing with Rails 8, 210 tests, deploy via Docker + Cloudflare Tunnel. Post published the same day. Done, right?</p>
<p>Three days later, 2 commits:</p>
<ul>
<li><code>db4bb705</code> — Add macOS, Linux, and Windows package MIME types to seed defaults</li>
<li><code>b0c4829a</code> — Fix MIME types to match Marcel detection, add normalizes strip</li>
</ul>
<p>Users tried to share <code>.dmg</code>, <code>.deb</code>, and <code>.msi</code> files. The MIME type detection (via the Marcel gem) didn&rsquo;t recognize those formats because they weren&rsquo;t in the seed defaults. Two commits. 22 lines. 15 minutes.</p>
<p>But without them, FrankMega couldn&rsquo;t be used to share installer packages — which is exactly the most common use case on a dev&rsquo;s home server.</p>
<blockquote>
  <p>No prompt in the world predicts that your users will want to share <code>.deb</code> files on day one.</p>

</blockquote>
<p>The simplest project of all, with the shortest post-production. And even then, it needed iteration.</p>
<h2>The Consolidated Numbers<span class="hx:absolute hx:-mt-20" id="the-consolidated-numbers"></span>
    <a href="#the-consolidated-numbers" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><table>
  <thead>
      <tr>
          <th>Project</th>
          <th>Commits (total)</th>
          <th>Tests</th>
          <th>Post-production</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>The M.Akita Chronicles</td>
          <td>335</td>
          <td>1,422</td>
          <td>56 commits, 10 days in production</td>
      </tr>
      <tr>
          <td>Frank Sherlock</td>
          <td>103</td>
          <td>621</td>
          <td>53 commits, 4 extra releases</td>
      </tr>
      <tr>
          <td>FrankMD</td>
          <td>226</td>
          <td>1,804</td>
          <td>14 commits, 3 contributors</td>
      </tr>
      <tr>
          <td>FrankMega</td>
          <td>28</td>
          <td>210</td>
          <td>2 commits, MIME fixes</td>
      </tr>
      <tr>
          <td><strong>Total</strong></td>
          <td><strong>692</strong></td>
          <td><strong>4,057</strong></td>
          <td><strong>125 post-publication commits</strong></td>
      </tr>
  </tbody>
</table>
<p>692 commits. 4,057 tests. 4 projects in production. February 2026. Me and an AI agent.</p>
<h2>Why One-Shot Prompting Is a Myth<span class="hx:absolute hx:-mt-20" id="why-one-shot-prompting-is-a-myth"></span>
    <a href="#why-one-shot-prompting-is-a-myth" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Look at the 125 post-production commits and tell me which one of them could have been predicted by a spec.</p>
<p>Gmail cuts off emails larger than 102KB, and you only find that out when you send the first long email. The Steam API returns dates with Portuguese abbreviations (<code>&quot;fev&quot;</code>, <code>&quot;abr&quot;</code>), and you only find that out when the parser breaks on generation Sunday. macOS users try to share <code>.dmg</code> files and can&rsquo;t because the MIME type isn&rsquo;t in the seed. Marvin opens every comment with <em>&ldquo;Ah,&rdquo;</em> and you only notice it after reading 30 in a row and wanting to claw your eyes out. The LLM model has to be swappable with 1 line of config, but it&rsquo;s spread across 24 files. Windows uses UNC paths starting with <code>\\?\</code>, and CI explodes in your face. And TTS in &ldquo;Auto&rdquo; mode? Generates an accent that sounds like Lisbon Portuguese trying to be carioca.</p>
<p>None of this is &ldquo;debugging&rdquo; in the traditional sense. It&rsquo;s navigating a problem space that only reveals itself when the software meets reality. Each one of these was a real-time decision made by someone with context.</p>
<p>The one-shot prompt fantasy is that, if you write a sufficiently detailed spec, the AI produces the perfect software. But the perfect spec would require you to know in advance everything that&rsquo;s going to go wrong. And if you knew in advance everything that&rsquo;s going to go wrong, you wouldn&rsquo;t need the spec — you&rsquo;d already have the software done in your head.</p>
<p>Good software is the result of hundreds of micro-decisions made with the system running. Not a single macro-decision made before the first line is written.</p>
<h2>The Role of the Experienced Developer<span class="hx:absolute hx:-mt-20" id="the-role-of-the-experienced-developer"></span>
    <a href="#the-role-of-the-experienced-developer" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>AI accelerated all of this. Without it, 692 commits in a month would be impossible for one person. But acceleration without direction is just faster entropy.</p>
<p>In every project there were decisions the AI couldn&rsquo;t have made on its own. Switching from Claude to Grok-4 because the previous model was weak in a specific domain. Benchmarking SCRFD against alternatives <em>before</em> implementing face detection, because the wrong choice would cost days. The +163 lines of hardening I committed immediately after merging the Omarchy PR, because I saw where it was going to break. The TTS revert from Auto mode the same day, because I know that &ldquo;Auto&rdquo; on a TTS model for Brazilian Portuguese will produce a mixed accent.</p>
<p>The circuit breaker case is the most illustrative. When I added rate limiting, the Brave Search API started returning 429s every once in a while. If I had asked, the agent would have implemented a retry with exponential backoff. But I didn&rsquo;t ask for retry. I asked for a circuit breaker:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">WebSearcher</span>
</span></span><span class="line"><span class="cl">  <span class="no">CIRCUIT_BREAKER_SECONDS</span> <span class="o">=</span> <span class="mi">120</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nc">self</span><span class="o">.</span><span class="nf">search</span><span class="p">(</span><span class="n">query</span><span class="p">,</span> <span class="ss">max_results</span><span class="p">:</span> <span class="mi">5</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="o">[]</span> <span class="k">if</span> <span class="n">circuit_open?</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">response</span> <span class="o">=</span> <span class="n">brave_search</span><span class="p">(</span><span class="n">query</span><span class="p">,</span> <span class="n">max_results</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">response</span><span class="o">.</span><span class="n">code</span> <span class="o">==</span> <span class="mi">429</span>
</span></span><span class="line"><span class="cl">      <span class="n">trip_circuit!</span>
</span></span><span class="line"><span class="cl">      <span class="no">Rails</span><span class="o">.</span><span class="n">logger</span><span class="o">.</span><span class="n">warn</span><span class="p">(</span><span class="s2">&#34;WebSearcher rate limited (429)&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="k">return</span> <span class="o">[]</span>
</span></span><span class="line"><span class="cl">    <span class="k">end</span>
</span></span><span class="line"><span class="cl">  <span class="k">rescue</span> <span class="no">Net</span><span class="o">::</span><span class="no">OpenTimeout</span><span class="p">,</span> <span class="no">Net</span><span class="o">::</span><span class="no">ReadTimeout</span> <span class="o">=&gt;</span> <span class="n">e</span>
</span></span><span class="line"><span class="cl">    <span class="n">trip_circuit!</span>
</span></span><span class="line"><span class="cl">    <span class="o">[]</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>If the API returns 429 or times out, it stops trying for 120 seconds. No retry, no backoff, no queue. Why? Because I know the cron runs every day at 8am and that on newsletter generation Sundays the query volume triples. A herd of retries delaying everything is worse than empty results in one section.</p>
<p>The AI implements a circuit breaker when you ask it to. But it isn&rsquo;t going to ask to implement a circuit breaker. It doesn&rsquo;t have the operational context. That&rsquo;s knowledge that comes from running systems in production for decades.</p>
<blockquote>
  <p>The agent writes the code. I decide what code to write. And that decision requires experience no prompt can replace.</p>

</blockquote>
<h2>The Lessons<span class="hx:absolute hx:-mt-20" id="the-lessons"></span>
    <a href="#the-lessons" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p><strong>1. Software in production diverges from the spec in hours, not months.</strong> The first M.Akita Chronicles bugs showed up in the first real newsletter. FrankMega&rsquo;s MIME types broke on the first uploads. Anyone who thinks deploy means done is living in a world that doesn&rsquo;t exist.</p>
<p><strong>2. Post-production isn&rsquo;t &ldquo;maintenance&rdquo;.</strong> 56 commits in 10 days on the M.Akita Chronicles aren&rsquo;t patches. They&rsquo;re new features (Steam Gaming, preview, moods), architecture refactoring (LLM config centralization), security hardening (rate limiting). That&rsquo;s development. The software didn&rsquo;t stop evolving just because I said <em>&ldquo;done&rdquo;</em> in a blog post.</p>
<p><strong>3. TDD protects evolution.</strong> 99 new tests in 10 days. FrankMD&rsquo;s 1,804 tests let me merge 4 external contributor PRs without fear. Without tests, every merge is Russian roulette.</p>
<p><strong>4. Small releases keep you sane.</strong> 7 Frank Sherlock releases in 7 days. Green CI, compiled binaries, release notes. Something broke? Roll back one version. Compare that with &ldquo;6 months + big bang release&rdquo; and tell me which one works better.</p>
<p><strong>5. Community shows up for real projects.</strong> Nobody sends a PR to a test-less prototype that <em>&ldquo;works on my machine&rdquo;</em>. FrankMD got 3 contributors because it has green CI, documentation, and versioned releases.</p>
<p><strong>6. The developer&rsquo;s experience is the bottleneck, not the AI&rsquo;s speed.</strong> 692 commits in a month. But every commit that mattered required decades of experience to know it was needed. The AI types fast. I know what to type.</p>
<p><strong>7. One-shot is for demos. Iteration is for production.</strong> If the goal is a 10-minute video showing a <em>&ldquo;finished SaaS&rdquo;</em>, one-shot will do. If the goal is software that survives contact with real users, only iteration works. And sustainable iteration demands discipline: TDD, CI, small releases, continuous refactoring. No shortcut.</p>
<h2>To the Senior Still Sitting on Their Hands<span class="hx:absolute hx:-mt-20" id="to-the-senior-still-sitting-on-their-hands"></span>
    <a href="#to-the-senior-still-sitting-on-their-hands" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>So far I&rsquo;ve been hitting the amateurs who think they can fire off a prompt and the AI spits out a finished system. Fair enough. But there&rsquo;s another group that worries me just as much: the senior developer who saw the AI mess up three times, declared <em>&ldquo;this is useless&rdquo;</em>, and went back to doing everything by hand.</p>
<p>I get the reasoning. The AI hallucinates, generates code with subtle bugs, suggests over-engineered solutions. All true, and I documented every one of these problems in the previous posts. But tell me something: have you never done exactly the same thing? Have you never spent 2 hours reading documentation only to find out it was a typo in the config? The difference is that when the AI gets it wrong, you catch it in the tests and fix it in minutes. When you get it wrong on your own, it takes you the same amount of time to make the mistake and even longer to find it, because you trust your own code.</p>
<p>Let&rsquo;s look at the concrete numbers of what I shipped in February, one person and one AI agent:</p>
<table>
  <thead>
      <tr>
          <th>Project</th>
          <th>Time</th>
          <th>Result</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>M.Akita Chronicles</td>
          <td>8 days</td>
          <td>4 apps (Rails + Python + Hugo), 335 commits, 1,422 tests, 3 weeks in production</td>
      </tr>
      <tr>
          <td>Frank Sherlock</td>
          <td>7 days</td>
          <td>Tauri desktop app (Rust + React), 103 commits, 621 tests, 7 releases, binaries for 3 OSes</td>
      </tr>
      <tr>
          <td>FrankMD</td>
          <td>~4 days</td>
          <td>Rails 8 Markdown editor, 226 commits, 1,804 tests, 3 external contributors</td>
      </tr>
      <tr>
          <td>FrankMega</td>
          <td>1 day</td>
          <td>Rails 8 file sharing, 28 commits, 210 tests, Docker + Cloudflare deploy</td>
      </tr>
  </tbody>
</table>
<p>Now do the math in your head. How long would it take you to build Frank Sherlock on your own? A Tauri app from scratch, with an LLM classification pipeline in Rust, OCR via Python, full-text search with FTS5, duplicate detection by perceptual hash, face detection with native ONNX, video support with keyframe extraction, CI for 3 platforms with macOS code signing and notarization, auto-update, and automatic publishing to the AUR. With 621 tests. In Rust, which doesn&rsquo;t forgive.</p>
<p>To be honest: without AI, a good senior dev would take at least 3-4 weeks on this, probably more. I did it in 7 days, alone, and every release is published with binaries anyone can download and install.</p>
<p>I already estimated The M.Akita Chronicles in the <a href="/en/2026/02/20/zero-to-post-production-in-1-week-using-ai-on-real-projects-behind-the-m-akita-chronicles/">previous post</a>: ~200 user stories. In Scrum with a senior team of 2-3 devs, no impediments, that would be 10-15 weeks. I did it in 8 days. Today, 3 weeks later, the system keeps running, evolving, with 99 more tests than when it <em>&ldquo;was done&rdquo;</em>.</p>
<p>FrankMega is more modest, but secure file sharing with Rails 8, I18n in 2 languages, 22 security issues fixed, Docker deploy, 210 tests. I did it in 1 day. A good senior, without AI, would do it in 1-2 weeks at best.</p>
<p>And I&rsquo;m not talking about disposable prototypes. People from outside send PRs to FrankMD. Real subscribers get the M.Akita Chronicles newsletter every Monday. Anyone can download Frank Sherlock from GitHub Releases or the AUR. Green CI, Brakeman clean, real tests, automated deploys. That&rsquo;s production software, not a conference demo.</p>
<p><em>&ldquo;Yeah, but I write better code without AI.&rdquo;</em> Maybe. But how long does it take you? What I showed here isn&rsquo;t that AI writes perfect code. Far from it. It&rsquo;s that with TDD, CI, pair programming with the agent, and continuous refactoring, the end result is production code with quality. 4,057 tests are there to prove it. Brakeman clean. 125 post-production commits show the code can take evolution without turning into a ball of mud.</p>
<p>And using AI here and there to generate a snippet of code, like glorified autocomplete, also isn&rsquo;t the answer. You&rsquo;re leaving 90% of the gain on the table. What I did in February was full-time pair programming with an agent. From the first commit to production deploy. With the same discipline I&rsquo;d use with a human pair. Result: 4 projects in production in 1 month, with quality I put my name on. Because I did put my name on it.</p>
<p>If you&rsquo;re a senior and you&rsquo;re still waiting for AI to <em>&ldquo;get better&rdquo;</em> before you really start using it, here&rsquo;s my message: it&rsquo;s already good enough. The 692 commits are there to prove it. The bottleneck now is you learning to work with it.</p>
<h2>Conclusion<span class="hx:absolute hx:-mt-20" id="conclusion"></span>
    <a href="#conclusion" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>In February 2026, I built 4 projects from zero to production with AI. But the build is the easy part. What separates real software from a demo is the 125 commits that came after, when the bugs nobody predicted appeared, when external contributors sent PRs that needed hardening, when new features came up from real use and not from a requirements spreadsheet.</p>
<p>AI made me absurdly more productive. Without it, Frank Sherlock wouldn&rsquo;t have face detection in 7 days. Without it, M.Akita Chronicles wouldn&rsquo;t be in its 3rd week of operation with 1,422 tests. The speed is real.</p>
<p>But none of the decisions that mattered came from the AI. Switching models, reverting the TTS the same day, looking at the contributor PR and seeing where it was going to break, asking for a circuit breaker instead of retry. All of that was me. The AI executed. The decisions were mine.</p>
<p>The AI is the accelerator. Extreme Programming techniques (TDD, small releases, pair programming, continuous refactoring) are the brake and the steering wheel. Without discipline, AI produces fast code that piles up technical debt even faster. With discipline, AI produces software that actually evolves, week after week.</p>
<p>692 commits. 4,057 tests. 4 projects in production. And tomorrow, Monday, at 7am, M.Akita Chronicles subscribers get the 3rd newsletter. Generated, reviewed, and sent by a system that will never be <em>&ldquo;done&rdquo;</em>. Because finished software is dead software.</p>
<blockquote>
  <p>&ldquo;Finished software is dead software. Live software iterates.&rdquo;</p>

</blockquote>
]]></content:encoded><category>themakitachronicles</category><category>frankmd</category><category>franksherlock</category><category>frankmega</category><category>agile</category><category>xp</category><category>extremeprogramming</category></item><item><title>RANT: Did Akita Bend Over for AI??</title><link>https://www.akitaonrails.com/en/2026/02/24/rant-akita-caved-to-ai/</link><guid isPermaLink="true">https://www.akitaonrails.com/en/2026/02/24/rant-akita-caved-to-ai/</guid><pubDate>Tue, 24 Feb 2026 11:54:26 GMT</pubDate><description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: I have always &amp;ldquo;bent over&amp;rdquo; for AI. But if you only watched clips from the podcasts, I get why you are confused. Let me explain.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Anyone remember this video of mine: &lt;a href="https://www.akitaonrails.com/2023/09/20/akitando-145-16-linguagens-em-16-dias-minha-saga-da-rinha-de-backend/"&gt;&amp;ldquo;16 Languages in 16 Days&amp;rdquo;&lt;/a&gt;? It is from late 2023 (1 year after the first ChatGPT launched) and it is about the first Rinha de Backend. How do you think I wrote code in 16 languages in 16 days??&lt;/p&gt;</description><content:encoded><![CDATA[<blockquote>
  <p><strong>TL;DR</strong>: I have always &ldquo;bent over&rdquo; for AI. But if you only watched clips from the podcasts, I get why you are confused. Let me explain.</p>

</blockquote>
<p>Anyone remember this video of mine: <a href="/2023/09/20/akitando-145-16-linguagens-em-16-dias-minha-saga-da-rinha-de-backend/">&ldquo;16 Languages in 16 Days&rdquo;</a>? It is from late 2023 (1 year after the first ChatGPT launched) and it is about the first Rinha de Backend. How do you think I wrote code in 16 languages in 16 days??</p>
<p>I have been using AI to write code since early 2023 (3 years already). I did not start now: I never stopped. 🤷‍♂</p>
<p><em>&ldquo;But you said AI would never be any good, you hate AI&rdquo;</em></p>
<p>That is what you understood because you are lazy and only watch out-of-context clips or out-of-context tweets. If you read my blog, I have literally dozens of posts (particularly in the last 2 years) detailing every aspect of the evolution and use of AIs (whether for code, images, or 3D modelling).</p>
<h2>&ldquo;Hype?&rdquo;<span class="hx:absolute hx:-mt-20" id="hype"></span>
    <a href="#hype" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>And what about my famous line:</p>
<blockquote>
  <p><strong>&ldquo;Your hype about AI is inversely proportional to your knowledge of AI&rdquo;</strong> ?</p>

</blockquote>
<p>Still correct. Same as the people who used to say Low Code would replace every programmer. It will not. Same thing now: no matter how good AI gets, it is not going to replace every programmer. Only the bad ones, as I already explained in <a href="https://akitaonrails.com/2026/02/08/rant-ia-acabou-com-programadores/"target="_blank" rel="noopener">this other rant</a>.</p>
<p>Programmers like me have zero problem: we are the decision-makers who can decide what AIs cannot today and never will (it is an impossibility baked into the foundations of computing, I will come back to that).</p>
<p>The &ldquo;hyped-up&rdquo; crowd I talked about is every non-programmer startup bro who - with or without AI - throws garbage code into production and that is how things like the <strong>&ldquo;Tea&rdquo;</strong> app fiasco happen, remember?</p>
<p><a href="https://www.npr.org/2025/08/02/nx-s1-5483886/tea-app-breach-hacked-whisper-networks"target="_blank" rel="noopener"><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/tea.jpg" alt="tea leak"  loading="lazy" /></a></p>
<p>There was no &ldquo;hacking&rdquo; attempt at all; their database was already wide open to the public in production: anyone could just download it. That is a &ldquo;hyped about AI&rdquo; guy.</p>
<p>Or the morons who already fired all their programmers because they think AI code will be so much better. Spoiler alert: it will not.</p>
<p>So now you are even more confused. <em>&ldquo;You don&rsquo;t like AI then?&rdquo;</em> No, no, I like it. <em>&ldquo;But if you like it, it means it is already intelligent?&rdquo;</em> No, no. See how your line of reasoning makes no sense.</p>
<h2>&ldquo;Stochastic Parrot?&rdquo;<span class="hx:absolute hx:-mt-20" id="stochastic-parrot"></span>
    <a href="#stochastic-parrot" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Someone reminded me today that I also said:</p>
<blockquote>
  <p><strong>&ldquo;AI is just a glorified text generator&rdquo;</strong></p>

</blockquote>
<p>Confused? <em>&ldquo;How can it be good if it is just a text generator??&rdquo;</em></p>
<p>I really do not understand this line of reasoning. Text generators are objectively good. You and I use the auto-corrector on every email and messaging app every day. Whether it is the iPhone or Android keyboard. Whether it is Grammarly. None of you will say it has &ldquo;personality&rdquo; or even &ldquo;intelligence,&rdquo; but I think we all agree it is very useful. Even when it slips up now and then, most of the time it fixes our text just fine, right?</p>
<p>GPT or Claude or GLM or Gemini or DeepSeek, all of them are still &ldquo;stochastic parrots&rdquo; or &ldquo;glorified text generators.&rdquo;</p>
<ul>
<li>Stochastic parrot: something that repeats stuff using a random or partially random method (there is an element of entropy)</li>
<li>Text generators: they use probabilistic math (transformers on the complicated side, or Markov chains on the simple side) to try to figure out the most likely next word/token, given the preceding text.</li>
</ul>
<p>Every &ldquo;AI&rdquo; (more correctly &ldquo;LLM&rdquo;) has worked exactly like this from their launch in 2022 until today: gigabytes of hyper-dimensional matrices of weights and probabilities where your text/prompt is computed against those matrices to pick the &ldquo;next token.&rdquo; Concatenate that new token onto the original text and recompute everything again against the same matrices (more correctly, tensors), <strong>draw</strong> the next token from a small group of probabilities, concatenate, repeat, and so on.</p>
<p>That is a &ldquo;text generator.&rdquo; But yes, they are so good that they &ldquo;seem&rdquo; to have personality in their replies. And human beings are very easily fooled into &ldquo;anthropomorphizing&rdquo; non-human things. It does not take much.</p>
<p>Back in 2023, in the video <a href="/2023/06/19/akitando-142-entendendo-como-chatgpt-funciona-rodando-sua-propria-ia/">&ldquo;Understanding How ChatGPT Works&rdquo;</a> I explain the Turing Test, one of the first &ldquo;conversational&rdquo; apps that passed the test, <a href="https://en.wikipedia.org/wiki/ELIZA"target="_blank" rel="noopener">&ldquo;Eliza&rdquo;</a>, which already passed it without needing AI at all.</p>
<p>That is why I said they are &ldquo;glorified,&rdquo; because everyone who has little knowledge of AI thinks of it the way the ape in the movie thinks of the monolith: as a divinity that must be glorified.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/2001-a-Space-Odyssey.jpg" alt="2001 a Space Odyssey"  loading="lazy" /></p>
<h2>&ldquo;But What About Flow?&rdquo;<span class="hx:absolute hx:-mt-20" id="but-what-about-flow"></span>
    <a href="#but-what-about-flow" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p><em>&ldquo;But Akita, on Flow you were against AIs.&rdquo;</em></p>
<p>Nope, again, you only watched clips. Look at how I open the conversation: I am talking about the hysteria around the <a href="https://ai-2027.com/"target="_blank" rel="noopener">&ldquo;AI 2027 Report&rdquo;</a>, I am talking about Sam Altman&rsquo;s nonsense that they were very close to hitting &ldquo;AGI&rdquo; (General Artificial Intelligence, an intelligence that, like a human, reasons and decides things on its own and is capable of evolving autonomously). And all the controversies about AIs coming up with a &ldquo;plan&rdquo; to kill their owner (what I call &ldquo;fanfics&rdquo;).</p>
<p>Then I spend hours explaining in detail the history and exactly how the &ldquo;next token calculation&rdquo; is done, as I also <a href="/en/2025/04/29/dissecting-an-ollama-modelfile-tuning-qwen3-for-code/">gave more details</a> here on the blog. And I always say there has to be some &ldquo;new breakthrough&rdquo; (that has not happened yet). As long as every new GPT or Claude is just an evolution of the previous one, there are limits. And I explained the limits.</p>
<p>It is not a &ldquo;matter of time.&rdquo; That does not exist. Even though we have never been to, say, Saturn, we know what it takes and more importantly: how much it costs. <strong>&ldquo;WITH TODAY&rsquo;S TECHNOLOGY&rdquo;</strong> - which is always the premise - there is no practical way, it does not make sense.</p>
<p>Tomorrow some &ldquo;new breakthrough&rdquo; may appear that changes everything. But until it shows up, we cannot &ldquo;count on it.&rdquo; And my entire explanation is based on that premise. And the conclusion is objective and mathematical:</p>
<blockquote>
  <p>AGI is not achievable. That has not changed.</p>

</blockquote>
<p>It does not mean current AIs are <strong>USELESS</strong>. That is the counter-conclusion whose reasoning I cannot understand either. On every podcast after the first one, I would come back saying: <em>&ldquo;The way I talk, some people seem to understand that I do not like AIs, but it is the opposite: I like them.&rdquo;</em></p>
<p>I got tired of repeating this on the podcasts, on the blog, on X, but that is the part everyone pretends is not there and <strong>OMITS</strong>. That is why I always put everything in writing here on the blog and, as I said, you can see I was already using it back in 2023 - 3 years ago.</p>
<blockquote>
  <p>In summary: EVERYTHING I said on the latest videos of my channel and on the podcasts <strong>STILL HOLDS</strong>. The explanation is still correct. What is wrong are YOUR CONCLUSIONS. Review them and interpret them literally and not subjectively, with the lack of knowledge you people have. Do not ignore the terms I used that you do not understand. The argument only makes sense if it is complete, and it cost me 4 hours to explain it in each video.</p>

</blockquote>
<h2>A Message to the &ldquo;Hyped&rdquo;<span class="hx:absolute hx:-mt-20" id="a-message-to-the-hyped"></span>
    <a href="#a-message-to-the-hyped" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>I said this on X the other day:</p>
<p><a href="https://x.com/AkitaOnRails/status/2025649633318555812"target="_blank" rel="noopener"><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-24_12-23-27.jpg" alt="agile vibe code"  loading="lazy" /></a></p>
<p>It does not matter if you want to call it &ldquo;Vibe Code,&rdquo; &ldquo;Agentic Engineering,&rdquo; &ldquo;AI Assisted Programming,&rdquo; it is all <strong>bullshit</strong>.</p>
<p>EVERYTHING now is &ldquo;programming with AI.&rdquo;</p>
<p>And there are only two ways to do it: the right way and the wrong way. The wrong way is the way you and the owner of the Tea app (probably, allegedly) do it: you let AI write the code, you have no clue how to review it or criticize it, you push it to production and let users use it, with no idea of the risks.</p>
<p>The right way: I spent TWO MONTHS writing more than TWENTY POSTS on this blog explaining the right way, which I called &ldquo;Agile Vibe Code,&rdquo; but it is basically <strong>&ldquo;Software Engineering applied to AI&rdquo;</strong> and guess what: you need to have studied and have experience to know it.</p>
<p>It is not 2026 and it will not be 2027 or anywhere near as soon as you think before Claude or Codex &ldquo;replace ALL programmers.&rdquo; No, they WILL replace all the bad ones and that is excellent! Again, <a href="https://akitaonrails.com/2026/02/08/rant-ia-acabou-com-programadores/"target="_blank" rel="noopener">read this rant</a>.</p>
<p>You are never going to build a &ldquo;new Linux&rdquo; and &ldquo;replace Linus Torvalds&rdquo; with AI. You will manage to &ldquo;copy parts,&rdquo; sure. Copying is not enough. Bad programmers were already copying before, and it never made them good.</p>
<h2>A Message to the &ldquo;AI Haters&rdquo;<span class="hx:absolute hx:-mt-20" id="a-message-to-the-ai-haters"></span>
    <a href="#a-message-to-the-ai-haters" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Give up, this is a one-way street. Same as mobile, same as the internet, same as microcomputers, etc. Pandora&rsquo;s box is open, there is no closing it anymore. AIs are here to stay.</p>
<blockquote>
  <p><em>&ldquo;But you said AI was a bubble that was going to pop!&rdquo;</em></p>

</blockquote>
<p>Again, either you are playing dumb or you are dumb.</p>
<p>The 2001 Internet Bubble popped, but the Internet did not disappear. On the contrary, it grew! What disappeared were the companies that thought they would make easy money riding the bubble wave, and everyone who blindly believed in it. Still, whoever was an <strong>&ldquo;Internet Hater&rdquo;</strong> lost out too.</p>
<p>At this point, every anti-AI argument already sounds like a lousy excuse. Let&rsquo;s look at a few:</p>
<p><em>&ldquo;AI makes lots of mistakes, every now and then I see some half-assed code.&rdquo;</em></p>
<p>True, and that is exactly why I said not to get hyped for nothing. It makes mistakes, I catch them every day. Up until last year, the error vs. success rate was high enough that I could not recommend any amateur use it on a daily basis. Only someone who pays close attention and knows how to fix it - like me.</p>
<p>However, in 2026, that rate has improved significantly. It does in fact hit more than it misses now, enough for me to trust it without &ldquo;micro-managing&rdquo; every step.</p>
<p>The truth is every human programmer makes mistakes, and they make MANY. Whoever makes mistakes has confirmation bias and does not think they mess up that much. Only someone watching from the outside - someone like me, who has managed hundreds of programmers across dozens of projects - knows they screw up much more than they are prepared to admit.</p>
<p>I GOT TIRED of asking people to only push PRs with unit tests: everyone ignores it.</p>
<p>I GOT TIRED of asking people to at least run it once on their own machine to see if it works, instead of me pulling the PR and finding out it does not even run: everyone ignores it.</p>
<p>I GOT TIRED of asking people not to just copy and paste from stackoverflow, and at least adjust and adapt it for our project. Everyone ignores it.</p>
<p>I GOT TIRED of asking people not to dump everything into the same giant file and to refactor from time to time so technical debt does not pile up: everyone ignores it.</p>
<p>I GOT TIRED of asking people not to push more commits with messages saying &ldquo;bug fix,&rdquo; without explaining what is actually in there: everyone ignores it.</p>
<p>I GOT TIRED of asking people to remember to update the documentation when they change some feature, to make it easier for whoever is going to test it: everyone ignores it.</p>
<p>I GOT TIRED of asking people to cover a bug fix with a regression test so we would not see the same error again. The same error kept repeating: everyone ignores it.</p>
<p>I GOT TIRED of asking people to follow the conventions we agreed on in the project and not write different code with different patterns. Everyone ignores it.</p>
<p>I GOT TIRED of asking people to adjust deploy scripts, CI or things like that when there are pieces that affect the infra: everyone ignores it.</p>
<p>I GOT TIRED of asking people not to pile up a bunch of code that makes no sense together and not to <code>git add .</code> at the end and just write &ldquo;new feature&rdquo; and commit everything together. Everyone ignores it.</p>
<p>These are just a few examples. Know what is new: Claude and Codex do not ignore. Everything I just listed, which happens on EVERY project with humans, no matter the size of the project, does not happen to me anymore with Claude.</p>
<p>Understand: all of this should be the basics of the basics, intern level. But I GOT TIRED of asking seniors to be more careful, to not set a bad example for the juniors: EVERYONE IGNORES IT.</p>
<p>And now, all these people who WORE ME OUT, are precisely the ones who became &ldquo;AI Haters.&rdquo; But of course, the AI does everything they DO NOT. Look in the mirror and reflect on whether your code was really that good (Spoiler: it was not).</p>
<p>90% of all code produced is nowhere near something like &ldquo;optimization of the Linux memory management solution&rdquo; or &ldquo;bug fix for a performance regression in the file system drivers&rdquo; or &ldquo;improvement in the firewall security algorithm&rdquo; or &ldquo;rewriting this old Assembly part in C&rdquo;..</p>
<p>90% of most code produced day to day is <strong>MUNDANE</strong>, it is consuming an API, it is writing a front-end, it is one more report, one more CRUD, one more email validation, one more cleanup job, one more deploy script. Absolutely NOTHING actually &ldquo;interesting.&rdquo;</p>
<p>What did I like about LLMs? It REMOVES from my plate all the mundane tasks and lets me focus on the parts I like: research, forming hypotheses, benchmarks, a/b tests so I can make better decisions, integration tests that make sure the various parts of my system actually work. Everything I could not do before, because 90% of my time was spent fixing somebody&rsquo;s crappy CSS.</p>
<p>I HATE messing with CSS. It is about time I did not have to anymore.</p>
<p>I HATE writing CRUD. It is about time I did not have to anymore.</p>
<p>I HATE doing the initial dev environment setup for every different project. It is about time I did not have to anymore.</p>
<p>I HATE having to spec out idiotic things like mapping fields on a poorly designed fintech/bank API. It is about time I did not have to anymore.</p>
<p>I HATE having five hundred poorly designed front-end frameworks to stitch everything together and pray it works through trial and error. It is about time I did not have to anymore.</p>
<p>I HATE having to attend sprint meetings, where what was asked for keeps getting modified along the way because nobody paid attention. It is about time I did not have to anymore.</p>
<p>I HATE being blocked, having to wait around because another dev or another team is working on something that affects my side and in the end I will spend days later just fixing merge conflicts by hand. It is about time I did not have to anymore.</p>
<p>Who are the &ldquo;AI Haters&rdquo;: exactly everyone who used to block me before, the ones responsible for dragging out and delivering in bad quality all the MUNDANE tasks, and thinking they were doing something big.</p>
<h2>What History Has Taught Me<span class="hx:absolute hx:-mt-20" id="what-history-has-taught-me"></span>
    <a href="#what-history-has-taught-me" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Computers understand machine instructions. They do not give a damn about our &ldquo;favorite languages.&rdquo; That is what I spent hours explaining on the podcasts and in the videos on my channel.</p>
<p>Fuck your favorite language/framework. Fuck your favorite design pattern. At the end of the day, the machine only cares about the binary that is going to run.</p>
<p>Back in the day, it was extremely costly to feed those instructions to the machine. We had to literally key in, bit by bit, each instruction, at the exact address in memory. Whether with WIRES or with SWITCHES, ONE BIT AT A TIME:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/7972f02c9ad7fe9bc30d1493b1295188.jpg" alt="eniac"  loading="lazy" /></p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/NV_0103_Driscoll_Large.jpg" alt="Altair 8800"  loading="lazy" /></p>
<p>Fortunately, things evolved and we improved the ways of &ldquo;INPUTTING&rdquo; instructions and data. Whether with punched paper or teletypes (electric typewriters adapted as dumb terminals):</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/s-l400.jpg" alt="punched card"  loading="lazy" /></p>
<p><a href="https://www.youtube.com/watch?v=zeL3mbq1mEg"target="_blank" rel="noopener"><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-24_12-55-27.jpg" alt="teletype"  loading="lazy" /></a></p>
<p>Programming was expensive, because it was VERY costly to &ldquo;fix&rdquo; an error. There was no easy &ldquo;backspace.&rdquo; The moment you went to type, you had to be VERY SURE of what you were going to type, without screwing up!</p>
<p>Even by the late 70s, early 80s, it had already evolved a lot, but having permanent storage was an OPTIONAL thing on most &ldquo;micro&rdquo; computers. We had to type the programs in from scratch to run them and when the machine shut down, whatever was in RAM was wiped and we lost everything. Recording was expensive, and one of the most popular options was recording to cassette tape:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/c64set.jpg" alt="c64set"  loading="lazy" /></p>
<p>But to record/read just a measly 1 kilobyte of data, it could take MORE than 20 seconds. A little 10-kilobyte game (which is very little), would take almost 4 MINUTES to load.</p>
<p>You have no idea what 10 kilobytes means. Any PNG or SVG icon or any stupid little JS file on your website has way more than that.</p>
<p>All this to say the following:</p>
<ul>
<li>computers run machine instructions, to this day</li>
<li>it has always been costly to feed those instructions to the computer</li>
</ul>
<p>Nowadays we have NVME SSDs that let me read 1 gigabyte today (&lt;70ms) faster than I used to read 10 kilobytes in the 80s.</p>
<p>But to compensate, our programs kept getting more and more complex and &ldquo;bloated.&rdquo; Once memory, storage, data bus, everything became orders of magnitude faster, we humans kept throwing more and more data at them. A text editor today does the same thing a text editor in the 80s did: edits text. But, of course, it has way more features: pretty fonts, smooth scrolling, auto-correct, auto-format, etc.</p>
<p>We traded resources for comfort, and that is a good thing.</p>
<p>I said in my videos, in the recent rant posts, that it was a very bad thing that the startup bubble only popped in 2022 (and its aftershocks are still going today). We traded programming efficiency for cheap bad programmers. Why bother trying to be more efficient if it was &ldquo;cheap&rdquo; to just throw more &ldquo;bodies&rdquo; at the problem? The same mentality that enabled the rise of cheap software sweatshops in the 2000s, cranking out bad software like there was no tomorrow.</p>
<p>Personally, I am very happy we finally took one more step toward feeding instructions to the machine more efficiently. LLMs, our stochastic parrots, are, in fact, the most efficient way to produce 90% of the instructions we need to give the machines so they can compute what we need. Same way I do not miss punched cards, or teletypes, or cassette tape, if I do not need to use an IDE in my day-to-day anymore, I will not miss it.</p>
<blockquote>
  <p>I did not choose to become a programmer to become an IDE operator. When I started, the concept of IDEs did not even exist.</p>

</blockquote>
<p>I chose to become a programmer because I like the idea of instructing a machine to compute the things I want. Whether it is a spreadsheet or a game. HOW those instructions are input is not the main thing for me. It never was. IDEs were just a small phase within decades of career and they will not be the last.</p>
<blockquote>
  <p>I do not understand the reasoning of the over-hyped or the over-haters. Why do you need to glorify a hammer? Why do you need to hate a hammer? That is all I needed to say 🤷‍♂</p>

</blockquote>
<h2>Some Idiotic Excuses<span class="hx:absolute hx:-mt-20" id="some-idiotic-excuses"></span>
    <a href="#some-idiotic-excuses" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p><em>&ldquo;But Anthropic (or OpenAI) are evil&rdquo;</em></p>
<p>Fuck that. Microsoft was too, it even went through an anti-trust trial in the year 2000 that almost split the company. Even though I prefer Mac or Linux today, has Windows stopped being the most used operating system in the world? No.</p>
<p><em>&ldquo;But AI uses a lot of energy, what about the environment?&rdquo;</em></p>
<p>Fuck that. This was never a consensus (and most likely never will be and is not even a problem). The planet has already gone through 5 or 6 great extinction events before, the last one was a meteor that fell and wiped out the dinosaurs. We have gone through multiple Ice Ages. The planet is going to be fine, don&rsquo;t worry.</p>
<p>The problem of the last decade was the stupidity of not investing in more nuclear power plants - and now that option is finally back on the table. I said on Flow that Germany shutting down theirs was one of the dumbest decisions of all time, and I stand by it: it was.</p>
<p>Those are the two most common ones I can think of today. But that is how it is: anything is an excuse now. Pandora&rsquo;s box has been opened, there is no going back.</p>
]]></content:encoded><category>vibecode</category><category>rant</category></item><item><title>Vibe Code: I Built a Smart Image Indexer with AI in 2 Days | Frank Sherlock</title><link>https://www.akitaonrails.com/en/2026/02/23/vibe-code-built-a-smart-image-indexer-with-ai-in-2-days-frank-sherlock/</link><guid isPermaLink="true">https://www.akitaonrails.com/en/2026/02/23/vibe-code-built-a-smart-image-indexer-with-ai-in-2-days-frank-sherlock/</guid><pubDate>Mon, 23 Feb 2026 18:34:34 GMT</pubDate><description>&lt;p&gt;Over the last 48 hours, I built a complete desktop application from scratch, with published binaries for Linux, macOS, and Windows. 50 commits, ~26 hours of effective work, 8,359 lines of Rust, 5,842 of TypeScript, 338 automated tests. If you told me this 2 years ago, I would have called it a lie.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://github.com/akitaonrails/FrankSherlock/raw/master/docs/frank_sherlock.png" alt="frank sherlock" loading="lazy" /&gt;&lt;/p&gt;
&lt;p&gt;The name is &lt;a href="https://github.com/akitaonrails/FrankSherlock"target="_blank" rel="noopener"&gt;&lt;strong&gt;Frank Sherlock&lt;/strong&gt;&lt;/a&gt; — a local image cataloging and search system using AI. You point it at a folder (it can be a NAS with terabytes), it scans everything, classifies each file using a vision LLM running locally on your GPU, and gives you full-text search over the content. It&amp;rsquo;s not cloud, nothing is sent out, it runs 100% on your machine.&lt;/p&gt;</description><content:encoded><![CDATA[<p>Over the last 48 hours, I built a complete desktop application from scratch, with published binaries for Linux, macOS, and Windows. 50 commits, ~26 hours of effective work, 8,359 lines of Rust, 5,842 of TypeScript, 338 automated tests. If you told me this 2 years ago, I would have called it a lie.</p>
<p><img src="https://github.com/akitaonrails/FrankSherlock/raw/master/docs/frank_sherlock.png" alt="frank sherlock"  loading="lazy" /></p>
<p>The name is <a href="https://github.com/akitaonrails/FrankSherlock"target="_blank" rel="noopener"><strong>Frank Sherlock</strong></a> — a local image cataloging and search system using AI. You point it at a folder (it can be a NAS with terabytes), it scans everything, classifies each file using a vision LLM running locally on your GPU, and gives you full-text search over the content. It&rsquo;s not cloud, nothing is sent out, it runs 100% on your machine.</p>
<p>Here are some examples of text it extracted from some of my images:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-23_16-08-03.png" alt="game boy example"  loading="lazy" /></p>
<p>And check out the Surya OCR details: it read the text on the Game Boy screen perfectly:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-23_16-08-14.png" alt="gameboy screen ocr"  loading="lazy" /></p>
<p>More than that, I have directories of screenshots of payment receipts. I would never find anything in there again, but now:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-23_16-07-37.png" alt="santander comprovante"  loading="lazy" /></p>
<p>It does make some mistakes, of course, with obscure anime (it keeps thinking everything it doesn&rsquo;t recognize is Evangelion 😂). But it surprisingly gets many of them right too. And the descriptions themselves already help a lot.</p>
<p>You&rsquo;ll need a minimum GPU (I only tested on my 5090, but there are smaller models to download for smaller GPUs, and it theoretically supports AMD and Mac too, but I haven&rsquo;t tested yet - I&rsquo;ll accept Issues and Pull Requests from anyone who wants to do beta-testing on Mac and Windows). You just need Ollama installed and running; optionally, Python (to have Surya OCR, which is optional, but is the best).</p>
<p>I&rsquo;ll mark it as &ldquo;1.0&rdquo; when I have more people testing on Windows/macOS and I have the certificates to sign the executables properly. This is still a &ldquo;beta&rdquo; version! Compile and run it yourselves on your machines, everything is explained in the <a href="https://github.com/akitaonrails/FrankSherlock/blob/master/README.md"target="_blank" rel="noopener">README</a>.</p>
<p>And I did this with my <a href="2026/02/20/do-zero-a-pos-producao-em-1-semana-como-usar-ia-em-projetos-de-verdade-bastidores-do-the-m-akita-chronicles/">&ldquo;Agile Vibe Coding&rdquo;</a> — basically, programming in partnership with an LLM (in this case, Claude Code).</p>
<blockquote>
  <p><strong>Agile Vibe Coding works. And it works very well. But the idea is only 10% of the work. The other 90% is engineering.</strong></p>

</blockquote>
<p>Engineering requires experience, judgment, and knowing how to ask the right questions. The LLM is an excellent executor. But whoever decides what to execute, in what order, and why, that&rsquo;s still the developer&rsquo;s job.</p>
<p>There&rsquo;s a growing discourse that anyone with a good idea can build software now. In a certain sense, yes, you can get a prototype up fast. But the distance between &ldquo;runs on my machine&rdquo; and &ldquo;software that works on 3 operating systems, survives cancellations in the middle of processing, doesn&rsquo;t corrupt data, and scales from 94 test files to 500,000 in production&rdquo; is still enormous. That gap is engineering, and engineering still demands someone who knows what they&rsquo;re doing.</p>
<p>I&rsquo;ll tell the complete story: from initial research to release, going through benchmarks, proof of concept, architecture decisions, multi-platform CI/CD, and everything that sat between &ldquo;I have an idea&rdquo; and &ldquo;here&rsquo;s the AppImage, the DMG, and the MSI&rdquo;.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-23_15-47-30.png" alt="4 image preview"  loading="lazy" /></p>
<h2>The Original Idea<span class="hx:absolute hx:-mt-20" id="the-original-idea"></span>
    <a href="#the-original-idea" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>It all started with a simple question: can open-source vision LLMs actually classify content? I&rsquo;m not talking about &ldquo;woman on the beach&rdquo; — any model does that. I&rsquo;m talking about looking at an image and saying &ldquo;this is Ranma, from the anime Ranma 1/2 by Rumiko Takahashi, in a scene from the OVA The Battle for Miss Beachside&rdquo;. Are we at that level yet? (TL;DR no, but enough)</p>
<p>And if we are, can you build a smart file catalog? Something where I point at my NAS with terabytes of media accumulated over decades and can search by content, not by filename? Anyone with a home NAS knows: after a few years, files pile up and directory organization simply stops scaling. You know you have that 2019 payment receipt somewhere, but the file is called <code>IMG_20190315_142301.jpg</code> and it&rsquo;s in a directory with 3,000 other photos.</p>
<p>My hardware: AMD 7850X3D, RTX 5090, Arch Linux. Absolute restriction: no remote APIs, no OpenAI, no cloud. Everything open-source, everything local. If I&rsquo;m going to process terabytes of personal files, including financial documents and private photos, I don&rsquo;t want to send anything to third-party servers. Plus the cloud API cost for that volume would be prohibitive.</p>
<p>But first: research. Without knowing if the technology delivers what I need, there&rsquo;s no point building anything. Small scripts, quick prototypes, different models. See what works before writing the first line of code of the real app. This is a pattern I&rsquo;ve followed for years: validate the riskiest assumption first. If the vision LLM can&rsquo;t classify decently, everything else is a waste.</p>
<h2>A/B Research: Benchmark Driven Development<span class="hx:absolute hx:-mt-20" id="ab-research-benchmark-driven-development"></span>
    <a href="#ab-research-benchmark-driven-development" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>This is the part most people skip, and it&rsquo;s exactly where experience makes the difference. The temptation to jump straight into implementation is enormous. <em>&ldquo;I&rsquo;ll use model X because I read on a blog that it&rsquo;s good.&rdquo;</em> No. Before choosing model, framework, or architecture, I set up a <a href="https://github.com/akitaonrails/FrankSherlock/tree/master/_research_ab_test"target="_blank" rel="noopener">formal benchmark</a>.</p>
<p>I built a test corpus with 94 files: 60 images (photos, screenshots, anime, documents, receipts), 9 audios, 13 videos, and 12 documents. For each file, I created a ground truth in JSON with the correct classification — type, description, series (when applicable). That ground truth is what lets you measure real accuracy, not <em>&ldquo;I looked at the result and it seemed OK&rdquo;</em>.</p>
<p>The benchmark has 6 phases, each answering a specific question:</p>
<ol>
<li><strong>Metadata</strong>: how much does it cost to extract basic metadata? (answer: cheap, 0.07s/file)</li>
<li><strong>Images</strong>: which vision model is best? Which OCR?</li>
<li><strong>Audio</strong>: does Whisper work? Which model size?</li>
<li><strong>Video</strong>: does frame-based classification work?</li>
<li><strong>Unified catalog</strong>: does the full integrated pipeline work?</li>
<li><strong>Cost projection</strong>: how much time and money to process a real NAS?</li>
</ol>
<h3>Phase 2: Vision — The Result Nobody Expected<span class="hx:absolute hx:-mt-20" id="phase-2-vision--the-result-nobody-expected"></span>
    <a href="#phase-2-vision--the-result-nobody-expected" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>I tested <code>qwen2.5vl:7b</code>, <code>llava:13b</code>, and <code>minicpm-v:8b</code> on 30 labeled images. The result:</p>
<table>
  <thead>
      <tr>
          <th>Model</th>
          <th style="text-align: center">Type Accuracy</th>
          <th style="text-align: center">Series Accuracy</th>
          <th style="text-align: center">JSON Valid</th>
          <th style="text-align: center">Latency/img</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>qwen2.5vl:7b</td>
          <td style="text-align: center"><strong>0.80</strong></td>
          <td style="text-align: center"><strong>0.14</strong></td>
          <td style="text-align: center"><strong>0.87</strong></td>
          <td style="text-align: center">0.55s</td>
      </tr>
      <tr>
          <td>minicpm-v:8b</td>
          <td style="text-align: center">0.50</td>
          <td style="text-align: center">0.00</td>
          <td style="text-align: center">0.83</td>
          <td style="text-align: center">1.63s</td>
      </tr>
      <tr>
          <td>llava:13b</td>
          <td style="text-align: center">0.33</td>
          <td style="text-align: center">0.06</td>
          <td style="text-align: center">0.83</td>
          <td style="text-align: center">1.62s</td>
      </tr>
  </tbody>
</table>
<p>The 7B parameter model crushed the larger ones. It&rsquo;s not a typo. <code>qwen2.5vl:7b</code> beat <code>llava:13b</code> (almost twice its size) in every metric, and was also 3x faster. This contradicts the intuition of <em>&ldquo;bigger model = better model&rdquo;</em>. In practice, it depends on the task and the prompt.</p>
<p>Naturally, the next question is: what about the 32B? Same model, giant version. We should be able to get much more, right? Wrong:</p>
<table>
  <thead>
      <tr>
          <th>Model</th>
          <th style="text-align: center">Type Accuracy</th>
          <th style="text-align: center">Series Accuracy</th>
          <th style="text-align: center">Latency/img</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>qwen2.5vl:7b</td>
          <td style="text-align: center">0.87</td>
          <td style="text-align: center">0.19</td>
          <td style="text-align: center"><strong>0.77s</strong></td>
      </tr>
      <tr>
          <td>qwen2.5vl:32b</td>
          <td style="text-align: center">0.87</td>
          <td style="text-align: center">0.25</td>
          <td style="text-align: center">22.46s</td>
      </tr>
  </tbody>
</table>
<p>The 32B gave +0.06 in <em>series accuracy</em> (literally 1 more hit out of 16 labeled items) and cost <strong>29x more time</strong>. For someone who&rsquo;s going to process hundreds of thousands of files, this trade doesn&rsquo;t close. 29x slower means a 6-hour job turns into a week-long job.</p>
<p>Here I&rsquo;ll make a comment about tooling: I did the first round with Claude Code and had asked it to pick the models it thought were best. But then I decided to go to GPT Codex and it made other suggestions I found interesting to test. In summary: I&rsquo;ve been finding Codex much better for <strong>experimentation</strong> and <strong>exploratory code</strong>, for actual research. I find Claude better when we already know exactly what we want.</p>
<p>With Codex, I tested the new candidates <code>qwen3-vl:8b</code> and <code>qwen3-vl:30b-a3b</code> with 3 repetitions for statistical significance. The result? Both <em>worse</em> than <code>qwen2.5vl:7b</code> — <code>type_accuracy</code> of 0.55 versus 0.89 for the incumbent, with a 95% confidence interval that doesn&rsquo;t even come close. And even slower: 2x and 2.2x respectively. A newer model isn&rsquo;t always a better model for your use case. qwen3-vl frequently returned truncated or malformed JSON — a real regression in robustness.</p>
<h3>OCR: Surya vs Ollama Vision<span class="hx:absolute hx:-mt-20" id="ocr-surya-vs-ollama-vision"></span>
    <a href="#ocr-surya-vs-ollama-vision" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>For text extraction (scanned documents, receipts, screenshots with text), I tested two engines:</p>
<table>
  <thead>
      <tr>
          <th>Engine</th>
          <th style="text-align: center">Coverage</th>
          <th style="text-align: center">Similarity</th>
          <th style="text-align: center">Latency/img</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Surya</td>
          <td style="text-align: center">54/65 files</td>
          <td style="text-align: center">0.9455</td>
          <td style="text-align: center">8.15s</td>
      </tr>
      <tr>
          <td>Ollama Vision</td>
          <td style="text-align: center">38/65 files</td>
          <td style="text-align: center">0.9419</td>
          <td style="text-align: center">1.73s</td>
      </tr>
  </tbody>
</table>
<p>Surya finds text in many more files (83% vs 58%), but is 5x slower. When it finds text, the quality is practically the same (similarity &gt; 0.94 on both). Obvious solution: hybrid approach. Use Surya when you need maximum coverage, Ollama Vision as fast fallback. The pipeline design became: try Surya → if it fails or doesn&rsquo;t find text → fallback to Ollama Vision. That&rsquo;s why I said at the beginning that Surya is optional, if you don&rsquo;t want to install it.</p>
<h3>Cost Projection<span class="hx:absolute hx:-mt-20" id="cost-projection"></span>
    <a href="#cost-projection" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Phase 6 did something I rarely see in open-source projects: cost projection for real usage. With the timings measured per file type, we extrapolated to NAS scenarios:</p>
<table>
  <thead>
      <tr>
          <th>Scenario</th>
          <th style="text-align: center">Files</th>
          <th style="text-align: center">Time</th>
          <th style="text-align: center">Electric Cost</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Test corpus</td>
          <td style="text-align: center">94</td>
          <td style="text-align: center">24 min</td>
          <td style="text-align: center">$0.02</td>
      </tr>
      <tr>
          <td>Small NAS</td>
          <td style="text-align: center">~5K</td>
          <td style="text-align: center">6.6 h</td>
          <td style="text-align: center">$0.36</td>
      </tr>
      <tr>
          <td>Medium NAS</td>
          <td style="text-align: center">~50K</td>
          <td style="text-align: center">2.6 days</td>
          <td style="text-align: center">$3.37</td>
      </tr>
      <tr>
          <td>Large NAS</td>
          <td style="text-align: center">~500K</td>
          <td style="text-align: center">26 days</td>
          <td style="text-align: center"><strong>$33.70</strong></td>
      </tr>
  </tbody>
</table>
<p>Take these numbers with some skepticism because it&rsquo;s back-of-napkin math. $34 in electricity to classify 500,000 files with a local GPU. Try doing that with the GPT-4 Vision API — at $0.01 per image (conservative), that&rsquo;s $5,000. The price of my setup (GPU + electricity) pays for itself on the first big use.</p>
<p>The lesson: <strong>benchmark, don&rsquo;t guess</strong>. I could have assumed the bigger model would be better, or that the newer one would beat the older. The data showed the opposite. ~2 hours of benchmarks saved me from wrong choices that would cost days of rework (in the old days I would have said &ldquo;weeks&rdquo;).</p>
<h2>Proof of Concept in Python<span class="hx:absolute hx:-mt-20" id="proof-of-concept-in-python"></span>
    <a href="#proof-of-concept-in-python" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>With the benchmarks in hand, the next step was to validate the complete pipeline before moving to Rust. Not the benchmark pipeline (which tests each model in isolation), but the real classification pipeline — the exact sequence of calls that the final app will make for each file.</p>
<p>I created a Python prototype — 754 lines in a single file (<a href="https://github.com/akitaonrails/FrankSherlock/blob/master/_classification/run_classification.py"target="_blank" rel="noopener"><code>classification/run_classification.py</code></a>) — implementing the winning strategy from the benchmarks.</p>
<p>The pipeline has 4 enrichment stages:</p>
<ol>
<li><strong>Primary classification</strong>: sends the image to <code>qwen2.5vl:7b</code> with a structured prompt, asks for JSON with type, description, tags, confidence</li>
<li><strong>Anime enrichment</strong>: if the primary type is anime/manga/cartoon, does a second pass with a specialized prompt asking for series, character, scene, artist</li>
<li><strong>Document/OCR</strong>: if it&rsquo;s a document or receipt, extracts text with Surya and/or Ollama Vision, then asks for structured data (dates, values, transaction IDs)</li>
<li><strong>Output</strong>: writes the result in YAML mirroring the directory structure of the source</li>
</ol>
<p>The most critical part is parsing the LLM&rsquo;s JSON. Anyone who has worked with LLM output knows they&rsquo;re&hellip; creative with formatting. Sometimes it comes with markdown fences (<code>json ... </code>). Sometimes there&rsquo;s text before and after the JSON. Sometimes the JSON is almost right but missing a closing brace. The prototype implemented a 3-attempt cascade that later became the rule for the whole project:</p>
<ol>
<li><strong>Direct parse</strong>: <code>json.loads(response)</code> — works in ~70% of cases</li>
<li><strong>Brace-balancing extraction</strong>: finds the first <code>{</code>, counts opened and closed braces, extracts the substring — catches another ~20%</li>
<li><strong>Regex field salvage</strong>: if all else fails, uses regex to extract individual fields (&ldquo;type&rdquo;: &ldquo;&hellip;&rdquo;, &ldquo;description&rdquo;: &ldquo;&hellip;&rdquo;) — saves the last ~10%</li>
</ol>
<p>This cascade stayed in the Rust code practically identical. A well-done PoC shortens the path to production.</p>
<p>Result: 60 images processed, zero errors, 6.29s/image average. The pipeline worked end to end. Time to build the real app.</p>
<h2>Building the Tauri App<span class="hx:absolute hx:-mt-20" id="building-the-tauri-app"></span>
    <a href="#building-the-tauri-app" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>At this point, I tried to continue with Codex, but then it choked on trying to convert the Python PoC to Tauri. But since the choices had already been made, I went back to Claude Code, and it had no problems mapping from Python to Rust.</p>
<p>Here&rsquo;s where vibe coding shows what it can do. I&rsquo;ll tell the real timeline, commit by commit, so you get a sense of the rhythm. The timestamps are from git log, so they&rsquo;re accurate.</p>
<h3>Saturday 02/21 (19:29 → 21:08) — 6 commits, 1h39<span class="hx:absolute hx:-mt-20" id="saturday-0221-1929--2108--6-commits-1h39"></span>
    <a href="#saturday-0221-1929--2108--6-commits-1h39" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>It all started with <a href="https://github.com/akitaonrails/FrankSherlock/tree/master/_research_ab_test"target="_blank" rel="noopener">research</a>. Six commits of setup, benchmark scripts, results analysis:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>f57221c 19:29 Phase 0: Project setup with uv, environment verification
4977497 19:45 Implement all phases: metadata, image/audio/video classification
af6e3aa 19:49 Add research results report
41d6c2a 20:12 Add per-file timing, OCR phase, cost estimation
0cd1b10 21:02 Fix Surya OCR, re-run on 94-file corpus
25b3ace 21:08 Add conclusions, cost analysis, recommended pipeline</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Saturday night, pure research only. Not a single line of app code. But when I went to bed, I knew which model to use, which OCR approach, and how much it would cost in time and electricity.</p>
<h3>Sunday 02/22 (13:05 → 23:30) — 14 commits, ~10h25<span class="hx:absolute hx:-mt-20" id="sunday-0222-1305--2330--14-commits-10h25"></span>
    <a href="#sunday-0222-1305--2330--14-commits-10h25" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Sunday was the day of heavy construction. I woke up, had lunch, and sat down to work.</p>
<p><strong>13:05</strong> — First commit of the day: the Python classification prototype. 749 lines validating the complete classification pipeline, the JSON parsing cascade, and the conditional enrichment. This prototype wasn&rsquo;t throwaway — it was the executable design document for the Rust pipeline that would come later.</p>
<p>I took a break to go out, take a walk, have a glass of wine, then came back and continued:</p>
<p><strong>17:31</strong> — Benchmarks updated with the new candidates (qwen3-vl:8b and qwen3-vl:30b-a3b). Three repetitions each, confidence intervals. They confirmed that qwen2.5vl:7b was the right choice — not by a little, but by a huge margin.</p>
<p><strong>17:56</strong> — The &ldquo;big bang&rdquo;: Tauri app scaffold. <strong>9,631 lines inserted</strong> in a single commit. The entire app structure: Rust backend with SQLite + FTS5, React frontend, config system, file scanner, data models, query parser for natural search. At that point the app was already searching the database. It didn&rsquo;t have classification yet, but the foundation was solid — and that&rsquo;s exactly the point. The scaffold came with tests, TypeScript types, and the directory architecture I defined. Claude generated the code, but the module structure (config, db, scan, models, query_parser) came from how I wanted to organize responsibilities.</p>
<p><strong>19:04</strong> — The heaviest commit of the project: <strong>4,186 lines</strong>. Classification pipeline in Rust (1,069 lines of <code>classify.rs</code> — practically the translation of the Python PoC), thumbnail generation (Lanczos3, 300px, JPEG 80%), incremental scanning with fingerprint, the Surya OCR Python script, runtime detection for Ollama, and the brutal expansion of the database with upsert, touch, delete, and FTS indexing. In one commit. With 47 tests. I had to make the architecture decisions (how the cache mirrors the rel_path, how the scan is divided into two phases, how errors propagate), but Claude wrote most of the code and the tests that came with it.</p>
<p><strong>19:55</strong> — UI redesign, scan cancellation, auto-cleanup of orphan classifications, reorganization of the whole repo (moved everything from research to <code>_research_ab_test/</code>). CI and release workflow already configured in this commit — I knew I was going to need them the next day.</p>
<p><strong>21:02</strong> — DB resilience (WAL mode, backup, health check), root management (add, remove, list monitored directories), zoom, sidebar redesign with tree view, thumbnail fix. Four features in one commit. In a manual workflow, each one of these would be a separate PR with days of review.</p>
<p><strong>21:14 → 21:39</strong> — Read-only database mode for sandbox filesystems, resume of interrupted scans on startup, grid tiles redesign with hover overlay, selection model, infinite scroll. Three commits in 25 minutes. Claude was on an absurd pace.</p>
<p><strong>22:41</strong> — Multi-select with Ctrl/Shift click, collage preview with selected items, Ctrl+C copies to the OS clipboard.</p>
<p><strong>23:30</strong> — The big refactor: monolithic frontend (everything in <code>App.tsx</code> - if you don&rsquo;t explain, Claude always does this) broken into 15 components + 10 hooks + 84 frontend tests. This is the kind of thing that normally takes a full day of tedious work. With vibe coding, it was a one-hour commit. Claude extracted each component, created the hooks, set up the tests with proper mocks, and kept everything working. I just had to say <em>&ldquo;refactor this monolith into components and hooks, and write tests for each one&rdquo;</em>.</p>
<h3>Monday 02/23 (00:09 → 14:33) — 30 commits, ~14h<span class="hx:absolute hx:-mt-20" id="monday-0223-0009--1433--30-commits-14h"></span>
    <a href="#monday-0223-0009--1433--30-commits-14h" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Monday was polish, robustness, and the cross-platform marathon.</p>
<p><strong>00:09</strong> — Multi-OS abstraction: all platform-specific code isolated in the <code>platform/</code> module. This decision, taken early, saved hours of pain in CI. When Windows needed special treatment for UNC paths, the change stayed contained in <code>platform/process.rs</code> instead of spread across 8 files.</p>
<p><strong>00:12 → 01:18</strong> — Quick sequence: cargo audit in CI, duplicate removal, GPL-3.0 license, help dialog (F1) with query syntax examples, sort controls for results, formal SQLite migration system, context menu (copy, delete, rename). Six commits in just over an hour.</p>
<p><strong>09:38</strong> — After sleeping about 8 hours, the first commit of the morning: extraction of the LLM module. The monolith of Ollama calls that lived inside <code>classify.rs</code> was separated into <code>llm/client.rs</code> (HTTP calls, JSON parsing), <code>llm/management.rs</code> (download, listing, cleanup of models), and <code>llm/model_selection.rs</code> (hardware-aware selection by tier). Atomic file ops so cache doesn&rsquo;t get corrupted if the process dies in the middle of a write.</p>
<p><strong>10:54</strong> — EXIF location extraction (GPS coordinates → readable address), metadata editing modal, setup instructions per OS (each OS has different dependencies for Ollama and Python).</p>
<p><strong>11:34</strong> — PDF support: scanning, indexing, and preview using PDFium. Not trivial — PDFium needs a native binary per platform, page rendering to image, blank page detection (so you don&rsquo;t generate a thumbnail of an empty cover), thumbnail assembly with the first 2 pages that have content, and native text extraction as a faster alternative to OCR.</p>
<p><strong>12:08</strong> — Albums and Smart Folders. Albums are manual collections (the user drags files in), Smart Folders are saved queries that appear in the sidebar and update automatically. Two database migrations, new sidebar component, drag-and-drop. In 34 minutes.</p>
<p><strong>12:11 → 12:52</strong> — macOS-inspired SVG icons in the sidebar, CLI argument support (<code>sherlock /path/to/folder</code>), copy description/OCR text in the context menu, PDFium path fix in production, icons and screenshots in the README, <code>tauri</code> script in npm, sidebar toggle, dynamic titlebar, Windows compilation fix (Unix-only imports). Ten commits in 41 minutes. Most of these were issues that showed up in CI or during manual testing.</p>
<p><strong>13:14 → 13:54</strong> — The CI fixes marathon. Auto-provision of the Surya OCR venv with progress bar in the SetupModal, icon regeneration with alpha channel, <code>cargo fmt</code> + clippy + UNC paths fix on Windows, tests for help dialog examples, individual folder rescan, Windows assertions fix, release workflow permissions fix. Seven hardening commits. Each one resolving a real bug that appeared in the CI matrix or in testing.</p>
<p><strong>14:26</strong> — Drag-and-drop to reorder roots in the sidebar, scan cancellation before deleting a root (so you don&rsquo;t leave the scan running in the background on a directory the user removed — a subtle edge case that could cause a crash).</p>
<p><strong>14:33</strong> — Last commit: responsiveness fix in scan cancellation. Check the cancel flag after each classification, immediate poll instead of waiting for the next tick. Small detail, big impact on UX.</p>
<h2>Architecture Decisions<span class="hx:absolute hx:-mt-20" id="architecture-decisions"></span>
    <a href="#architecture-decisions" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Here&rsquo;s what separates <em>&ldquo;letting the LLM write code&rdquo;</em> from &ldquo;building real software&rdquo;. None of these decisions came from a prompt. Claude didn&rsquo;t suggest any of them spontaneously. I had to ask for each one.</p>
<h3>Read-Only Principle<span class="hx:absolute hx:-mt-20" id="read-only-principle"></span>
    <a href="#read-only-principle" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>The app <strong>never</strong> writes to the scanned directories. Everything — thumbnails, classification cache, database — sits in <code>~/.local/share/frank_sherlock/</code>. This is more than good practice, it&rsquo;s respect for the user&rsquo;s data. If someone points at the company NAS, the app can&rsquo;t go around creating <code>.sherlock/</code> in every subdirectory. If the directory is mounted as read-only via NFS, the app needs to work normally. It sounds obvious, but many cataloging apps you know create caches and thumbnails <strong>inside</strong> the source directories. (cough Synology @eaDir cough)</p>
<h3>Incremental Scanning<span class="hx:absolute hx:-mt-20" id="incremental-scanning"></span>
    <a href="#incremental-scanning" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Scanning terabytes of data every time the app opens would be insane. The scan is incremental in two senses:</p>
<ol>
<li><strong>Discovery phase</strong> (fast): walks the filesystem comparing mtime + size of each file. If nothing changed since the last scan, it doesn&rsquo;t even read the content — just updates the &ldquo;seen in this scan&rdquo; marker. For a NAS with 500K files where 99% hasn&rsquo;t changed, this phase takes seconds, not hours.</li>
<li><strong>Processing phase</strong> (heavy): only for new or modified files. Calculates fingerprint (SHA-256 of the first 64KB), generates thumbnail, classifies with the LLM. And here&rsquo;s where move detection comes in: if a file changed path but the fingerprint is the same, the app preserves the entire classification already done and just updates the path. You reorganized 10,000 photos into new folders? The app detects and doesn&rsquo;t reclassify any of them.</li>
</ol>
<p>The checkpoint is per file. If the scan is interrupted (the app crashed, the user closed it, the power went out), the next time it resumes from the last processed file, not from zero. This is implemented via scan job persistence in the database: the scan cursor is saved in <code>scan_jobs</code>.</p>
<h3>Cooperative Cancellation<span class="hx:absolute hx:-mt-20" id="cooperative-cancellation"></span>
    <a href="#cooperative-cancellation" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>The scan runs on a separate thread via <code>tokio::spawn_blocking</code>. To cancel, I use an <code>AtomicBool</code> shared between the scan thread and the frontend. The flag is checked:</p>
<ul>
<li>Before each file in the discovery phase</li>
<li>Before each classification in the processing phase</li>
<li>After each classification (in case the Ollama call takes a while)</li>
</ul>
<p>This ensures cancellation responds in at most the time of one classification (~1 second), not the time of the whole scan. Without this design, canceling a scan of 500K files could take minutes — or simply not work.</p>
<h3>Database Resilience<span class="hx:absolute hx:-mt-20" id="database-resilience"></span>
    <a href="#database-resilience" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>SQLite with WAL mode (allows concurrent reads during writes), health check on startup, automatic backup before migrations, formal migration system via <code>rusqlite_migration</code>. Five migrations in total:</p>
<ol start="0">
<li>Initial schema (files, roots, scan_jobs tables, and the virtual FTS5 table for search)</li>
<li><code>location_text</code> column for EXIF address</li>
<li>FTS index rebuild (needed after changing tokenization)</li>
<li><code>albums</code> and <code>album_files</code> tables for manual collections</li>
<li><code>smart_folders</code> table for saved queries</li>
</ol>
<p>Migrations are identified by position and can never be edited or reordered after being published. This is the kind of rule you learn after corrupting someone&rsquo;s production database once. The rule is coded in the project&rsquo;s CLAUDE.md so future vibe coding sessions don&rsquo;t violate it.</p>
<h3>Hardware-Aware Model Selection<span class="hx:absolute hx:-mt-20" id="hardware-aware-model-selection"></span>
    <a href="#hardware-aware-model-selection" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Not everyone has an RTX 5090. The app detects the GPU on startup and picks the appropriate model:</p>
<ul>
<li><strong>Weak GPU or no GPU</strong>: <code>qwen2.5vl:3b</code> (small tier — runs on anything)</li>
<li><strong>GPU with &gt;= 6GB VRAM</strong>: <code>qwen2.5vl:7b</code> (medium tier, the benchmark default)</li>
<li><strong>Apple Silicon with &gt;= 48GB unified</strong>: <code>qwen2.5vl:32b</code> (large tier, only where unified memory allows without swap)</li>
</ul>
<p>Detection uses <code>nvidia-smi</code> on Linux/Windows, <code>system_profiler</code> on macOS, and <code>sysinfo</code> as fallback for system RAM. The result is cached in the Tauri <code>AppState</code> so it doesn&rsquo;t keep running subprocesses all the time.</p>
<h2>Multi-OS and CI/CD<span class="hx:absolute hx:-mt-20" id="multi-os-and-cicd"></span>
    <a href="#multi-os-and-cicd" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>If there&rsquo;s one part of the project that justifies having experience, it&rsquo;s this one. Making software that compiles is easy. Making software that compiles and passes all tests on Linux, macOS, and Windows at the same time teaches you humility.</p>
<h3>Platform Module<span class="hx:absolute hx:-mt-20" id="platform-module"></span>
    <a href="#platform-module" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>All OS-specific code lives in <code>src-tauri/src/platform/</code>:</p>
<ul>
<li><code>gpu.rs</code>: GPU detection (NVIDIA via nvidia-smi, AMD via sysfs/rocm-smi, Apple via system_profiler)</li>
<li><code>clipboard.rs</code>: image copy to clipboard (xclip on Linux, pbcopy on macOS, PowerShell on Windows)</li>
<li><code>python.rs</code>: Python location (python3 vs python in PATH), venv paths per OS</li>
<li><code>process.rs</code>: subprocess execution abstraction with output handling</li>
</ul>
<p>This means <code>classify.rs</code>, <code>scan.rs</code>, <code>thumbnail.rs</code> — none of them know which OS they&rsquo;re running on. They ask the platform and the platform resolves it. When Windows needed special treatment for UNC paths (those that start with <code>\\?\</code>), the change stayed contained in <code>platform/</code>. When macOS needed a conditional import, same thing. The rest of the codebase wasn&rsquo;t touched.</p>
<h3>GitHub Actions Matrix<span class="hx:absolute hx:-mt-20" id="github-actions-matrix"></span>
    <a href="#github-actions-matrix" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Two workflows:</p>
<ul>
<li><strong>CI</strong> (push + PR): build and test on Linux, macOS, and Windows. Each push runs <code>cargo test</code> + <code>npm test</code> on the 3 OSes. Includes <code>cargo fmt --check</code>, <code>cargo clippy -- -D warnings</code>, and <code>cargo audit</code>. If any platform fails, the PR doesn&rsquo;t pass.</li>
<li><strong>Release</strong> (tags v*): build via <code>tauri-action</code>, generates AppImage (Linux), DMG (macOS arm64), MSI (Windows), and creates a Draft Release on GitHub with the binaries attached.</li>
</ul>
<p>The 10+ CI fix commits on Monday morning were the least glamorous part of the project. Things like:</p>
<ul>
<li><code>#[cfg(not(target_os = &quot;windows&quot;))]</code> on imports that use <code>std::os::unix::fs::PermissionsExt</code></li>
<li><code>dunce::canonicalize</code> instead of <code>std::fs::canonicalize</code> because Windows generates paths with <code>\\?\</code> prefix that break string comparisons</li>
<li>Install <code>rustfmt</code> and <code>clippy</code> explicitly on the runner because they don&rsquo;t always come in the default GitHub Actions toolchain</li>
<li>Remove the macOS Intel target from the release workflow (Apple Silicon only — not worth the cost of maintaining two Mac targets)</li>
</ul>
<p>Nobody posts these things on X. But without them, your app doesn&rsquo;t build on 2 of the 3 targets.</p>
<h2>External Integrations<span class="hx:absolute hx:-mt-20" id="external-integrations"></span>
    <a href="#external-integrations" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The app depends on 3 external systems. Each one brought its own problems.</p>
<h3>Ollama<span class="hx:absolute hx:-mt-20" id="ollama"></span>
    <a href="#ollama" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Ollama serves the vision models via local REST API (port 11434). The app does:</p>
<ul>
<li><strong>Status check</strong>: checks if Ollama is running and lists installed/loaded models</li>
<li><strong>Model download</strong>: if the recommended model isn&rsquo;t installed, offers download with a progress bar via API streaming</li>
<li><strong>Generation</strong>: sends image in base64 + prompt, receives JSON (with the 3-level parsing cascade)</li>
<li><strong>Cleanup</strong>: unloads models from VRAM when not classifying, so it doesn&rsquo;t monopolize the GPU from the user&rsquo;s other applications</li>
</ul>
<p>Ollama is the only hard requirement. Without it, classification doesn&rsquo;t work. The SetupModal guides the user through installation and model download.</p>
<h3>Surya OCR<span class="hx:absolute hx:-mt-20" id="surya-ocr"></span>
    <a href="#surya-ocr" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Surya is a Python OCR engine that runs locally. The problem: the app is Rust and can&rsquo;t depend on a system Python installation. The solution:</p>
<ul>
<li>The app maintains an isolated Python venv at <code>~/.local/share/frank_sherlock/surya_venv/</code></li>
<li>The <code>surya_ocr.py</code> script is bundled as a Tauri resource (packaged in the binary)</li>
<li>On first use, the <code>SetupModal</code> offers to automatically provision the venv (finds Python, creates venv, pip install surya-ocr + dependencies)</li>
<li>Classification calls the script via subprocess, passes the image as argument, reads the extracted text from stdout</li>
</ul>
<p>Surya is a <strong>soft requirement</strong>: if it&rsquo;s not installed, the app works normally — it just won&rsquo;t have dedicated OCR. The pipeline gracefully degrades to use Ollama Vision as fallback, which is worse in coverage but works. The user sees a warning in setup, not an error that blocks usage.</p>
<h3>PDFium<span class="hx:absolute hx:-mt-20" id="pdfium"></span>
    <a href="#pdfium" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>For PDFs, I needed native text extraction and page rendering for thumbnails. PDFium is Chrome&rsquo;s PDF engine, and has Rust bindings via <code>pdfium-render</code>.</p>
<p>The PDFium binary is downloaded by a script (<code>scripts/download-pdfium.sh</code>) and bundled via <code>lib/</code> in Tauri resources. Each platform gets the correct binary (.so, .dylib, .dll). <code>lib/</code> is gitignored — the binaries are downloaded at build time, not versioned.</p>
<p>The PDF pipeline:</p>
<ol>
<li>Tries to extract native text (no OCR) — many PDFs already have a text layer</li>
<li>If there&rsquo;s enough text, uses it directly for indexing (faster and more accurate than OCR)</li>
<li>If not, renders the page and sends it to the image pipeline (Ollama Vision)</li>
<li>Detects blank pages, finds the first page with real content</li>
<li>Generates thumbnail as a montage of the first 2 pages with content (gives a better sense of the document than just the cover)</li>
</ol>
<h2>What Agile Vibe Coding Really Is<span class="hx:absolute hx:-mt-20" id="what-agile-vibe-coding-really-is"></span>
    <a href="#what-agile-vibe-coding-really-is" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>OK, now the point that really matters. The reason I&rsquo;m writing this.</p>
<h3>What Claude Did<span class="hx:absolute hx:-mt-20" id="what-claude-did"></span>
    <a href="#what-claude-did" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><ul>
<li>Wrote most of the Rust and TypeScript code</li>
<li>Generated 166 Rust tests and 172 frontend tests</li>
<li>Implemented JSON parsing with 3 fallback levels</li>
<li>Set up CI/CD with 3-OS matrix</li>
<li>Did massive refactors (monolith → 15 components + 10 hooks)</li>
<li>Handled edge cases of encoding, Unicode paths, and exotic file formats</li>
<li>Wrote SQL queries, migrations, FTS5 indexes</li>
<li>Implemented GPU detection, clipboard per OS, Python resolution</li>
<li>Created the setup flow with progress bar and model download</li>
<li>Debugged and fixed dozens of cross-platform compilation issues</li>
</ul>
<p>The speed is hard to describe without sounding like exaggeration. Saturday&rsquo;s 19:04 commit, the one with 4,186 lines and 47 tests, took about an hour including my review. A human dev, even a good one, would take a full day to write that with the same test coverage.</p>
<h3>What I Did<span class="hx:absolute hx:-mt-20" id="what-i-did"></span>
    <a href="#what-i-did" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><ul>
<li>Decided to do benchmarks before writing any app code</li>
<li>Chose Tauri over Electron (smaller footprint, native Rust, no Node runtime in prod)</li>
<li>Defined the read-only principle as an inviolable rule</li>
<li>Designed the incremental scan with move detection via fingerprint</li>
<li>Decided on cooperative cancellation with AtomicBool</li>
<li>Demanded formal schema migration (no ad-hoc ALTER TABLE in loose scripts)</li>
<li>Insisted on platform abstraction from the first multi-OS commit</li>
</ul>
<p>And mainly: I asked the annoying questions. <em>&ldquo;What if the scan is canceled in the middle?&rdquo;</em> became the checkpoint system. <em>&ldquo;What if the database corrupts?&rdquo;</em> became WAL + backup + health check. <em>&ldquo;What if the person doesn&rsquo;t have a good GPU?&rdquo;</em> became tier model selection. <em>&ldquo;What if Surya isn&rsquo;t installed?&rdquo;</em> became a soft requirement with fallback. <em>&ldquo;What if the user deletes a root that&rsquo;s in the middle of a scan?&rdquo;</em> became cancel-before-delete. <em>&ldquo;What if the file moves but the content is the same?&rdquo;</em> became move detection.</p>
<p>Oh, and I decided when to stop adding features and publish.</p>
<p>That last one is underrated. The temptation to keep adding &ldquo;just one more thing&rdquo; is enormous when the marginal cost of implementing is low. Claude implements any feature I ask for in minutes. But software that&rsquo;s never published is useless to anyone. Knowing when to stop is a skill no LLM will give you.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-23_16-37-22.jpg" alt="github releases"  loading="lazy" /></p>
<h3>The Real Pattern<span class="hx:absolute hx:-mt-20" id="the-real-pattern"></span>
    <a href="#the-real-pattern" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Agile Vibe coding isn&rsquo;t <em>&ldquo;asking the LLM to make an app&rdquo;</em>. It&rsquo;s pair programming with a partner who&rsquo;s stupid fast, has perfect memory, and never complains about refactoring. You say what you want, it implements, you review and adjust the direction. But if you don&rsquo;t know where to go, speed doesn&rsquo;t help — you just get to the wrong place faster.</p>
<p>The questions I asked — about resilience, cancellation, multi-OS, graceful degradation, edge case detection — none of them came from the LLM. They came from years building software that has to work in real environments, with real users, on diverse hardware. Claude isn&rsquo;t going to ask you <em>&ldquo;what if the user pulls the network cable in the middle of downloading the model?&rdquo;</em>. But if you ask, it implements the handling in minutes.</p>
<p>It&rsquo;s tempting to look at this project and conclude that anyone with a good idea could have done the same. But try to imagine: without the decision to do benchmarks, I would have picked the wrong model. Without the Python PoC, I&rsquo;d have discovered JSON parsing problems in production. Without the platform abstraction, I&rsquo;d be debugging Windows issues scattered across 15 files. Without the scan checkpoint, users would lose hours of processing on every crash. Without the formal schema migration, the first update would break everyone&rsquo;s database.</p>
<p>Think of an architect with an absurdly efficient construction crew. The architect doesn&rsquo;t need to lift every wall, but needs to know where they go and what happens if you take one out. The crew executes fast, works at night, doesn&rsquo;t complain. But someone needed to have drawn the blueprint. Without a blueprint, it&rsquo;s just a pile of bricks stacked quickly.</p>
<h2>Final Numbers<span class="hx:absolute hx:-mt-20" id="final-numbers"></span>
    <a href="#final-numbers" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>For those who like concrete numbers:</p>
<table>
  <thead>
      <tr>
          <th>Metric</th>
          <th>Value</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Commits</td>
          <td>50</td>
      </tr>
      <tr>
          <td>Hours of effective work</td>
          <td>~26h</td>
      </tr>
      <tr>
          <td>Lines of Rust</td>
          <td>8,359</td>
      </tr>
      <tr>
          <td>Lines of TypeScript</td>
          <td>5,842</td>
      </tr>
      <tr>
          <td>Lines of CSS</td>
          <td>1,649</td>
      </tr>
      <tr>
          <td>Rust tests</td>
          <td>166</td>
      </tr>
      <tr>
          <td>Frontend tests</td>
          <td>172</td>
      </tr>
      <tr>
          <td>Total tests</td>
          <td>338</td>
      </tr>
      <tr>
          <td>Platforms</td>
          <td>3 (Linux, macOS, Windows)</td>
      </tr>
      <tr>
          <td>Database migrations</td>
          <td>5</td>
      </tr>
      <tr>
          <td>Rust modules</td>
          <td>13+</td>
      </tr>
      <tr>
          <td>React components</td>
          <td>15+</td>
      </tr>
      <tr>
          <td>React hooks</td>
          <td>10+</td>
      </tr>
  </tbody>
</table>
<p>The first commit was Friday 02/21 at 19:29. The last was Monday 02/23 at 14:33. Discounting sleep (~8h on Saturday/Sunday night, ~8h on Sunday/Monday early morning) and breaks, that&rsquo;s ~26 hours of work distributed over a weekend.</p>
<p>From zero — without a single file in the repository — to published binaries for 3 operating systems, with automated tests running in CI on every push. Including the research phase, which on its own would justify a sprint.</p>
<h2>Conclusion<span class="hx:absolute hx:-mt-20" id="conclusion"></span>
    <a href="#conclusion" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Agile Vibe coding works. But it works like any powerful tool: in the hands of someone who knows what they&rsquo;re doing.</p>
<p>The idea for Frank Sherlock would fit in a tweet: <em>&ldquo;classify images with local LLM&rdquo;</em>. But turning that into real software required: benchmarked research with formal ground truth, proof of concept validating the complete pipeline, incremental architecture, error handling at 3 levels, cooperative cancellation, formal schema migration, platform abstraction, CI/CD with 3-OS matrix, integration with 3 external systems with graceful degradation, and 338 tests to ensure none of that breaks when someone runs cargo update.</p>
<p>The LLM sped all that up absurdly. But it didn&rsquo;t replace the need to know what to do. If I had asked <em>&ldquo;make an app that classifies images&rdquo;</em> without the 2 hours of benchmarks, without the proof of concept, without the architecture decisions, without the annoying questions about edge cases, the result would be a prototype that works on my machine and breaks anywhere else. And I probably wouldn&rsquo;t even notice until someone complained.</p>
<p>The vibe needs an experienced conductor. For now, that conductor is still human.</p>
<p>Code at <a href="https://github.com/akitaonrails/FrankSherlock"target="_blank" rel="noopener">github.com/akitaonrails/FrankSherlock</a>. GPL-3.0, local-only, open source.</p>
]]></content:encoded><category>franksherlock</category><category>vibecode</category><category>rust</category><category>tauri</category><category>qwen</category></item></channel></rss>