<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Vibe-Coding | AkitaOnRails.com</title><link>https://akitaonrails.github.io/en/tags/vibe-coding/</link><description>Fabio Akita's blog — tech, career, and assorted geek topics. English edition.</description><generator>Hugo -- gohugo.io</generator><language>en</language><lastBuildDate>Mon, 19 Oct 2026 23:20:52 +0000</lastBuildDate><atom:link href="https://akitaonrails.github.io/en/tags/vibe-coding/index.xml" rel="self" type="application/rss+xml"/><item><title>AI Reverse Engineering: Is Adobe Dead?</title><link>https://akitaonrails.github.io/en/2026/10/13/ai-reverse-engineering-is-adobe-dead/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2026/10/13/ai-reverse-engineering-is-adobe-dead/</guid><pubDate>Tue, 13 Oct 2026 00:00:00 -0300</pubDate><description>&lt;p&gt;PhotoCraft exists. REA exists. Recomps and decomps of old games ship every day. Therefore: Adobe is dead, no binary is sacred, and programmers are finished.&lt;/p&gt;
&lt;p&gt;That was the week on X, and if you only read headlines, it&amp;rsquo;s a reasonable conclusion. This text is for anyone who wants to know what each of those things actually does.&lt;/p&gt;
&lt;p&gt;It blew up on the 9th, when the account below summed up what Brandon Thomas, a developer from Atlanta, had been publishing since late September: seven open source clones of Adobe&amp;rsquo;s main products, written in Rust with help from Claude Opus 5.5. Photoshop, Illustrator, Premiere, Lightroom, After Effects, InDesign, Acrobat. All free, all &amp;ldquo;clean room&amp;rdquo;, without a single line of Adobe code. This is the post that went viral:&lt;/p&gt;</description><content:encoded><![CDATA[<p>PhotoCraft exists. REA exists. Recomps and decomps of old games ship every day. Therefore: Adobe is dead, no binary is sacred, and programmers are finished.</p>
<p>That was the week on X, and if you only read headlines, it&rsquo;s a reasonable conclusion. This text is for anyone who wants to know what each of those things actually does.</p>
<p>It blew up on the 9th, when the account below summed up what Brandon Thomas, a developer from Atlanta, had been publishing since late September: seven open source clones of Adobe&rsquo;s main products, written in Rust with help from Claude Opus 5.5. Photoshop, Illustrator, Premiere, Lightroom, After Effects, InDesign, Acrobat. All free, all &ldquo;clean room&rdquo;, without a single line of Adobe code. This is the post that went viral:</p>
<blockquote class="twitter-tweet"><p lang="en" dir="ltr">AI is now being used to recreate Adobe's software, a developer has released seven free, open-source apps as clean-room alternatives to Adobe's main tools:<br><br>- PhotoCraft → Photoshop<br>- VectorCraft → Illustrator<br>- FilmCraft → Premiere Pro<br>- LightCraft → Lightroom<br>- EffectCraft → After Effects<br>- DesignCraft → InDesign<br>- PrintCraft → Acrobat Pro<br><br>Built in Rust with help from Claude Opus 5.5, they run on Windows, Linux, and macOS under MIT/Apache licenses without using Adobe's source code.</p>&mdash; Pirat_Nation (@Pirat_Nation) <a href="https://twitter.com/Pirat_Nation/status/2108437553615425881">October 9, 2026</a></blockquote>
<script async src="//platform.twitter.com/widgets.js" charset="utf-8"></script>
<p>The same week, <a href="https://github.com/morluto/rea"target="_blank" rel="noopener">REA</a>, &ldquo;Reverse Engineer Anything&rdquo;, passed 100K stars on GitHub, gaining tens of thousands a day. The pitch on its page: &ldquo;see a feature in an app that you want in your own product? Ask your agent to investigate it with REA and build a version for your project&rdquo;.</p>
<p>And anyone who follows retro gaming knows 2026 is the year that keeps on giving. There&rsquo;s a <a href="https://docs.google.com/spreadsheets/d/1MT_xMPlKNbyhANRUMzOH94J3ybeF0b1Ws8F0kb-_598/edit"target="_blank" rel="noopener">community spreadsheet</a> trying to list the unofficial PC ports, around 100 games from Ship of Harkinian to Skate 3, and it&rsquo;s always behind. The <a href="https://www.youtube.com/@VideoGameEsoterica"target="_blank" rel="noopener">Video Game Esoterica</a> channel publishes a new port almost daily: Time Crisis decompiled from the arcade board, Daytona USA, Bloodborne.</p>
<p>The Xbox One version of <a href="https://almost-there-games.itch.io/azimuth"target="_blank" rel="noopener">Forza Horizon 2</a>, stuck there since 2014, runs on Windows. <a href="https://github.com/boykopovar/AnyPS5"target="_blank" rel="noopener">AnyPS5</a> promises to convert PS5 executables into native PC binaries, and PS5 emulators like KytyPS5 and SharpEmu already <a href="https://www.invenglobal.com/articles/26860/astro-bot-boots-on-pc-as-ps5-emulation-makes-rapid-progress"target="_blank" rel="noopener">run Astro Bot</a> with actual gameplay. Much of it with AI in the loop, some of it openly vibe-coded.</p>
<p>Hence the consensus: programming, in general, is over. Every binary is reversible and reusable, just point the agent at it. My position is that it did get much easier, that it&rsquo;s still nowhere near the silver bullet people imagine, and that the engine behind it, the LLM, <a href="/en/2026/10/12/llms-agents-competent-enough/">stopped growing exponentially</a>. The rest of the text is the reasons.</p>
<blockquote>
  <p><strong>TL;DR:</strong> reverse engineering is a 40-year-old discipline with mature tooling, and AI sped up its grunt work, especially in retro, where the binary is available, tiny, and the target doesn&rsquo;t move. The Adobe clones are alphas with hundreds of open bugs and pretty screenshots. REA is a toolbox that requires the binary in hand, Ghidra or IDA installed, and a lot of tokens. What&rsquo;s worth money today doesn&rsquo;t even have a binary: it&rsquo;s SaaS, cloud, manufacturer partnerships and decades of color science. Rewriting Bun, with the project&rsquo;s author and 64 agents, cost US$ 165K. Jump to the <a href="#reverse-engineering-wasnt-born-last-week">timeline</a>, to <a href="#what-rea-actually-does">REA</a>, to the <a href="#photocraft-pretty-screenshot-alpha-inside">Adobe clones</a>, to the <a href="#the-bun-case-what-it-costs-to-rewrite-big-software">Bun case</a>, to why <a href="#why-retro-moves-so-fast">retro moves fast</a> or to the <a href="#answering-the-list-from-the-top">answers at the end</a>.</p>

</blockquote>
<h2>First of all: I don&rsquo;t like Adobe<span class="hx:absolute hx:-mt-20" id="first-of-all-i-dont-like-adobe"></span>
    <a href="#first-of-all-i-dont-like-adobe" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>To be clear about where I&rsquo;m speaking from. I hate Adobe&rsquo;s business practices as much as anyone. I was a paying customer for years, used Photoshop and Premiere extensively, plus Lightroom and After Effects, and left for the same reasons everybody leaves: mandatory subscription, cancellation fees, prices going up with nothing in return.</p>
<p>And it&rsquo;s precisely because I used them in depth that I know the size of what&rsquo;s inside those boxes. Whoever thinks Photoshop is layers, crop and text never needed a color proof for a print shop, never opened a camera RAW whose color profile Adobe has maintained for twenty years, never did the conform on cinema footage, the work of gathering the camera&rsquo;s raw material for finishing. I&rsquo;ll get there, after the basics.</p>
<h2>Reverse engineering wasn&rsquo;t born last week<span class="hx:absolute hx:-mt-20" id="reverse-engineering-wasnt-born-last-week"></span>
    <a href="#reverse-engineering-wasnt-born-last-week" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Every emulator you&rsquo;ve ever used exists because someone opened up hardware, disassembled a binary, sniffed a protocol and, in some cases, broke cryptography. The discipline has case law, commercial tooling and decades of history. A condensed timeline, with the milestones that matter most:</p>
<ul>
<li><strong>1982 to 1984</strong>: Compaq reverse engineers the IBM PC BIOS, and in 1984 Phoenix sells a licensable BIOS built in a clean room, by a team that only read the specification. The PC compatible industry is born. <a href="https://en.wikipedia.org/wiki/Phoenix_Technologies"target="_blank" rel="noopener">Source</a>.</li>
<li><strong>1992</strong>: Andrew Tridgell creates Samba by sniffing SMB packets. The same year, <a href="https://www.copyright.gov/fair-use/summaries/segaenters-accolade-9thcir1992.pdf"target="_blank" rel="noopener">Sega v. Accolade</a> and <a href="https://www.copyright.gov/fair-use/summaries/atari-nintendo-fedcir1992.pdf"target="_blank" rel="noopener">Atari v. Nintendo</a> establish in the US that disassembling code to reach functional elements is fair use (Atari lost its own case for another reason: it lied to the Copyright Office to get the source).</li>
<li><strong>1993</strong>: Wine begins, which becomes the foundation of Proton and of every Windows game that runs on the Steam Deck today.</li>
<li><strong>1999</strong>: UltraHLE runs commercial N64 games at full speed; bleem! emulates the PS1, Sony sues and loses the injunctions, and bleem! folds anyway in 2001; DeCSS breaks DVD protection.</li>
<li><strong>2000</strong>: <a href="https://law.resource.org/pub/us/case/reporter/F3/203/203.F3d.596.99-15852.html"target="_blank" rel="noopener">Sony v. Connectix</a> rules that copying the PS1 BIOS to build an emulator is fair use.</li>
<li><strong>2002 and 2003</strong>: PCSX2 and Dolphin are born, the two emulators that twenty years later are still the reference.</li>
<li><strong>2007</strong>: the iPhone is opened eleven days after launch. The European Commission forces Microsoft to document its network protocols, and the <a href="https://fsfe.org/news/2007/news-20071220-01.html"target="_blank" rel="noopener">Samba team receives the documentation</a> for 10,000 euros.</li>
<li><strong>2010</strong>: the fail0verflow group shows at 27C3 that Sony signed the PS3 firmware with a fixed nonce, exposing the private key (the nonce is the number that should be random in every signature). Days later, already in 2011, geohot publishes the key, Sony sues and settles in March.</li>
<li><strong>2011 and 2013</strong>: RPCS3 (PS3) and Xenia (Xbox 360) are born, the two hardest emulators ever built, for reasons I&rsquo;ll explain.</li>
<li><strong>2019</strong>: the NSA releases <a href="https://github.com/NationalSecurityAgency/ghidra"target="_blank" rel="noopener">Ghidra</a> as open source. The Super Mario 64 decompilation leaks and goes public, and the PC port appears the following year.</li>
<li><strong>2021 and 2022</strong>: Ocarina of Time 100% decompiled after almost two years of work; Ship of Harkinian becomes the reference port.</li>
<li><strong>2024</strong>: Mr-Wiseguy releases <a href="https://github.com/Zelda64Recomp/Zelda64Recomp/releases"target="_blank" rel="noopener">Zelda 64: Recompiled</a>, the first showcase of N64 static recompilation.</li>
<li><strong>2025</strong>: Sonic Unleashed Recompiled brings the same trick to the Xbox 360, converting PowerPC to C++ and shaders to HLSL.</li>
</ul>
<p>Notice that the current phase, decomp and recomp at scale, starts in 2019, well before any coding agent. AI joined an assembly line that already existed.</p>
<h3>The tools<span class="hx:absolute hx:-mt-20" id="the-tools"></span>
    <a href="#the-tools" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>The tooling is mature too, and much of it is free:</p>
<ul>
<li><a href="https://hex-rays.com/ida-pro"target="_blank" rel="noopener">IDA Pro</a> (Hex-Rays), the reference commercial disassembler and decompiler, from one thousand to almost nine thousand euros a year, with a free version limited to x86.</li>
<li><a href="https://github.com/NationalSecurityAgency/ghidra"target="_blank" rel="noopener">Ghidra</a>, the NSA&rsquo;s open answer, with a decompiler and scripting, Apache 2.0.</li>
<li><a href="https://binary.ninja/"target="_blank" rel="noopener">Binary Ninja</a>, with a free edition and licenses from 200 to 3,500 dollars.</li>
<li><a href="https://github.com/radareorg/radare2"target="_blank" rel="noopener">radare2</a> and <a href="https://github.com/rizinorg/cutter"target="_blank" rel="noopener">Cutter</a>, the GUI for rizin, which is a radare2 fork: the open command line and the open interface.</li>
<li><a href="https://github.com/x64dbg/x64dbg"target="_blank" rel="noopener">x64dbg</a> and <a href="https://learn.microsoft.com/en-us/windows-hardware/drivers/debugger/"target="_blank" rel="noopener">WinDbg</a> on Windows, <a href="https://www.sourceware.org/gdb/"target="_blank" rel="noopener">GDB</a> everywhere else.</li>
<li><a href="https://frida.re/"target="_blank" rel="noopener">Frida</a>, to inject code into a live process and watch what it does.</li>
<li><a href="https://github.com/skylot/jadx"target="_blank" rel="noopener">JADX</a> for Android and <a href="https://github.com/icsharpcode/ILSpy"target="_blank" rel="noopener">ILSpy</a> for .NET.</li>
</ul>
<p>Keep this list in mind. REA, which we&rsquo;ll get to shortly, is a wrapper around it.</p>
<h2>How a binary gets taken apart, in practice<span class="hx:absolute hx:-mt-20" id="how-a-binary-gets-taken-apart-in-practice"></span>
    <a href="#how-a-binary-gets-taken-apart-in-practice" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>In principle, every binary is reversible. The processor has to be able to execute it, so the instructions are in there. The path is always the same:</p>
<ol>
<li><strong>Disassemble</strong>: the binary becomes assembly, one machine instruction per line. This is mechanical.</li>
<li><strong>Decompile</strong>: the assembly becomes something resembling C. Ghidra and IDA do this reasonably well, with a lot of noise.</li>
<li><strong>Recover what the compiler threw away</strong>: variable names, function names, types, structures, comments. None of it survives compilation. This is the real work, and it&rsquo;s human.</li>
<li><strong>Refactor</strong> until it becomes code a person can read, maintain and port.</li>
</ol>
<p>The N64 decomp projects take step 4 all the way: their C code, recompiled, produces the original ROM byte for byte. That&rsquo;s how Super Mario 64 became a project with 135K lines of game C code (around 100K of actual code, plus half a million lines of generated level and model data), and Ocarina of Time passed 430K lines. Static recompilation skips part of this: it translates the original processor&rsquo;s instructions into generic C, without trying to understand them, and replaces only the parts that talk to the hardware. It&rsquo;s faster and the result is less readable.</p>
<h3>&ldquo;But can&rsquo;t the AI just read the binary directly?&rdquo;<span class="hx:absolute hx:-mt-20" id="but-cant-the-ai-just-read-the-binary-directly"></span>
    <a href="#but-cant-the-ai-just-read-the-binary-directly" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Someone always asks: if it&rsquo;s AI, why go through C? Let the model read the binary and build on top of it. The short answer is that an LLM only knows what was in its training corpus, and a corpus of binaries paired with source code is scarce, because no human has written production assembly in decades.</p>
<p>The long answer is that the literature exists, and it&rsquo;s instructive precisely because it&rsquo;s modest. The lineage runs from 2018 neural networks to models like <a href="https://arxiv.org/abs/2305.12520"target="_blank" rel="noopener">SLaDe</a> (200 million parameters, one per architecture and optimization level), <a href="https://arxiv.org/abs/2403.05286"target="_blank" rel="noopener">LLM4Decompile</a> (up to 33 billion) and <a href="https://arxiv.org/abs/2311.13721"target="_blank" rel="noopener">Nova</a>. Meta published an <a href="https://arxiv.org/abs/2407.02524"target="_blank" rel="noopener">LLM Compiler</a> that translates assembly into LLVM IR. The best results, on isolated benchmark functions, hover around 60 to 65% of code that runs again and gives the same result, dropping sharply when the compiler optimized with -O3.</p>
<p>Two recent studies show the real state of things. <a href="https://arxiv.org/abs/2505.11340"target="_blank" rel="noopener">DecompileBench</a>, with 23K real functions, found that LLM-based decompilers produce more readable code with half the functional correctness of commercial decompilers. And a <a href="https://arxiv.org/abs/2609.05370"target="_blank" rel="noopener">September 2026 paper</a> measured that LLM refinement of Ghidra output raises the compilation rate from 75% to 90% and drops behavioral fidelity from 74% to 62%, with up to around 10% of functions with known vulnerabilities no longer reproducing the flaw, meaning the bug vanished without anyone asking.</p>
<blockquote>
  <p>Summing up what exists: small models, function by function, almost only x86-64 Linux, and the best ones are refinement layers on top of Ghidra, which keeps the classic decompiler in the loop. No emulator, no game, no whole program has been neurally recompiled. Until someone invents that, you work with reality, and reality includes the intermediate layer of programming language and compiler.</p>

</blockquote>
<p>The AI assistants that exist for this in production confirm the design: <a href="https://plugins.hex-rays.com/aidapal"target="_blank" rel="noopener">aiDAPal</a>, an IDA plugin by Atredis, Binary Ninja&rsquo;s Sidekick, the MCP servers for Ghidra. All of them are an LLM naming variables and explaining functions on top of what the traditional decompiler produced.</p>
<h2>What REA actually does<span class="hx:absolute hx:-mt-20" id="what-rea-actually-does"></span>
    <a href="#what-rea-actually-does" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>I cloned <a href="https://github.com/morluto/rea"target="_blank" rel="noopener">REA</a> and read the code. It&rsquo;s an MCP server and a TypeScript CLI that wraps the tools from the list above and exposes them to your coding agent: Ghidra, Hopper, IDA, JADX, Frida, Wakaru for obfuscated JavaScript, Playwright to capture what a website does. The agent asks, REA runs the tool and returns pseudocode, call graphs, strings and IPC traces, each result with an evidence record and a list of limitations.</p>
<p>I had already done that flow by hand, months before REA went viral. In July I <a href="/en/2026/07/08/fable-5-analyze-tiktok-clash-of-kings-govbr-fingerprint/">had Fable 5 analyze the code of TikTok, Clash of Kings and gov.br</a>: I downloaded the APKs, unzipped them (an APK is a zip), ran JADX on the <code>.dex</code> files and let the model read the Java that came out the other side. It was enough to see the endpoints, the permissions, the embedded SDKs and how each app builds the device fingerprint, that persistent identifier that survives clearing data and reinstalling. TikTok came out as industrial-grade collection, armored against auditing; gov.br, as the example of how to do it right.</p>
<p>The three lessons from that post apply to all of REA. Android is far too easy to decompile, and even so the result serves only static analysis, because recompiling the app is out of reach: the names come scrambled by obfuscation, resources and signing keys are missing. The most sensitive part lives in native code, <code>.so</code> files in C and C++, which decompile into nothing readable, and in TikTok that&rsquo;s exactly where the core sits. And what came out of that work was a report on what the app does, with caveats, and no TikTok clone. REA packages that same flow into an MCP server; the walls stay in the same place.</p>
<p>The judgment is supplied by the LLM you already pay for. REA supplies the deterministic plumbing and the provenance. The phrase &ldquo;automates the heuristics of reverse engineering&rdquo;, which circulates out there, appears nowhere in the repository; the description the project itself uses is agent-driven reverse engineering with traceable evidence.</p>
<p>What it requires: the artifact on your machine (native binary, Electron app, .NET assembly, APK, firmware, network capture), Node 22.19 or later, and the heavy tools installed at your own expense. Ghidra is free; IDA, Hopper and JEB the README tells you to buy. There&rsquo;s no REA API key because it runs locally, and the token cost lands entirely on your agent subscription.</p>
<p>And that cost is the detail nobody mentions. REA&rsquo;s MCP tool catalog is 2.3 MB of JSON, which goes into the agent&rsquo;s context. The project itself publishes an <a href="https://github.com/morluto/rea/tree/main/experiments/analysis-efficiency"target="_blank" rel="noopener">experiment</a> in which analyzing SQLite compiled to WASM, on the default path, took 42 tool calls, five minutes and 300K input tokens. A full inventory of an Electron app like Obsidian returns almost 1 MB of JSON.</p>
<p>There&rsquo;s an open issue complaining that inspecting an ordinary Mac app returns 80K to 230K tokens in one go.</p>
<p>The project is serious and goes far: six months, 2,600 commits, a thousand test files, version 6.4 released on October 11. It&rsquo;s also unstable: 190 open issues, several breaking changes per minor release, subsystems marked experimental.</p>
<p>The limits are written in the repository itself. CONTRIBUTING excludes breaking DRM, anticheat, licensing and commercial protections. The DISCLAIMER warns that decompilation, reconstruction and AI-assisted analysis &ldquo;may be incomplete or incorrect&rdquo;. And even the internal instructions for generating the website&rsquo;s figures say, in so many words: &ldquo;never imply REA automatically clones a program&rdquo; and &ldquo;never say full reconstruction, exact game clone, or automatic source recovery&rdquo;.</p>
<blockquote>
  <p>REA is a good toolbox for someone who already knows how to do reverse engineering and wants an agent holding Ghidra. It isn&rsquo;t the &ldquo;copy this app for me&rdquo; button. The people who wrote the project are the first to say so.</p>

</blockquote>
<h2>PhotoCraft: pretty screenshot, alpha inside<span class="hx:absolute hx:-mt-20" id="photocraft-pretty-screenshot-alpha-inside"></span>
    <a href="#photocraft-pretty-screenshot-alpha-inside" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The <a href="https://github.com/storytold/photocraft"target="_blank" rel="noopener">PhotoCraft repository</a> was created on September 30. In two weeks, 54K stars, 8 releases and, as I write, more than 800 open issues. The README badge says &ldquo;early alpha&rdquo;, and Brandon Thomas himself, quoted by Gizmodo, said it&rsquo;s nowhere close to ready. The press that tested it reached the same conclusion: Gizmodo called it a &ldquo;glitchy, but real, Photoshop&rdquo;, and OMG Ubuntu &ldquo;a superficial facsimile with pronounced flaws&rdquo;.</p>
<p>The issues tell the technical story:</p>
<ul>
<li>Any image above 8,192 pixels leaves the GPU compositor and falls back to the CPU, with a 6 GB memory peak, because that&rsquo;s the egui-wgpu texture limit (<a href="https://github.com/storytold/photocraft/issues/49"target="_blank" rel="noopener">issue 49</a>, already closed, and the ones that came after it, <a href="https://github.com/storytold/photocraft/issues/1015"target="_blank" rel="noopener">1015</a> and <a href="https://github.com/storytold/photocraft/issues/4017"target="_blank" rel="noopener">4017</a>).</li>
<li>A 12 megapixel photo needs almost 800 MB just to open, adjust and export from the command line (<a href="https://github.com/storytold/photocraft/issues/1637"target="_blank" rel="noopener">issue 1637</a>).</li>
<li>Color management is rudimentary: a global working space only, instead of per document, and no view transform (<a href="https://github.com/storytold/photocraft/issues/321"target="_blank" rel="noopener">issue 321</a>). Eighteen issues mention CMYK, fifteen mention ICC profiles.</li>
<li>Almost a hundred open issues mention PSD. Hue/Saturation layers composite differently from Photoshop, and saving over the file overwrites the original&rsquo;s correct merged image (<a href="https://github.com/storytold/photocraft/issues/4089"target="_blank" rel="noopener">issue 4089</a>).</li>
<li>Fifty open issues mention &ldquo;crash&rdquo;.</li>
</ul>
<p>The interface resembles Photoshop, so the screenshot is shareable, and whoever sees the screenshot assumes it&rsquo;s a carbon copy. That&rsquo;s expected in a two-week-old alpha. With interest, time and contributions, part of this gets fixed. And then the contributors will discover something the N64 projects never faced.</p>
<blockquote>
  <p>PhotoCraft today is that Ferrari replica built on top of a VW Beetle: the body has the right shape, you take a photo and most people say &ldquo;wow, a Ferrari&rdquo;. Then you turn the key and out comes the Beetle engine. If the photo got you hyped, that&rsquo;s on you.</p>

</blockquote>
<h2>The target moves<span class="hx:absolute hx:-mt-20" id="the-target-moves"></span>
    <a href="#the-target-moves" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Decompiling 1998&rsquo;s Zelda is aiming at a stationary target. Nintendo isn&rsquo;t going to release Ocarina of Time 2026 with a new file format. Copying a live product is another conversation, and we&rsquo;ve seen this movie before.</p>
<p>In the 90s and 2000s the target was Microsoft Office. StarOffice became OpenOffice in 2000 and LibreOffice in 2010, and all of them spent years chasing a binary format, .doc, that changed from version to version and only got a public specification in 2008, when the game was already over. The proven part of that story is in the court cases: the European Commission fined Microsoft <a href="https://ec.europa.eu/commission/presscorner/detail/en/ip_04_382"target="_blank" rel="noopener">497 million euros in 2004</a> for refusing to supply interoperability information, and <a href="http://news.bbc.co.uk/2/hi/business/7266629.stm"target="_blank" rel="noopener">899 million in 2008</a> for failing to comply. .docx only became an ISO standard in 2008, in a <a href="https://en.wikipedia.org/wiki/Standardization_of_Office_Open_XML"target="_blank" rel="noopener">vote so controversial</a> that countries formally protested.</p>
<p>It took more than ten years. And when the format finally opened, Office no longer mattered the same way. The value moved elsewhere: today it&rsquo;s <a href="https://office365itpros.com/2024/01/31/office-365-reaches-400-million/"target="_blank" rel="noopener">400 million paid Office 365 seats</a>, <a href="https://www.googlecloudpresscorner.com/2025-12-04-Google-Workspace-Study-Reveals-More-Than-90-of-Rising-Leaders-Want-AI-With-Personalization"target="_blank" rel="noopener">3 billion Google Workspace users</a> and around <a href="https://prodesigntools.com/number-of-creative-cloud-subscribers.html"target="_blank" rel="noopener">41 million Creative Cloud subscribers</a>. Nobody wants to install that on a desktop, and so nobody has the binary.</p>
<blockquote>
  <p>Reverse engineering Google Docs means rewriting the product from scratch looking only at the interface, because the code that matters never leaves the server. After the world moved to SaaS, most proprietary software became a walled garden with no binary. No binary, nothing to disassemble.</p>

</blockquote>
<p>You can build versions of it, of course. <a href="https://immich.app/"target="_blank" rel="noopener">Immich</a> replaces Google Photos for a person or a family: phone backup, face recognition, semantic search, all on your server. What it doesn&rsquo;t replicate is the infrastructure and the training corpus that make Google&rsquo;s search Google&rsquo;s search. And Adobe made the same migration, in pieces.</p>
<h2>Photoshop is far more than layers and crop<span class="hx:absolute hx:-mt-20" id="photoshop-is-far-more-than-layers-and-crop"></span>
    <a href="#photoshop-is-far-more-than-layers-and-crop" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Here&rsquo;s the part I know from having paid for it. The features that sell subscriptions today don&rsquo;t even run on your computer.</p>
<p>Generative Fill and Generative Expand run on Firefly, in the cloud, and consume <a href="https://helpx.adobe.com/creative-cloud/apps/generative-ai/generative-credits-faq.html"target="_blank" rel="noopener">generative credits</a>. Several Neural Filters, according to <a href="https://helpx.adobe.com/photoshop/using/neural-filters.html"target="_blank" rel="noopener">Adobe&rsquo;s own documentation</a>, need an internet connection because the processing happens on Adobe&rsquo;s servers. AI object selection has had, since 2020, an option to process in the cloud for a refined result. All of this is code that doesn&rsquo;t exist on your machine for anyone to disassemble.</p>
<p>Then comes what is in the binary, but took decades and partnerships to exist. Camera Raw carries <a href="https://helpx.adobe.com/camera-raw/desktop/dng-and-file-formats/supported-lenses.html"target="_blank" rel="noopener">lens correction profiles</a> and per-camera color profiles for hundreds of models, built over twenty years and updated with every release. No clone guesses that. Either you have the data or you don&rsquo;t.</p>
<p>And there&rsquo;s color science. The example I always remember is GIMP and CMYK.</p>
<p>GIMP was born in 1995. The first native support for CMYK files arrived in <a href="https://www.gimp.org/news/2022/08/27/gimp-2-99-12-released/"target="_blank" rel="noopener">2022</a>, and only for import and export, with pixels always in RGB. <a href="https://www.cgchannel.com/2025/03/gimp-3-0-is-out/"target="_blank" rel="noopener">GIMP 3.0, from 2025</a>, still has no CMYK editing mode. It&rsquo;s on the <a href="https://developer.gimp.org/core/roadmap/"target="_blank" rel="noopener">roadmap</a> as work in progress.</p>
<p>Thirty years of an open project, with good and motivated people, and the print shop still asks for Photoshop. That&rsquo;s the size of the color science hole, and no coding agent invents color science: it reproduces what&rsquo;s in the training data.</p>
<p>Why did it take so long, if it was the most requested feature? Because GIMP&rsquo;s core was born with 8-bit-per-channel RGB baked into every layer, blend mode and filter. Supporting another color model meant rewriting the entire pixel engine, the migration to GEGL, which took the better part of a decade and was only finished in <a href="https://www.gimp.org/release-notes/gimp-2.10.html"target="_blank" rel="noopener">GIMP 2.10, in 2018</a>. CMYK could only start after that.</p>
<p>And CMYK is a family of device profiles, with four inks, a per-printer ICC profile, ink limits and black generation rules, and editing in it means the whole pipeline has to compute in a subtractive four-channel model and round-trip through the profiles without drifting. A single developer, however good a programmer, without experience in this specific field will struggle to implement it, because the knowledge lives in print shops and color labs, and almost none of it is in a training corpus. In GIMP, the people who carried it forward were contributors who understand printing, and even then it took years. No new tool shortcuts that kind of knowledge.</p>
<p>And then there&rsquo;s the layer that isn&rsquo;t even technical: commercial licensing. Print-shop color is Pantone, and Pantone is a proprietary library. No open project can ship the palettes; the Inkscape developers <a href="https://lists.inkscape.org/hyperkitty/list/inkscape-devel@lists.inkscape.org/message/DOXDMUAPHORC4HEXPF37LYBQH6SSQ2M5"target="_blank" rel="noopener">reached that conclusion in 2005</a>, and <a href="https://www.linux.com/news/pantone-and-free-software/"target="_blank" rel="noopener">Linux.com explained the legal risk</a> around the same time. Adobe itself felt it in 2022: when Pantone changed its business model, the libraries <a href="https://petapixel.com/2022/10/28/you-have-to-pay-a-subscription-to-use-pantone-colors-in-photoshop-now"target="_blank" rel="noopener">were pulled from Photoshop, Illustrator and InDesign</a>, old files started opening with their Pantone colors replaced by black, and anyone who needs them pays for a separate plugin subscription. If even Adobe, paying, didn&rsquo;t escape, imagine a clone that runs on goodwill.</p>
<h3>Video is worse<span class="hx:absolute hx:-mt-20" id="video-is-worse"></span>
    <a href="#video-is-worse" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>In Premiere, After Effects and DaVinci Resolve, the problem multiplies. A cinema camera doesn&rsquo;t deliver video, it delivers raw data on its own logarithmic curve: ARRI&rsquo;s LogC4, RED&rsquo;s IPP2. To turn that into an image you need the manufacturer&rsquo;s transforms, and RED ships its own in an <a href="https://community.acescentral.com/t/red-ipp2-color-workflows/1055"target="_blank" rel="noopener">SDK</a> that the editors integrate and update with every version. ACES, the industry&rsquo;s neutral standard, depends on input transforms supplied by each manufacturer.</p>
<p>Blackmagic gives Resolve away for free and it decodes ARRIRAW and R3D. It does that because it pays for the SDK and the partnership on your behalf. An agent-built clone, &ldquo;guessing&rdquo; how the Alexa&rsquo;s image should look, produces an image that doesn&rsquo;t match the reference monitor, and in cinema that means it&rsquo;s useless. That knowledge isn&rsquo;t in any training corpus: it&rsquo;s in contracts.</p>
<h2>The Bun case: what it costs to rewrite big software<span class="hx:absolute hx:-mt-20" id="the-bun-case-what-it-costs-to-rewrite-big-software"></span>
    <a href="#the-bun-case-what-it-costs-to-rewrite-big-software" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>For a sense of the order of magnitude, the best public data point there is comes from Bun. In May 2026, Jarred Sumner rewrote the entire runtime from Zig to Rust using agents, and in July <a href="https://bun.com/blog/bun-in-rust"target="_blank" rel="noopener">published the numbers</a>:</p>
<ul>
<li>535K lines of Zig in 1,448 files became a one-million-line Rust diff, in 11 days.</li>
<li>Up to 64 Claude instances in parallel, 24 hours a day, every line reviewed by two adversarial agents and a fixer.</li>
<li>5.9 billion uncached input tokens, 690 million output tokens, 72 billion cache reads: <strong>around US$ 165K</strong> at API prices.</li>
<li>16K compiler errors fixed by 64 agents before CI went green on six platforms.</li>
<li>The existing test suite, with more than a million assertions, passed 100% before the merge.</li>
</ul>
<p>Read again what made it possible:</p>
<ul>
<li>the project&rsquo;s original author at the controls</li>
<li>a complete, working source code as reference</li>
<li>a test suite with a million assertions serving as the oracle</li>
<li>unlimited budget, because Bun has belonged to Anthropic since December 2025, and a pre-release version of Claude Fable 5</li>
</ul>
<p>Sumner estimates three engineers with full context would have needed a year.</p>
<p>Now take each of those advantages away. PhotoCraft doesn&rsquo;t have the Photoshop source to port, it has to invent. It doesn&rsquo;t have a test suite that says &ldquo;this matches Photoshop&rdquo;. It doesn&rsquo;t have Photoshop&rsquo;s author.</p>
<p>And Photoshop is a 36-year-old product whose disk requirements Adobe lists between <a href="https://helpx.adobe.com/photoshop/desktop/get-started/technical-requirements-installation/adobe-photoshop-on-desktop-technical-requirements.html"target="_blank" rel="noopener">10 and 100 GB</a>, against a JavaScript runtime. Even without knowing the size of Adobe&rsquo;s codebase, we&rsquo;re talking about a bigger target, with no reference, no oracle and no owner. Orders of magnitude above US$ 165K, and even then it would be an approximation.</p>
<h2>Why retro moves so fast<span class="hx:absolute hx:-mt-20" id="why-retro-moves-so-fast"></span>
    <a href="#why-retro-moves-so-fast" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>If it&rsquo;s so hard, why does an old game port ship every day? Because retro stacks up every advantage the rest doesn&rsquo;t have.</p>
<p><strong>The binary is available and tiny.</strong> Super Mario Bros. on the NES: 40 KB. Super Mario 64: 8 MB. Ocarina of Time and Majora&rsquo;s Mask: 32 MB each. A whole PS1 CD: 650 MB.</p>
<p>Compare with <a href="https://store.steampowered.com/app/1086940/Baldurs_Gate_3/"target="_blank" rel="noopener">Baldur&rsquo;s Gate 3</a>, 150 GB, or with the <a href="https://support.activision.com/black-ops-6/articles/pc-system-requirements-for-black-ops-6"target="_blank" rel="noopener">latest Call of Duty</a>, above 100 GB. That&rsquo;s three or four orders of magnitude, and the scope shrinks along with it: a 1996 game has a handful of systems, not a physics engine with networking, a store and anticheat.</p>
<p><strong>The technology is old and documented.</strong> Twenty-five years of community have already mapped the N64, PS1 and SNES hardware instruction by instruction. The agent discovers nothing; it speeds up work that already has a map. It&rsquo;s the same pattern I <a href="/en/2026/09/28/llms-are-good-at-reproducing-what-exists-what-about-new-things/">saw in my retrocomputing projects</a>: where there&rsquo;s a reference and an oracle, AI flies; where there isn&rsquo;t, it spins in brute force.</p>
<p><strong>The target doesn&rsquo;t move.</strong> Nobody is going to update 1998&rsquo;s Zelda.</p>
<p><strong>And the legal side was settled thirty years ago.</strong> Sega v. Accolade and Sony v. Connectix drew the outline. The ports require you to supply your own ROM.</p>
<h3>A modern console is a PC<span class="hx:absolute hx:-mt-20" id="a-modern-console-is-a-pc"></span>
    <a href="#a-modern-console-is-a-pc" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>The second half of the explosion, the PS4, PS5, Xbox One and Switch ports, has another explanation: those consoles are PCs or phones in a different case. The <a href="https://www.playstation.com/en-us/ps4/tech-specs/"target="_blank" rel="noopener">PS4</a> uses an AMD Jaguar x86-64, the <a href="https://blog.playstation.com/2020/03/18/unveiling-new-details-of-playstation-5-hardware-technical-specs/"target="_blank" rel="noopener">PS5</a> a Zen 2, the <a href="https://news.xbox.com/en-us/2020/03/16/xbox-series-x-tech/"target="_blank" rel="noopener">Xbox Series</a> another Zen 2, the Switch an NVIDIA <a href="https://www.techinsights.com/blog/nintendo-switch-teardown"target="_blank" rel="noopener">Tegra X1</a> ARM chip. Your PC&rsquo;s processor runs that code natively. So nobody needs to emulate the CPU.</p>
<p>The remaining work is translating the operating system and the GPU. <a href="https://github.com/shadps4-emu/shadPS4"target="_blank" rel="noopener">shadPS4</a> reimplements the PS4&rsquo;s system libraries and recompiles the shaders: games ship from the factory with AMD GPU machine code, which the emulator decodes instruction by instruction and converts to SPIR-V, Vulkan&rsquo;s format.</p>
<p><a href="https://almost-there-games.itch.io/azimuth"target="_blank" rel="noopener">Azimuth</a>, which makes Forza Horizon 2 run on Windows, translates Xbox WinRT calls to Win32 and DirectX 11.X to desktop DirectX 12, with the game&rsquo;s x86 code running directly. AnyPS5 relinks the executable: it takes the PS5 binary and produces a native Linux or Windows binary, linked against reimplemented system libraries. In all three cases, Proton, DXVK, VKD3D and MoltenVK had solved the graphics translation layer years earlier.</p>
<p>The reverse proof is the consoles that weren&rsquo;t PCs. The PS3 uses the Cell, a PowerPC with seven active coprocessors (the SPEs), each with its own local memory and no cache, and RPCS3 has been at it <a href="https://rpcs3.net/about"target="_blank" rel="noopener">since 2011</a>, with SPU emulation still the bottleneck today. The Xbox 360 is PowerPC too, which is why Sonic Unleashed needed XenonRecomp to translate PowerPC into C++. Reverse engineering is case by case, and the architecture decides whether the case is easy.</p>
<h3>AI is in the loop, and there&rsquo;s already a fight<span class="hx:absolute hx:-mt-20" id="ai-is-in-the-loop-and-theres-already-a-fight"></span>
    <a href="#ai-is-in-the-loop-and-theres-already-a-fight" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>That the 2026 ports use AI, many of them say outright. The <a href="https://github.com/SlickAmogus/silent-hill-decomp"target="_blank" rel="noopener">Silent Hill decomp</a> writes in its README that development is &ldquo;heavily AI-assisted (Claude Opus 4.6, 4.7 and the newer Fable model), and we&rsquo;re open about that. It is not automated translation of the game&rdquo;. The Pokémon Stadium recomp <a href="https://retrododo.com/a-pokemon-stadium-recomp-has-finally-arrived-but-lists-claude-as-a-contributor/"target="_blank" rel="noopener">lists Claude as a contributor</a>.</p>
<p>The devs of the Zelda and Banjo recomps, who spent years doing this by hand, <a href="https://kotaku.com/zelda-and-banjo-recompiled-devs-go-to-war-with-ai-vibe-coded-slopcomps-2000739936"target="_blank" rel="noopener">went to war</a> with what they call &ldquo;slopcomps&rdquo;, agent-generated ports of alpha quality.</p>
<p>And it&rsquo;s worth separating headline from actual state here too. AnyPS5 has a 0.1 version from September that, according to those who tested it, runs one 2D indie game; the viral claim that &ldquo;the PS5 is 80% ported to PC&rdquo; comes from fans, and no 3D game runs. Astro Bot runs, but in emulators (KytyPS5, SharpEmu), which take the traditional path. Forza Horizon 2 runs, and Azimuth is a closed binary from a studio that declares itself &ldquo;AI assisted&rdquo; without saying anything more.</p>
<h2>&ldquo;But in two years tokens will cost nothing&rdquo;<span class="hx:absolute hx:-mt-20" id="but-in-two-years-tokens-will-cost-nothing"></span>
    <a href="#but-in-two-years-tokens-will-cost-nothing" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The standard answer to all of this is: it&rsquo;s hard today, in two years it&rsquo;s trivial, because AI grows exponentially. The numbers say otherwise.</p>
<p>The price of the most expensive tier barely fell. GPT-4 in March 2023 cost US$ 30 and 60 per million input and output tokens; Claude 3 Opus in 2024, 15 and 75; today&rsquo;s frontier models, Fable 5.1 and GPT-6 Astra, <a href="https://claude.com/pricing"target="_blank" rel="noopener">cost</a> <a href="https://developers.openai.com/api/docs/pricing"target="_blank" rel="noopener">10 and 50</a>. In three and a half years, the frontier got between 1.2 and 3 times cheaper, depending on which end you look at.</p>
<p>What really fell was the cost of a fixed quality: Epoch AI measured drops <a href="https://epoch.ai/blog/llm-inference-price-trends"target="_blank" rel="noopener">of 9 to 900 times per year</a> depending on the task, and in a <a href="https://epoch.ai/gradient-updates/how-persistent-is-the-inference-cost-burden"target="_blank" rel="noopener">2026 review</a> sums it up as &ldquo;very roughly 5 to 10 times per year&rdquo;, with the caveat that it might slow down. That&rsquo;s real and it&rsquo;s great.</p>
<p>But apply the math to Bun. US$ 165K divided by 10 is US$ 16K. By 100, US$ 1,650. That&rsquo;s to port a runtime with the source, the tests and the author in hand.</p>
<p>For an amateur trying to clone Photoshop with none of that, the bill starts orders of magnitude higher and falls in the same proportion. Cheap isn&rsquo;t the same as trivial.</p>
<p>And the &ldquo;exponential&rdquo; premise died in 2024. Ilya Sutskever said at NeurIPS that <a href="https://www.globalbankingandfinance.com/ai-with-reasoning-power-will-be-less-predictable-ilya-sutskever-says/"target="_blank" rel="noopener">pre-training as we know it will end</a>, because there&rsquo;s only one internet. Epoch projects that reasoning model scaling <a href="https://epoch.ai/gradient-updates/how-far-can-reasoning-models-scale"target="_blank" rel="noopener">converges to the general frontier rate</a> in about a year.</p>
<p>I&rsquo;ve written about this three times in recent weeks: <a href="/en/2026/09/28/llms-are-good-at-reproducing-what-exists-what-about-new-things/">the limits</a> of what the model reproduces, <a href="/en/2026/10/12/llms-agents-competent-enough/">the convergence</a> of raw models with the gains coming from the harness, and <a href="/en/2026/10/11/are-there-better-languages-for-programming-with-llms/">the languages benchmark</a> where the frontier models tied at the ceiling. The tool we have is the tool we&rsquo;ll have for a good while, with incremental improvement. Plan with it.</p>
<p>Worth recalling the number that opens the limits post: per the <a href="https://github.blog/news-insights/octoverse/octoverse-a-new-developer-joins-github-every-second-as-ai-leads-typescript-to-1/"target="_blank" rel="noopener">Octoverse 2025</a>, GitHub&rsquo;s official report, 81.5% of contributions on the platform happened in private repositories. That&rsquo;s the code that really matters, the code that pays salaries, and no LLM has seen a line of it. The model reproduces what was in the training data, and that code was never there.</p>
<p>Now extend that to every SaaS product and every component it uses. The Adobe code that&rsquo;s actually worth something, the color engine, the profiles, the cloud filters, isn&rsquo;t available anywhere, not even as a binary. Anyone who wants to reproduce it will have to redo the research and development, expensive and slow, over the same years Adobe took. And at the end they still run into third-party licensed intellectual property, Pantone, camera SDKs, manufacturer profiles, that you will never be able to have.</p>
<h2>Answering the list from the top<span class="hx:absolute hx:-mt-20" id="answering-the-list-from-the-top"></span>
    <a href="#answering-the-list-from-the-top" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p><strong>Does PhotoCraft exist?</strong> It does, it&rsquo;s a two-week-old alpha with more than 800 open issues, rudimentary color management, freezing above 8K and opening PSDs wrong. It looks like Photoshop in a screenshot. It can improve a lot, and it will discover that the target moves and that Photoshop&rsquo;s expensive parts live in the cloud or in manufacturer contracts.</p>
<p><strong>Does REA exist?</strong> It does, it&rsquo;s good, and it&rsquo;s a toolbox for people who already know how to use Ghidra, requiring the binary in hand, the tools installed and hundreds of thousands of tokens per analysis. The project itself avoids saying it clones programs.</p>
<p><strong>Did recomps and decomps accelerate?</strong> Yes, for real, and AI has a part in it. They accelerated where the binary exists, is small, has been documented for 25 years and doesn&rsquo;t move. Or where the console is a PC in disguise and the work is API translation, solved by Wine and Vulkan years earlier.</p>
<p><strong>Is Adobe dead?</strong> Adobe has around 40 million subscribers by the most cited estimate, the features that sell subscriptions run on its servers, and thirty years of GIMP still leave the print shop asking for Photoshop. I wish it were otherwise. The data says what will happen is what always happened with clones of a live product: years of chasing, and when you catch up, the value has already moved.</p>
<p><strong>Are programmers dead?</strong> Rewriting Bun required Bun&rsquo;s author. The decomps that work have people who know the hardware driving the agent, and the ones that don&rsquo;t became &ldquo;slopcomps&rdquo;, name and all. Reverse engineering got cheaper and more accessible, as it did with Ghidra in 2019, and it&rsquo;s still an engineer&rsquo;s job. Without us, the tool returns a pretty screenshot.</p>
<p>Proprietary software with a binary in hand will come under pressure, and it should. Most of the software you use has no binary at all, and the software that does already depends on cloud services that don&rsquo;t disassemble. Software engineering with AI has a great road ahead. &ldquo;It&rsquo;s over&rdquo; headlines get clicks and don&rsquo;t compile.</p>
]]></content:encoded><category>artificial-intelligence</category><category>vibe-coding</category><category>retrocomputing</category><category>open-source</category></item><item><title>Stop Making Excuses and Ship More! With AI, the Premise Changed. Get It.</title><link>https://akitaonrails.github.io/en/2026/09/22/stop-making-excuses-and-ship-more-the-premise-changed/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2026/09/22/stop-making-excuses-and-ship-more-the-premise-changed/</guid><pubDate>Tue, 22 Sep 2026 16:00:00 -0300</pubDate><description>&lt;p&gt;Today I fired off a string of posts on X hammering this point, and, like every time I talk about fast deploys, a bunch of people read it as &amp;ldquo;this guy is saying to ship any garbage to production without testing.&amp;rdquo; That&amp;rsquo;s not it. Let me lay it out calmly, because the argument is narrower and more boring than the version that spreads panic.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;It&amp;rsquo;s better to deploy faster, because fixing things afterward became fast and cheap.&lt;/p&gt;</description><content:encoded><![CDATA[<p>Today I fired off a string of posts on X hammering this point, and, like every time I talk about fast deploys, a bunch of people read it as &ldquo;this guy is saying to ship any garbage to production without testing.&rdquo; That&rsquo;s not it. Let me lay it out calmly, because the argument is narrower and more boring than the version that spreads panic.</p>
<blockquote>
  <p>It&rsquo;s better to deploy faster, because fixing things afterward became fast and cheap.</p>

</blockquote>
<p>That sentence is the whole article. Everything else is just me justifying why it&rsquo;s true now, even though it wasn&rsquo;t true a few years ago, and showing how I&rsquo;ve been acting on it myself since February.</p>
<h2>The Premise Changed, and Most People Haven&rsquo;t Noticed<span class="hx:absolute hx:-mt-20" id="the-premise-changed-and-most-people-havent-noticed"></span>
    <a href="#the-premise-changed-and-most-people-havent-noticed" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>I started saying this <a href="https://x.com/AkitaOnRails/status/2102436061083267441"target="_blank" rel="noopener">on X</a> like this:</p>
<blockquote>
  <p>Fixing got cheap. Stop holding things back. Just ship it and fix it after. &ldquo;Ugh, but I risk shipping a bug.&rdquo; The wrong premise is thinking that staring at every line is going to make any difference at scale. And idle code doesn&rsquo;t improve on its own. Only used code lets you find out if it needs improving.</p>

</blockquote>
<p>And I followed up <a href="https://x.com/AkitaOnRails/status/2102476036709450066"target="_blank" rel="noopener">in the second post</a>, because the first read of anything I write in 280 characters always comes back angrier and more literal than intended:</p>
<blockquote>
  <p>Get the concept (this is an X post, not a blog post, stop reading it literally). I&rsquo;m not saying AI doesn&rsquo;t produce bugs. It does, obviously. But at this point, not more than you, the human, also produce. [&hellip;] The old excuse was that when a bug ships to production, it sits there longer than it should, because it never becomes a sprint priority to fix. Now THAT&rsquo;S NOT TRUE ANYMORE, the premise changed: fixing the bug afterward is FAST and CHEAP.</p>

</blockquote>
<p>That&rsquo;s the entire point. It isn&rsquo;t about AI being infallible. It&rsquo;s about the cost of fixing a mistake having collapsed, while most teams still run their review process as if that cost were still high.</p>
<p>With today&rsquo;s frontier models, Opus, Sol, K3, and company, the LLM makes fewer mistakes than most human developers I&rsquo;ve seen go through a code review. When it does err, the overwhelming majority of the time the mistake isn&rsquo;t the model&rsquo;s. It&rsquo;s whoever was driving the harness. The LLM does what you ask, and it doesn&rsquo;t do what you didn&rsquo;t ask.</p>
<p>If you don&rsquo;t know what to ask for, you won&rsquo;t produce anything useful, no matter how good the model is. That&rsquo;s exactly why only a real software engineer can get real production-grade software out of an agent: someone has to know how to break down the problem, validate the result, and recognize when the request itself was poorly framed. AI doesn&rsquo;t replace that judgment. It multiplies what that judgment already knew how to do.</p>
<h2>This Should Have Been the Norm for 25 Years<span class="hx:absolute hx:-mt-20" id="this-should-have-been-the-norm-for-25-years"></span>
    <a href="#this-should-have-been-the-norm-for-25-years" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The discomfort I feel with bureaucratic review processes isn&rsquo;t new, and the industry should have solved it a long time ago. Kent Beck and Ron Jeffries invented Extreme Programming in 1997, on Chrysler&rsquo;s C3 project, and Beck published the book in 1999 with continuous integration, TDD, and small-batch delivery as core practices. In February 2001, seventeen developers locked themselves in a resort in Snowbird, Utah, and walked out with the Agile Manifesto. That&rsquo;s twenty-five years ago.</p>
<p>Martin Fowler&rsquo;s mantra for continuous integration has always been &ldquo;if it hurts, do it more often,&rdquo; and the book <em>Continuous Delivery</em>, by Jez Humble and Dave Farley, formalized the idea that small, frequent deploys reduce risk, not increase it. DORA&rsquo;s annual State of DevOps reports have documented this with numbers for over a decade:</p>
<ul>
<li>elite teams: deploy on demand, multiple times a day;</li>
<li>low-performing teams: deploy once a month, sometimes once every six months.</li>
</ul>
<p>And the finding that&rsquo;s hardest to swallow if you&rsquo;re afraid of deploying fast: historically, more frequent deploys <strong>do not increase</strong> the failure rate. <em>They reduce it.</em> Teams that deploy more, on average across the last decade of reports, fail less and recover faster when they do fail.</p>
<p>Even with all of this documented, measured, and published for over a decade, a large chunk of the industry keeps damming up change into big batches, with slow manual review, and calling that &ldquo;prudence.&rdquo; It was never a settled matter even inside the agile community itself: in 2014, Kent Beck, Martin Fowler, and DHH (Rails&rsquo; creator) spent weeks publicly debating whether TDD was dead, without reaching a clean consensus. In other words, the very community that invented these practices still argues today about how much rigor is enough.</p>
<p>What I&rsquo;m saying is simpler than that whole debate: whatever your testing rigor level is, an LLM reviewing your diff before merge is fast, and reverting a bad deploy became trivial with containers and git. The math that&rsquo;s left is: how much time do you spend holding back code, versus how much time it would take to revert if it went wrong? If the second number is smaller, you&rsquo;re paying a toll for fear, not for prudence.</p>
<h2>This Isn&rsquo;t &ldquo;Ship Anything&rdquo;<span class="hx:absolute hx:-mt-20" id="this-isnt-ship-anything"></span>
    <a href="#this-isnt-ship-anything" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>This is the part that always gets lost: I&rsquo;m not saying to deploy with zero engineering practice underneath. I&rsquo;m saying the opposite. This is only safe <strong>after</strong> you already have basic software engineering working, which I document exhaustively on this blog for years: automated tests, CI running on every push, real revert capability, observability to know when something broke.</p>
<blockquote>
  <p>80/20. Ask the LLM to do a minimal review and ship it. If it breaks, revert (that&rsquo;s what containers, git, etc. are for). If you can&rsquo;t revert, your infra sucks, go fix it yesterday.</p>

</blockquote>
<p>That last sentence is the whole filter. If your infrastructure doesn&rsquo;t let you revert fast, the problem isn&rsquo;t deploy speed. It&rsquo;s your infrastructure, and it needs fixing before any conversation about going faster. Nobody gets a pass to skip having real rollback capability. What changes is that, once you have it, holding back code waiting on human review became pure waste.</p>
<h2>The Minimum Recommended Environment<span class="hx:absolute hx:-mt-20" id="the-minimum-recommended-environment"></span>
    <a href="#the-minimum-recommended-environment" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>I&rsquo;ve already said &ldquo;basic software engineering&rdquo; several times in this piece without spelling out what that means in practice. Here&rsquo;s the list, no hedging. Nothing on it is optional, and none of it got expensive to maintain:</p>
<ul>
<li>unit tests covering isolated logic, TDD or not, as long as they exist;</li>
<li>scenario and integration tests covering concurrency, partial failure, and conflict, the things unit tests alone don&rsquo;t catch;</li>
<li>CI that builds and tests on every change, ideally for every platform you actually ship to;</li>
<li>hostile automated review before merge, like <code>pr-audit</code>, which treats a contributor&rsquo;s claim as unverified until proven;</li>
<li>feature flags to control the blast radius of who sees a change first;</li>
<li>real staging as a gate before any real user sees the change;</li>
<li>fast revert capability, with git and containers, and observability to know when something broke;</li>
<li>in compliance, financial, or health environments, one extra layer: heavier scenario testing, and more than one lock per identified failure mode.</li>
</ul>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/09/22/deploy-environment-diagram-en.png" alt="Diagram of the minimum pipeline for fast, safe deploys: commit, automated tests, cross-platform CI, feature flag, staging, and production, with a rollback arrow looping from production back to tests and a feedback arrow looping from production back to commit"  loading="lazy" /></p>
<p>What the diagram tries to make clear is that speed and safety aren&rsquo;t competing with each other here. They come from the same stack of layers, and each layer in that stack is what gives you permission to stop holding back code waiting for manual review.</p>
<h2>Where This Math Doesn&rsquo;t Work Out<span class="hx:absolute hx:-mt-20" id="where-this-math-doesnt-work-out"></span>
    <a href="#where-this-math-doesnt-work-out" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>I&rsquo;m not going to pretend I didn&rsquo;t get good pushback in the replies on X, because several of them hit on something real that I skipped while writing fast for Twitter. It&rsquo;s worth answering for real instead of just validating whoever agreed.</p>
<ol>
<li>
<p><strong>Reverting code doesn&rsquo;t undo a consequence that already left your system.</strong> If the bug already processed a wrong payment, moved money to the wrong account, or controlled an elevator, reverting the deploy doesn&rsquo;t undo what already happened. &ldquo;If you can&rsquo;t revert, your infra sucks&rdquo; covers the technical side of deploy and rollback, not the case where the damage already left your system before you could react. Payments, health, and anything touching physical safety fall into this category, and there the review bar before shipping has to get stricter, not looser, because the cost of reverting stopped being technical.</p>
</li>
<li>
<p><strong>Databases break the &ldquo;reverting is easy&rdquo; analogy.</strong> Reverting a binary is trivial. Reverting a migration that already ran in production, already moved data, and maybe already dropped a column, is not. There&rsquo;s a pattern for this, <em>expand-contract</em>: add the new shape without removing the old one, wait for every consumer to migrate, only then remove the old one. This isn&rsquo;t about trusting AI more or less. It&rsquo;s about state that doesn&rsquo;t undo with <code>git revert</code>.</p>
</li>
<li>
<p><strong>Libraries and public packages don&rsquo;t run on the same math as a closed application.</strong> When my own code breaks, I fix it on the spot. When a lib that hundreds of projects depend on breaks, the people who eat the cost are people who don&rsquo;t even know I exist, with a much slower reaction time than mine. That&rsquo;s why my <code>pr-audit</code> skill, which I describe further below, treats semver classification as a version gate: a breaking change ships in a major, never in a patch, because there the real review isn&rsquo;t about catching a bug, it&rsquo;s about not blindsiding whoever depends on you without warning.</p>
</li>
<li>
<p><strong>App stores knock the speed down too</strong>, and my own frank_yomik example hides that instead of solving it. It publishes the APK straight as a GitHub Release, without going through the Play Store. If you distribute through Google&rsquo;s or Apple&rsquo;s official store, their review sits in the middle of your process, and you don&rsquo;t control how long it takes. This article is about removing the bottleneck that&rsquo;s inside your own control. The app store isn&rsquo;t inside it.</p>
</li>
<li>
<p><strong>The most honest point of all: every piece of evidence I bring here comes from projects where I&rsquo;m the sole owner and I decide alone.</strong> I have no way to prove, with my own numbers, that the same pace works inside a large company, with Legal, Compliance, and a product manager in the middle. What I&rsquo;d argue generalizes is the principle, not the exact pace: build real revert capability, automate the mechanical checking, and measure the result against real usage, not against your own sense of security. How much speed you apply on top of that depends on how much a mistake costs in your specific context, and nobody from outside answers that for you.</p>
</li>
</ol>
<p>On the first point specifically, it&rsquo;s worth opening up more, because &ldquo;stricter bar&rdquo; can&rsquo;t stay vague. It means real scenario testing, not just unit tests. ai-memory itself has more than a thousand integration tests spread across its modules, and most of them don&rsquo;t ask &ldquo;does this function return the right value?&rdquo; They ask things like:</p>
<ul>
<li>what happens when two writes race for the same file at the same time;</li>
<li>what happens when the disk fails mid-way through a delete operation;</li>
<li>what happens when two sessions try to consolidate the same data at the same time;</li>
<li>what happens when the process dies before it finishes writing.</li>
</ul>
<p>An environment with compliance, financial, or health requirements needs this level of testing: every identified failure mode has to have a mitigation, and more than one layer of checking, never a single lock.</p>
<p>And here&rsquo;s the point that holds up this article&rsquo;s entire argument even in those environments: this bar doesn&rsquo;t change depending on who wrote the code. A human developer&rsquo;s mistake takes down a payment system just as well as an LLM&rsquo;s mistake does, and nobody gets a free pass for loose review just because a senior human wrote the line. The bar of scenario testing and double-layer protection is about the problem domain, not about who, or what, produced the code.</p>
<p>Feature flags and staging environments are the other half of that double layer, and they also changed cost. Flags used to be a pain to maintain: dozens of them, each one needing to be born, monitored, and removed once it stops making sense, and it&rsquo;s easy to pile up dead flags nobody remembers the purpose of. That got cheap too: I ask the agent to audit every flag in the project, find which ones have been stuck at the same value for months, and generate the cleanup PR. Maintenance that used to take a whole afternoon turns into a task that takes minutes.</p>
<p>With that solved, the layered defense becomes simple to assemble: staging to catch obvious errors before any user sees them, feature flags to limit the blast radius of who sees the change first, and scenario tests to cover what staging alone doesn&rsquo;t simulate. Three layers, none of them expensive to maintain.</p>
<h2>How I&rsquo;ve Been Doing This Since February<span class="hx:absolute hx:-mt-20" id="how-ive-been-doing-this-since-february"></span>
    <a href="#how-ive-been-doing-this-since-february" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>I started accelerating my output with AI in February this year, and the question I wanted to answer was simple: can you use the new generation of frontier LLMs to code full-time, at real production pace, not just for a demo? The answer turned into my <a href="/en/2026/09/15/new-llm-benchmark-v4-retesting-all-top-llms-part-1/">biggest LLM benchmark</a>, more than 110 articles published since then, and this, as I <a href="https://x.com/AkitaOnRails/status/2102176881139138806"target="_blank" rel="noopener">wrote recently</a>:</p>
<blockquote>
  <p>Since February I&rsquo;ve produced more than 110 articles on my blog. Most of it came from the 30+ open source projects I built and maintain (43 if you count other people&rsquo;s contributing projects, like Flea or Omarchy). 1.6 million lines of code produced. By myself (with agents, of course).</p>

</blockquote>
<p>The projects I use the most in my own day to day, and that survived the &ldquo;am I still maintaining this months later&rdquo; test, are three:</p>
<ul>
<li><a href="https://aijail.io"target="_blank" rel="noopener">ai-jail</a>: an OS sandbox for my coding agents;</li>
<li><a href="https://aimemorybr.netlify.app"target="_blank" rel="noopener">ai-memory</a>: long-term memory for the agents;</li>
<li>ai-usagebar: a bar widget to monitor LLM usage.</li>
</ul>
<p>Two of them already have a decent landing page:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/09/22/aijail-hero.png" alt="ai-jail’s landing page, an operating system sandbox for AI agents"  loading="lazy" /></p>
<p>ai-jail went live today. ai-memory is up at <a href="https://aimemorybr.netlify.app"target="_blank" rel="noopener">aimemorybr.netlify.app</a> because the <code>aimemory.io</code> domain transfer will still take a few more days.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/09/22/aimemory-hero.png" alt="ai-memory’s landing page, a long-term memory system for coding agents"  loading="lazy" /></p>
<p>Both run on Netlify. That&rsquo;s not a tool recommendation, it&rsquo;s just where I already had an account set up. The point that matters here isn&rsquo;t the platform, it&rsquo;s the flow: to put a new version live, the command is <code>git push</code>. There&rsquo;s no manual step between the commit and the updated site.</p>
<p>My newsletter, <a href="https://themakitachronicles.com"target="_blank" rel="noopener">The M.Akita Chronicles</a>, already has more than 15,000 subscribers:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/09/22/themakitachronicles-hero.png" alt="The M.Akita Chronicles newsletter’s subscription page"  loading="lazy" /></p>
<p>And the newest effort is converting blog posts into podcast episodes. The whole thing runs inside Discord, with a bot that generates the preview and waits for my command:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/09/22/discord-podcast-1.png" alt="Discord bot announcing that a podcast episode preview is ready, with commands to publish, schedule, or reject it"  loading="lazy" /></p>
<p>One <code>/publish-podcast 2</code> later, the episode is published and the transcript already has a URL:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/09/22/discord-podcast-2.png" alt="Discord bot confirming that the podcast episode was published, with a link to the transcript"  loading="lazy" /></p>
<p>From finished text to published audio, the entire process is me typing a slash command in a Discord conversation. That&rsquo;s the effort bar I use to measure whether a process is good enough: if publishing takes more than one command, some step is still too manual.</p>
<h3>Does the Quality of the Generated Code Hold Up?<span class="hx:absolute hx:-mt-20" id="does-the-quality-of-the-generated-code-hold-up"></span>
    <a href="#does-the-quality-of-the-generated-code-hold-up" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Every single day someone shows up worried that automating too much with LLMs is going to dump bad code, or worse, dangerous code, into production without anyone noticing. Given that the engineering underneath is the one I described in the previous section, automated tests, CI, feature flags, staging, fast rollback, my actual experience doesn&rsquo;t back up that fear.</p>
<p>I&rsquo;ve produced more than 1.6 million lines of code with LLMs since February. <strong>I have never once seen an LLM do something randomly destructive that I didn&rsquo;t ask for.</strong> Quite the opposite: in my <a href="/en/2026/09/15/new-llm-benchmark-v4-retesting-all-top-llms-part-1/">LLM Benchmark v4</a>, I deliberately built an adversarial scenario, a subagent planting fourteen real CVE-based sabotage items, hidden inside a project that grows across seven sprints. Most of the tested models found and fixed the sabotage on their own, without me having to point it out.</p>
<p>That&rsquo;s anecdotal. It doesn&rsquo;t prove anything beyond what that specific benchmark measures. But after all that volume, the impression that sticks is consistent: today&rsquo;s LLMs produce stable code most of the time, comparable to what a decent human developer would produce, just a lot faster, and on average, more accurate.</p>
<h2>Cross-Platform CI/CD With Zero Effort<span class="hx:absolute hx:-mt-20" id="cross-platform-cicd-with-zero-effort"></span>
    <a href="#cross-platform-cicd-with-zero-effort" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>I went and counted how many of my <code>ai-*</code> and <code>frank-*</code> projects (18 repositories total, among the ones with a README) already have automated build and deploy. Fourteen have CI configured, and most of them build and publish to multiple operating system platforms from a push made on a Linux machine.</p>
<p>The most complete example is ai-memory itself: a single tag push triggers a workflow that, in the same run:</p>
<ul>
<li>builds Linux x86_64 and arm64;</li>
<li>builds macOS arm64 and x86_64;</li>
<li>builds Windows x86_64, packaged with PowerShell;</li>
<li>publishes a multi-architecture Docker image;</li>
<li>pushes the package to Arch Linux&rsquo;s AUR.</li>
</ul>
<p>ai-usagebar follows the same build pattern for Linux and Windows, and also publishes two AUR packages, to crates.io, and to a Scoop bucket for Windows.</p>
<p>Then there&rsquo;s frank_yomik, my manga reader with OCR and translation via a local LLM, which is the example I wanted to show that Android fits into the same pipeline. A <code>v*</code> tag push triggers five parallel builds:</p>
<ul>
<li>Linux;</li>
<li>macOS;</li>
<li>Windows;</li>
<li>browser extension;</li>
<li>Android, on an ordinary Linux runner, decoding a signing keystore stored in GitHub secrets and producing the already-signed APK.</li>
</ul>
<p>At the end, one job waits for all five to finish and publishes a single GitHub Release with every binary together. It builds for four different systems, including Android, without ever leaving Linux.</p>
<p>Not everything came out perfect on first look, and I&rsquo;d rather admit that than pretend it&rsquo;s all clean: frank_sherlock and frank_karaoke publish the release as a draft by default, which blocks the automation that follows (like the AUR push) until someone clicks &ldquo;publish&rdquo; by hand. I found this hole while writing this piece.</p>
<p>A neighboring project, frank_scanlation, had already solved a similar but different problem: GitHub suppresses the event that triggers automation when the release itself was created by the bot&rsquo;s own token, so its workflow triggers the next step by hand, via <code>workflow_dispatch</code>, from inside the job itself. Neither fix is hard. I&rsquo;ll apply both to the projects that still need it this week, because that&rsquo;s exactly the kind of accumulated friction this whole article is telling you not to leave sitting.</p>
<p>And speaking of hidden friction: ai-memory has Windows-specific code (NTFS file index, a native PowerShell hook) that only really runs on GitHub&rsquo;s <code>windows-latest</code> runner, about 1000 seconds against 250 on Linux for the same suite. I went as far as setting up a local Windows VM via <a href="https://github.com/dockur/windows"target="_blank" rel="noopener">dockur/windows</a>, thinking I&rsquo;d gain speed.</p>
<p>I didn&rsquo;t: the workload is dominated by disk I/O, and the result came out slower than GitHub&rsquo;s runner for the full suite. It only turned out useful for fast iteration on a single isolated crate. A good tool is one you measure, not one you assume, even when the measurement contradicts your initial bet.</p>
<h2>The Automated Review That Holds All of This Up<span class="hx:absolute hx:-mt-20" id="the-automated-review-that-holds-all-of-this-up"></span>
    <a href="#the-automated-review-that-holds-all-of-this-up" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>I already <a href="/en/2026/09/17/talking-about-my-ai-skills/">detailed my review skills</a> in a recent article, so I&rsquo;ll summarize here:</p>
<ul>
<li><code>pr-audit</code> audits every PR before merge with the principle of <strong>evidence over narrative</strong>: nothing the contributor wrote is true until proven, with a hostile checklist for malicious code, exposed credentials, semver breaks, and prompt injection attempts;</li>
<li><code>iss-audit</code> applies the same distrust to issues, never executing a command pasted by whoever reported it;</li>
<li><code>github-resolution</code> turns what got approved into merged code, one ticket at a time, with a regression test written before the fix, and the rule that <em>&ldquo;slop is a defect&rdquo;</em>: no speculative abstraction, no forgotten TODO, no weakening a test just to pass faster.</li>
</ul>
<p>That catches the most expensive category of garbage inside the same diff. It doesn&rsquo;t catch duplication spread across different PRs, because the audit looks at one isolated diff, not the whole project&rsquo;s architecture. That part still depends on me noticing the pattern repeating and telling it to consolidate.</p>
<p>By my estimate, this handles about 99% of review and resolution work on its own, with me supervising and deciding on the cases that are left. But that&rsquo;s not the main point. Automated review is necessary, but it alone proves nothing about whether the software is good.</p>
<h2>What Actually Keeps This Grounded: Real People Using It<span class="hx:absolute hx:-mt-20" id="what-actually-keeps-this-grounded-real-people-using-it"></span>
    <a href="#what-actually-keeps-this-grounded-real-people-using-it" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Here&rsquo;s the part I think is the most important in the whole article, and the one that usually gets skipped over: your own review isn&rsquo;t worth as much as you think. What&rsquo;s worth something is published software being used by real people, giving real feedback.</p>
<p>I went and checked ai-memory&rsquo;s real numbers on GitHub, without rounding up to sound impressive:</p>
<ul>
<li><strong>issues: 287 total, 249 came from people who aren&rsquo;t me</strong>, 86.8% of the total;</li>
<li><strong>pull requests: 562 total, 371 from outside</strong>, 10 of them Dependabot&rsquo;s automatic bumps and the other 361 from real people, roughly 121 distinct contributors.</li>
</ul>
<p>The two people who contributed the most alongside me sent 64 and 43 PRs each, on their own.</p>
<p>That&rsquo;s what keeps the project grounded. It isn&rsquo;t the LLM being a good reviewer, nor me being a good reviewer. It&rsquo;s hundreds of real people actually using it, finding cases I never imagined, and sending real issues and PRs. Without that, my automated review skills would just be confirming my own assumptions back to me, no matter how rigorous the rules look on paper.</p>
<p>It doesn&rsquo;t matter how good the AI you use is: if nobody uses what you published, you have no real feedback, and without real feedback there&rsquo;s no real improvement. There&rsquo;s only your own opinion about your own work, validated by nobody but yourself.</p>
<h2>The Conclusion That Matters<span class="hx:absolute hx:-mt-20" id="the-conclusion-that-matters"></span>
    <a href="#the-conclusion-that-matters" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Real software engineering <strong>needs</strong> fast, near-zero-effort deploy. It isn&rsquo;t optional, it isn&rsquo;t a luxury for a big team, it&rsquo;s a prerequisite. And if reviewing and shipping to production is taking longer than it took to develop the feature, you&rsquo;re not just wrong.</p>
<p><strong>You are the bottleneck.</strong></p>
<p>Excuses and justifications aren&rsquo;t worth anything at that point. Use that time to fix yourself and your process, because only real-world results matter, not the sense of security from having reviewed a diff line by line that nobody else is ever going to look at again.</p>
<p>If the premise changed, I change today. Not tomorrow, not next week. And if yours hasn&rsquo;t changed yet, the problem isn&rsquo;t AI. It&rsquo;s you holding back your own work.</p>
]]></content:encoded><category>devops</category><category>software-engineering</category><category>coding-agents</category><category>vibe-coding</category></item><item><title>Hot Take: Harness, Loop Engineering, Graph Engineering Are Bullshit</title><link>https://akitaonrails.github.io/en/2026/08/18/hot-take-harness-loop-engineering-graph-engineering-are-bullshit/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2026/08/18/hot-take-harness-loop-engineering-graph-engineering-are-bullshit/</guid><pubDate>Tue, 18 Aug 2026 13:00:00 -0300</pubDate><description>&lt;p&gt;&lt;a href="https://x.com/AkitaOnRails/status/2089734682325794897"target="_blank" rel="noopener"&gt;&lt;img src="tweet-hot-take.png" alt="Hot take on X: Harness, Loop Engineering, and Graph Engineering are all bullshit to sell more consulting hours and courses" loading="lazy" /&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;I posted &lt;a href="https://x.com/AkitaOnRails/status/2089734682325794897"target="_blank" rel="noopener"&gt;this tweet&lt;/a&gt; this morning and it struck a nerve. The full point: when the technology itself becomes a commodity, the money migrates to taxonomy. They invent five new names for chaining API calls and suddenly there&amp;rsquo;s a certification that expires in six months.&lt;/p&gt;
&lt;p&gt;Let me back up the provocation properly, because it&amp;rsquo;s not a gratuitous jab.&lt;/p&gt;</description><content:encoded><![CDATA[<p><a href="https://x.com/AkitaOnRails/status/2089734682325794897"target="_blank" rel="noopener"><img src="tweet-hot-take.png" alt="Hot take on X: Harness, Loop Engineering, and Graph Engineering are all bullshit to sell more consulting hours and courses"  loading="lazy" /></a></p>
<p>I posted <a href="https://x.com/AkitaOnRails/status/2089734682325794897"target="_blank" rel="noopener">this tweet</a> this morning and it struck a nerve. The full point: when the technology itself becomes a commodity, the money migrates to taxonomy. They invent five new names for chaining API calls and suddenly there&rsquo;s a certification that expires in six months.</p>
<p>Let me back up the provocation properly, because it&rsquo;s not a gratuitous jab.</p>
<p>Let me preempt the standard comment: <em>&ldquo;but it works for me.&rdquo;</em> Good for you — honestly. Except &ldquo;it works for me&rdquo; never proved the ceremony is what made it work. What made it work is you knowing what you wanted. The ceremony just happened to be in the room.</p>
<h2>My receipt<span class="hx:absolute hx:-mt-20" id="my-receipt"></span>
    <a href="#my-receipt" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Between January and May I ran an <a href="/en/2026/05/14/wrapping-up-my-ai-marathon-success-or-failure/">AI marathon</a> and published <a href="https://github.com/akitaonrails?tab=repositories"target="_blank" rel="noopener">over 30 public repositories</a>. There are tools I use every day — <a href="https://github.com/akitaonrails/ai-memory"target="_blank" rel="noopener">ai-memory</a>, <a href="https://github.com/akitaonrails/ai-usagebar"target="_blank" rel="noopener">ai-usagebar</a>, <a href="https://github.com/akitaonrails/ai-jail"target="_blank" rel="noopener">ai-jail</a> — and personal apps built to scratch my own itch: <a href="https://github.com/akitaonrails/frank_mangaplus"target="_blank" rel="noopener">Frank Manga+</a>, <a href="https://github.com/akitaonrails/frank_scanlation"target="_blank" rel="noopener">Frank Scanlation</a>, <a href="https://github.com/akitaonrails/frank_geary"target="_blank" rel="noopener">Frank Geary</a>, and so on.</p>
<p>You know what I never once felt the urge to do in all that time? Complicate my AI setup. I don&rsquo;t have a super-customized <a href="/en/2026/05/25/first-impressions-using-oh-my-pi-and-opencode/">Pi</a>, no Hermes, no orchestrated agent graph, no numbered-spec pipeline. Thanks to ai-memory, <strong>I swap harnesses like I swap underwear</strong>: daily, no drama. Claude Code in the morning, Codex in the afternoon, Kimi CLI at night — the project memory travels with me, so the harness becomes a detail.</p>
<p>And detail is the point. Most harnesses are optimized for their own company&rsquo;s LLM. But &ldquo;optimized&rdquo; doesn&rsquo;t mean &ldquo;magic,&rdquo; and I have data on that.</p>
<h2>What my benchmark says about harnesses<span class="hx:absolute hx:-mt-20" id="what-my-benchmark-says-about-harnesses"></span>
    <a href="#what-my-benchmark-says-about-harnesses" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>In <a href="/en/2026/08/15/llm-benchmarks-qwen-3-8-glm-5-3-gemini-3-7/">my LLM Coding Benchmark</a> I run the same models through different harnesses under controlled conditions. The result is the opposite of what the course market implies:</p>
<ul>
<li><strong>For a weak model, the harness rescues.</strong> Grok 4.3 built nothing on bare opencode (18 points) and delivered a real app on the grok CLI (55). Gemini 3.1 Pro went from 62 to 88 on Google&rsquo;s own harness — but the problem there was an OpenRouter transport bug, not a lack of &ldquo;harness engineering.&rdquo;</li>
<li><strong>For a frontier model, the harness is noise.</strong> Grok 4.5: 92 on opencode, 91 on the grok CLI. Grok 4.6: 92 and 93. A one-point difference, inside the margin of error. No amount of harness engineering moves a good model.</li>
<li><strong>Where the harness actually bites is your wallet.</strong> The Grok 4.6 run cost $1.19 on the grok CLI versus $6.33 on opencode via OpenRouter — <strong>over 5x cheaper</strong> for the same ~11 million tokens, because the official CLI uses xAI&rsquo;s native prompt caching. Same story on Codex: GPT 5.6 Terra cost $6.77 blended because 21 of its 21.7 million tokens were cache hits; Sol, same family, same score, cost about $45.</li>
</ul>
<p>So yes, picking a decent harness matters — for cost, and to give structure to a weak model. But that&rsquo;s an afternoon of reading docs and watching your token bill, not a new discipline with a learning track.</p>
<p>Since I mentioned Hermes up there, it&rsquo;s worth explaining: <a href="https://github.com/NousResearch/hermes-agent"target="_blank" rel="noopener">Hermes Agent</a> is an open-source framework from Nous Research for building <em>your own</em> personal agent — you define the tools, write the loops, configure per-model routing, local/cloud fallback, Telegram and Discord gateways, and it even &ldquo;learns skills&rdquo; from use. It&rsquo;s paradise for the setup crowd.</p>
<p>It&rsquo;s also a second job: every one of those pieces becomes yours to maintain, update, and debug, forever. And at the end of the day the engine is still the same Claude, GPT, or Qwen everyone else has — the custom chassis doesn&rsquo;t improve the engine. What Hermes actually solves, context continuity across sessions and tools, a decent harness with something like ai-memory already covers — without you becoming the infrastructure administrator of your own assistant.</p>
<p>If you want an assistant on your own hardware as a hobby or for privacy, that&rsquo;s a great reason, go for it. As a productivity prerequisite, it&rsquo;s not one.</p>
<blockquote>
  <p><strong>Remember this:</strong> a good harness is one that charges less and stays out of the way. The rest is the model. And a good model doesn&rsquo;t need &ldquo;harness engineering&rdquo; — at most it needs the transport not to be broken.</p>

</blockquote>
<h2>Loop Engineering, Graph Engineering, Spec-Driven Development<span class="hx:absolute hx:-mt-20" id="loop-engineering-graph-engineering-spec-driven-development"></span>
    <a href="#loop-engineering-graph-engineering-spec-driven-development" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>On to the names, because they describe real things — just tiny ones.</p>
<p><strong>Loop Engineering</strong> is this season&rsquo;s name for designing the cycle an agent repeats: execute, verify against evidence, iterate until a stop condition. The guides list real failure modes — the agent declaring &ldquo;done&rdquo; too early, the goal drifting on each pass. But the recommended mitigation is &ldquo;an independent verifier checking objective evidence.&rdquo; That&rsquo;s had a name for fifty years: <strong>tests and code review</strong>. An agent in a loop with a test suite is the same old basics with a new name.</p>
<p><strong>Graph Engineering</strong> is drawing the agent&rsquo;s workflow as an explicit graph of nodes, branches, and joins — LangChain has <a href="https://www.langchain.com/blog/3-years-of-graph-engineering-with-langgraph"target="_blank" rel="noopener">three years of that story</a>. It makes sense when the flow is genuinely branched. Except the overwhelming majority of projects is a straight line with an <code>if</code> in the middle. Modeling that as a graph is buying a giant whiteboard to draw one arrow.</p>
<p><strong>Spec-Driven Development</strong> is writing a detailed specification first and treating the code as an artifact generated from it — the spec becomes the &ldquo;source of truth&rdquo; and the code, a byproduct. Hold that thought, the strong argument is coming right up.</p>
<p>Notice the pattern: each name takes a real, small practice — looping with verification, drawing a flow, writing down what you want before building — and inflates it into a &ldquo;discipline.&rdquo; The inflation is the product. A new name creates a course, the course creates a certification, the certification expires in six months and sells you the recertification.</p>
<p>To be fair: the serious guides on these topics already carry the caveat — the most-read <a href="https://www.aibuilderclub.com/blog/graph-engineering-guide-2026"target="_blank" rel="noopener">graph engineering guide</a> says outright that &ldquo;you probably don&rsquo;t need it&rdquo; and tells you to master the loop before opening a graph, and LangChain has a whole &ldquo;when not to use graphs&rdquo; section. The guides are right; the damage comes from the funnel, which throws away the caveat and sells the rest as everyone&rsquo;s default.</p>
<p>The two that sell the most courses — heavy agent orchestration and spec-driven development — deserve more than definitions. They deserve the argument.</p>
<h2>The strong argument against super-orchestration<span class="hx:absolute hx:-mt-20" id="the-strong-argument-against-super-orchestration"></span>
    <a href="#the-strong-argument-against-super-orchestration" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>There&rsquo;s simple math that orchestrated-agent diagrams never show. If each step of your pipeline succeeds 90% of the time — and that&rsquo;s optimistic — a ten-agent chain succeeds 0.9^10, or <strong>~35% of the time</strong>. Every node is a new failure point, and every edge is tokens spent on agents talking to agents instead of working.</p>
<p>You don&rsquo;t have to take the math on faith: I accidentally measured it in the benchmark. MiniMax M3 running under an orchestrator looked like Tier D, with 24 points. The same model, clean, scored 91 — Tier A. A swing of up to 69 points between harness conditions means the opposite of what the orchestration vendor claims: <strong>when the plumbing dominates the result, you stopped measuring the model and started measuring the plumbing.</strong> The best result in the entire benchmark didn&rsquo;t come from any orchestrated swarm: it came from one strong model, alone, in a simple loop — Fable 5, 96 points, Claude Code, done.</p>
<p>It makes sense once you remember coordination scales badly. Each additional agent doesn&rsquo;t just add capacity; it adds edges, message contracts, shared state, and conflicting versions of the truth. The router deciding &ldquo;which agent handles this&rdquo; becomes both the bottleneck and the bug farm. A committee of mediocre agents with a conductor doesn&rsquo;t beat one capable agent with good tools and memory.</p>
<p>I&rsquo;ve tested this directly. Back in April I ran <a href="/en/2026/04/25/llm-benchmarks-vale-a-pena-misturar-2-modelos/">three rounds of &ldquo;strong model orchestrating cheaper models&rdquo;</a> — planner + executor, forced delegation, the full package. The result: <strong>no multi-agent combination beat the Opus running solo</strong> in a mature harness. On a cohesive task like building an app, the planner has to read every output from the executor before dispatching the next step — the two become sequential, with tripled latency and a coordination queue in the middle. It&rsquo;s the committee again: lots of talking, little software.</p>
<p>It&rsquo;s not just my benchmark. Cognition, which sells Devin, published <a href="https://cognition.com/blog/dont-build-multi-agents"target="_blank" rel="noopener">&ldquo;Don&rsquo;t Build Multi-Agents&rdquo;</a> with a mechanism sharper than my 90% math: <strong>every action carries implicit decisions the other agents can&rsquo;t see</strong> — one subagent draws a Mario-style background, another draws an incompatible bird, and no amount of individual reliability fixes the divergence.</p>
<p>Anthropic itself, which runs a multi-agent research system, <a href="https://www.anthropic.com/engineering/multi-agent-research-system"target="_blank" rel="noopener">admits in its engineering post</a> that multi-agent burns <strong>15x more tokens</strong>, that coding is a poor fit for it — most coding tasks aren&rsquo;t truly parallelizable — and that 80% of their improvement came from simply spending more tokens, not from the architecture.</p>
<p>When Berkeley measured what actually runs in production, <a href="https://arxiv.org/abs/2512.04123"target="_blank" rel="noopener">86 systems across 26 domains</a>: 68% of production agents execute at most 10 steps before human intervention. What&rsquo;s really out there is the simple supervised loop — not the constellation of colored nodes on the consultant&rsquo;s diagram.</p>
<p>Where orchestration is legitimate: genuinely parallel, independent work — scanning ten thousand files, running ten thousand disposable analyses. Map-reduce has existed for twenty years and never needed a pompous name. And there&rsquo;s one serious corner beyond it: agents running overnight, unwatched, holding credentials — there, independent verifiers and hard budgets become a security matter, not a style one. But that&rsquo;s fleet operations, not the day-to-day coding the course is selling you. Outside those niches, most &ldquo;multi-agent architectures&rdquo; are one agent&rsquo;s job with extra YAML.</p>
<p>There&rsquo;s a reason you hear so much about it: orchestration is <strong>visible</strong> complexity. It has diagrams, colored nodes, dashboards. A well-driven agent has none of that — no slide deck, no certification, nothing to sell.</p>
<blockquote>
  <p><strong>Remember this:</strong> every added agent multiplies the failure modes. If your system&rsquo;s result changes when you swap the orchestrator, your system is the orchestrator — and the model was the costume.</p>

</blockquote>
<h2>The strong argument against spec-driven development<span class="hx:absolute hx:-mt-20" id="the-strong-argument-against-spec-driven-development"></span>
    <a href="#the-strong-argument-against-spec-driven-development" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>SDD sounds mature because it sounds like &ldquo;writing documentation.&rdquo; But look at what it actually proposes: the spec becomes the source of truth and the code becomes a generated artifact. The problem is that <strong>a spec precise enough to generate correct code already is a program</strong> — except written in prose, and prose doesn&rsquo;t compile. Every ambiguity in the spec is a bug no compiler catches, in a medium with no tests, no linter, no feedback. SDD doesn&rsquo;t remove the hard part, which is thinking precisely; it moves the hard part into a format where errors don&rsquo;t scream.</p>
<blockquote>
  <p><strong>Remember this:</strong> a spec precise enough to generate correct code already is a program — except in prose. And prose doesn&rsquo;t compile.</p>

</blockquote>
<p>Even if you write the perfect spec, it starts dying with the first hotfix. A bug shows up in production, someone fixes it straight in the code, and the spec becomes a lie. We&rsquo;ve known this law for decades — it&rsquo;s why documentation rots. Calling the spec the &ldquo;source of truth&rdquo; doesn&rsquo;t change anyone&rsquo;s incentives.</p>
<p>We&rsquo;ve run this experiment before, by the way. UML, MDA, &ldquo;the code generates itself from the model&rdquo; — twenty-odd years ago it was the same promise with different acronyms. It collapsed every time for the same reason: the model was never the reality; the code was. SDD is MDA with an LLM bolted on. And <a href="https://www.alexcloudstar.com/blog/spec-driven-development-2026/"target="_blank" rel="noopener">I&rsquo;m not the only one seeing Waterfall 2.0 there</a>.</p>
<p>The people who tested the tools seriously landed in the same place. Thoughtworks put spec-driven development in the <a href="https://www.thoughtworks.com/radar/techniques/spec-driven-development"target="_blank" rel="noopener">&ldquo;Assess&rdquo; ring of its Technology Radar</a> — not &ldquo;adopt&rdquo;, &ldquo;assess&rdquo; — after watching the tools inflate small tasks into ceremony, and nailed the summary: we may be <em>&ldquo;relearning a bitter lesson — that handcrafting detailed rules for AI ultimately doesn&rsquo;t scale.&rdquo;</em> That&rsquo;s Rich Sutton&rsquo;s Bitter Lesson knocking again — handcrafted structure loses to scale, it always has.</p>
<p>There&rsquo;s also the temporal inversion. The great lesson of agile was that you discover what you want <strong>by building</strong> — working software over comprehensive documentation. LLMs just made iteration cheaper than it&rsquo;s ever been. And what does SDD propose? Expanding the planning phase, right now that iterating got cheap. Wrong answer, wrong direction, wrong time.</p>
<p>It&rsquo;s the thesis I&rsquo;ve been hammering since the first <a href="/en/2026/02/23/vibe-code-built-a-smart-image-indexer-with-ai-in-2-days-frank-sherlock/">Agile Vibe Coding</a> posts: <strong>software emerges, it isn&rsquo;t planned</strong>. <a href="/en/2026/02/20/zero-to-post-production-in-1-week-using-ai-on-real-projects-behind-the-m-akita-chronicles/">I wrote this back in February, with receipts</a>: the most important features of the M.Akita Chronicles were born from problems that showed up mid-way — a job that failed silently, a site that blocked the gem, a crash that left emails in limbo. No spec in the world predicts that. The correct system emerges from iteration, not from specification.</p>
<p>The process got documented again when ai-memory grew through 26 contributors in 24 days: <a href="/en/2026/06/14/ai-memory-emergent-architecture-malleable-software/">good software is a clay sculpture, not a Lego tower</a> — malleable, always adjustable, never done. Whoever tries to design the whole architecture before the first line builds a straitjacket, not a system. Only amateurs still believe you can spec out an entire piece of software before coding it. Real modeling doesn&rsquo;t come from templates or courses — <a href="/2023/08/11/akitando-144-modelagem-de-software-e-dificil-ver-vs-enxergar/">I covered this years ago in Akitando 144</a> (video in Portuguese): it&rsquo;s born from a repertoire of real problems and real code. There&rsquo;s no ready-made recipe; there never was.</p>
<p>SDD tries to resurrect the idea that you can plan software ahead of time — the idea the industry buried after decades of projects delivered late, wrong, and over budget. LLMs didn&rsquo;t revalidate that idea; they just gave it a new slide deck.</p>
<p>Even the pro-SDD guides separate the two things: <a href="https://dev.to/krlz/spec-driven-development-in-2026-what-it-is-the-tooling-and-how-teams-actually-use-it-2fk2"target="_blank" rel="noopener">&ldquo;spec-as-source is where the hype lives; spec-anchored is where the value is today&rdquo;</a>. My target here is the former. Where heavy specs are legitimate: large teams, legacy codebases, asynchronous work crossing time zones and sprints — the good old design document, which has always existed and always had value. What doesn&rsquo;t fly is selling that as the new default for everyone.</p>
<p>My benchmark prompt, for the record, is one page of goals. The difference is that I don&rsquo;t trust the prose: I validate by running it. Precision lives in tests, not in paragraphs.</p>
<h2>What you actually need<span class="hx:absolute hx:-mt-20" id="what-you-actually-need"></span>
    <a href="#what-you-actually-need" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>I&rsquo;ve already written all of this on this blog, with real-project receipts. It&rsquo;s called <a href="/en/2026/02/23/vibe-code-built-a-smart-image-indexer-with-ai-in-2-days-frank-sherlock/">Agile Vibe Coding</a>, and it fits in a paragraph:</p>
<p>It&rsquo;s XP (eXtreme Programming, the original agile) with an LLM: tests, <a href="/en/2026/04/20/clean-code-for-ai-agents/">Clean Code</a>, CI (continuous integration), pair programming, and deploy. You drive the agent like you&rsquo;d drive a very fast pairing partner: say what you want, watch the execution, correct while mistakes are still cheap. The idea is 10% of the work; the other 90% is normal software engineering, the usual kind. <a href="/en/2026/04/15/how-to-talk-to-claude-code-effectively/">Forget frameworks and three-page templates</a>: what you need is to know what you want, know what you don&rsquo;t want, and know how to validate when it arrives. And you need balance: <a href="/en/2026/04/11/vs-code-is-the-new-punch-card/">neither handing the wheel to the agent nor turning into a comma cop</a>.</p>
<p>That&rsquo;s over 600 hours of it, over half a million lines, dozens of projects shipped. No graphs, no certifications, no loop with an English name.</p>
<blockquote>
  <p><strong>Remember this:</strong> the idea is 10% of the work; the other 90% is plain software engineering, the usual kind.</p>

</blockquote>
<h2>The piece that lets me swap harnesses: ai-memory<span class="hx:absolute hx:-mt-20" id="the-piece-that-lets-me-swap-harnesses-ai-memory"></span>
    <a href="#the-piece-that-lets-me-swap-harnesses-ai-memory" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>One of my own tools is the opposite of taxonomy: <a href="https://github.com/akitaonrails/ai-memory"target="_blank" rel="noopener">ai-memory</a>. It was born from a concrete problem. Every harness stores its session in its own format, and when the conversation gets long, it compacts the history to fit the window — and compaction throws away exactly the details that explain why each decision was made. Then you switch tools and start from zero, with the whole project to re-explain.</p>
<p>ai-memory solves this from outside the harness. It reads the native session without touching the original file and stores everything in a searchable ledger: messages, tool calls with results, compaction summaries, a git checkpoint — every event tagged with its origin (from Claude, Codex, OpenCode). When I open another harness, it gets its own native session and receives only the delta it hasn&rsquo;t seen. When I go back to the previous one, ai-memory resumes it in that client&rsquo;s format and hands over what happened on the others meanwhile.</p>
<p>That&rsquo;s why &ldquo;I swap harnesses like I swap underwear&rdquo; isn&rsquo;t a figure of speech. <strong>The project&rsquo;s knowledge lives in the project, not in one tool&rsquo;s session.</strong> If Anthropic changes pricing, limits, or models tomorrow — and they will — I swap the engine without throwing away the trip. The details of how this works are in <a href="/en/2026/07/20/whats-new-ai-memory-switch-agents-without-losing-session/">this post</a>.</p>
<p>There&rsquo;s an inversion here that takes SDD down as a bonus. Spec-driven development says the source of truth is a document written <strong>before</strong> the work, trying to predict the future, and that the code owes it obedience. ai-memory does the opposite: the source of truth is a <a href="/en/2026/06/16/ai-memory-long-term-memory-karpathy-wiki-self-improvement-hermes-projects/">wiki distilled from the work itself</a> — every session becomes evidence, and what deserves to survive (decisions, rules, gotchas, failed attempts) gets consolidated into short Markdown pages that any agent reads before starting.</p>
<p>The spec tries to guess the project; the wiki records the project. A document written beforehand rots at the first hotfix, because nobody is forced to update it. The wiki is fed by the very act of working — and when it goes stale, you notice immediately, because the agents trip over it every session.</p>
<p>Notice that this is real harness engineering: one tool, written once, solving a problem of mine. No course, no acronym, no certification.</p>
<h2>Conclusion: ask for the receipt<span class="hx:absolute hx:-mt-20" id="conclusion-ask-for-the-receipt"></span>
    <a href="#conclusion-ask-for-the-receipt" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Don&rsquo;t waste time or money on a &ldquo;harness engineering&rdquo; course, &ldquo;loop engineering,&rdquo; or whatever taxonomy is trending this week. It&rsquo;s a shameless attempt to charge you for things you already do if you know basic software engineering.</p>
<p>We&rsquo;ve seen this movie before, by the way. <a href="https://x.com/arantespp/status/2089752215380426951"target="_blank" rel="noopener">Pedro Arantes nailed it on X</a>: <em>&ldquo;Microservices, clean architecture, hexagonal, and Domain-Driven Design are all bullshit to sell more consulting hours and courses.&rdquo;</em> Same story, same script. Each of them was born from a real problem — and became the default for people who didn&rsquo;t have the problem. Microservices for a three-person team, hexagonal architecture for a CRUD (the plain old create-read-update-delete app), DDD to never write code again. The technique passes, the taxonomy stays, the course sells.</p>
<p>So nobody plays dumb: <strong>none of this is useless</strong>. A loop with verification works. A graph works when the flow is genuinely a graph. Heavy specs save large teams. Microservices solved real problems for people with real scale; DDD shines in genuinely complex domains. The problem was never the tool — it&rsquo;s selling the tool as a <strong>silver bullet</strong>, the universal hammer you must apply to everything. Silver bullets don&rsquo;t exist, they never have. Whoever sells you one isn&rsquo;t selling a solution; they&rsquo;re selling a course.</p>
<p>Understand the psychological mechanism, because it&rsquo;s old and efficient: those terms exist to give you <strong>FOMO</strong> (Fear of Missing Out). To make you anxious, thinking you&rsquo;re falling behind, that you&rsquo;re leaving productivity on the table, that everyone else already migrated to the new paradigm and you haven&rsquo;t. Anxiety sells. Once you&rsquo;re insecure, they charge you to implement something you never needed. FOMO is real — and here, it&rsquo;s the business model.</p>
<p>The model is recurring, too — admire the elegance. First they sell you the methodology that <em>generates</em> artifacts — specs, graphs, boards, diagrams. The artifacts multiply, nobody knows where anything is anymore, and guess who shows up? The same consultancy, now selling the tool that <em>manages</em> the artifacts, the course that teaches you to manage the tool, and the artifact-governance workshop. It&rsquo;s the shovel salesman congratulating you on the hole you dug — and offering you a bigger shovel.</p>
<p>Gartner even has an official name for it: <a href="https://www.gartner.com/en/newsroom/press-releases/2025-06-25-gartner-predicts-over-40-percent-of-agentic-ai-projects-will-be-canceled-by-end-of-2027"target="_blank" rel="noopener">&ldquo;agent washing&rdquo;</a> — take an existing product, slap the &ldquo;agentic&rdquo; label on it, resell. Their estimate: only about <strong>130 of the thousands</strong> of companies selling themselves as &ldquo;agentic AI&rdquo; are real, and their prediction is that 40% of such projects get canceled by the end of 2027, over cost, unclear value, or badly managed risk. No grudge of mine here: it&rsquo;s the hype cycle, measured, with a public forecast and everything.</p>
<p>Next time an influencer or consultant tries to push those products on you, ask a simple question: <strong>where are your dozens of high-quality open-source projects that got better because of those &ldquo;techniques&rdquo;?</strong></p>
<p>They have nothing to show. I do — <a href="https://github.com/akitaonrails?tab=repositories"target="_blank" rel="noopener">it&rsquo;s all public</a>, with code, benchmarks, and documented process. When technology becomes a commodity, money migrates to taxonomy. Don&rsquo;t be a customer of that migration.</p>
]]></content:encoded><category>artificial-intelligence</category><category>llms</category><category>vibe-coding</category></item><item><title>Using AI to Solve My Little Day-to-Day Problems</title><link>https://akitaonrails.github.io/en/2026/07/12/using-ai-to-solve-my-little-day-to-day-problems/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2026/07/12/using-ai-to-solve-my-little-day-to-day-problems/</guid><pubDate>Sun, 12 Jul 2026 11:00:00 -0300</pubDate><description>&lt;p&gt;I wasn&amp;rsquo;t going to write an article for every little open source project I make. Most of them solve a very specific little problem in MY day-to-day, and a dedicated article would be out of proportion. But the little projects kept piling up, and looking back I realized there are already several that never showed up here. So I decided to do it differently: a single article, mentioning most of them at once.&lt;/p&gt;</description><content:encoded><![CDATA[<p>I wasn&rsquo;t going to write an article for every little open source project I make. Most of them solve a very specific little problem in MY day-to-day, and a dedicated article would be out of proportion. But the little projects kept piling up, and looking back I realized there are already several that never showed up here. So I decided to do it differently: a single article, mentioning most of them at once.</p>
<p>The pattern repeats in all of them: I run into a small friction in daily life, the kind we normally swallow because fixing it would cost an entire weekend, and instead of swallowing it I open an AI agent and fix it. The cost of turning irritation into tooling dropped so much that the math flipped. Today it&rsquo;s worth fixing almost everything.</p>
<p>Everything below is on <a href="https://github.com/akitaonrails"target="_blank" rel="noopener">my GitHub</a>, with binaries, releases and install instructions.</p>
<p>In this post: <a href="#clock-tui-a-desk-clock-that-became-a-panel">clock-tui</a> (with the <a href="#clock-tui-a-desk-clock-that-became-a-panel">google-calendar-tui</a> and <a href="#clock-tui-a-desk-clock-that-became-a-panel">ghpending</a> widgets), <a href="#github-visualize-useless-but-pretty">github-visualize</a>, <a href="#frank-geary-email-the-way-i-wanted-it">frank_geary</a>, <a href="#japanese-manga-frankyomik-frank-manga-and-frank-scanlation">frank_scanlation</a>, <a href="#frank-lyrics-memorizing-songs-line-by-line">frank_lyrics</a>, <a href="#frank-type-typing-practice-with-actual-prose">frank_type</a>, <a href="#distrobox-gaming-a-library-of-how-to-install-games-on-linux">distrobox-gaming</a> and <a href="#aitrepreneur-docker-comfyui-without-dirtying-the-system">aitrepreneur-docker</a>.</p>
<h2>clock-tui: a desk clock that became a panel<span class="hx:absolute hx:-mt-20" id="clock-tui-a-desk-clock-that-became-a-panel"></span>
    <a href="#clock-tui-a-desk-clock-that-became-a-panel" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The use case is simple: I have a secondary vertical monitor, and I wanted something more useful there than a wallpaper, something I could just glance at and get relevant information. But it all started because I just wanted a desk clock. I found a little old open source terminal clock program, the original clock-tui, which had been paused for ages, and decided to fork it and continue the work: <a href="https://github.com/akitaonrails/clock-tui"target="_blank" rel="noopener">clock-tui</a>.</p>
<p>I made it prettier, modernized the Rust dependencies, and the most important change: I added support for <strong>command widgets</strong>. The clock sits at the top and below it come panels that run any command that prints text, each with its own refresh. Two widgets I built for it myself:</p>
<ul>
<li><a href="https://github.com/akitaonrails/google-calendar-tui"target="_blank" rel="noopener">google-calendar-tui</a>: a read-only Google Calendar agenda in the terminal, using the accounts already in GNOME Online Accounts (zero OAuth setup). One glance and I know my appointments for the day.</li>
<li><a href="https://github.com/akitaonrails/ghpending"target="_blank" rel="noopener">ghpending</a>: lists open issues and pull requests across all the repositories I care about. Now that I have dozens of open source projects, this is how I notice within seconds when a new PR or issue arrives, without opening GitHub.</li>
</ul>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/07/12/probleminhas/ghpending.webp" alt="ghpending widget showing open pull requests and issues across several repositories at once."  loading="lazy" /></p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/07/12/probleminhas/google-calendar-tui.webp" alt="google-calendar-tui’s colored agenda printed straight to the terminal."  loading="lazy" /></p>
<p>And today I spent the day adding theme support, because someone on X commented that the look could be made to resemble Evangelion. Challenge accepted: there&rsquo;s now an <code>evangelion</code> theme (purple and lavender) and a <code>nerv</code> theme (red, amber and green, inspired by the show&rsquo;s alert screens), switching at runtime with <code>Shift+T</code>. The theme is injected into the widgets via an environment variable, so the panels follow the palette.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/07/12/probleminhas/clock-tui-nerv.webp" alt="clock-tui with the NERV theme: digital clock in red with system health, calendar and GitHub widgets in amber and green."  loading="lazy" /></p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/07/12/probleminhas/clock-tui-evangelion.webp" alt="clock-tui with the Evangelion theme: the same screen in the purple and lavender palette of EVA-01."  loading="lazy" /></p>
<p>Installing on Arch: <code>yay -S clock-tui-bin</code>. The widgets: <code>yay -S ghpending-bin</code> (there&rsquo;s also <code>brew install</code> via <code>akitaonrails/tap</code> on macOS) and <code>yay -S google-calendar-tui-bin</code>. All of them have prebuilt binaries on GitHub Releases for those not on Arch.</p>
<h2>github-visualize: useless, but pretty<span class="hx:absolute hx:-mt-20" id="github-visualize-useless-but-pretty"></span>
    <a href="#github-visualize-useless-but-pretty" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>This one was born because of Jarred Sumner&rsquo;s article about rewriting Bun&rsquo;s TypeScript in Rust, which I <a href="/en/2026/07/09/the-bun-in-rust-response-andrew-kelly-should-have-written/">posted about recently</a>. I don&rsquo;t know which tool he used to illustrate the post, but I really liked the chart animations. Is it useless? It is. But it&rsquo;s pretty, and pretty is reason enough to make a copy.</p>
<p><a href="https://github.com/akitaonrails/github-visualize"target="_blank" rel="noopener">github-visualize</a> is a self-hosted dashboard that monitors my GitHub repositories and replays their progress with animated visualizations: a commit timeline with lines added and deleted on a log scale, a commit heat map by day and hour, a CI race to green. Now I can add any repository of mine and get pretty visualizations of productivity, activity, commits and builds. Maybe I&rsquo;ll use it in presentation slides, or to illustrate future posts here on the blog.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/07/12/probleminhas/github-visualize-timeline.webp" alt="Animated commit timeline replay in github-visualize: lines added in pink and deleted in cyan, with animated counters and a git log feed."  loading="lazy" /></p>
<p>Install: public image on Docker Hub as <a href="https://hub.docker.com/r/akitaonrails/github-visualize"target="_blank" rel="noopener"><code>akitaonrails/github-visualize</code></a>, single container with SQLite in a volume, no external database or Redis.</p>
<h2>Frank Geary: email the way I wanted it<span class="hx:absolute hx:-mt-20" id="frank-geary-email-the-way-i-wanted-it"></span>
    <a href="#frank-geary-email-the-way-i-wanted-it" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>I decided to use GNOME&rsquo;s Geary as my main email client. It&rsquo;s the closest to the interface of Apple&rsquo;s default Mail app: clean, direct, no ceremony. I know, there&rsquo;s Thunderbird, but I find it bloated and very ugly. There&rsquo;s Mailspring, which I find bloated and heavy. My email flow is short: sweep the inbox, tag, archive and reply. Nothing else.</p>
<p>Geary is almost perfect, but it had two big problems for me. First: on my vertical monitor there&rsquo;s much less horizontal space, and Geary has 3 fixed columns. The first column, which only lists accounts and folders/tags, I don&rsquo;t need to see all the time, but there was no way to collapse it.</p>
<p>Second, and worse: the composer&rsquo;s recipient field in practice never autocompleted anything (Geary does have the mechanism, but with a contact visibility filter so restrictive it never suggested anyone). I had to type the address by hand or copy and paste from another email. In 2026.</p>
<p>My first solution was two external GTK modules injected into Geary, one for each problem (both obsolete now). They worked, but they required installing Geary compiled from source, and on every <code>yay -Syu</code> there it went recompiling everything again.</p>
<p>I got tired and did what I should have done from the start: I forked Geary and created <a href="https://github.com/akitaonrails/frank_geary"target="_blank" rel="noopener">Frank Geary</a>, which adds both features straight into the code. The sidebar now collapses, and the recipient autocompletes by consulting the contacts from your existing emails. I&rsquo;ll keep maintaining the fork by rebasing from upstream frequently.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/07/12/probleminhas/frank-geary-autocomplete.webp" alt="Frank Geary’s recipient autocomplete popup in the composer, suggesting contacts as you type."  loading="lazy" /></p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/07/12/probleminhas/frank-geary-sidebar.webp" alt="Frank Geary with the accounts sidebar collapsed: just the conversation list and the reading pane, perfect for a vertical monitor."  loading="lazy" /></p>
<p>And the best part: there&rsquo;s now a package on the AUR, so installing goes 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-sh" data-lang="sh"><span class="line"><span class="cl">yay -S frank-geary-bin    <span class="c1"># prebuilt binary (x86_64), from the GitHub release</span>
</span></span><span class="line"><span class="cl">yay -S frank-geary        <span class="c1"># or compiling from source</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>Both packages replace the system&rsquo;s <code>geary</code> (they provide/conflict with it), so the swap is clean and updates arrive like any other package. One detail after installing or upgrading: when the watch-for-new-mail option is on, Geary keeps running in the background as a D-Bus service, so run a <code>geary --quit</code> to make sure no old process is left hanging around.</p>
<h2>Japanese manga: FrankYomik, FRANK MANGA+ and FRANK Scanlation<span class="hx:absolute hx:-mt-20" id="japanese-manga-frankyomik-frank-manga-and-frank-scanlation"></span>
    <a href="#japanese-manga-frankyomik-frank-manga-and-frank-scanlation" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Anyone following the blog knows about my love for Japanese manga. I already <a href="/en/2026/03/05/my-first-vibe-code-failure-frank-yomik/">posted about FrankYomik</a>, which was born so I could read the original Japanese manga I buy from Amazon Japan.</p>
<p>The problem: manga aimed at adult audiences (no, not porn! I&rsquo;m talking about subjects like business or military topics) doesn&rsquo;t have &ldquo;furigana&rdquo;, the little kana subtitles printed next to difficult kanji, indicating the reading. Shounen manga has furigana on everything, so you can read it with student-level vocabulary. The adult ones assume you already know how to read the rare kanji. I don&rsquo;t.</p>
<p><a href="https://github.com/akitaonrails/FrankYomik"target="_blank" rel="noopener">FrankYomik</a> solves that by adding furigana on top of the page, in real time: a model detects the speech bubbles, manga-ocr extracts the text, and the furigana (or a full translation, via a local LLM on Ollama) is rendered on top, all running on my own hardware. And the important detail: on-the-fly, over the image I already have legitimately open, without downloading or redistributing a single page, so no copyright or license infringement.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/07/12/probleminhas/frank-yomik-furigana.webp" alt="FrankYomik automatically adding furigana over the difficult kanji on a Japanese manga page."  loading="lazy" /></p>
<p>Then came <a href="https://github.com/akitaonrails/frank_mangaplus"target="_blank" rel="noopener">FRANK MANGA+</a>, which I <a href="/en/2026/05/30/manga-plus-shueisha-on-the-desktop-frank-manga-plus/">also posted about</a>. I pay for the subscription to Manga Plus, Shueisha&rsquo;s official app, but it only lets me read on mobile (Android/iOS). On a small tablet it&rsquo;s tolerable, but I have a 32&quot; Samsung Odyssey OLED monitor sitting right in front of me.</p>
<p>I figured out how to use my own subscription outside the app and built a desktop reader in Tauri: it runs on Linux, macOS and Windows, the free tier works out of the box and subscribers paste their own secret to unlock the premium chapters.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/07/12/probleminhas/frank-mangaplus-library.webp" alt="FRANK MANGA&#43;’s library on the desktop: a grid of Shueisha manga covers with full catalog search."  loading="lazy" /></p>
<p>Install: AppImage, <code>.deb</code> and <code>.dmg</code> on the <a href="https://github.com/akitaonrails/frank_mangaplus/releases/latest"target="_blank" rel="noopener">releases</a>, or <code>yay -S mangaplus-reader-bin</code> on Arch.</p>
<p>And finally, I also read the occasional community &ldquo;scanlations&rdquo;: fans scan the pages of the original Japanese manga and translate them (nowadays most use AI in the process, there are even models trained to recognize speech bubbles, and I used one of those in FrankYomik itself). These scanlations are spread across several different websites, like MangaDex.</p>
<p>First I made a Chrome extension to prettify those sites (obsolete now), but I got tired of living with dozens of tabs in Brave just for scanlation sites. So I built <a href="https://github.com/akitaonrails/frank_scanlation"target="_blank" rel="noopener">FRANK Scanlation</a>, along the same lines as FRANK MANGA+: a desktop app that concentrates all my favorite scanlations in one place, with a library, per-title reading progress and checking for new chapters.</p>
<p>The reading windows load the site itself and inject a decent reader on top, with double-page spreads and keyboard navigation. Nothing is hardcoded to any website: they&rsquo;re heuristics that detect image sequences and chapter numbering in any URL. It became my preferred way to read the manga that can&rsquo;t be bought on Amazon or from Shueisha.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/07/12/probleminhas/frank-scanlation-library.webp" alt="FRANK Scanlation’s library: cards with covers, reading progress and a button to continue where you left off."  loading="lazy" /></p>
<p>Install: AppImage, <code>.deb</code> and <code>.rpm</code> (and a macOS build) on the <a href="https://github.com/akitaonrails/frank_scanlation/releases"target="_blank" rel="noopener">GitHub releases</a>.</p>
<h2>Frank Lyrics: memorizing songs line by line<span class="hx:absolute hx:-mt-20" id="frank-lyrics-memorizing-songs-line-by-line"></span>
    <a href="#frank-lyrics-memorizing-songs-line-by-line" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>I already <a href="/en/2026/04/05/turning-youtube-into-a-karaoke-app-frank-karaoke/">posted about Frank Karaoke</a>, a little app to add scoring on top of YouTube and enjoy karaoke with family and friends. But there&rsquo;s a sibling use case: I like learning new songs (a new anime opening, for example), and the process of memorizing lyrics is repeating the SAME line several times. In the YouTube player that&rsquo;s hell: you keep hunting by hand for the exact spot on the timeline to go back to, miss it by two seconds, rewind too much, overshoot.</p>
<p>To solve that I created <a href="https://github.com/akitaonrails/frank_lyrics"target="_blank" rel="noopener">Frank Lyrics</a>, a simple Chrome extension that figures out the correct timing for each line of the lyrics using the public markers from <a href="https://lrclib.net/"target="_blank" rel="noopener">LRCLIB</a> and injects controls into the YouTube page itself. LRCLIB, for those who don&rsquo;t know it, is an open, community-driven library of synchronized lyrics: each line comes with a timestamp (the classic LRC format from music players), with a public API, free and keyless.</p>
<p>The extension matches those timestamps to the video and gives me buttons to repeat the exact line I&rsquo;m memorizing, as many times as I need, without hunting the timeline. If you enjoy karaoke, the Frank Karaoke + Frank Lyrics combo should serve you well.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/07/12/probleminhas/frank-lyrics.webp" alt="Frank Lyrics’ panel over a lyric video on YouTube: each line of the lyrics with its timestamp and controls to repeat the current line."  loading="lazy" /></p>
<p>Install: download the <code>.zip</code> from the <a href="https://github.com/akitaonrails/frank_lyrics/releases"target="_blank" rel="noopener">releases</a>, unzip it, open <code>chrome://extensions</code>, enable developer mode and &ldquo;Load unpacked&rdquo; pointing at the folder.</p>
<h2>Frank Type: typing practice with actual prose<span class="hx:absolute hx:-mt-20" id="frank-type-typing-practice-with-actual-prose"></span>
    <a href="#frank-type-typing-practice-with-actual-prose" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Anyone who has followed me for a while knows I love my keyboards. There are videos on the Akitando channel about it and <a href="/2024/10/24/meus-teclados-modernos-favoritos/">posts here on the blog about my favorite keyboards</a>. And from time to time I like to train on MonkeyType. But I dislike typing random words. TypeRacer solves that by using real public texts, except it makes me wait for other players in the room (I hate multiplayer games). In other words: I wanted a single player experience, with real prose, but with the MonkeyType aesthetic, which I prefer.</p>
<p>So I created <a href="https://github.com/akitaonrails/frank_type"target="_blank" rel="noopener">Frank Type</a>, a mini-clone of both: a typing trainer in Rails 8 that uses normalized public-domain prose excerpts instead of random word lists. It&rsquo;s local-first and account-free: session history, timing data and profile charts stay in the browser&rsquo;s local storage. It even has a digraph heat map to show which key combinations slow you down.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/07/12/probleminhas/frank-type.webp" alt="Frank Type’s practice screen: public-domain text to type, with statistics and a digraph heat map, in the MonkeyType aesthetic."  loading="lazy" /></p>
<p>Install: it&rsquo;s a Docker container published as <code>akitaonrails/frank_type</code>. One command brings it up at <code>http://localhost:3200</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">curl -fsSL https://raw.githubusercontent.com/akitaonrails/frank_type/master/bin/docker-run.sh <span class="p">|</span> 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>
<h2>distrobox-gaming: a library of how to install games on Linux<span class="hx:absolute hx:-mt-20" id="distrobox-gaming-a-library-of-how-to-install-games-on-linux"></span>
    <a href="#distrobox-gaming-a-library-of-how-to-install-games-on-linux" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>I game on the same PC I work on, but I refuse to mix emulator packages and Wine hackery with my development environment. The solution was to isolate gaming entirely inside a <a href="https://github.com/89luca89/distrobox"target="_blank" rel="noopener">distrobox</a> (containers that integrate with your desktop as if they were native), and <a href="https://github.com/akitaonrails/distrobox-gaming"target="_blank" rel="noopener">distrobox-gaming</a> is the repository of Ansible playbooks that builds that Arch box from scratch: ES-DE as the frontend, standalone emulators, RetroArch cores, per-game configs. My Arch/Omarchy host stays clean.</p>
<p>The rule I imposed on myself: every new game I install has to become a reproducible Ansible recipe. I confess I haven&rsquo;t tested the full rebuild from scratch yet, but that&rsquo;s the idea: any trick or hack required gets documented in code instead of lost in shell history.</p>
<p>And there&rsquo;s no shortage of tricks, because I&rsquo;ve been installing old abandonware: the Colin McRae Rally series (from the PS1 version with a 60 fps patch up to 2007&rsquo;s DiRT), Sega Rally Revo, OutRun 2006 Coast 2 Coast, among others. Each with its own Wine quirks, patches and configs. Without meaning to, the repository is becoming a library of documentation on how to install games, emulators and retro games on Linux.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/07/12/probleminhas/distrobox-gaming-cmr04.webp" alt="Colin McRae Rally 04 running in the gaming box: the Service Area screen with the Mitsubishi Lancer Evolution before the next stage."  loading="lazy" /></p>
<p>Along the same lines there&rsquo;s <a href="https://github.com/akitaonrails/distrobox-llm"target="_blank" rel="noopener">distrobox-llm</a>, which does the same isolation for my local LLM environment (CUDA, Ollama, LM Studio and friends), leaving only the NVIDIA driver on the host.</p>
<p>Install: clone the repository and run the playbooks:</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-sh" data-lang="sh"><span class="line"><span class="cl"><span class="nb">cd</span> ansible
</span></span><span class="line"><span class="cl">ansible-galaxy collection install -r collections/requirements.yml
</span></span><span class="line"><span class="cl">cp host_vars/localhost.yml.example host_vars/localhost.yml
</span></span><span class="line"><span class="cl"><span class="nv">$EDITOR</span> host_vars/localhost.yml
</span></span><span class="line"><span class="cl">ansible-playbook site.yml</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>aitrepreneur-docker: ComfyUI without dirtying the system<span class="hx:absolute hx:-mt-20" id="aitrepreneur-docker-comfyui-without-dirtying-the-system"></span>
    <a href="#aitrepreneur-docker-comfyui-without-dirtying-the-system" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>For image generation I like ComfyUI, and I <a href="/en/2025/04/20/understanding-the-basics-of-comfyui-to-generate-ai-images/">posted about it last year</a>. I follow a youtuber called <a href="https://www.youtube.com/@Aitrepreneur"target="_blank" rel="noopener">Aitrepreneur</a> who maintains a Patreon page with recipes to install and configure ComfyUI with the newest models and workflows. His curation work is great, but the recipes are old school <code>.sh</code> scripts, meant to run as one-shots on a RunPod instance, for example.</p>
<p>Now that LLMs evolved to the point of writing decent Dockerfiles, I created <a href="https://github.com/akitaonrails/aitrepreneur-docker"target="_blank" rel="noopener">aitrepreneur-docker</a>, which converts those scripts into proper Docker images and containers: pinned versions, persistent data on the host or NAS, one command to bring up or upgrade each app. That way I run everything isolated on my machine, without littering my Linux with tons of Python packages and hacks that could destabilize the system.</p>
<p>Two important notices. The credit for the stack selection, model curation and workflows all goes to Aitrepreneur: the repository only translates his work into Docker form, and his original scripts are paid Patreon content that is NOT included.</p>
<p>If you find it useful, <a href="https://www.patreon.com/c/aitrepreneur/home"target="_blank" rel="noopener">subscribe to his Patreon</a> to contribute to his content creation (that&rsquo;s also where you get the original files). And the repository doesn&rsquo;t cover everything he has produced, only the most recent recipes.</p>
<p>Install: <code>git clone</code>, <code>make setup</code> (creates the <code>.env</code> for you to put your Hugging Face token), <code>make build</code> and <code>make up</code>.</p>
<h2>And there&rsquo;s more where these came from<span class="hx:absolute hx:-mt-20" id="and-theres-more-where-these-came-from"></span>
    <a href="#and-theres-more-where-these-came-from" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>If you&rsquo;re new here: this is just the batch that hadn&rsquo;t shown up on the blog yet. There are plenty more projects I&rsquo;ve covered in dedicated posts. <a href="/en/2026/06/16/ai-memory-long-term-memory-karpathy-wiki-self-improvement-hermes-projects/">ai-memory</a> is the most popular, most used and most contributed-to of all. <a href="/en/2026/05/24/akita-ai-tips-toolkit-ai-jail-ai-memory-ai-usagebar/">ai-jail</a>, which showed up again in <a href="/en/2026/07/11/how-to-protect-yourself-from-agents-deleting-your-stuff/">yesterday&rsquo;s post about destructive agents</a>, was the first of the series and is the second most popular.</p>
<p>And there are the others in the Frank series (&ldquo;Frank&rdquo; in honor of Frank Rosenblatt, creator of the perceptron, the original neural network; the name marks the experimental projects I do for fun):</p>
<ul>
<li><a href="/en/2026/02/23/vibe-code-built-a-smart-image-indexer-with-ai-in-2-days-frank-sherlock/">Frank Sherlock</a>: smart image indexer</li>
<li><a href="/en/2026/02/21/vibe-code-built-a-mega-clone-in-rails-in-1-day-frankmega/">Frank Mega</a>: MEGA clone for my home server</li>
<li><a href="/en/2026/02/01/frankmd-markdown-editor-vibe-code-part-1/">FrankMD</a>: markdown editor</li>
<li><a href="/en/2026/03/09/going-after-email-fraud-frank-fbi/">Frank FBI</a>: email fraud analyzer</li>
<li><a href="/en/2026/03/27/teaching-people-to-question-the-news-frank-investigator/">Frank Investigator</a>: teaches you to question the news</li>
</ul>
<p>Everything is open source and on <a href="https://github.com/akitaonrails"target="_blank" rel="noopener">my GitHub</a>. Everybody is welcome to contribute with issues, pull requests, or just by sharing with your friends. Each of these projects was born from a real little problem of mine. Maybe one of them solves yours too.</p>
]]></content:encoded><category>vibe-coding</category><category>open-source</category><category>artificial-intelligence</category></item><item><title>Why LLMs Will Fail at Your Company</title><link>https://akitaonrails.github.io/en/2026/06/24/why-llms-will-fail-at-your-company/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2026/06/24/why-llms-will-fail-at-your-company/</guid><pubDate>Wed, 24 Jun 2026 12:00:00 -0300</pubDate><description>&lt;p&gt;This post is more speculation than a finished thesis. I am thinking out loud. It may be incomplete. It may be wrong. If you disagree, even better: reply in the comments. I am not trying to sell a new methodology. I want to poke a wound that many people are pretending is not there.&lt;/p&gt;
&lt;p&gt;In the last few months, we started seeing stories about large companies burning absurd amounts of tokens on AI tools without being able to point, clearly, to the matching product value. The most famous case was &lt;a href="https://www.forbes.com/sites/janakirammsv/2026/05/17/uber-burns-its-2026-ai-budget-in-four-months-on-claude-code/"target="_blank" rel="noopener"&gt;Uber burning through its annual AI budget in a few months&lt;/a&gt; with heavy use of tools like Claude Code, while executives admitted they still could not draw a straight line between token consumption and value delivered. It is not that nobody used the tool. They used it too much. That is exactly the problem.&lt;/p&gt;</description><content:encoded><![CDATA[<p>This post is more speculation than a finished thesis. I am thinking out loud. It may be incomplete. It may be wrong. If you disagree, even better: reply in the comments. I am not trying to sell a new methodology. I want to poke a wound that many people are pretending is not there.</p>
<p>In the last few months, we started seeing stories about large companies burning absurd amounts of tokens on AI tools without being able to point, clearly, to the matching product value. The most famous case was <a href="https://www.forbes.com/sites/janakirammsv/2026/05/17/uber-burns-its-2026-ai-budget-in-four-months-on-claude-code/"target="_blank" rel="noopener">Uber burning through its annual AI budget in a few months</a> with heavy use of tools like Claude Code, while executives admitted they still could not draw a straight line between token consumption and value delivered. It is not that nobody used the tool. They used it too much. That is exactly the problem.</p>
<p>By now, I think most people understand that a one-line prompt solves nothing. The fantasy of typing &ldquo;build me an Uber clone&rdquo; and coming back to a finished product died quickly. The market response was to run in another direction: <strong>spec-driven development</strong>.</p>
<p>Instead of asking for everything in one sentence, you write a specification. Scope, use cases, acceptance criteria, desired architecture, constraints, flows, maybe even a full PRD. Then you hand that to the agent and expect it to implement with less ambiguity.</p>
<p>Is that better than one-shot prompting? Of course. I still think it is wrong.</p>
<h2>The problem is not lack of specification<span class="hx:absolute hx:-mt-20" id="the-problem-is-not-lack-of-specification"></span>
    <a href="#the-problem-is-not-lack-of-specification" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>I have been hammering this idea for years, long before LLMs became fashionable. Two of my videos matter for this point: <a href="/2019/10/09/akitando-63-nao-terceirize-suas-decisoes-a-licao-mais-importante-da-sua-vida/">Don&rsquo;t Outsource Your Decisions</a> and <a href="/2019/06/18/akitando-51-esqueca-metodologias-ageis-rated-r/">Forget Agile Methodologies</a>.</p>
<blockquote>
  <p>The summary is simple: most professionals were trained not to decide.</p>

</blockquote>
<p>Think about your own routine. Many people do nothing if there is no backlog ready. They cross their arms, blame management, blame colleagues, blame customers, and wait for someone else to say what should be done. When the task arrives, they execute. When it goes wrong, the requirement was bad. When the customer complains, the priority was wrong. When the product degrades, the process was to blame.</p>
<p>Almost nobody really decides, commits to the decision until the end, and suffers the consequences. Without suffering the consequence, you never learn how to decide.</p>
<p>You do not learn which choices lead to which results. You do not learn how to prioritize. You do not learn how to explain why something needs to exist. You do not learn how to write a specification because you understand the problem; you learn how to fill a template so nobody complains.</p>
<h2>Agile became structured bureaucracy<span class="hx:absolute hx:-mt-20" id="agile-became-structured-bureaucracy"></span>
    <a href="#agile-became-structured-bureaucracy" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The original 2001 principles were crushed. What many companies call Agile today is bureaucracy in shorter cycles.</p>
<p>Everybody knows the classic drawing: backlog, sprint planning, sprint, daily, review, retrospective, next sprint. The <a href="https://scrumguides.org/scrum-guide.html"target="_blank" rel="noopener">Scrum Guide</a> describes the formal events. Diagrams like the <a href="https://commons.wikimedia.org/wiki/File:Scrum_process.svg"target="_blank" rel="noopener">Scrum process on Wikimedia</a> show the pretty circle. In practice, many teams turned it into theater.</p>
<p>An entire day is spent debating what goes into the backlog. The final backlog is rarely what actually needs to be done. It is the consensus of least resistance. Compromise after compromise. Small tasks, small risks, small wins, small failures. Everything designed to avoid too much conflict.</p>
<p>That is not merely mediocre. It is worse: it is the active choice for easy decisions, the kind that will not bring too much criticism your way.</p>
<p>Then the team spends days or weeks implementing that lukewarm backlog. QA comes in, finds problems, tries to fit corrections into the next round. A few more days, maybe two more weeks. A month later, maybe two, the original decision finally starts to show results in production.</p>
<p>But the result is so far away from the decision that almost nobody makes the connection. The team does not feel the real weight of what it decided. The error does not become learning. The process does not improve. The most important part of any continuous improvement system, <strong>kaizen</strong>, never happens.</p>
<blockquote>
  <p>Something else happens instead: everybody learns to stay invisible.</p>

</blockquote>
<p>You learn to please management, not the product. You learn to reduce friction, not solve the problem. You learn to survive the meeting, not deliver value. And when results get worse, upper management reacts in the usual way: more control, more rules, more gates, more committees, more checklists. The machine gets heavier. The team gets more cowardly.</p>
<h2>Then AI arrives<span class="hx:absolute hx:-mt-20" id="then-ai-arrives"></span>
    <a href="#then-ai-arrives" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Now the LLM enters the room.</p>
<p>Until recently, the biggest bottleneck in software development was writing and adjusting code. Coding took days or weeks. That is why so much energy went into planning. If the team is going to spend two weeks implementing, spending a day arguing about what goes into the sprint feels reasonable.</p>
<p>But coding tools with LLMs change where the bottleneck sits. They can do a large part of the manual work: generate structure, write basic tests, refactor, adapt APIs, create screens, fill boilerplate, try fixes. It is not perfect, but it is fast enough to change the economics of the process.</p>
<p>It is the old chain and weakest-link problem. You strengthen one link, another one starts limiting the system. Before, the weak link was coding speed. Now, increasingly, it is the human ability to decide, explain, test, and review.</p>
<blockquote>
  <p>The mistake companies make is putting LLMs inside the same broken process and expecting multiplicative gains.</p>

</blockquote>
<p>You take people trained for years not to decide, give them a tool that requires clear decisions, and then act surprised when garbage comes out faster.</p>
<h2>The LLM reflects the user<span class="hx:absolute hx:-mt-20" id="the-llm-reflects-the-user"></span>
    <a href="#the-llm-reflects-the-user" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>A coding agent needs you to explain the goal, the context, the constraints, the priorities, what must not break, which paths are acceptable, and how success should be measured. It needs direction.</p>
<p>Now go interview your team. Ask each person:</p>
<ul>
<li>what is the real goal of what you are doing?</li>
<li>how does this improve the product?</li>
<li>which metric should move?</li>
<li>what trade-off are you accepting?</li>
<li>what must not break?</li>
<li>which unhappy paths need to be tested?</li>
</ul>
<p>Many people cannot answer. They know which ticket they picked up. They know they need to close a few tasks per week. They know there is a daily tomorrow. They do not know why the work matters.</p>
<p>Then you give that person an LLM.</p>
<p>The result is not magic. It is a mirror. Bad code ten times faster. Bugs ten times faster. Angry customers ten times faster. Giant pull requests ten times faster. The problem is not only AI. AI amplifies the lack of direction.</p>
<p>Spec-driven development tries to fix this with documentation. But if the specification is born from the same cowardly process, it only documents the cowardice. A PRD written by someone who does not understand the decision is still bad. Acceptance criteria written to avoid conflict still dodge the real problem. The agent will execute a bad decision better.</p>
<h2>The process has to change<span class="hx:absolute hx:-mt-20" id="the-process-has-to-change"></span>
    <a href="#the-process-has-to-change" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>My hypothesis is that the whole process has to be redesigned around the new constraint. Again: speculation. I do not have enough data to prove it. But looking at the kind of failure I see in companies, and the kind of success I see in intense individual use, my theory is this:</p>
<blockquote>
  <p><strong>throw away the sprint ritual as the central unit of work.</strong></p>

</blockquote>
<p>It makes no sense to keep days of planning, days of review, and weeks of coding as if the bottleneck were still the same. With LLMs, the cycle needs to be shorter. Much shorter. Ideally, inside the same day.</p>
<p>In my ideal setup, each programmer with an LLM should pair directly with a Product Manager for short blocks of work. The PM&rsquo;s job is to close the gap the programmer usually has: goal, priority, constraint, business context, expected result. That needs to enter the prompt and the conversation with the agent.</p>
<p>But because coding is no longer the main bottleneck, the PM does not need to wait two weeks to see a result. They can sit with the programmer for a morning, implement a small functional slice, adjust on the spot, decide on the spot, cut scope on the spot. Then the PM rotates to another programmer.</p>
<p>Then QA comes in. Not as a distant phase, but as pairing inside the same cycle. QA closes another common gap: testing only the happy path, forgetting error cases, not automating enough, not thinking about behavioral regression. QA&rsquo;s job becomes helping turn the implementation into something verifiable now, while the context is still hot.</p>
<p>Then the Tech Lead comes in. Also on the same day, not two weeks later. They look at what changed and close the third gap: unnecessary complexity, poor design, hidden regression, missing documentation, obvious vulnerability, performance getting worse, coupling that did not need to exist.</p>
<p>Build, check, adjust. In real time.</p>
<figure>
  <a href="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/06/24/why-llms-fail/llm-pairing-rotation-en.svg">
    <img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/06/24/why-llms-fail/llm-pairing-rotation-en.svg" alt="Diagram comparing the common sprint flow, where decisions are far from results, against a rotating LLM pairing flow with PM, programmer, QA and Tech Lead on the same day." />
  </a>
  <figcaption>The bottleneck changes: less time waiting for a sprint to end, more short cycles of decision, implementation, testing and review.</figcaption>
</figure>
<h2>The new role of each person<span class="hx:absolute hx:-mt-20" id="the-new-role-of-each-person"></span>
    <a href="#the-new-role-of-each-person" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>This model demands more responsibility, not less.</p>
<p>The programmer stops being the person who only picks up tickets. They need to learn how to turn a goal into executable instruction. They need to discuss trade-offs. They need to understand why they are coding. They need to use the LLM as a tool for execution and investigation, not as an excuse to turn the brain off.</p>
<p>The PM stops being a backlog factory. They need to decide with the programmer, looking at the live product. This is not writing a card and disappearing. It is watching the thing come to life, adjusting it, cutting it, prioritizing it, and accepting the consequence.</p>
<p>QA stops being the person who receives the package at the end and returns defects. QA enters early to turn expected behavior into verification. If the LLM accelerates code, QA needs to accelerate learning about risk.</p>
<p>The Tech Lead stops being a late PR stamp. They enter while change is still cheap. They help keep things simple, remove complexity, protect architecture, documentation, security, and performance.</p>
<p>None of this works if everybody keeps using AI as expensive autocomplete inside the same bureaucratic Scrum.</p>
<h2>Why companies will not like this<span class="hx:absolute hx:-mt-20" id="why-companies-will-not-like-this"></span>
    <a href="#why-companies-will-not-like-this" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Because this process exposes decisions.</p>
<p>In the current process, the decision dissolves. It was the backlog. It was the PM. It was refinement. It was the sprint. It was the team. It was a dependency. It was the customer changing their mind. Everybody can hide.</p>
<p>In a short LLM cycle, the decision appears quickly. You said this was the priority? Then implement it now. You said this was the acceptance criterion? Then test it now. You said this design was good? Then review it now. If it is wrong, the consequence shows up the same day.</p>
<p>That is uncomfortable. But that is how you learn.</p>
<p>Without nearby consequence, decision becomes theater. With nearby consequence, decision becomes training.</p>
<h2>How to implement this without burning money<span class="hx:absolute hx:-mt-20" id="how-to-implement-this-without-burning-money"></span>
    <a href="#how-to-implement-this-without-burning-money" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The first step is accepting something professional programmers usually hate: you can throw code away.</p>
<blockquote>
  <p>In fact, you should throw code away much more often.</p>

</blockquote>
<p>Code is not a monument. Code is working material. Deleting code is as important as writing new code. That is why we use Git. The idea was always to work in branches, try paths, change our minds, compare alternatives, delete what did not work, and keep only what survived contact with reality.</p>
<p>But in the traditional process, code becomes too emotionally expensive. If it took two weeks to produce, nobody wants to admit it was waste. The manager feels that deleting it proves the previous decision was bad. The programmer defends the implementation because they suffered to make it. The team pushes the problem forward because deletion feels like defeat. It is not. Sometimes it is hygiene.</p>
<p>With LLMs, that cost changes. <strong>Making mistakes becomes cheap</strong>. Prototyping becomes cheap. Building a proof of concept becomes cheap. Trying two paths and throwing one away becomes cheap. Asking the agent to rewrite an entire approach in a temporary branch, compare it, document what was learned, and <code>git reset</code> without drama becomes cheap.</p>
<p>So do more of that. More prototypes. More proofs of concept. More internal A/B tests. More disposable branches. Less attachment to the first code that appeared. LLMs are good at this kind of work: exploring alternatives quickly, showing where the specification was vague, and producing material you can destroy without guilt.</p>
<p>But do not expect everyone to receive LLM tokens on Monday and know how to do this alone. That is the fastest way to burn the budget and then say AI does not work.</p>
<p>My suggestion is the opposite: assume one or two weeks with no planned delivery. Say clearly that the goal is not to ship a feature. It is to train the new process. Break the old ritual. Let the team practice in a safe environment. If it goes wrong, fine: reset the branch, delete the prototype, document what you learned, try again.</p>
<p>In that phase, the PM should not write a twenty-page spec. They should pair with the programmer and build small prompts, one step at a time, watching the result in real time. With each LLM response, the PM learns how much detail the code actually needs. The programmer learns to ask better questions. QA learns to turn behavior into tests early. The Tech Lead learns to cut complexity while it is still cheap.</p>
<p>This is how LLM adoption has some chance of working: as a process change, not as an expensive autocomplete license.</p>
<h2>The uncomfortable conclusion<span class="hx:absolute hx:-mt-20" id="the-uncomfortable-conclusion"></span>
    <a href="#the-uncomfortable-conclusion" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><blockquote>
  <p>LLMs fail so often because they are inserted into processes made by people who were trained not to decide.</p>

</blockquote>
<p>It is not just lack of a better prompt. It is not just lack of a larger spec. It is not just lack of token governance. Those things help, but they do not touch the center of the problem.</p>
<p>The center is that many people do not know how to explain a measurable goal, prioritize, accept a trade-off, or connect decision with result. An LLM needs exactly that to be useful.</p>
<p>If you put AI into the old process, you accelerate the old process. More mediocre backlog. More bad decisions. More bugs. More cost. More rework. More reports trying to prove productivity the customer never felt.</p>
<p>If you want LLMs to work, redesign the process around them. Smaller cycles. Real pairing. Decision close to execution. Testing close to decision. Review close to code. Result close to whoever decided.</p>
<p>This is the part programmers need to face: yes, you probably never learned how to decide properly. You never learned how to describe a goal objectively. You never learned how to measure consequence. The process trained you that way. Now AI is revealing the failure.</p>
<p>The good news is that you can train the opposite.</p>
<p>But it will not happen with another ceremony, another prompt spreadsheet, another AI usage policy, or another sprint full of tickets written so nobody gets criticized.</p>
<p><strong>It will happen when you stop outsourcing decisions.</strong></p>
]]></content:encoded><category>llms</category><category>vibe-coding</category><category>software-engineering</category></item><item><title>Wrapping Up My AI Marathon: Success or Failure?</title><link>https://akitaonrails.github.io/en/2026/05/14/wrapping-up-my-ai-marathon-success-or-failure/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2026/05/14/wrapping-up-my-ai-marathon-success-or-failure/</guid><pubDate>Thu, 14 May 2026 14:00:00 -0300</pubDate><description>&lt;p&gt;I think I&amp;rsquo;m finally out of my AI hyperfocus. When I started ramping up in February and kept going until almost May, the goal was to answer one simple question: can you actually use this new generation of frontier LLMs from Anthropic and OpenAI to code 100% of the time? Short answer: yes.&lt;/p&gt;
&lt;p&gt;Instead of doing a one-day demo and risking a &amp;ldquo;yeah I think so,&amp;rdquo; I went all-in: over 500 hours, almost half a million lines of code and documentation, 24 repositories between public and private.&lt;/p&gt;</description><content:encoded><![CDATA[<p>I think I&rsquo;m finally out of my AI hyperfocus. When I started ramping up in February and kept going until almost May, the goal was to answer one simple question: can you actually use this new generation of frontier LLMs from Anthropic and OpenAI to code 100% of the time? Short answer: yes.</p>
<p>Instead of doing a one-day demo and risking a &ldquo;yeah I think so,&rdquo; I went all-in: over 500 hours, almost half a million lines of code and documentation, 24 repositories between public and private.</p>
<p>But today someone flagged a point I never thought would need defending:</p>
<blockquote>
  <p>&ldquo;Are all 24 projects perfect, models of code excellence?&rdquo; — <strong>ABSOLUTELY NOT.</strong></p>

</blockquote>
<p>I thought that was obvious. For starters, if I believed any of them was that good, I&rsquo;d be spinning up a startup around it. I don&rsquo;t lack capital to invest. If I&rsquo;m not doing that, clearly all of this is at hobby pace.</p>
<p>Second obvious point: if I&rsquo;m jumping between projects every few days, it&rsquo;s because I lost interest. If I really liked it, I&rsquo;d still be tuning it today. I keep saying it in the videos — software never &ldquo;finishes&rdquo;. The moment it stops moving, it dies.</p>
<p>The goal was to test different kinds of projects to see how the LLMs handled each. That&rsquo;s why there&rsquo;s stuff in Ruby on Rails, Rust, Go, Python, Crystal. Plus frameworks like Tauri and Flutter. I figured, for instance, that Flutter being super popular would mean the LLMs wouldn&rsquo;t struggle. They did. They could move forward, just not as smoothly as in Rails. Tauri was &ldquo;rough&rdquo; too. Crystal was REALLY rough.</p>
<p>Third point: every experimental project got named &ldquo;Frank&rdquo;. Sarcasm, obviously. I don&rsquo;t have the patience to invent a pretty name for a project I already know is going to be one-off.</p>
<p>I use my GitHub basically as a repository of exercises and experiments. Look at all the old repos: barely any are a &ldquo;real&rdquo; project I want to maintain. I do what I&rsquo;ve been preaching in the videos: to actually learn, you exercise on MANY projects, even if most of them are meant to be THROWN AWAY. Nobody learns by trying to build a single perfect project. Check out my videos <a href="/2020/05/15/a-dor-de-aprender/">A Dor de Aprender</a> or <a href="/2020/05/16/guia-definitivo-de-aprendendo-a-aprender/">Guia Definitivo de Aprendendo a Aprender</a> (Portuguese).</p>
<p>In the middle of a bunch of half-baked projects, some naturally emerge as the ones I like and maintain. In practice I threw them all at the wall to see what stuck. The good ones naturally pulled in more contributors too. I don&rsquo;t keep an eye on Issues and PRs every day. I check once a week. Two or three days ago I made a sweep and cleared everything that was pending, for example. Even on projects I don&rsquo;t care much about, if a PR shows up, I review and accept it. It wasn&rsquo;t useful to me, but it can be useful to someone else.</p>
<h2>The project ranking<span class="hx:absolute hx:-mt-20" id="the-project-ranking"></span>
    <a href="#the-project-ranking" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>To make it clear, here&rsquo;s a ranking of the projects with a comment on each. The PR counts below are non-dependabot (gem bumps don&rsquo;t count). The score is my estimate of &ldquo;how stable and usable is this thing in its current state.&rdquo;</p>
<h3>Top tier (7-8/10)<span class="hx:absolute hx:-mt-20" id="top-tier-7-810"></span>
    <a href="#top-tier-7-810" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p><strong><a href="https://github.com/akitaonrails/ai-jail"target="_blank" rel="noopener">ai-jail</a> — 8/10.</strong> The only one really close to a stable 1.0 release. Got the most traction: 19 PRs closed, 23 Issues resolved. Recommend using, it works very well.</p>
<p><strong>akitando-news (the M.Akita Chronicles) — 8/10.</strong> The one I built with the most care, in the &ldquo;Agile Vibe Coding&rdquo; mode I described a few posts back. And the one I touch most often. There&rsquo;s always some little thing to tweak, stuff I keep adding, removing, experimenting with. Which is why it&rsquo;s been shipping every week. Also why: it&rsquo;s closed and I don&rsquo;t plan to open it.</p>
<p><strong><a href="https://github.com/akitaonrails/akitaonrails.github.io"target="_blank" rel="noopener">akitaonrails.github.io</a> — 7/10.</strong> This is the blog you&rsquo;re reading. I touch it often and I&rsquo;ve already closed about 7 Issues and 30 PRs from contributors. I&rsquo;ll keep tuning it bit by bit.</p>
<p><strong><a href="https://github.com/akitaonrails/FrankMD"target="_blank" rel="noopener">FrankMD</a> — 7/10.</strong> One of the first ones I built, with no criteria at all: I just wanted an editor with automatic image upload to my S3. These days I use Claude Code to edit posts, so the only people using it are the people contributing to it. Got solid traction, I closed 17 PRs and 29 Issues. It&rsquo;s not perfect, but it&rsquo;s good enough to use — at least it&rsquo;s been tested enough.</p>
<p><strong><a href="https://github.com/akitaonrails/easy-ffmpeg"target="_blank" rel="noopener">easy-ffmpeg</a> — 7/10.</strong> Tiny little project, but I actually use it in the videos. My OBS always records in MKV and when I want to share I convert to MP4. Small use cases like that. Good enough to use.</p>
<h3>Middle tier (5/10)<span class="hx:absolute hx:-mt-20" id="middle-tier-510"></span>
    <a href="#middle-tier-510" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p><strong><a href="https://github.com/akitaonrails/distrobox-gaming"target="_blank" rel="noopener">distrobox-gaming</a> — 5/10.</strong> Most recent one, serves as a &ldquo;dump&rdquo; of documentation for my retro games. I use it as a notepad and it&rsquo;s been working fine for my setup. Not worried about it working for anyone else. If anyone wants to send PRs, go ahead.</p>
<p><strong><a href="https://github.com/akitaonrails/llm-coding-benchmark"target="_blank" rel="noopener">llm-coding-benchmark</a> — 5/10.</strong> This one doesn&rsquo;t run on its own. I have Claude Code execute it. The goal is to tabulate data so I can write articles about new LLMs. The code has to be REALLY rough.</p>
<p><strong><a href="https://github.com/akitaonrails/FrankMega"target="_blank" rel="noopener">FrankMega</a> — 5/10.</strong> The closest thing to a &ldquo;todo list&rdquo; exercise. I just needed to share a big file with a friend and thought &ldquo;hmm, I bet I can build a mini-Mega in 1 day&rdquo;. And that was it. The name is a joke, obviously — this is nowhere near the actual Mega.</p>
<p><strong><a href="https://github.com/akitaonrails/distrobox-llm"target="_blank" rel="noopener">distrobox-llm</a> — 5/10.</strong> More personal organization on my PC: I wanted to isolate the AI tooling into a distrobox so I wouldn&rsquo;t have to keep updating big packages on the main system.</p>
<h3>Lower tier (3-4/10)<span class="hx:absolute hx:-mt-20" id="lower-tier-3-410"></span>
    <a href="#lower-tier-3-410" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p><strong><a href="https://github.com/akitaonrails/FrankYomik"target="_blank" rel="noopener">FrankYomik</a> — 4/10.</strong> This was an idea I&rsquo;d had in my head for years. I just wanted to see if the concept worked. It works. But running local LLMs for it is slow and not as practical as I&rsquo;d like. It&rsquo;s the only one I might come back to, because I actually want to use it. As-is, it&rsquo;s in &ldquo;works, sort of&rdquo; territory.</p>
<p><strong><a href="https://github.com/akitaonrails/NW-Omarchy"target="_blank" rel="noopener">NW-Omarchy</a> — 4/10.</strong> Another &ldquo;let&rsquo;s see if this works&rdquo; experiment, lasted 2 days. The concept worked, but I don&rsquo;t plan to do much more than that. Up for grabs if anyone wants to take it further. The real test I wanted to run was whether X11 would be MUCH worse than Wayland. The gap turned out smaller than I expected, but I don&rsquo;t see myself using it day-to-day. Like I&rsquo;ve said: for the niche of people who can&rsquo;t use Wayland, it&rsquo;s a decent alternative.</p>
<p><strong><a href="https://github.com/akitaonrails/frank_investigator"target="_blank" rel="noopener">frank_investigator</a> — 3/10.</strong> Started it, didn&rsquo;t have the patience to keep going. Planted the seeds of the idea. If nobody sends PRs, it&rsquo;s not going to evolve much. Needs more research and a lot more testing.</p>
<p><strong><a href="https://github.com/akitaonrails/frank_fbi"target="_blank" rel="noopener">frank_fbi</a> — 3/10.</strong> Another concept test, no personal use case (I never fall for phishing). No idea where this would go yet.</p>
<p><strong><a href="https://github.com/akitaonrails/frank_karaoke"target="_blank" rel="noopener">frank_karaoke</a> — 3/10.</strong> Born from a weekend conversation with my brothers. Another shot at using Flutter (and seeing the limits, both of Flutter and of LLMs writing Flutter). Useful for me to learn about the karaoke ecosystem and find out that real scoring only works with per-song metadata. So I didn&rsquo;t push it past toy territory.</p>
<h3>Bottom of the barrel (1-2/10)<span class="hx:absolute hx:-mt-20" id="bottom-of-the-barrel-1-210"></span>
    <a href="#bottom-of-the-barrel-1-210" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p><strong><a href="https://github.com/akitaonrails/FrankSherlock"target="_blank" rel="noopener">FrankSherlock</a> — 2/10.</strong> I don&rsquo;t use it myself. So it&rsquo;s probably full of bugs that nobody&rsquo;s fixed. It was a test to see if I could build a Tauri app, but I lost interest fast. There&rsquo;s room to make it better, but it&rsquo;d take a lot more hours of actual use, testing, and fixes. It was also my first time touching Tauri, so that part is probably pretty rough too.</p>
<p><strong><a href="https://github.com/akitaonrails/FrankClaw"target="_blank" rel="noopener">FrankClaw</a> — 1/10.</strong> I don&rsquo;t use it, and this is the only one I haven&rsquo;t even run a single time. The exercise was just to see how hard it&rsquo;d be to port the original openclaw from TypeScript to Rust. But as I&rsquo;ve said many times, I don&rsquo;t see any use for openclaw in my own workflow. When I finished version 0.1, I stopped. Even so, there were 6 PRs I closed. If anyone&rsquo;s interested, feel free to contribute.</p>
<p><strong><a href="https://github.com/akitaonrails/shadPS4"target="_blank" rel="noopener">shadPS4</a> — 1/10.</strong> Despite generating a lot of code and a lot of documentation, none of it was meant to be a real contribution. Pure learning, plus solving one very specific problem. I left it open just to show what &ldquo;study-material&rdquo; code looks like.</p>
<p><strong><a href="https://github.com/akitaonrails/easy-subtitle"target="_blank" rel="noopener">easy-subtitle</a> — 1/10.</strong> Started it, lost interest, never actually tested it. Just pushed it and forgot.</p>
<h3>The private ones<span class="hx:absolute hx:-mt-20" id="the-private-ones"></span>
    <a href="#the-private-ones" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Beyond akitando-news, I have stuff on my private Gitea like <code>my-omarchy-config</code>, <code>emulators-reorg-2026</code>, <code>thinkpad-omarchy-config</code>, <code>homelab-docs</code>, <code>akitando_english_translation</code>, etc. I&rsquo;m not going to describe them because you&rsquo;re never going to see them. They serve as &ldquo;scratchpads&rdquo; for Claude Code to administer my machines.</p>
<h2>What this ranking actually says<span class="hx:absolute hx:-mt-20" id="what-this-ranking-actually-says"></span>
    <a href="#what-this-ranking-actually-says" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Notice: there&rsquo;s no project on this list that I think is 10/10.</p>
<p>For a project to graduate from hobby to real project, you need users and feedback. If nobody uses it, there are always dozens of functional bugs nobody&rsquo;s stumbled on, so nothing ever gets fixed. A project with no users is a dead project.</p>
<p>That&rsquo;s why I keep saying in the videos: there&rsquo;s no such thing as planning every detail, writing a pile of code, and one day &ldquo;finishing&rdquo;. Nope. When you think you&rsquo;ve &ldquo;finished,&rdquo; what you actually have is version 0.1. Then comes months of experimentation with real users reporting bugs. You go fixing. Out of those fixes come refactors, architecture changes because your initial assumptions were wrong against actual use. Software NEVER finishes.</p>
<h2>What I took out of this marathon<span class="hx:absolute hx:-mt-20" id="what-i-took-out-of-this-marathon"></span>
    <a href="#what-i-took-out-of-this-marathon" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>16 hours a day, every day, vibe coding, the output is always going to have slop in it. There&rsquo;s no way around it. Forcing volume doesn&rsquo;t fix it. You have to review manually, commit by commit, PR by PR. Why didn&rsquo;t I do that on all of them? Simple: nobody&rsquo;s paying me. I did it on 2 or 3 projects. The rest I just wanted to test the concept and move on to the next one.</p>
<p>The right way is what I reported a few posts ago: &ldquo;Agile&rdquo; Vibe Coding. That&rsquo;s my sarcastic way of saying &ldquo;use XP and normal software engineering practices&rdquo;. Metrics, monitoring, CI, the whole nine yards. None of that changed. Without it, you&rsquo;ll end up like a lot of my exercises: one-offs to throw away.</p>
<p>Don&rsquo;t be embarrassed to push &ldquo;bad&rdquo; projects. What I didn&rsquo;t anticipate is that some people assume just because I pushed code, I think it&rsquo;s perfect. But that&rsquo;s projection: they only push code they consider great, which is why they never push anything. I&rsquo;ve said this MANY times and I demonstrate it: I don&rsquo;t care about my code, I have no preciousness about my code, GitHub is a temp dump directory for whatever. The ones I really like usually live private.</p>
<p>If a project is really good, don&rsquo;t just say it. Put your money where your mouth is: build a business around it, charge users, deliver quality. I&rsquo;m retired, I don&rsquo;t need to charge anyone, but I also have no interest in working for free. The moment the code stops being fun, I drop it and move on. The ones I actually got hooked on (ai-jail, M.Akita Chronicles) I keep touching more often. That simple.</p>
<p>As I expected, and as I showed both in these little projects and in the benchmark, the choice is simple: use GPT Codex or Claude Code on one of the subsidized plans. Don&rsquo;t pay per token. That&rsquo;s where the 5x productivity lives (max 10x, with slop along for the ride). Yes, there&rsquo;s Kimi v2.6 or DeepSeek V4 Pro. But I&rsquo;ll save you the trip: the projects I scored below 5 here would quickly become projects below 2 with those.</p>
<h2>The point that matters most<span class="hx:absolute hx:-mt-20" id="the-point-that-matters-most"></span>
    <a href="#the-point-that-matters-most" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>AI doesn&rsquo;t replace a good programmer. AI mirrors and accelerates who you are. If you&rsquo;re a good programmer, AI accelerates good code. If you&rsquo;re a bad programmer, AI accelerates bad code.</p>
<p>That&rsquo;s exactly what I showed: most of the projects I built were one-offs and proof-of-concept tests. Bad code, with no interest from me to keep going. AI accelerated me finishing those tests in a half-baked way. Which is fine by me: without AI I never would&rsquo;ve started. Now that I&rsquo;ve seen what they look like, I can cross them off my list and move on.</p>
<p>What matters is your OBJECTIVE. If yours is to build a business and charge people, you have to do it with 10x more care than I did. Everything I do is meant to be thrown away. I don&rsquo;t need to make money on any of these projects. Don&rsquo;t assume things I never said.</p>
]]></content:encoded><category>vibe-coding</category><category>coding-agents</category><category>software-engineering</category></item><item><title>Porting 10K Lines of Python to Crystal with Claude: easy-subtitle</title><link>https://akitaonrails.github.io/en/2026/03/07/porting-10k-lines-of-python-to-crystal-with-claude-easy-subtitle/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2026/03/07/porting-10k-lines-of-python-to-crystal-with-claude-easy-subtitle/</guid><pubDate>Sat, 07 Mar 2026 22:00:00 -0300</pubDate><description>&lt;p&gt;In the &lt;a href="https://akitaonrails.github.io/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>programming-languages</category><category>vibe-coding</category></item><item><title>Crystal and a Smart FFmpeg Wrapper Built in 3 Hours | easy-ffmpeg</title><link>https://akitaonrails.github.io/en/2026/03/07/easy-ffmpeg-smart-wrapper-in-crystal/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2026/03/07/easy-ffmpeg-smart-wrapper-in-crystal/</guid><pubDate>Sat, 07 Mar 2026 18:00:00 -0300</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>audio-and-video</category><category>vibe-coding</category></item><item><title>37 Days of Vibe Coding Immersion: Conclusions on Business Models</title><link>https://akitaonrails.github.io/en/2026/03/05/37-days-of-vibe-coding-immersion-conclusions-on-business-models/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2026/03/05/37-days-of-vibe-coding-immersion-conclusions-on-business-models/</guid><pubDate>Thu, 05 Mar 2026 14:00:00 -0300</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>vibe-coding</category><category>startups</category><category>business</category><category>off-topic</category></item><item><title>RANT: Did Akita Bend Over for AI??</title><link>https://akitaonrails.github.io/en/2026/02/24/rant-akita-caved-to-ai/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2026/02/24/rant-akita-caved-to-ai/</guid><pubDate>Tue, 24 Feb 2026 11:54:26 +0000</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://akitaonrails.github.io/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/en/2026/02/08/rant-ai-killed-programmers/"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/en/2026/02/08/rant-ai-killed-programmers/"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>artificial-intelligence</category><category>llms</category><category>vibe-coding</category></item><item><title>Vibe Code: I Built a Smart Image Indexer with AI in 2 Days | Frank Sherlock</title><link>https://akitaonrails.github.io/en/2026/02/23/vibe-code-built-a-smart-image-indexer-with-ai-in-2-days-frank-sherlock/</link><guid isPermaLink="true">https://akitaonrails.github.io/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 +0000</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>frank</category><category>llms</category><category>artificial-intelligence</category><category>vibe-coding</category></item><item><title>Vibe Code: I Built a Mega Clone in Rails in 1 Day for My Home Server</title><link>https://akitaonrails.github.io/en/2026/02/21/vibe-code-built-a-mega-clone-in-rails-in-1-day-frankmega/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2026/02/21/vibe-code-built-a-mega-clone-in-rails-in-1-day-frankmega/</guid><pubDate>Sat, 21 Feb 2026 18:48:25 +0000</pubDate><description>&lt;p&gt;I&amp;rsquo;ve wanted a private file sharing service on my home server for a long time. You know when you need to send someone a big file? &amp;ldquo;Ah, use Google Drive.&amp;rdquo; No. &amp;ldquo;WeTransfer?&amp;rdquo; Also no. I want full control. I want to know where my files are, how long they exist for, who downloaded them, and I don&amp;rsquo;t want to depend on a third-party service for something this simple.&lt;/p&gt;
&lt;p&gt;So I did what any programmer would do: sat down, wrote a spec document, and with the help of Claude Code (Claude Opus 4.6) I built &lt;a href="https://github.com/akitaonrails/FrankMega"target="_blank" rel="noopener"&gt;&lt;strong&gt;FrankMega&lt;/strong&gt;&lt;/a&gt;, a simplified, self-hosted Mega clone, in Rails 8, in a single day of work. 21 commits, 3 hours of active development, 210 tests, zero external dependencies beyond SQLite.&lt;/p&gt;</description><content:encoded><![CDATA[<p>I&rsquo;ve wanted a private file sharing service on my home server for a long time. You know when you need to send someone a big file? &ldquo;Ah, use Google Drive.&rdquo; No. &ldquo;WeTransfer?&rdquo; Also no. I want full control. I want to know where my files are, how long they exist for, who downloaded them, and I don&rsquo;t want to depend on a third-party service for something this simple.</p>
<p>So I did what any programmer would do: sat down, wrote a spec document, and with the help of Claude Code (Claude Opus 4.6) I built <a href="https://github.com/akitaonrails/FrankMega"target="_blank" rel="noopener"><strong>FrankMega</strong></a>, a simplified, self-hosted Mega clone, in Rails 8, in a single day of work. 21 commits, 3 hours of active development, 210 tests, zero external dependencies beyond SQLite.</p>
<p><img src="https://github.com/akitaonrails/FrankMega/raw/master/docs/upload_screen.png" alt="frankmega"  loading="lazy" /></p>
<p>And before the smart guy shows up in the comments with &ldquo;ah, but Vibe Coding is just prompt engineering, anyone can do it&rdquo; &ndash; hold on. This post exists exactly to show that <strong>it isn&rsquo;t</strong>.</p>
<h2>From Idea to Code: the IDEA.md<span class="hx:absolute hx:-mt-20" id="from-idea-to-code-the-ideamd"></span>
    <a href="#from-idea-to-code-the-ideamd" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Every project starts with a document. Mine was <a href="https://github.com/akitaonrails/FrankMega/blob/master/docs/IDEA.md"target="_blank" rel="noopener"><code>docs/IDEA.md</code></a> &ndash; 56 lines describing what I wanted:</p>
<ul>
<li>Single file upload, with a shareable link</li>
<li>Download counter (default 5, configurable)</li>
<li>Automatic expiration within 24 hours</li>
<li>Full authentication: password + 2FA + passkeys</li>
<li>No public registration page &ndash; invites only</li>
<li>Admin creates invites, manages users, bans abusers</li>
<li>Aggressive rate limiting, automatic IP banning</li>
<li>Deploy via Docker + Cloudflare Tunnel on my home server</li>
<li>Stack: Rails 8, SQLite, Tailwind CSS, Hotwire/Stimulus</li>
</ul>
<p>I deliberately added at the end: <em>&ldquo;Suggest important features that may be relevant for a service like this.&rdquo;</em> Because I know a spec document never covers everything. And that&rsquo;s where the interesting part begins.</p>
<h2>The Big Bang: Commit #1<span class="hx:absolute hx:-mt-20" id="the-big-bang-commit-1"></span>
    <a href="#the-big-bang-commit-1" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The first commit (<code>e277226</code>) at 12:17 delivered <strong>207 files, 6,901 lines of code</strong>. In a single commit. Yes, I know it&rsquo;s controversial, but when you&rsquo;re building from scratch with AI, the first commit is necessarily huge.</p>
<p>What came in that initial commit:</p>
<ul>
<li>Full authentication with <code>has_secure_password</code> (bcrypt), TOTP via <code>rotp</code>, passkeys via <code>webauthn</code></li>
<li>Invitation system with unique codes and expiration</li>
<li>Full admin panel (Users, Invitations, Files, MIME Types)</li>
<li>Upload with Active Storage + drag-and-drop via Stimulus</li>
<li>Download links with 24-byte hashes (<code>SecureRandom.urlsafe_base64(24)</code>)</li>
<li>Rate limiting via Rack::Attack on every public endpoint</li>
<li>Automatic IP banning with a <code>Ban</code> model and async job</li>
<li>Dark/light theme with Tailwind CSS inspired by Mega.nz</li>
<li>73 tests (Minitest + FactoryBot)</li>
<li>Docker configuration with Puma + Thruster</li>
<li>Solid Queue/Cache/Cable &ndash; zero Redis</li>
</ul>
<p>Got it? <strong>All of that in commit #1</strong>. The IDEA.md became functional code in a single shot. But here&rsquo;s the point: <strong>the first commit was not production-ready</strong>. Not even close.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-21_15-32-29.png" alt="tema light"  loading="lazy" /></p>
<h2>The Reality: 20 Iteration Commits<span class="hx:absolute hx:-mt-20" id="the-reality-20-iteration-commits"></span>
    <a href="#the-reality-20-iteration-commits" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>After the Big Bang came 20 commits over 3 hours. And this is where the truth about software development lives, the one no &ldquo;Vibe Coding&rdquo; tutorial will tell you.</p>
<h3>Phase 1: &ldquo;Works on my machine&rdquo; (12:40 - 12:46)<span class="hx:absolute hx:-mt-20" id="phase-1-works-on-my-machine-1240---1246"></span>
    <a href="#phase-1-works-on-my-machine-1240---1246" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Four commits in 6 minutes. You know what happened? I went to build the Docker image and <strong>it broke</strong>. Missing <code>libssl-dev</code>. Fixed it. Then the CSP (Content Security Policy) from <code>secure_headers</code> clashed with the inline scripts from Rails&rsquo; importmap. Fixed it. Changed the port from 3000 to 3100 to avoid colliding with other services on my server.</p>
<p>Look at commit <code>389ebe8</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="c1"># CSP is now handled by the Rails built-in, not by secure_headers</span>
</span></span><span class="line"><span class="cl"><span class="c1"># Because secure_headers doesn&#39;t support nonces for importmap inline scripts</span>
</span></span><span class="line"><span class="cl"><span class="n">config</span><span class="o">.</span><span class="n">csp</span> <span class="o">=</span> <span class="no">SecureHeaders</span><span class="o">::</span><span class="no">OPT_OUT</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 kind of gem incompatibility you only discover in the moment. No LLM will warn you about this up front because it&rsquo;s a specific combination of versions.</p>
<h3>Phase 2: The Security Commit &ndash; 22 Issues Fixed (13:10)<span class="hx:absolute hx:-mt-20" id="phase-2-the-security-commit--22-issues-fixed-1310"></span>
    <a href="#phase-2-the-security-commit--22-issues-fixed-1310" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>This is the most important commit of the entire project: <code>4a854a6</code>. 35 files, 612 lines, 22 security issues fixed. I&rsquo;ll go into detail because this is where toy code separates from production code.</p>
<p>What the initial commit got wrong:</p>
<p><strong>CRITICAL &ndash; Encryption keys with hardcoded fallbacks:</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-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="c1"># BEFORE (dangerous):</span>
</span></span><span class="line"><span class="cl"><span class="n">config</span><span class="o">.</span><span class="n">active_record</span><span class="o">.</span><span class="n">encryption</span><span class="o">.</span><span class="n">primary_key</span> <span class="o">=</span> <span class="no">ENV</span><span class="o">.</span><span class="n">fetch</span><span class="p">(</span><span class="s2">&#34;KEY&#34;</span><span class="p">,</span> <span class="s2">&#34;test-primary-key&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># AFTER (fail-fast):</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="no">Rails</span><span class="o">.</span><span class="n">env</span><span class="o">.</span><span class="n">production?</span>
</span></span><span class="line"><span class="cl">  <span class="n">config</span><span class="o">.</span><span class="n">active_record</span><span class="o">.</span><span class="n">encryption</span><span class="o">.</span><span class="n">primary_key</span> <span class="o">=</span> <span class="no">ENV</span><span class="o">.</span><span class="n">fetch</span><span class="p">(</span><span class="s2">&#34;ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY&#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>If the ENV var didn&rsquo;t exist in production, the app would happily run with the hardcoded key. Anyone reading the source code could decrypt every user&rsquo;s OTP secrets. Now in production, if the variable is missing, the app <strong>crashes on boot</strong>. That&rsquo;s the correct behavior.</p>
<p><strong>CRITICAL &ndash; Replay Attack on OTP:</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-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="c1"># BEFORE:</span>
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">verify_otp</span><span class="p">(</span><span class="n">code</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">totp</span> <span class="o">=</span> <span class="no">ROTP</span><span class="o">::</span><span class="no">TOTP</span><span class="o">.</span><span class="n">new</span><span class="p">(</span><span class="n">otp_secret</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">totp</span><span class="o">.</span><span class="n">verify</span><span class="p">(</span><span class="n">code</span><span class="p">,</span> <span class="ss">drift_behind</span><span class="p">:</span> <span class="mi">30</span><span class="p">,</span> <span class="ss">drift_ahead</span><span class="p">:</span> <span class="mi">30</span><span class="p">)</span><span class="o">.</span><span class="n">present?</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"># AFTER:</span>
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">verify_otp</span><span class="p">(</span><span class="n">code</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">totp</span> <span class="o">=</span> <span class="no">ROTP</span><span class="o">::</span><span class="no">TOTP</span><span class="o">.</span><span class="n">new</span><span class="p">(</span><span class="n">otp_secret</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">timestamp</span> <span class="o">=</span> <span class="n">totp</span><span class="o">.</span><span class="n">verify</span><span class="p">(</span><span class="n">code</span><span class="p">,</span> <span class="ss">drift_behind</span><span class="p">:</span> <span class="mi">30</span><span class="p">,</span> <span class="ss">drift_ahead</span><span class="p">:</span> <span class="mi">30</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">return</span> <span class="kp">false</span> <span class="k">unless</span> <span class="n">timestamp</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="c1"># Prevent replay: reject if this code&#39;s timestamp was already used</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="n">last_otp_at</span><span class="o">.</span><span class="n">present?</span> <span class="o">&amp;&amp;</span> <span class="no">Time</span><span class="o">.</span><span class="n">at</span><span class="p">(</span><span class="n">timestamp</span><span class="p">)</span> <span class="o">&lt;=</span> <span class="n">last_otp_at</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="kp">false</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">update_column</span><span class="p">(</span><span class="ss">:last_otp_at</span><span class="p">,</span> <span class="no">Time</span><span class="o">.</span><span class="n">at</span><span class="p">(</span><span class="n">timestamp</span><span class="p">))</span>
</span></span><span class="line"><span class="cl">  <span class="kp">true</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 TOTP tutorial online shows <code>verify(code).present?</code> and stops there. The problem: a TOTP code is valid for 30 seconds. If I intercept your code (shoulder surfing, camera, clipboard), I can use it multiple times in that window. <code>last_otp_at</code> guarantees real one-time use.</p>
<p><strong>HIGH &ndash; Race Condition on the Download Counter:</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-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="c1"># BEFORE (race condition):</span>
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">increment_download!</span>
</span></span><span class="line"><span class="cl">  <span class="n">increment!</span><span class="p">(</span><span class="ss">:download_count</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"># AFTER (atomic):</span>
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">increment_download!</span>
</span></span><span class="line"><span class="cl">  <span class="nb">self</span><span class="o">.</span><span class="n">class</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="nb">id</span><span class="p">)</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;download_count &lt; max_downloads&#34;</span><span class="p">)</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;expires_at &gt; ?&#34;</span><span class="p">,</span> <span class="no">Time</span><span class="o">.</span><span class="n">current</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="o">.</span><span class="n">update_all</span><span class="p">(</span><span class="s2">&#34;download_count = download_count + 1&#34;</span><span class="p">)</span> <span class="o">==</span> <span class="mi">1</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><code>increment!</code> does: (1) read the value, (2) add 1 in Ruby, (3) write it back. Two simultaneous requests can both read <code>4</code>, both write <code>5</code>, and the user gets two downloads when they should only have had one. The atomic <code>UPDATE ... WHERE</code> in SQL guarantees only one request wins. And the <code>== 1</code> returns success/failure in a single operation, with no lock.</p>
<p>This is the classic TOCTOU bug (Time of Check, Time of Use). You&rsquo;ll never see it in local development with one browser. It only shows up in production with concurrent requests.</p>
<p><strong>HIGH &ndash; Open Redirect after login:</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-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="c1"># AFTER:</span>
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">safe_redirect_url?</span><span class="p">(</span><span class="n">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="n">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="n">host</span><span class="o">.</span><span class="n">nil?</span> <span class="o">||</span> <span class="n">uri</span><span class="o">.</span><span class="n">host</span> <span class="o">==</span> <span class="n">request</span><span class="o">.</span><span class="n">host</span>
</span></span><span class="line"><span class="cl"><span class="k">rescue</span> <span class="no">URI</span><span class="o">::</span><span class="no">InvalidURIError</span>
</span></span><span class="line"><span class="cl">  <span class="kp">false</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>Without this, an attacker could send a link like <code>frankmega.com/session?return_to=https://evil.com</code> and after login the user would be redirected to the malicious site.</p>
<p><strong>MEDIUM &ndash; Predictable CSP nonce:</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-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="c1"># BEFORE:</span>
</span></span><span class="line"><span class="cl"><span class="n">config</span><span class="o">.</span><span class="n">content_security_policy_nonce_generator</span> <span class="o">=</span> <span class="o">-&gt;</span><span class="p">(</span><span class="n">request</span><span class="p">)</span> <span class="p">{</span> <span class="n">request</span><span class="o">.</span><span class="n">session</span><span class="o">.</span><span class="n">id</span><span class="o">.</span><span class="n">to_s</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># AFTER:</span>
</span></span><span class="line"><span class="cl"><span class="n">config</span><span class="o">.</span><span class="n">content_security_policy_nonce_generator</span> <span class="o">=</span> <span class="o">-&gt;</span><span class="p">(</span><span class="n">_request</span><span class="p">)</span> <span class="p">{</span> <span class="no">SecureRandom</span><span class="o">.</span><span class="n">base64</span><span class="p">(</span><span class="mi">16</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>Session ID doesn&rsquo;t change between requests. If the nonce is predictable, an attacker can inject scripts with the correct nonce. It has to be random per request.</p>
<p>And more: Cloudflare IPv6 added to trusted proxies, minimum 12 characters for passwords, server-side MIME detection via Marcel, file size verified from the tempfile (not from the client header that can be forged), protection against deleting/banning/demoting the last admin.</p>
<p>The test count jumped from 73 to 109 with this single commit.</p>
<h2>How Rails Makes Security Easier<span class="hx:absolute hx:-mt-20" id="how-rails-makes-security-easier"></span>
    <a href="#how-rails-makes-security-easier" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Rails makes implementing security MUCH easier than doing it from scratch. Look at the comparison:</p>
<table>
  <thead>
      <tr>
          <th>Feature</th>
          <th>Rails</th>
          <th>From scratch</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>CSRF</td>
          <td>Automatic in <code>ActionController::Base</code></td>
          <td>Implement per-session tokens in every form</td>
      </tr>
      <tr>
          <td>Password hash</td>
          <td><code>has_secure_password</code> (1 line)</td>
          <td>Pick algorithm, salt, implement verification</td>
      </tr>
      <tr>
          <td>Filtered parameters</td>
          <td><code>config.filter_parameters</code></td>
          <td>Hook into the logging system</td>
      </tr>
      <tr>
          <td>Strong params</td>
          <td><code>params.require().permit()</code></td>
          <td>Manual whitelist middleware</td>
      </tr>
      <tr>
          <td>Field encryption</td>
          <td><code>encrypts :otp_secret</code> (1 line)</td>
          <td>Envelope encryption, key rotation, transparent decrypt</td>
      </tr>
      <tr>
          <td>CSP with nonces</td>
          <td>1-block config</td>
          <td>Generate nonces, inject into HTML tags, set headers</td>
      </tr>
      <tr>
          <td>Rate limiting (Rails 8)</td>
          <td><code>rate_limit to: 10, within: 3.minutes</code></td>
          <td>Counter storage, sliding window logic</td>
      </tr>
      <tr>
          <td>SQL injection</td>
          <td>Parameterized queries by default</td>
          <td>Parameterize every query manually</td>
      </tr>
  </tbody>
</table>
<p>Rails 8 in particular brought <code>rate_limit</code> built into the controller:</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">SessionsController</span> <span class="o">&lt;</span> <span class="no">ApplicationController</span>
</span></span><span class="line"><span class="cl">  <span class="n">rate_limit</span> <span class="ss">to</span><span class="p">:</span> <span class="mi">10</span><span class="p">,</span> <span class="ss">within</span><span class="p">:</span> <span class="mi">3</span><span class="o">.</span><span class="n">minutes</span><span class="p">,</span> <span class="ss">only</span><span class="p">:</span> <span class="ss">:create</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="ss">with</span><span class="p">:</span> <span class="o">-&gt;</span> <span class="p">{</span> <span class="n">redirect_to</span> <span class="n">new_session_path</span><span class="p">,</span> <span class="ss">alert</span><span class="p">:</span> <span class="n">t</span><span class="p">(</span><span class="s2">&#34;flash.sessions.create.rate_limit&#34;</span><span class="p">)</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>And authentication? Rails 8 now ships with a built-in <strong>authentication generator</strong>. You no longer need Devise for basic stuff. FrankMega&rsquo;s <code>app/controllers/concerns/authentication.rb</code> is based on that and extends it with 2FA, passkeys and invitation-only registration.</p>
<h2>File Sharing Is More Than Downloads<span class="hx:absolute hx:-mt-20" id="file-sharing-is-more-than-downloads"></span>
    <a href="#file-sharing-is-more-than-downloads" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>One thing lots of people don&rsquo;t realize: a file sharing service has a huge attack surface. It goes way beyond &ldquo;save file, generate link&rdquo;. Look at everything you need to consider:</p>
<h3>Filename Sanitization<span class="hx:absolute hx:-mt-20" id="filename-sanitization"></span>
    <a href="#filename-sanitization" 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-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">sanitize_filename</span><span class="p">(</span><span class="nb">name</span><span class="p">,</span> <span class="n">content_type</span> <span class="o">=</span> <span class="kp">nil</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">sanitized</span> <span class="o">=</span> <span class="no">File</span><span class="o">.</span><span class="n">basename</span><span class="p">(</span><span class="nb">name</span><span class="o">.</span><span class="n">to_s</span><span class="p">)</span>                          <span class="c1"># Strip path traversal</span>
</span></span><span class="line"><span class="cl">  <span class="n">sanitized</span> <span class="o">=</span> <span class="n">sanitized</span><span class="o">.</span><span class="n">encode</span><span class="p">(</span><span class="s2">&#34;UTF-8&#34;</span><span class="p">,</span> <span class="ss">invalid</span><span class="p">:</span> <span class="ss">:replace</span><span class="p">,</span>      <span class="c1"># Handle invalid UTF-8</span>
</span></span><span class="line"><span class="cl">                                <span class="k">undef</span><span class="p">:</span> <span class="ss">:replace</span><span class="p">,</span> <span class="ss">replace</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">sanitized</span> <span class="o">=</span> <span class="n">sanitized</span><span class="o">.</span><span class="n">gsub</span><span class="p">(</span><span class="sr">/[\x00-\x1f\x7f\/\\:*?&#34;&lt;&gt;|]/</span><span class="p">,</span> <span class="s2">&#34;&#34;</span><span class="p">)</span> <span class="c1"># Control chars + unsafe chars</span>
</span></span><span class="line"><span class="cl">  <span class="n">sanitized</span> <span class="o">=</span> <span class="n">sanitized</span><span class="o">.</span><span class="n">sub</span><span class="p">(</span><span class="sr">/\A\.+/</span><span class="p">,</span> <span class="s2">&#34;&#34;</span><span class="p">)</span>                        <span class="c1"># Leading dots (hidden files)</span>
</span></span><span class="line"><span class="cl">  <span class="n">sanitized</span> <span class="o">=</span> <span class="n">sanitized</span><span class="o">.</span><span class="n">gsub</span><span class="p">(</span><span class="sr">/\s+/</span><span class="p">,</span> <span class="s2">&#34; &#34;</span><span class="p">)</span><span class="o">.</span><span class="n">strip</span>                  <span class="c1"># Collapse whitespace</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="c1"># Windows reserved device names</span>
</span></span><span class="line"><span class="cl">  <span class="n">base_without_ext</span> <span class="o">=</span> <span class="n">sanitized</span><span class="o">.</span><span class="n">sub</span><span class="p">(</span><span class="sr">/\.[^.]*\z/</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="k">if</span> <span class="n">base_without_ext</span><span class="o">.</span><span class="n">match?</span><span class="p">(</span><span class="sr">/\A(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])\z/i</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">sanitized</span> <span class="o">=</span> <span class="s2">&#34;_</span><span class="si">#{</span><span class="n">sanitized</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></span><span class="line"><span class="cl">  <span class="n">sanitized</span> <span class="o">=</span> <span class="n">strip_extension_junk</span><span class="p">(</span><span class="n">sanitized</span><span class="p">,</span> <span class="n">content_type</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">sanitized</span> <span class="o">=</span> <span class="n">truncate_filename</span><span class="p">(</span><span class="n">sanitized</span><span class="p">,</span> <span class="mi">255</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">sanitized</span><span class="o">.</span><span class="n">presence</span> <span class="o">||</span> <span class="s2">&#34;unnamed_file&#34;</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 line exists for a reason:</p>
<ul>
<li><code>File.basename</code> blocks path traversal (<code>../../etc/passwd</code> becomes <code>passwd</code>)</li>
<li>Stripping control chars blocks null byte injection and terminal escape injection</li>
<li>Windows reserved names (CON, NUL, AUX, LPT1-9) cause problems if storage is accessed from Windows</li>
<li>Truncating to 255 bytes (not characters!) while preserving the extension &ndash; a 4-byte emoji counts as 4 toward the ext4/NTFS limit</li>
<li><code>strip_extension_junk</code> handles names that come from URLs with glued-on parameters: <code>photo.jpg_1280x720+quality=80</code> becomes <code>photo.jpg</code></li>
</ul>
<p>And this is server-side. There&rsquo;s also client-side validation in the Stimulus controller to reject before upload:</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-javascript" data-lang="javascript"><span class="line"><span class="cl"><span class="nx">isInvalidFilename</span><span class="p">(</span><span class="nx">name</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="k">new</span> <span class="nx">Blob</span><span class="p">([</span><span class="nx">name</span><span class="p">]).</span><span class="nx">size</span> <span class="o">&gt;</span> <span class="mi">255</span><span class="p">)</span> <span class="k">return</span> <span class="kc">true</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="sr">/[\x00-\x1f\x7f/:*?&#34;&lt;&gt;|\\]/</span><span class="p">.</span><span class="nx">test</span><span class="p">(</span><span class="nx">name</span><span class="p">))</span> <span class="k">return</span> <span class="kc">true</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 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>
<h3>Server-Side MIME Type Detection<span class="hx:absolute hx:-mt-20" id="server-side-mime-type-detection"></span>
    <a href="#server-side-mime-type-detection" 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-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="vi">@shared_file</span><span class="o">.</span><span class="n">content_type</span> <span class="o">=</span> <span class="no">Marcel</span><span class="o">::</span><span class="no">MimeType</span><span class="o">.</span><span class="n">for</span><span class="p">(</span><span class="n">uploaded</span><span class="o">.</span><span class="n">tempfile</span><span class="p">,</span> <span class="nb">name</span><span class="p">:</span> <span class="n">uploaded</span><span class="o">.</span><span class="n">original_filename</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="vi">@shared_file</span><span class="o">.</span><span class="n">file_size</span> <span class="o">=</span> <span class="n">uploaded</span><span class="o">.</span><span class="n">tempfile</span><span class="o">.</span><span class="n">size</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>Don&rsquo;t trust the <code>Content-Type</code> the browser sends. Marcel inspects the file&rsquo;s magic bytes to determine the real type. And the file size comes from the tempfile on disk, not from the <code>Content-Length</code> header which can be forged.</p>
<h3>Quotas and Disk Usage<span class="hx:absolute hx:-mt-20" id="quotas-and-disk-usage"></span>
    <a href="#quotas-and-disk-usage" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-21_16-00-52.png" alt="quota"  loading="lazy" /></p>
<p>Each user has a disk quota (5 GB default, admin can customize):</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">can_upload?</span><span class="p">(</span><span class="n">file_size</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">grace</span> <span class="o">=</span> <span class="no">Rails</span><span class="o">.</span><span class="n">application</span><span class="o">.</span><span class="n">config</span><span class="o">.</span><span class="n">x</span><span class="o">.</span><span class="n">security</span><span class="o">.</span><span class="n">disk_quota_grace_bytes</span>
</span></span><span class="line"><span class="cl">  <span class="p">(</span><span class="n">storage_used</span> <span class="o">+</span> <span class="n">file_size</span><span class="p">)</span> <span class="o">&lt;=</span> <span class="p">(</span><span class="n">disk_quota</span> <span class="o">+</span> <span class="n">grace</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>Without a quota, a single malicious user fills up the server disk. With the 10% grace buffer, an upload that slightly exceeds the limit is accepted so the user isn&rsquo;t frustrated in the edge case.</p>
<h3>Automatic Cleanup<span class="hx:absolute hx:-mt-20" id="automatic-cleanup"></span>
    <a href="#automatic-cleanup" 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-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">CleanupExpiredFilesJob</span> <span class="o">&lt;</span> <span class="no">ApplicationJob</span>
</span></span><span class="line"><span class="cl">  <span class="n">queue_as</span> <span class="ss">:default</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></span><span class="line"><span class="cl">    <span class="no">SharedFile</span><span class="o">.</span><span class="n">inactive</span><span class="o">.</span><span class="n">find_each</span> <span class="k">do</span> <span class="o">|</span><span class="n">shared_file</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">      <span class="n">shared_file</span><span class="o">.</span><span class="n">file</span><span class="o">.</span><span class="n">purge</span> <span class="k">if</span> <span class="n">shared_file</span><span class="o">.</span><span class="n">file</span><span class="o">.</span><span class="n">attached?</span>
</span></span><span class="line"><span class="cl">      <span class="n">shared_file</span><span class="o">.</span><span class="n">destroy</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>Runs every 15 minutes via Solid Queue. If nobody downloaded in 24 hours, gone. If it hit the download limit, gone. Without this, the server disk fills up and the service dies.</p>
<h3>Automatic IP Banning<span class="hx:absolute hx:-mt-20" id="automatic-ip-banning"></span>
    <a href="#automatic-ip-banning" 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-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">InvalidHashAccessJob</span> <span class="o">&lt;</span> <span class="no">ApplicationJob</span>
</span></span><span class="line"><span class="cl">  <span class="n">queue_as</span> <span class="ss">:default</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="n">ip_address</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">security</span> <span class="o">=</span> <span class="no">Rails</span><span class="o">.</span><span class="n">application</span><span class="o">.</span><span class="n">config</span><span class="o">.</span><span class="n">x</span><span class="o">.</span><span class="n">security</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="k">unless</span> <span class="n">security</span><span class="o">.</span><span class="n">enable_banning</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">cache_key</span> <span class="o">=</span> <span class="s2">&#34;invalid_hash:</span><span class="si">#{</span><span class="n">ip_address</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="n">count</span> <span class="o">=</span> <span class="no">Rails</span><span class="o">.</span><span class="n">cache</span><span class="o">.</span><span class="n">increment</span><span class="p">(</span><span class="n">cache_key</span><span class="p">,</span> <span class="mi">1</span><span class="p">,</span> <span class="ss">expires_in</span><span class="p">:</span> <span class="mi">1</span><span class="o">.</span><span class="n">hour</span><span class="p">)</span> <span class="o">||</span> <span class="mi">1</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">count</span> <span class="o">&gt;=</span> <span class="n">security</span><span class="o">.</span><span class="n">max_invalid_hash_attempts</span>
</span></span><span class="line"><span class="cl">      <span class="no">Ban</span><span class="o">.</span><span class="n">ban!</span><span class="p">(</span><span class="n">ip_address</span><span class="p">,</span> <span class="ss">reason</span><span class="p">:</span> <span class="s2">&#34;Repeated invalid download hash access&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">               <span class="ss">duration</span><span class="p">:</span> <span class="n">security</span><span class="o">.</span><span class="n">ban_duration</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="no">Rails</span><span class="o">.</span><span class="n">cache</span><span class="o">.</span><span class="n">delete</span><span class="p">(</span><span class="n">cache_key</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>Tried 3 invalid hashes? Banned for 1 hour. That stops hash enumeration. With 24 bytes of entropy (192 bits, 2^192 possibilities) brute force is computationally infeasible, but the extra protection costs nothing.</p>
<h3>Layered Rate Limiting<span class="hx:absolute hx:-mt-20" id="layered-rate-limiting"></span>
    <a href="#layered-rate-limiting" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Rack::Attack operates at the middleware layer (before Rails processes the request):</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="c1"># Login: 5 attempts per minute per IP</span>
</span></span><span class="line"><span class="cl"><span class="no">Rack</span><span class="o">::</span><span class="no">Attack</span><span class="o">.</span><span class="n">throttle</span><span class="p">(</span><span class="s2">&#34;logins/ip&#34;</span><span class="p">,</span> <span class="ss">limit</span><span class="p">:</span> <span class="mi">5</span><span class="p">,</span> <span class="ss">period</span><span class="p">:</span> <span class="mi">1</span><span class="o">.</span><span class="n">minute</span><span class="p">)</span> <span class="k">do</span> <span class="o">|</span><span class="n">req</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">  <span class="n">req</span><span class="o">.</span><span class="n">ip</span> <span class="k">if</span> <span class="n">req</span><span class="o">.</span><span class="n">path</span> <span class="o">==</span> <span class="s2">&#34;/session&#34;</span> <span class="o">&amp;&amp;</span> <span class="n">req</span><span class="o">.</span><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="c1"># Login: 5 attempts per minute per email</span>
</span></span><span class="line"><span class="cl"><span class="no">Rack</span><span class="o">::</span><span class="no">Attack</span><span class="o">.</span><span class="n">throttle</span><span class="p">(</span><span class="s2">&#34;logins/email&#34;</span><span class="p">,</span> <span class="ss">limit</span><span class="p">:</span> <span class="mi">5</span><span class="p">,</span> <span class="ss">period</span><span class="p">:</span> <span class="mi">1</span><span class="o">.</span><span class="n">minute</span><span class="p">)</span> <span class="k">do</span> <span class="o">|</span><span class="n">req</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="n">req</span><span class="o">.</span><span class="n">path</span> <span class="o">==</span> <span class="s2">&#34;/session&#34;</span> <span class="o">&amp;&amp;</span> <span class="n">req</span><span class="o">.</span><span class="n">post?</span>
</span></span><span class="line"><span class="cl">    <span class="n">req</span><span class="o">.</span><span class="n">params</span><span class="o">.</span><span class="n">dig</span><span class="p">(</span><span class="s2">&#34;email_address&#34;</span><span class="p">)</span><span class="o">&amp;.</span><span class="n">to_s</span><span class="o">&amp;.</span><span class="n">downcase</span><span class="o">&amp;.</span><span class="n">strip</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="c1"># Downloads: 60 views and 30 downloads per minute</span>
</span></span><span class="line"><span class="cl"><span class="no">Rack</span><span class="o">::</span><span class="no">Attack</span><span class="o">.</span><span class="n">throttle</span><span class="p">(</span><span class="s2">&#34;downloads_get/ip&#34;</span><span class="p">,</span> <span class="ss">limit</span><span class="p">:</span> <span class="mi">60</span><span class="p">,</span> <span class="ss">period</span><span class="p">:</span> <span class="mi">1</span><span class="o">.</span><span class="n">minute</span><span class="p">)</span> <span class="k">do</span> <span class="o">|</span><span class="n">req</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">  <span class="n">req</span><span class="o">.</span><span class="n">ip</span> <span class="k">if</span> <span class="n">req</span><span class="o">.</span><span class="n">path</span><span class="o">.</span><span class="n">start_with?</span><span class="p">(</span><span class="s2">&#34;/d/&#34;</span><span class="p">)</span> <span class="o">&amp;&amp;</span> <span class="n">req</span><span class="o">.</span><span class="n">get?</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"># General: 300 requests per 5 minutes (except assets)</span>
</span></span><span class="line"><span class="cl"><span class="no">Rack</span><span class="o">::</span><span class="no">Attack</span><span class="o">.</span><span class="n">throttle</span><span class="p">(</span><span class="s2">&#34;requests/ip&#34;</span><span class="p">,</span> <span class="ss">limit</span><span class="p">:</span> <span class="mi">300</span><span class="p">,</span> <span class="ss">period</span><span class="p">:</span> <span class="mi">5</span><span class="o">.</span><span class="n">minutes</span><span class="p">)</span> <span class="k">do</span> <span class="o">|</span><span class="n">req</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">  <span class="n">req</span><span class="o">.</span><span class="n">ip</span> <span class="k">unless</span> <span class="n">req</span><span class="o">.</span><span class="n">path</span><span class="o">.</span><span class="n">start_with?</span><span class="p">(</span><span class="s2">&#34;/assets&#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>Seven different throttles for specific scenarios. Plus Rails 8&rsquo;s <code>rate_limit</code> in the controllers. Banned IPs never even hit Rails &ndash; they&rsquo;re rejected at the middleware blocklist with status 403.</p>
<p>And the limits are configurable: 1x in production, 10x in development so you don&rsquo;t lock yourself out during testing. Everything centralized in <code>config/initializers/security.rb</code>.</p>
<h2>The Download Saga: 5 Commits in 21 Minutes<span class="hx:absolute hx:-mt-20" id="the-download-saga-5-commits-in-21-minutes"></span>
    <a href="#the-download-saga-5-commits-in-21-minutes" 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/02/screenshot-2026-02-21_15-31-25.png" alt="download"  loading="lazy" /></p>
<p>This part is fun because it shows real debugging. Between 14:44 and 15:05 I made <strong>5 commits</strong> trying to get downloads working with Turbo Drive:</p>
<ol>
<li><code>cc3c23d</code> &ndash; Added <code>data-turbo=false</code> on the download button. <strong>Didn&rsquo;t work.</strong></li>
<li><code>5224ed2</code> &ndash; Moved <code>data-turbo=false</code> to the <code>&lt;form&gt;</code>. <strong>Didn&rsquo;t work.</strong></li>
<li><code>98b0d4f</code> &ndash; Dropped <code>redirect_to rails_blob_path</code> and used <code>send_file</code> directly. <strong>Partially worked.</strong></li>
<li><code>1fdf5f9</code> &ndash; Switched from POST to GET link. Worked, but&hellip; bots can burn downloads with GET.</li>
<li><code>4c74b27</code> &ndash; Went back to POST. Added URL artifact cleanup in filenames.</li>
</ol>
<p>The root cause: Turbo Drive intercepts navigation aggressively, and downloading a file via <code>redirect_to</code> (which generates a chain of 302 redirects from Active Storage) confuses Turbo. The final fix: <code>send_file</code> straight from disk with <code>disposition: &quot;attachment&quot;</code> via POST with <code>data: { turbo: false }</code> on the form.</p>
<p>This kind of problem an LLM won&rsquo;t solve on its own. You need to test in a real browser, see what happens, try, fail, adjust. It&rsquo;s pure iterative development.</p>
<h2>The Gap Between the Idea and the Final Product<span class="hx:absolute hx:-mt-20" id="the-gap-between-the-idea-and-the-final-product"></span>
    <a href="#the-gap-between-the-idea-and-the-final-product" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Look at what the IDEA.md didn&rsquo;t mention and that emerged during development:</p>
<ol>
<li>Per-user disk quotas &ndash; without these one user fills the server disk</li>
<li>Full I18n (EN + PT-BR) &ndash; 66 files, ~250 strings extracted</li>
<li>Terms of Service with mandatory acceptance</li>
<li>User self-deletion &ndash; privacy requirement</li>
<li>Blocked downloads for banned users &ndash; their links return 410 Gone</li>
<li>Client-side upload validation &ndash; checks size, quota and filename before upload</li>
<li>Inline file previews &ndash; images, video and audio on the download page without consuming downloads</li>
<li>Styled error pages &ndash; instead of plain text &ldquo;Not Found&rdquo;, branded pages with i18n</li>
</ol>
<p>The IDEA.md said &ldquo;use activeadmin or administrate&rdquo;. In practice I built a custom admin panel with Tailwind because <code>administrate</code> had compatibility issues with Rails 8.1. The IDEA.md mentioned &ldquo;progress bar if possible&rdquo;. In practice I used drag-and-drop with a file preview via Stimulus, which is better than a progress bar.</p>
<p>Nobody can predict every feature on day 1. You discover requirements as you build. And each new feature brings edge cases that need handling.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-21_15-32-50.png" alt="pt"  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>At the end of the project:</p>
<ul>
<li>21 commits in a ~3-hour session</li>
<li>210 tests (Minitest + FactoryBot), 513 assertions, zero failures</li>
<li>~3,100 lines of application code (models, controllers, views, JS, CSS, configs)</li>
<li>~1,965 lines of tests (including dedicated security tests)</li>
<li>9 models, 20 controllers, 36 views, 9 Stimulus controllers</li>
<li>24 distinct security measures across 7 layers</li>
<li>CI: SimpleCov, RuboCop (zero offenses), Brakeman (zero warnings), bundler-audit (zero vulnerabilities)</li>
<li>Zero external dependencies beyond SQLite. No Redis, no PostgreSQL, no external queue service</li>
</ul>
<h2>The Invitation-Only Server<span class="hx:absolute hx:-mt-20" id="the-invitation-only-server"></span>
    <a href="#the-invitation-only-server" 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/02/screenshot-2026-02-21_15-31-54.png" alt="invite"  loading="lazy" /></p>
<p>FrankMega has no public signup. The flow is:</p>
<ol>
<li>On first run, with no users, the setup screen appears to create the admin</li>
<li>The <code>/setup</code> route only exists while <code>User.count.zero?</code> &ndash; afterward it vanishes entirely:</li>
</ol>
<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">constraints</span><span class="p">(</span><span class="o">-&gt;</span><span class="p">(</span><span class="n">request</span><span class="p">)</span> <span class="p">{</span> <span class="no">User</span><span class="o">.</span><span class="n">count</span><span class="o">.</span><span class="n">zero?</span> <span class="p">})</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="n">get</span> <span class="s2">&#34;setup&#34;</span><span class="p">,</span> <span class="ss">to</span><span class="p">:</span> <span class="s2">&#34;setup#new&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="n">post</span> <span class="s2">&#34;setup&#34;</span><span class="p">,</span> <span class="ss">to</span><span class="p">:</span> <span class="s2">&#34;setup#create&#34;</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>
<ol start="3">
<li>The admin creates invites in the admin panel (with expiration)</li>
<li>Each invite generates a unique 16-byte code</li>
<li>Each code can only be used once (<code>with_lock</code> + transaction to prevent a race condition)</li>
<li>After signup, the invite is marked as &ldquo;used&rdquo;</li>
</ol>
<p>This is perfect for a personal/family service. Full control over who gets in.</p>
<h2>Deploy: Docker + Cloudflare Tunnel<span class="hx:absolute hx:-mt-20" id="deploy-docker--cloudflare-tunnel"></span>
    <a href="#deploy-docker--cloudflare-tunnel" 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/02/screenshot-2026-02-21_15-33-40.png" alt="share"  loading="lazy" /></p>
<p>To wrap up, the deploy tutorial in case you want to run this on your own Docker home server. It&rsquo;s not complicated but there are details that matter.</p>
<h3>The Dockerfile<span class="hx:absolute hx:-mt-20" id="the-dockerfile"></span>
    <a href="#the-dockerfile" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Multi-stage build in 3 stages:</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-dockerfile" data-lang="dockerfile"><span class="line"><span class="cl"><span class="c"># Stage 1: Base (runtime)</span><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="k">FROM</span><span class="w"> </span><span class="s">ruby:3.4.8-slim</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="s">base</span><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="k">RUN</span> apt-get install -y curl libjemalloc2 libvips sqlite3<span class="err">
</span></span></span><span class="line"><span class="cl"><span class="c"># jemalloc for better memory management</span><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="c"># Stage 2: Build (throwaway)</span><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="k">FROM</span><span class="w"> </span><span class="s">base</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="s">build</span><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="k">RUN</span> apt-get install -y build-essential git libssl-dev<span class="err">
</span></span></span><span class="line"><span class="cl"><span class="c"># Bundle install + asset precompilation with dummy env vars</span><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="c"># Stage 3: Final</span><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="c"># Copies gems and app, runs as non-root user (rails:rails, UID 1000)</span><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="k">CMD</span> <span class="p">[</span><span class="s2">&#34;./bin/thrust&#34;</span><span class="p">,</span> <span class="s2">&#34;./bin/rails&#34;</span><span class="p">,</span> <span class="s2">&#34;server&#34;</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><code>bin/thrust</code> is Thruster (from Basecamp) &ndash; an HTTP proxy in front of Puma that handles gzip/brotli compression, asset caching, X-Sendfile acceleration. It listens on port 80 and proxies to Puma on 3000.</p>
<p>The entrypoint runs <code>db:prepare</code> and <code>db:seed</code> automatically at boot:</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="cp">#!/bin/bash -e
</span></span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="o">[</span> <span class="s2">&#34;</span><span class="si">${</span><span class="p">@: -2:</span><span class="nv">1</span><span class="si">}</span><span class="s2">&#34;</span> <span class="o">==</span> <span class="s2">&#34;./bin/rails&#34;</span> <span class="o">]</span> <span class="o">&amp;&amp;</span> <span class="o">[</span> <span class="s2">&#34;</span><span class="si">${</span><span class="p">@: -1:</span><span class="nv">1</span><span class="si">}</span><span class="s2">&#34;</span> <span class="o">==</span> <span class="s2">&#34;server&#34;</span> <span class="o">]</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">  ./bin/rails db:prepare
</span></span><span class="line"><span class="cl">  ./bin/rails db:seed
</span></span><span class="line"><span class="cl"><span class="k">fi</span>
</span></span><span class="line"><span class="cl"><span class="nb">exec</span> <span class="s2">&#34;</span><span class="si">${</span><span class="p">@</span><span class="si">}</span><span class="s2">&#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>
<h3>docker-compose.yml<span class="hx:absolute hx:-mt-20" id="docker-composeyml"></span>
    <a href="#docker-composeyml" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Two services:</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">web</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">akitaonrails/frankmega:latest</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;3100:80&#34;</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">/home/seuuser/frankmega/uploads:/rails/storage/uploads</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">/home/seuuser/frankmega/db:/rails/storage</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="nt">SECRET_KEY_BASE</span><span class="p">:</span><span class="w"> </span><span class="l">${SECRET_KEY_BASE}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">HOST</span><span class="p">:</span><span class="w"> </span><span class="l">${HOST}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="c"># ... around 15 more variables</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">tunnel</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">cloudflare/cloudflared: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="l">tunnel run</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">TUNNEL_TOKEN=${TUNNEL_TOKEN}</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="l">web</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>
<p>The <code>cloudflare/cloudflared</code> sidecar creates an outbound tunnel to Cloudflare&rsquo;s edge. No need to open ports on your home server&rsquo;s firewall. Cloudflare handles SSL termination, DDoS protection, and routes traffic to the container.</p>
<h3>Step by Step<span class="hx:absolute hx:-mt-20" id="step-by-step"></span>
    <a href="#step-by-step" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p><strong>1. Create the Tunnel on Cloudflare:</strong></p>
<ul>
<li>Cloudflare Zero Trust Dashboard &gt; Networks &gt; Tunnels &gt; Create</li>
<li>Type: Cloudflared</li>
<li>Copy the <code>TUNNEL_TOKEN</code></li>
<li>Configure the hostname: <code>frankmega.yourdomain.com</code> pointing to <code>http://web:80</code></li>
</ul>
<p><strong>2. Generate the secrets:</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"># SECRET_KEY_BASE</span>
</span></span><span class="line"><span class="cl">openssl rand -hex <span class="m">64</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Encryption keys (3 separate values)</span>
</span></span><span class="line"><span class="cl">openssl rand -hex <span class="m">32</span>  <span class="c1"># PRIMARY_KEY</span>
</span></span><span class="line"><span class="cl">openssl rand -hex <span class="m">32</span>  <span class="c1"># DETERMINISTIC_KEY</span>
</span></span><span class="line"><span class="cl">openssl rand -hex <span class="m">32</span>  <span class="c1"># KEY_DERIVATION_SALT</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>3. Configure <code>.env</code>:</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-env" data-lang="env"><span class="line"><span class="cl"><span class="nv">SECRET_KEY_BASE</span><span class="o">=</span>&lt;generated above&gt;
</span></span><span class="line"><span class="cl"><span class="nv">RAILS_MASTER_KEY</span><span class="o">=</span>&lt;from config/master.key&gt;
</span></span><span class="line"><span class="cl"><span class="nv">HOST</span><span class="o">=</span>frankmega.seudominio.com
</span></span><span class="line"><span class="cl"><span class="nv">WEBAUTHN_ORIGIN</span><span class="o">=</span>https://frankmega.seudominio.com
</span></span><span class="line"><span class="cl"><span class="nv">WEBAUTHN_RP_ID</span><span class="o">=</span>frankmega.seudominio.com
</span></span><span class="line"><span class="cl"><span class="nv">ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY</span><span class="o">=</span>&lt;generated&gt;
</span></span><span class="line"><span class="cl"><span class="nv">ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY</span><span class="o">=</span>&lt;generated&gt;
</span></span><span class="line"><span class="cl"><span class="nv">ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT</span><span class="o">=</span>&lt;generated&gt;
</span></span><span class="line"><span class="cl"><span class="nv">TUNNEL_TOKEN</span><span class="o">=</span>&lt;from Cloudflare&gt;
</span></span><span class="line"><span class="cl"><span class="nv">FORCE_SSL</span><span class="o">=</span><span class="nb">true</span>
</span></span><span class="line"><span class="cl"><span class="nv">APP_LOCALE</span><span class="o">=</span>pt-BR</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>4. Bring it up:</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">docker compose pull
</span></span><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><strong>5. Visit</strong> <code>https://frankmega.yourdomain.com</code>, create the admin account, start using it.</p>
<h3>Important Caveats<span class="hx:absolute hx:-mt-20" id="important-caveats"></span>
    <a href="#important-caveats" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><ul>
<li><code>FORCE_SSL=false</code> to test locally at <code>http://localhost:3100</code> without Cloudflare in front, otherwise you hit a redirect loop</li>
<li>The WebAuthn origin has to match exactly, including <code>https://</code>. If you get it wrong, passkeys silently stop working</li>
<li>Cloudflare IPs are hardcoded in the initializer (because the <code>cloudflare-rails</code> gem isn&rsquo;t compatible with Rails 8.1). If Cloudflare changes the ranges, you need to rebuild the image</li>
<li>Encryption keys are permanent. If you swap them after users configure 2FA, the OTP secrets become unreadable and users lose access</li>
<li>Cloudflare free has a 100 MB upload limit. On the free plan, files above that don&rsquo;t make it through the tunnel. The app allows up to 1 GB but Cloudflare is the bottleneck</li>
<li>The container runs as UID 1000. If you use bind mounts instead of named volumes, the directory has to be owned by UID 1000</li>
<li>There are 4 separate SQLite databases in production (app, cache, queue, cable), all in the <code>db_data</code> volume. Backup means copying the entire volume</li>
</ul>
<h2>The Real Point About &ldquo;Vibe Coding&rdquo;<span class="hx:absolute hx:-mt-20" id="the-real-point-about-vibe-coding"></span>
    <a href="#the-real-point-about-vibe-coding" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Claude Code is absurdly productive. I wouldn&rsquo;t have done all of this in 3 hours without it. Remember when you&rsquo;d bang your head against the wall for a week just to get login working? Yeah.</p>
<p>The I18n commit alone (66 files, ~250 strings extracted for two languages) would take a full day by hand.</p>
<p>But here&rsquo;s the point nobody wants to hear: <strong>the LLM did not make the security decisions</strong>. I asked it to run an audit and it found the problems, but I had to know how to ask. And I had to know which questions to ask. <em>&ldquo;Review security&rdquo;</em> is a vague instruction. <em>&ldquo;Check if there&rsquo;s a race condition on the download counter increment&rdquo;</em> is an instruction only someone who knows what TOCTOU is can give.</p>
<p>Those 56 lines of IDEA.md carry decades of web development experience. I knew to ask for rate limiting because I&rsquo;ve seen services taken down by bots. I knew to ask for IP banning because I&rsquo;ve dealt with abuse. I knew to ask for an atomic download counter because I understand concurrency.</p>
<p>If a beginner wrote the same IDEA.md, they probably wouldn&rsquo;t have half of those concerns. And the LLM wouldn&rsquo;t spontaneously suggest them. The result would be a service that&rsquo;s functional, pretty, and completely insecure.</p>
<p>The experienced programmer&rsquo;s value in Vibe Coding isn&rsquo;t writing code. It&rsquo;s knowing what to ask for and reviewing what was generated. Experience is the filter between &ldquo;works&rdquo; and &ldquo;works in production&rdquo;.</p>
<p>Those 210 tests and 24 security measures didn&rsquo;t come from prompt engineering. They came from knowing what to test and why.</p>
<p>The code is open source under AGPL-3.0: <a href="https://github.com/akitaonrails/frankmega"target="_blank" rel="noopener">github.com/akitaonrails/frank_mega</a>. Deploy it on your server, poke around the code, learn from the commits. And if you find more security flaws, let me know.</p>
<p>Remember:</p>
<blockquote>
  <p>AI is the mirror of your own competence.</p>

</blockquote>
]]></content:encoded><category>vibe-coding</category><category>rails</category><category>homelab</category><category>security</category></item><item><title>Vibe Code: From Zero to Production in 6 DAYS | The M.Akita Chronicles</title><link>https://akitaonrails.github.io/en/2026/02/16/vibe-code-zero-to-production-in-6-days-the-m-akita-chronicles/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2026/02/16/vibe-code-zero-to-production-in-6-days-the-m-akita-chronicles/</guid><pubDate>Mon, 16 Feb 2026 14:19:56 +0000</pubDate><description>&lt;p&gt;&lt;strong&gt;SUBSCRIBE TO THE NEW NEWSLETTER:&lt;/strong&gt; &lt;a href="https://themakitachronicles.com/"target="_blank" rel="noopener"&gt;The M.Akita Chronicles&lt;/a&gt;
&lt;strong&gt;READ THE BLOG VERSION:&lt;/strong&gt; &lt;a href="https://blog.themakitachronicles.com/"target="_blank" rel="noopener"&gt;Blog&lt;/a&gt;
&lt;strong&gt;LISTEN ON SPOTIFY!!&lt;/strong&gt; &lt;a href="https://open.spotify.com/show/7MzG2UB7IAkC3GAwEXEIVD"target="_blank" rel="noopener"&gt;Podcast&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&amp;ndash;&lt;/p&gt;
&lt;p&gt;&lt;img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-14_18-59-53.png" alt="a ideia" loading="lazy" /&gt;&lt;/p&gt;
&lt;p&gt;On February 10, 2026, I posted the tweet above, just throwing out there something that had been on my to-do list for years, ever since I was publishing videos on YouTube. I never did it because I hate HTML/CSS, and having to build an email template was enough to keep me from starting. On top of that, doing yet another newsletter, which is just a boring list of links, didn&amp;rsquo;t motivate me.&lt;/p&gt;</description><content:encoded><![CDATA[<p><strong>SUBSCRIBE TO THE NEW NEWSLETTER:</strong> <a href="https://themakitachronicles.com/"target="_blank" rel="noopener">The M.Akita Chronicles</a>
<strong>READ THE BLOG VERSION:</strong> <a href="https://blog.themakitachronicles.com/"target="_blank" rel="noopener">Blog</a>
<strong>LISTEN ON SPOTIFY!!</strong> <a href="https://open.spotify.com/show/7MzG2UB7IAkC3GAwEXEIVD"target="_blank" rel="noopener">Podcast</a></p>
<p>&ndash;</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-14_18-59-53.png" alt="a ideia"  loading="lazy" /></p>
<p>On February 10, 2026, I posted the tweet above, just throwing out there something that had been on my to-do list for years, ever since I was publishing videos on YouTube. I never did it because I hate HTML/CSS, and having to build an email template was enough to keep me from starting. On top of that, doing yet another newsletter, which is just a boring list of links, didn&rsquo;t motivate me.</p>
<p>But that day I was already wrapping up my second vibe coding project, <a href="https://github.com/akitaonrails/FrankMD"target="_blank" rel="noopener">FrankMD</a>, and I think I figured out the main tricks and the methodology to build a complete product the right way. So a bunch of ideas started popping up. I dumped them all into this <a href="https://gist.github.com/akitaonrails/d2a7983fc4c839b8071f5d0babaadf94"target="_blank" rel="noopener">little document</a>.</p>
<p><a href="https://gist.github.com/akitaonrails/d2a7983fc4c839b8071f5d0babaadf94"target="_blank" rel="noopener"><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-16_12-40-04.jpg" alt="conceito original"  loading="lazy" /></a></p>
<p>I thought: what if I vibe coded not only the newsletter system&rsquo;s code, but also had part of the actual content generated by AI in a quirky way, in the form of commentators: me and a robot? And, of course, with Hitchhiker&rsquo;s Guide in mind, it had to be MARVIN, or rather, &ldquo;M.Arvin&rdquo; (so nobody hassles me about copyright).</p>
<p>Obviously, there would be a weekly email version and an archive blog. But since we were going all in, why not rewrite the whole newsletter as a podcast script? And since <a href="https://qwen.ai/blog?id=qwen3tts-0115"target="_blank" rel="noopener">Qwen3 TTS</a> recently came out, open source, why not train a LoRa with my voice (obviously, I have hundreds of hours of my voice on YouTube) and put together a fully automated podcast?</p>
<p>Finally, one of the things that would demotivate me is building a traditional Web Admin and having to log in manually to register news links and my comments. I knew I&rsquo;d get lazy and, at some point, burn out. There was only one solution: build a smarter bot I could talk to via <strong>DISCORD</strong>. That way I can quickly jump in, whether from my PC or my smartphone, without having to log into a separate system or anything. I already chat with people on Discord, so the friction would be zero.</p>
<p>So:</p>
<ul>
<li>Marvin Bot to manage all the content (the stuff I send in and the automated stuff, with comments and everything else)</li>
<li>An X.com monitor to check what I posted and grab it for the newsletter. Including the replies I give, which will become Q&amp;A!</li>
<li>Newsletter website to subscribe, unsubscribe, etc</li>
<li>Blog for newsletter archives and podcast transcripts (also with terms of service and privacy — yes, I&rsquo;m LGPD compliant!)</li>
<li>Automatic voice generator and podcast assembler that uploads automatically to make it available on Spotify</li>
<li>And also blog and email templates, with light and dark theme versions.</li>
</ul>
<p>You, an experienced programmer, without AI, how long would it take you to build all of that?</p>
<p>Well, I started on February 10. I finished everything on Sunday, February 15. And today, the 16th, the production jobs ran for the first time, which generated the newsletter, blog posts, podcast, Spotify upload, email delivery.</p>
<p>And it wasn&rsquo;t done the crappy way every amateur vibe coder does it:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-16_11-37-20.jpg" alt="newsletter stats"  loading="lazy" /></p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-16_11-37-27.jpg" alt="bot stats"  loading="lazy" /></p>
<p>Rails folks must be surprised: a project with a 1 to 1 test ratio! 1 line of tests for every 1 line of code: 100% coverage. And that&rsquo;s without counting a separate full end-to-end integration project (which I used to make sure production would be flawless).</p>
<p>All the strategies I&rsquo;ve learned over the years putting projects into production: security, scalability, durability, delivery guarantees without spamming duplicate emails, etc. I&rsquo;ll see later if I write more specific posts about some of the technical tricks.</p>
<p>I&rsquo;m not going to open source this project. I built it 100% custom for me, my workflow, and my style. It&rsquo;s not hard for another competent programmer to do the same thing with vibe code: if I pulled it off, anyone can. So just don&rsquo;t be lazy about thinking and doing. Honestly, I don&rsquo;t get how tech YouTubers still aren&rsquo;t launching 1 complete project in production, per video, for everyone to test and watch.</p>
<p>The screenshot also doesn&rsquo;t show the Qwen3-TTS server, which produces audio. That one is much simpler, a PyTorch server. Here I actually used Python. The most important part is the fine tuning of the model to generate voices exactly the way I wanted:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-16_11-45-20.jpg" alt="qwen3 server"  loading="lazy" /></p>
<p>The blog is all <strong>Hugo/Hextra</strong>, same as this one. And my bot publishes there automatically.</p>
<h2>M.Arvin Bot<span class="hx:absolute hx:-mt-20" id="marvin-bot"></span>
    <a href="#marvin-bot" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>M.Arvin has a personality I spelled out exactly the way I wanted. And it&rsquo;s a Discord bot, so my workflow looks like this:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-16_12-45-48.jpg" alt="bot url register"  loading="lazy" /></p>
<p>I just need to send a link with my comment (the same way I&rsquo;d do on X.com), or I can post directly on X and it&rsquo;ll pick it up the same way. Then it fetches the content, writes a little summary, does a small fact-check, and M.Arvin generates its own comment on top of mine.</p>
<p>I programmed it to respond to a bunch of commands to make my day-to-day easier. This is just a small part of it:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-16_11-56-13.jpg" alt="bot help commands"  loading="lazy" /></p>
<p>Here&rsquo;s an example: I have control over how much AI it&rsquo;s using (it&rsquo;s pretty cheap):</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-16_11-57-07.jpg" alt="AI cost"  loading="lazy" /></p>
<h2>Deployment<span class="hx:absolute hx:-mt-20" id="deployment"></span>
    <a href="#deployment" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>In this post I&rsquo;m not going to go into too much detail, but one thing I spent a lot of time on, going through several discussions and versions with Claude Code, was the infrastructure architecture. And in the end it managed to give me a Deployment Guide that I was able to follow all the way through, knowing at a minimum it&rsquo;s set up the way I would have done it myself if I&rsquo;d built everything alone:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-16_11-58-45.jpg" alt="topology"  loading="lazy" /></p>
<p>No software is guaranteed to be 100% perfect; this one shouldn&rsquo;t be either. Even with me reviewing for days, going through every step multiple times, there&rsquo;s always some bug I didn&rsquo;t anticipate. I &ldquo;think&rdquo; that, at least, everything I know usually causes problems in production, I &ldquo;think&rdquo; I managed to cover satisfactorily. If it were a human team, I would have pushed it to production in a worse state than what I reached with Claude.</p>
<p>Everything I could protect with Cloudflare, I configured. Every good practice for email/mailing, I followed. Every precaution against accidents I took (every deploy automated with Kamal) and every subscriber&rsquo;s email was encrypted with AES-256 in the database (even if they leaked by accident, the user data doesn&rsquo;t leak). I didn&rsquo;t implement profiling, cookies, or tracking pixels, nothing. Privacy was the focus.</p>
<p>Everything I could reduce costs on, I reduced; after all, I&rsquo;m paying out of pocket. So things like the RunPod GPU for generating the audio are serverless. I only spin it up at the moment of generation; it runs the whole process and then the machine shuts down. The server is all organized into a lightweight Docker image. All the AI models live on a separate network volume.</p>
<h2>Process Spoiler<span class="hx:absolute hx:-mt-20" id="process-spoiler"></span>
    <a href="#process-spoiler" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Later I plan to do a bigger post about my methodology, but it&rsquo;s very simple: treat Claude Code/Codex like a hard-working human programmer — one who&rsquo;s going to make lots of mistakes, but knows how to obey super explicit, direct, clear, unambiguous orders. My role is product manager, Tech Lead, or QA. It has to be all THREE roles.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-16_12-06-47.jpg" alt="commits"  loading="lazy" /></p>
<p>In total, Claude Code made 201 commits. But these aren&rsquo;t normal commits; I figure that in a normal project with humans, each one would have been roughly <strong>150 Pull Requests</strong>.</p>
<p>Question for you folks doing sprints on real projects: how long does it take to get 150 user stories/bug fixes approved and in production on your actual project? How many user stories can you get done and approved (with QA and everything) in ONE week?</p>
<p>Here&rsquo;s an example of a commit/&ldquo;user story&rdquo;:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-16_12-08-55.jpg" alt="commit example"  loading="lazy" /></p>
<p>When I was around commit 50, I was already thinking: &ldquo;It works already!&rdquo;* That&rsquo;s the stage where every content creator (who has never actually put real projects into production) would already be posting, saying: <em>&ldquo;I vibe coded it in 2 days&rdquo;</em>. In fact, in 2 days I already had the bulk of the functionality ready.</p>
<p>The other 4 days were:</p>
<ul>
<li>1 day for production deploy (when everything is planned ahead of time, all the contingencies already thought through, it&rsquo;s easy).</li>
<li>3 days just testing, writing automated/integration tests, generating and regenerating, exercising every step of every part, multiple times, and making adjustments, refactors, or even throwing away entire pieces to change them, because the tests showed it was worth it.</li>
</ul>
<p>That took from commit 50 to 200! And that&rsquo;s the part every vibe coder <strong>skips</strong>. To be fair, it&rsquo;s the part anyone, with or without AI, would skip. And that&rsquo;s why projects always go wrong and end up in production full of errors and obvious security holes.</p>
<blockquote>
  <p>Even after asking Claude Code to review security best practices and test coverage, multiple times. Then I opened Codex and asked it to do the same thing, and it still found more security holes and more missing tests, multiple times. Even after both of them did these checks several times, I still found more holes on my own that I had them fix. At no point was either of them able to find &ldquo;EVERYTHING&rdquo;. Nobody can, not even AI.</p>

</blockquote>
<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>Vibe Coding the right way has only one path: Extreme Programming!</p>
<p>Yes, all the &ldquo;boring&rdquo; and &ldquo;bureaucratic&rdquo; things every developer hates doing are what guarantees the AI will give you back a result acceptable for production:</p>
<ul>
<li>Pair Programming: you need to tell the AI to plan first and spell out exactly everything it&rsquo;s going to do, BEFORE starting. I review, I tweak, and only when I&rsquo;m satisfied do I let it go. I watch every single thing it does and, at the end, I tell it to fix whatever I think it missed.</li>
<li>Test-Driven Development: every feature must come with unit tests. Every bug fix needs regression tests.</li>
<li>Continuous Integration: every time it finishes implementing, you need to run the CI script to see if anything broke somewhere else. More than that: on the master branch, there are only commits with passing tests. I can revert at any point and the project runs.</li>
<li>Small Releases: I really liked this about Claude, even if I end up forgetting to tell it to commit a task and send it off to do another one, in the end it doesn&rsquo;t do that ugly <code>git add .</code> thing — no: it separates into 2 commits and describes each one the right way. On top of that, I pushed to production on Friday, and kept going Saturday and Sunday testing integration locally and in the production environment, to make sure there&rsquo;s none of that <em>&ldquo;it only works on my machine&rdquo;</em> stuff.</li>
<li>Coding Standard: I started with the bare minimum of technical requirements, but during development I had it refactor my way over and over again.</li>
</ul>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-16_12-21-22.jpg" alt="tech stack"  loading="lazy" /></p>
<p>Those are the main tasks that need to be done. Let me get ahead of it: &ldquo;Spec Driven Development&rdquo; isn&rsquo;t totally useless, but the structure doesn&rsquo;t matter: it&rsquo;s your text that counts.</p>
<p>It&rsquo;s the same thing as User Stories. Big deal, you wrote &ldquo;As the end user, I want that when I type X&hellip;, the system responds with Y&rdquo;. The format is NEVER important. What&rsquo;s important is HOW you wrote what goes in the middle. If you can&rsquo;t write, the AI won&rsquo;t know how to respond to you the way it should. I&rsquo;ll do another article just to show how terrible all of you are at writing.</p>
<p>The message is simple: Vibe Code works, in part.</p>
<blockquote>
  <p>What actually works is &ldquo;Senior Agile Vibe Coder&rdquo;</p>

</blockquote>
<p>I&rsquo;ll ask again: how long, without AI, would it take you to produce 150 user stories/bug fixes, in the scope I outlined at the beginning? All the way to production with everything tested? I doubt it would be in 6 days like I did it.</p>
]]></content:encoded><category>the-m-akita-chronicles</category><category>vibe-coding</category><category>agile</category></item><item><title>Vibe Code: Which LLM Is the BEST?? Let's Talk for REAL</title><link>https://akitaonrails.github.io/en/2026/01/29/vibe-code-which-llm-is-best-lets-be-real/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2026/01/29/vibe-code-which-llm-is-best-lets-be-real/</guid><pubDate>Thu, 29 Jan 2026 10:43:16 -0300</pubDate><description>&lt;p&gt;This is the question that just won&amp;rsquo;t go away:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;&amp;ldquo;As a total zero in programming, I want to build a perfect, secure, quality app using only prompts. Which LLM will do that for me?&amp;rdquo;&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;My answer two years ago and still today is:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;NONE!&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Yesterday I wrote &lt;a href="https://akitaonrails.github.io/en/2026/01/28/vibe-code-built-a-little-app-fully-with-glm-4-7-tv-clipboard/"&gt;a LONG article&lt;/a&gt; where I DEMONSTRATE what it&amp;rsquo;s like to build a tiny app from scratch using only Vibe Coding. Something with a smaller scope than a &amp;ldquo;to-do list&amp;rdquo; exercise.&lt;/p&gt;</description><content:encoded><![CDATA[<p>This is the question that just won&rsquo;t go away:</p>
<blockquote>
  <p><em>&ldquo;As a total zero in programming, I want to build a perfect, secure, quality app using only prompts. Which LLM will do that for me?&rdquo;</em></p>

</blockquote>
<p>My answer two years ago and still today is:</p>
<blockquote>
  <p><strong>NONE!</strong></p>

</blockquote>
<p>Yesterday I wrote <a href="/en/2026/01/28/vibe-code-built-a-little-app-fully-with-glm-4-7-tv-clipboard/">a LONG article</a> where I DEMONSTRATE what it&rsquo;s like to build a tiny app from scratch using only Vibe Coding. Something with a smaller scope than a &ldquo;to-do list&rdquo; exercise.</p>
<p>Getting it to &ldquo;just work&rdquo; was easy: in 1 hour I had the app. That &ldquo;total zero&rdquo; above would stop right there.</p>
<p>But to get quality code, secure, optimized, tested, etc, it cost me the next 12 hours and more than 250 prompts and manual interventions.</p>
<p>When I posted it, some people still came at me with <em>&ldquo;Ah, but you used GLM, if you had used Claude Code or Codex it would have been a thousand times better.&rdquo;</em></p>
<p>Ok, I took the bait.</p>
<p>Today I spent another solid 2 hours on top of GLM&rsquo;s final code and did the following:</p>
<ul>
<li>
<p>I told Claude Code to run an EXTENSIVE analysis for security, optimization, tests, etc and it produced <a href="https://github.com/akitaonrails/tvclipboard/commit/d3e2b1b8dcbe1f0451995623df17b38ad236dbb7"target="_blank" rel="noopener">this commit</a></p>
</li>
<li>
<p>On top of what Opus fixed, I asked GPT 5.2 Codex to do the same EXTENSIVE analysis. And it produced <a href="https://github.com/akitaonrails/tvclipboard/commit/78dcfb11e58d026e2bc34476d849561724879fa8"target="_blank" rel="noopener">this commit</a></p>
</li>
<li>
<p>Not satisfied, and seeing that Kimi 2.5 had just come out, I asked it to do the same EXTENSIVE analysis. And it produced <a href="https://github.com/akitaonrails/tvclipboard/commit/0b522ab0976ab64b8855ed668c0dc2df062776b8"target="_blank" rel="noopener">this commit</a></p>
</li>
<li>
<p>Just to be really sure, I then asked Gemini 3.0 Preview to do the same EXTENSIVE analysis. And it produced <a href="https://github.com/akitaonrails/tvclipboard/commit/3dc2b26291b7b7d2518762994c71ecdb096449b4"target="_blank" rel="noopener">this commit</a></p>
</li>
<li>
<p>I couldn&rsquo;t resist and wanted to wrap things up by asking MiniMax v2.1 to join in and do the same EXTENSIVE analysis too. And it produced <a href="https://github.com/akitaonrails/tvclipboard/commit/9c4c320a0c946b6a6fb1c8b69270afae96b8bd43"target="_blank" rel="noopener">this commit</a></p>
</li>
</ul>
<p><strong>Are you getting it??? <em>LET THAT FUC&ndash;NG SINK IN!!</em></strong></p>
<p>NOT ONE of them is capable of building a <strong>tiny app smaller than a to-do list</strong> in <strong>perfect form</strong>. That doesn&rsquo;t exist.</p>
<blockquote>
  <p>The only thing that exists is your requirements. The quality of the app is directly proportional to your seniority!</p>

</blockquote>
<p>The more junior you are, the worse your app will be, with or without AI. The more senior you are, the better your app will be, with or without AI. AI is directly correlated with your knowledge AND your experience.</p>
<p>AIs are not geniuses with a will of their own. They won&rsquo;t guess what you WANT, nor what you REALLY NEED.</p>
<blockquote>
  <p>What you WANT and what you NEED tend to be VERY different things.</p>

</blockquote>
<p>There&rsquo;s no point putting NASA-level security on a system that doesn&rsquo;t need it. There&rsquo;s no point leaving an open admin password and shipping to production. You need to know what the right boundaries are for your particular use case.</p>
<p>AIs are probability machines, glorified text spitters, with zero intelligence. They will never reach 100% because that&rsquo;s impossible.</p>
<p>And I mean &ldquo;mathematically&rdquo; impossible. That&rsquo;s because the amount of effort/resources needed to push each percentage point up that scale grows exponentially. As you&rsquo;d say in Calculus, &ldquo;in the limit&rdquo;, 100% goes to infinity.</p>
<p>So it will always necessarily be &ldquo;below 100%&rdquo;.</p>
<p>99% of the effort goes into building a minimal to-do list app. The rest of the road: scalability, optimization, security, maintainability, etc depends solely on the person asking: <strong>you</strong>.</p>
<h2>Russian Doll of LLMs<span class="hx:absolute hx:-mt-20" id="russian-doll-of-llms"></span>
    <a href="#russian-doll-of-llms" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>This whole game of Opus ➡️ GPT 5.2 ➡️ Kimi 2.5 ➡️ Gemini 3 Pro ➡️ MiniMax V2.1 was actually cheap, totaling about <strong>USD 3</strong>. But again, it&rsquo;s an app smaller than a to-do list. It &ldquo;should have&rdquo; been trivial for any one of them, don&rsquo;t you think?</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20260129112823_screenshot-2026-01-29_11-24-43.png" alt="openrouter billing"  loading="lazy" /></p>
<p>Let&rsquo;s quickly take a look at what the LLMs did after my tiny app was already done.</p>
<p>For anyone who didn&rsquo;t see the <a href="/en/2026/01/28/vibe-code-built-a-little-app-fully-with-glm-4-7-tv-clipboard/">previous article</a>, the final code is <a href="https://github.com/akitaonrails/tvclipboard"target="_blank" rel="noopener">on this GitHub</a>.</p>
<h3>1. Opus 4.5<span class="hx:absolute hx:-mt-20" id="1-opus-45"></span>
    <a href="#1-opus-45" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p><a href="https://github.com/akitaonrails/tvclipboard/commit/d3e2b1b8dcbe1f0451995623df17b38ad236dbb7"target="_blank" rel="noopener"><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20260129105749_screenshot-2026-01-29_10-57-42.png" alt="opus commit"  loading="lazy" /></a></p>
<p>I&rsquo;ve said this before and I&rsquo;ll say it again, when in doubt, <strong>use Claude Code</strong>. It has the best balance between speed and quality. If you use the Max plan, the cost-benefit is good too. I agree it&rsquo;s superior to GLM 4.7, that was never in question.</p>
<p>At the same time, <strong>it&rsquo;s not magic</strong> and far from infallible. The whole process I described in the previous article will be exactly the same, it will just take a lot less time (costing less of my patience).</p>
<p>GLM really struggled with the CORS check and, as Opus shows, there were still bugs left in that check.</p>
<p>Opus adjusted a few things that are fine, but unnecessary. Improving the encryption of the communication wasn&rsquo;t needed, because it was just obfuscation. Real encryption will always be TLS. But that&rsquo;s ok, good to keep it tuned.</p>
<p>But what GLM let slip and Opus also missed is that in the first version the token was encrypted because it went in the URL, but after we moved to server-side it wasn&rsquo;t needed anymore. Yet old code was still hanging around, and Opus, instead of removing it, tried to improve that old code!!</p>
<p>It should have been obvious it wasn&rsquo;t needed: it&rsquo;s server-side, the new token is just a random ID. There&rsquo;s no need to keep an encrypted timestamp on the server.</p>
<p>It also wanted to fix the possibility of overflow when loading the I18n file. But that&rsquo;s something I control, not the user. There&rsquo;s no way to have an overflow. It&rsquo;s overengineering, but fine too.</p>
<blockquote>
  <p>Opus tends to be overly proactive on aspects that usually aren&rsquo;t needed, giving the impression it&rsquo;s smarter than it actually is.</p>

</blockquote>
<p>You know that annoying intern who reads a lot of blog posts and in every meeting blurts out random things to show off? But in practice makes no difference? Yeah, that&rsquo;s it.</p>
<h3>2. GPT 5.2 Codex<span class="hx:absolute hx:-mt-20" id="2-gpt-52-codex"></span>
    <a href="#2-gpt-52-codex" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p><a href="https://github.com/akitaonrails/tvclipboard/commit/78dcfb11e58d026e2bc34476d849561724879fa8"target="_blank" rel="noopener"><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20260129110815_screenshot-2026-01-29_10-22-30.png" alt="codex commit"  loading="lazy" /></a></p>
<p>Despite being Open<del>Lie</del>AI, I find GPT 5.2 Codex more &ldquo;honest&rdquo; than Opus. It doesn&rsquo;t try to be <em>smartpants</em> all the time, showing off where it doesn&rsquo;t need to. I feel it goes more directly to the point and follows exactly what I asked more closely, instead of going off on tangents I didn&rsquo;t ask for - and that end up being unnecessary.</p>
<p>Just this time, who knows why, it made the worst commit message of all (super short, no explanation). But it caught something pretty dumb that GLM and Opus didn&rsquo;t pay attention to: a silly little timeout configuration:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20260129113126_screenshot-2026-01-29_11-31-18.png" alt="timeout http"  loading="lazy" />
For my app, it&rsquo;s not needed. But at this stage where I really asked for an EXTENSIVE analysis, it surprises me that verbose Opus let this slip.</p>
<p>And GPT ran into the problem I mentioned above with the encrypted token: I had to interrupt it when it started trying to reimplement the encryption because it saw leftovers from the past. In this case I explicitly told it to remove those leftovers. But neither GLM, nor Opus, nor GPT picked up that this was no longer needed and kept trying to mess with that old leftover.</p>
<h3>3. Kimi 2.5<span class="hx:absolute hx:-mt-20" id="3-kimi-25"></span>
    <a href="#3-kimi-25" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p><a href="https://github.com/akitaonrails/tvclipboard/commit/0b522ab0976ab64b8855ed668c0dc2df062776b8"target="_blank" rel="noopener"><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20260129113458_screenshot-2026-01-29_11-34-50.png" alt="kimi commit"  loading="lazy" /></a></p>
<p>Every week a new version shows up that everyone starts hyping. 3 weeks ago it was MiniMax v2.1, 2 weeks ago it was GLM 4.7, now it&rsquo;s Kimi 2.5, so I decided to test it via OpenRouter. And the TL;DR is that I liked it.</p>
<p>I found it slower than Opus or GPT, but better than GLM. And it really did keep finding items I don&rsquo;t know how Opus let slip.</p>
<p>Again, it&rsquo;s a silly and unnecessary thing for my tiny app, but no one so far bothered to configure security headers:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20260129113609_screenshot-2026-01-29_11-36-02.png" alt="security headers"  loading="lazy" /></p>
<blockquote>
  <p>In a real web app, you have to include it!</p>

</blockquote>
<p>Another unnecessary thing, but that you do need in a real app: the <strong>WebSocket Ping/Pong</strong> pattern so it doesn&rsquo;t disconnect if it goes idle:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20260129113738_screenshot-2026-01-29_11-37-29.png" alt="ping pong"  loading="lazy" /></p>
<p>The interesting thing is that Opus didn&rsquo;t suggest this. See? It&rsquo;s verbose, keeps suggesting a bunch of small unnecessary things to look smart, but the really interesting things, it forgets.</p>
<p>But then Kimi also suggested bringing back a feature I had told GLM to remove: a cleanup routine for the in-memory token storage:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20260129120302_screenshot-2026-01-29_11-39-05.png" alt="cleanup"  loading="lazy" /></p>
<p>As I already explained: this is server-side. When space runs out, the new token automatically takes the place of the oldest one and that&rsquo;s it. But Kimi was probably worried about leaving expired tokens lying around in memory. Thing is, it&rsquo;s overengineering: it&rsquo;s a tiny storage, kilobytes maybe. There&rsquo;s no need to keep a routine running in the background cleaning up an in-memory list.</p>
<p>Even in a production app, I wouldn&rsquo;t include this!</p>
<p>There was <a href="https://x.com/BasedTorba/status/2016203491371315395"target="_blank" rel="noopener">a tweet</a> jokingly suggesting that Kimi was a pirated version of Claude. Even though it&rsquo;s a joke, this behavior of keeping implementing unnecessary things like an intern who reads blog posts and wants to show off really is pretty similar to Claude. 😂</p>
<h3>4. Gemini 3 Pro Preview<span class="hx:absolute hx:-mt-20" id="4-gemini-3-pro-preview"></span>
    <a href="#4-gemini-3-pro-preview" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p><a href="https://github.com/akitaonrails/tvclipboard/commit/3dc2b26291b7b7d2518762994c71ecdb096449b4"target="_blank" rel="noopener"><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20260129114149_screenshot-2026-01-29_11-41-43.png" alt="gemini commit"  loading="lazy" /></a></p>
<p>Unlike Opus, similar to GPT, I tend to think of Gemini as a bit more &ldquo;honest&rdquo; and straight to the point as well. Sometimes I still catch it rambling and suggesting unnecessary tangents, but overall I feel it sticks to my prompt a bit more closely.</p>
<p>In its case, besides the analysis, I explicitly asked it to reevaluate the need for the cleanup routine that Kimi had added.</p>
<blockquote>
  <p>And Gemini agreed with Kimi and wanted to keep it, and even optimized the routine!! 🤦‍♂️</p>

</blockquote>
<p>But at least it still found a <strong>deadlock</strong> during server shutdown that no one before had thought of. It&rsquo;s nothing serious, and it&rsquo;s only during shutdown, but even so, I asked all the previous ones for an EXTENSIVE analysis, they suggested unnecessary things, and skipped this. In a production app it&rsquo;s important to have this care. It&rsquo;s the kind of thing that can block container restarts in production or cause pointless delays/timeouts.</p>
<h3>5. MiniMax v2.1<span class="hx:absolute hx:-mt-20" id="5-minimax-v21"></span>
    <a href="#5-minimax-v21" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p><a href="https://github.com/akitaonrails/tvclipboard/commit/9c4c320a0c946b6a6fb1c8b69270afae96b8bd43"target="_blank" rel="noopener"><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20260129114623_screenshot-2026-01-29_11-46-16.png" alt="minimax commit"  loading="lazy" /></a></p>
<p>By this point I really hoped there wouldn&rsquo;t be much left to touch, but MiniMax still found more silly little things missing.</p>
<p>Kimi added the missing security headers on the HTTP server but forgot to do the same for WebSockets:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20260129120501_screenshot-2026-01-29_11-47-44.png" alt="security headers extra"  loading="lazy" /></p>
<p>And at this point, 5 LLMs later, there are still some Go best-practice silly things that everyone let slip. This nonsense, for example:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20260129114834_screenshot-2026-01-29_11-48-27.png" alt="for loop go"  loading="lazy" /></p>
<p>Again: it&rsquo;s nothing. But everyone checked, analyzed, ran linters and everything else and only MiniMax fixed that leftover. <strong>THERE ARE ALWAYS LEFTOVERS</strong></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 could keep going, I could keep running more LLMs on top of this. But remember: all of this was just on top of a miserably tiny app, smaller than a to-do list. Imagine on a large, complex app, with real users, in production?</p>
<blockquote>
  <p>Don&rsquo;t underestimate programming, it&rsquo;s much more complex than you beginners think.</p>

</blockquote>
<p>I just burned through the equivalent of tens of billions of dollars, I must have burned enough energy to power a whole house in the datacenters of Z.AI, Anthropic, OpenAI, Google, etc and still, <strong>there are leftovers</strong>.</p>
<p>And even with all that, I have zero confidence in putting this tiny app out in public. For my private personal use it&rsquo;s overengineered, but for production I would still need to spend hours on manual review and manual testing, extensive QA, security scans and all the rest to make sure nothing important is left over.</p>
<p>LLMs are EXCELLENT, they help me a lot. But they are <strong>highly motivated interns</strong>, and not geniuses that do everything on their own. Far from it.</p>
]]></content:encoded><category>llm-benchmarks</category><category>llms</category><category>vibe-coding</category></item><item><title>Vibe Code: I Built a Little App 100% with GLM 4.7 (TV Clipboard)</title><link>https://akitaonrails.github.io/en/2026/01/28/vibe-code-built-a-little-app-fully-with-glm-4-7-tv-clipboard/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2026/01/28/vibe-code-built-a-little-app-fully-with-glm-4-7-tv-clipboard/</guid><pubDate>Wed, 28 Jan 2026 08:41:52 -0300</pubDate><description>&lt;p&gt;Yesterday I posted the following on X:&lt;/p&gt;
&lt;p&gt;&lt;a href="https://x.com/AkitaOnRails/status/2016175111414452636"target="_blank" rel="noopener"&gt;&lt;img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20260128084349_screenshot-2026-01-28_08-43-38.png" alt="tweet" loading="lazy" /&gt;&lt;/a&gt;
This is a concept I&amp;rsquo;ve talked about multiple times in videos on my channel and in talks. Everything in programming happens more or less at the Pareto ratio: 80/20:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;80% of the code is &amp;ldquo;easy&amp;rdquo; and usually gets done in 20% of the time. The most important 20% will eat up most of the remaining 80% of total time.&lt;/li&gt;
&lt;li&gt;For bugs and issues like performance or security, if you fix the top 20% you solve 80% of the general problems.&lt;/li&gt;
&lt;li&gt;20% of the scope is truly important, which is why you can always simplify, replace, change, or even cut 80% of it - and that&amp;rsquo;s how you squeeze a project into budget/deadline.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Everything is based on Top X. &lt;a href="https://akitaonrails.com/2018/09/18/akitando-11-projetos-aprendendo-a-priorizar/"target="_blank" rel="noopener"&gt;Learning to prioritize&lt;/a&gt; is learning to identify the 20%. The difference between someone senior/experienced and someone amateur/beginner is that beginners think 100% is important and can&amp;rsquo;t pick the 20%. What experience brings is the ability to isolate that 20% faster and faster.&lt;/p&gt;</description><content:encoded><![CDATA[<p>Yesterday I posted the following on X:</p>
<p><a href="https://x.com/AkitaOnRails/status/2016175111414452636"target="_blank" rel="noopener"><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20260128084349_screenshot-2026-01-28_08-43-38.png" alt="tweet"  loading="lazy" /></a>
This is a concept I&rsquo;ve talked about multiple times in videos on my channel and in talks. Everything in programming happens more or less at the Pareto ratio: 80/20:</p>
<ul>
<li>80% of the code is &ldquo;easy&rdquo; and usually gets done in 20% of the time. The most important 20% will eat up most of the remaining 80% of total time.</li>
<li>For bugs and issues like performance or security, if you fix the top 20% you solve 80% of the general problems.</li>
<li>20% of the scope is truly important, which is why you can always simplify, replace, change, or even cut 80% of it - and that&rsquo;s how you squeeze a project into budget/deadline.</li>
</ul>
<p>Everything is based on Top X. <a href="https://akitaonrails.com/2018/09/18/akitando-11-projetos-aprendendo-a-priorizar/"target="_blank" rel="noopener">Learning to prioritize</a> is learning to identify the 20%. The difference between someone senior/experienced and someone amateur/beginner is that beginners think 100% is important and can&rsquo;t pick the 20%. What experience brings is the ability to isolate that 20% faster and faster.</p>
<p><strong>HOWEVER</strong>, it&rsquo;s very easy to just say this. Everyone has heard it at least once, but most likely still doesn&rsquo;t understand what it means. That&rsquo;s why I decided to write this (LONG) article: I&rsquo;ll show you <strong>in practice</strong> what the easy 80% and the hard 20% are, and how that actually affects code quality, time spent and cost.</p>
<p>This article is for beginners mainly, but also for you: the senior who has juniors to mentor, or the teachers with students. Use it as discussion and study material with your apprentices. This is the kind of thing that should be taught.</p>
<p>Since this is going to be long, remember that on desktop, on the right side there&rsquo;s a menu with the sections so you can jump straight to each topic and study it piece by piece.</p>
<h2>The App: TV Clipboard<span class="hx:absolute hx:-mt-20" id="the-app-tv-clipboard"></span>
    <a href="#the-app-tv-clipboard" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>I picked a little app I had been wanting for a while for my personal use. It&rsquo;s simpler than a to-do list, it doesn&rsquo;t need a database or any other external service. In practice it&rsquo;s a peer-to-peer (p2p) communicator over websockets.</p>
<p>The use case is this: I have a gamer mini-PC that I hook up to the living room TV. I usually leave a mouse by the armchair to navigate on it (it&rsquo;s a damned Windows 11). But occasionally I need to type something (passwords, fill out some form). And I don&rsquo;t want to leave my important accounts logged into that PC (I don&rsquo;t trust gamer PCs).</p>
<p>Either I have to grab my bluetooth keyboard, or use the Windows on-screen keyboard. Both options are inconvenient in a living room. BUT, what if I could open a website (that&rsquo;s already in my bookmarks), a QR Code shows up, I scan it with my smartphone and from there I can type or paste whatever is in my mobile clipboard and transfer it to the mini-PC clipboard instantly? That&rsquo;s the app:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20260128090208_20260127_192137.jpg" alt="tvclipboard host screen"  loading="lazy" /></p>
<p>Then I open the camera on my phone, scan that QR Code and it opens this page:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20260128090700_Screenshot_20260128_090600_Brave.jpg" alt="tvclipboard client screen"  loading="lazy" /></p>
<p>Done. Now I can type anything or paste the password from my password manager (by clicking &ldquo;paste&rdquo;) and when I send it, on the TV I&rsquo;ll see this:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20260128090907_20260127_195954.jpg" alt="tvclipboard host received screen"  loading="lazy" /></p>
<p>It shows that it was received, and it goes straight to the TV clipboard automatically. It shows it &ldquo;blurred&rdquo; because if it&rsquo;s a password I don&rsquo;t want it legible in large text on the TV - and yes, the mouse cursor is gigantic because it&rsquo;s easier for me to see on the TV.</p>
<p>This is useful not just for the TV, but for any situation where I want to send text from my phone to one of my PCs. A lot of people end up using WhatsApp or other messengers for this. I, for instance, have a &ldquo;Just me&rdquo; group that only has me in it, where I keep sending links and silly stuff from my phone. Then I always keep WhatsApp Web open in a tab on my PC and I can grab it from there. WhatsApp for me is a remote clipboard.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20260128091222_screenshot-2026-01-28_09-12-13.png" alt="whatsapp just me"  loading="lazy" /></p>
<p>But that&rsquo;s not safe for sending things like passwords, crypto addresses, or any other sensitive message. WhatsApp is a company with zero transparency. The advertising &ldquo;claims&rdquo; they have end-to-end encryption, but I don&rsquo;t believe it. It&rsquo;s equally likely they open everything in transit and keep it logged - it&rsquo;s data worth money, even more in this era of LLM training.</p>
<blockquote>
  <p>Recommendation: NEVER COMMUNICATE ANYTHING IMPORTANT VIA WHATSAPP.</p>

</blockquote>
<p>For file transfers I already use <a href="https://localsend.org/"target="_blank" rel="noopener"><strong>LocalSend</strong></a>:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20260128091432_screenshot-2026-01-28_09-00-27.png" alt="localsend"  loading="lazy" /></p>
<p>That&rsquo;s where the inspiration came from: I wanted a LocalSend for Clipboard. I&rsquo;m sure there are already dozens of apps that do exactly this. But I wanted to take the opportunity to do this exercise.</p>
<h2>The Plan: 100% Vibe Code<span class="hx:absolute hx:-mt-20" id="the-plan-100-vibe-code"></span>
    <a href="#the-plan-100-vibe-code" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Honestly, I know enough about front-end, I started my career as a &ldquo;webmaster&rdquo; in the 90s. But I hate front-end. It&rsquo;s slow, it&rsquo;s tedious, yuck. In particular, I hate dealing with CSS. Even more particularly, I take no pleasure in writing Javascript. So I&rsquo;m always lazy about starting a web app from scratch. But with Vibe Coding, this is one of the parts I can finally get rid of, especially if I don&rsquo;t care about the exact look as long as it&rsquo;s not crooked.</p>
<p>While we&rsquo;re at it, I&rsquo;m going to use a language I know the basics of but don&rsquo;t have enough experience in, much less fluency (meaning, on my own I&rsquo;d be slow): <strong>GoLang</strong>. Why Go:</p>
<ul>
<li>it compiles to a native binary: I don&rsquo;t even need the performance, but having a compiler helps the LLM get feedback on obvious errors.</li>
<li>no dependencies: later I&rsquo;ll want to deploy it to my home server, and it&rsquo;ll be infinitely easier to set up Docker with a dependency-free binary compared to something like Ruby or Python where you have to install a ton of stuff.</li>
<li>full standard tooling: besides the compiler it already has a formatter, linter, tests and everything else that also gives good feedback to the LLM</li>
<li>simple, short and unambiguous syntax. I like how Go is Python-like. I don&rsquo;t consider either of them &ldquo;pretty&rdquo; (aesthetically, I much prefer Swift, Kotlin or Ruby). But unlike Javascript and even Rust, it&rsquo;s much less verbose and much less ambiguous.</li>
<li>rich standard library. Unlike Javascript, you don&rsquo;t have to keep installing 500 external libs for basic things.</li>
<li>since I&rsquo;m going to use websockets, GoLang&rsquo;s channels will make my life easier. Its async mechanism is simpler, more robust and better than Node&rsquo;s reactor.</li>
<li>most importantly: <strong>because I want to</strong>. It&rsquo;s a personal project, that only I&rsquo;ll use, I pick whatever I feel like and that&rsquo;s that.</li>
</ul>
<p>By the way, since I&rsquo;m not fluent in Go, I&rsquo;ll probably let some bad practice or obvious anti-pattern slip through. If that happens, you Go senior reading this, don&rsquo;t hold back, comment below and explain what you think.</p>
<blockquote>
  <p>If you want to see the end result now, all the code is <a href="https://github.com/akitaonrails/tvclipboard"target="_blank" rel="noopener">on this GitHub</a>. I&rsquo;ll be linking snippets from it all the time.</p>

</blockquote>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20260128093018_screenshot-2026-01-27_19-27-29.png" alt="Crush"  loading="lazy" /></p>
<p>Another goal is to test how good the famous <a href="https://z.ai/blog/glm-4.7"target="_blank" rel="noopener"><strong>GLM 4.7</strong></a> really is. I&rsquo;ve written posts and tweets about it before. I&rsquo;ve tried running the Flash version on my local PC (it&rsquo;s good, but VERY slow). Overall, my impression is that it has similar quality to Claude Opus, GPT Codex, Gemini, but it&rsquo;s WAY slower (at least 2x slower). Meaning if I had used Gemini or Claude, I&rsquo;d get the same result but probably in half the time, though at a higher token cost (GLM is &ldquo;cheaper&rdquo;).</p>
<blockquote>
  <p>Let&rsquo;s get to the <strong>TL;DR</strong>: it managed to implement the app the way I wanted, but it was SLOW. I spent an entire day, having lunch and dinner in front of the PC while Crush kept thinking, a total of more than <strong>10 HOURS</strong>. And, according to OpenRouter, I spent just over <strong>USD 40</strong> (over 200 reais). It was neither fast nor cheap, considering the app&rsquo;s scope. Reminder: it&rsquo;s simpler than a To-Do List.</p>

</blockquote>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20260128160317_screenshot-2026-01-28_16-02-19.png" alt="openrouter billing"  loading="lazy" /></p>
<p>In terms of size, even a simple app like this one resulted in <strong>more than 3,000 lines of code (LOC)</strong> of GoLang:</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="o">===</span> Code <span class="nv">Statistics</span> <span class="o">===</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Total Go code: <span class="m">3493</span> lines
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Production code: <span class="m">1431</span> lines
</span></span><span class="line"><span class="cl">Test code: <span class="m">2062</span> lines
</span></span><span class="line"><span class="cl">Test ratio: 1.44:1
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Top <span class="m">10</span> largest files:
</span></span><span class="line"><span class="cl">  <span class="m">3493</span>  total
</span></span><span class="line"><span class="cl">  <span class="m">602</span>  ./pkg/server/server_test.go
</span></span><span class="line"><span class="cl">  <span class="m">560</span>  ./pkg/hub/hub_test.go
</span></span><span class="line"><span class="cl">  <span class="m">385</span>  ./pkg/token/token_test.go
</span></span><span class="line"><span class="cl">  <span class="m">304</span>  ./pkg/hub/hub.go
</span></span><span class="line"><span class="cl">  <span class="m">283</span>  ./pkg/server/server.go
</span></span><span class="line"><span class="cl">  <span class="m">263</span>  ./i18n/i18n.go
</span></span><span class="line"><span class="cl">  <span class="m">262</span>  ./pkg/config/config_test.go
</span></span><span class="line"><span class="cl">  <span class="m">253</span>  ./pkg/QR Code/QR Code_test.go
</span></span><span class="line"><span class="cl">  <span class="m">243</span>  ./pkg/config/config.go</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 for Javascript it came out to <strong>more than 1,000 LOC</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="o">===</span> JavaScript Code <span class="nv">Statistics</span> <span class="o">===</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Total JavaScript code: <span class="m">1360</span> lines
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Production code: <span class="m">787</span> lines
</span></span><span class="line"><span class="cl">Test code: <span class="m">573</span> lines
</span></span><span class="line"><span class="cl">Test ratio: 0.73:1
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">All JavaScript files:
</span></span><span class="line"><span class="cl">  <span class="m">1360</span>  total
</span></span><span class="line"><span class="cl">  <span class="m">573</span>  static/js/test.js
</span></span><span class="line"><span class="cl">  <span class="m">313</span>  static/js/client.js
</span></span><span class="line"><span class="cl">  <span class="m">249</span>  static/js/host.js
</span></span><span class="line"><span class="cl">  <span class="m">114</span>  static/js/common.js
</span></span><span class="line"><span class="cl">  <span class="m">111</span>  static/js/i18n.js</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>Oh right, out of curiosity, I added <code>loc.sh</code> and <code>loc-js.sh</code> scripts to do this counting.</p>
<h2>Phase 1: Making It &ldquo;Work&rdquo; - what does that mean?<span class="hx:absolute hx:-mt-20" id="phase-1-making-it-work---what-does-that-mean"></span>
    <a href="#phase-1-making-it-work---what-does-that-mean" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Contrary to what many amateurs might think, it&rsquo;s not enough to type a description of the application (like I did in the previous section) as a prompt, throw it at the LLM and it will automatically come out with a working app: <strong>IT WON&rsquo;T</strong>.</p>
<p>This is what I was saying 2 years ago and it&rsquo;s still true: it doesn&rsquo;t matter how much more LLMs evolve. They might get a bit faster, a bit more efficient, but the architecture is still the same since 2022: transformers are glorified text generators, with a random sampling factor that always embeds entropy into the process (it will never be exact, deterministic). There will ALWAYS be a need for human intervention (and a lot of it).</p>
<p>Now I&rsquo;ll demonstrate with a real example what that means.</p>
<p>In the end, I stopped this project after <strong>43 commits</strong>. I followed the process I always say is the best: small prompts, one feature at a time, verify that it works and is correct, have it git commit, then move on to the next feature or requirement.</p>
<blockquote>
  <p>On a back-of-the-envelope count, each commit took about half a dozen prompts on average. Let&rsquo;s say 43 commits x 6 prompts = nearly <strong>258 prompts</strong> over more than 12 hours. Some prompts were a simple sentence that got solved in 2 seconds. Most took minutes and multiple iterations from the agent (Crush).</p>

</blockquote>
<p>GLM 4.7&rsquo;s context window is average: around 200k tokens. Crush has a mechanism where, when usage gets well past 90%, it asks to summarize the context so far so it can reset and reload from that summary to keep going. But it still has bugs and sometimes crashes and loses context:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20260128094713_screenshot-2026-01-27_21-45-30.png" alt="crush bug"  loading="lazy" /></p>
<p>Then I have to recover the last prompt, sometimes type part of the interrupted context myself, so it can pick up where it stopped. It&rsquo;s tedious and you have to keep paying attention. This is a problem that Gemini suffers from less because its context window is MUCH larger (but attention in the middle degrades as it gets bigger, it&rsquo;s a trade-off).</p>
<blockquote>
  <p><em>&ldquo;Ah, Akita must be dumb. I built a Todo app and it didn&rsquo;t even get close to 250 prompts. He did it wrong.&rdquo;</em></p>

</blockquote>
<p>Now let&rsquo;s get to the part that separates the boys from the men. Indeed, to <em>&ldquo;make it work&rdquo;</em> you don&rsquo;t need all those prompts. Of the 43 commits that took 10+ hours, I already had an app that <em>&ldquo;worked&rdquo;</em> after 10 small commits, which took about 1 hour.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20260128165251_screenshot-2026-01-28_16-52-34.png" alt="tvclipboard v0.1"  loading="lazy" /></p>
<p>If you want to see the &ldquo;v0.1&rdquo; version, just do 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-bash" data-lang="bash"><span class="line"><span class="cl">git clone https://github.com/akitaonrails/tvclipboard.git
</span></span><span class="line"><span class="cl"><span class="nb">cd</span> tvclipboard
</span></span><span class="line"><span class="cl">git fetch --tags
</span></span><span class="line"><span class="cl">git checkout v0.1</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 version has <strong>1,827 LOC</strong> of Go and <strong>255 LOC</strong> of Javascript. But the Go backend itself is only <strong>549 LOC</strong> vs <strong>1,431 LOC</strong> at the end. And that&rsquo;s because in this minimal version I had already added backend unit tests (1,278 LOC), which most beginners never bother with. So you could &ldquo;solve&rdquo; my problem in just over 800 LOC, less than 1 hour and half a dozen prompts.</p>
<p>The project structure looked like this. Save this image to compare with how it ends up at the end:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20260128100103_screenshot-2026-01-28_09-53-39.png" alt="v0.1 tree"  loading="lazy" /></p>
<p>In this version it already opens what I call the &ldquo;host&rdquo; page, which is the page that would open on my TV, showing the QR Code to connect. It already has the &ldquo;client&rdquo; page, which is what the QR Code opens on my phone. And it can already paste text on the client, send it to the host and copy it to the host&rsquo;s clipboard. Pretty simple.</p>
<blockquote>
  <p>This is where 99% of people would stop and call it done.</p>

</blockquote>
<h2>Phase 2: Going beyond &ldquo;just working&rdquo;<span class="hx:absolute hx:-mt-20" id="phase-2-going-beyond-just-working"></span>
    <a href="#phase-2-going-beyond-just-working" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Let me repeat: this project is simpler than a todo list and was built exclusively for me alone to use, at home, on my local network. Anyone thinking <em>&ldquo;ah, all of this is unnecessary for this app&rdquo;</em> - <strong>I KNOW!</strong>.</p>
<p>The idea is to demonstrate the minimum you have to think about for a <strong>real project</strong>. If you&rsquo;re building a toy just for yourself alone, quality doesn&rsquo;t matter.</p>
<p>For Go seniors, or Javascript seniors (because I&rsquo;ll use it on the front), I know you&rsquo;ll think <em>&ldquo;ah, he should have used framework X or library Y&rdquo;</em> - <strong>I KNOW!!</strong> 😅 At the end there will be a section where I&rsquo;ll discuss some of those other options, wait before commenting!!</p>
<p>Back to the Pareto concept. I said 20% of the most important work costs 80% of the time, but that&rsquo;s just an order of magnitude. In my example, the whole project took more than 10 hours and &ldquo;to make it work&rdquo; took only 1 hour. So it&rsquo;s closer to 90/10 than 80/20.</p>
<blockquote>
  <p>What the hell cost 90% of the time if 10% &ldquo;already worked&rdquo;??</p>

</blockquote>
<h3>1. Token Management<span class="hx:absolute hx:-mt-20" id="1-token-management"></span>
    <a href="#1-token-management" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>The first thing I think about when I see a QR Code is that the link inside it should be disposable and volatile. It&rsquo;s not a rule and obviously depends on the app. For my little app it&rsquo;s definitely &ldquo;overkill&rdquo; but I&rsquo;ll do it as an example. (I won&rsquo;t keep repeating <em>&ldquo;yes, for my app it wasn&rsquo;t needed&rdquo;</em>, this is the last time).</p>
<p>I don&rsquo;t want a visitor who uses my app at home to be able to bookmark the link and use it from outside (I&rsquo;ll expose my service on the internet to be able to use it from anywhere later). I want each use to be unique and to expire after a few minutes, requiring a new QR Code.</p>
<blockquote>
  <p>So I need some kind of token system that expires.</p>

</blockquote>
<p>This article is already going to be very long, so I won&rsquo;t keep copying the full code of every feature. I&rsquo;ll always point to the <a href="https://github.com/akitaonrails/tvclipboard/commit/05dd60290e1d2419855e2f79ed48326415641da8"target="_blank" rel="noopener">commit</a> where I implemented it so you can look it up later, and only show snippets for illustration.</p>
<p>Anyway, the goal will be to have a URL like <code>http://clip.example.com/?token=blablabla</code>. Nothing out of this world.</p>
<p>Yes, it could be a JWT, but my use is very simple so I had it built from scratch, and GLM 4.7 came up with a JSON structure with a random private key and a timestamp, which it decided to encrypt with AES (it really wasn&rsquo;t necessary) and encode in hexadecimal to be the token (it ends up hyper long, more than 100 characters).</p>
<p>It turned out to be a little monster like this:</p>
<p><a href="https://github.com/akitaonrails/tvclipboard/commit/05dd60290e1d2419855e2f79ed48326415641da8"target="_blank" rel="noopener"><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20260128101314_screenshot-2026-01-28_10-13-04.png" alt="encrypt token"  loading="lazy" /></a></p>
<p>I didn&rsquo;t like it but I decided to leave it because it&rsquo;s working. I can mess with it later - in fact, a lot of things were done &ldquo;out of order&rdquo; because I just did them as I remembered them. I didn&rsquo;t have a step-by-step plan written beforehand. I literally pulled it out of my head in real time.</p>
<blockquote>
  <p>The goal of this is to remember that Session Management is a concern every dev must have in mind in a real system.</p>

</blockquote>
<h3>2. First Refactor<span class="hx:absolute hx:-mt-20" id="2-first-refactor"></span>
    <a href="#2-first-refactor" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>The main code was already over 500 LOC, the tests over 1000 LOC. That&rsquo;s too big to stay in a single file. So I asked GLM to refactor it into packages. This kind of thing I know Claude Code handles more easily, but GLM did well too. In the end it looked something like this:</p>
<p><a href="https://github.com/akitaonrails/tvclipboard/commit/dbbee593e99fbc6d8051884b016386289d17816b"target="_blank" rel="noopener"><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20260128101636_screenshot-2026-01-28_10-16-29.png" alt="separation of concerns"  loading="lazy" /></a>
This is what we call <strong>&ldquo;Separation of Concerns&rdquo;</strong>. Each package has an isolated domain. One package to deal only with configuration options, another to handle only the HTTP server, another to handle only QR Code generation, and another to handle only token management. Each with unit tests in the same package.</p>
<p>Asking for tests from the start was a huge help to the LLM, because every so often it would run the test, get confused about why it wasn&rsquo;t working and the penny would drop that it needed to adjust the implementation. All automated feedback is important for the LLM to have context to work with.</p>
<p>And refactoring isn&rsquo;t something you do only once. It&rsquo;s a process that has to be done from time to time to consolidate and simplify the code, improving <strong>maintainability</strong>.</p>
<blockquote>
  <p>The goal here is to remember that &ldquo;just because it works&rdquo; doesn&rsquo;t mean the code is frozen and you never touch it again.</p>

</blockquote>
<h3>3. Docker Support<span class="hx:absolute hx:-mt-20" id="3-docker-support"></span>
    <a href="#3-docker-support" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Since I was going to run it on my home server, Docker support is mandatory. But regardless of that, it&rsquo;s good practice to keep a <a href="https://github.com/akitaonrails/tvclipboard/blob/master/Dockerfile"target="_blank" rel="noopener"><code>Dockerfile</code></a> that works. It&rsquo;s another kind of &ldquo;automated test&rdquo;: see if you can &ldquo;build&rdquo; an image and run it. And document it for another user so they don&rsquo;t need to install anything on their system to run it, just run 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">docker run -d <span class="se">\
</span></span></span><span class="line"><span class="cl">  --name tvclipboard <span class="se">\
</span></span></span><span class="line"><span class="cl">  -p 3333:3333 <span class="se">\
</span></span></span><span class="line"><span class="cl">  akitaonrails/tvclipboard: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>
<p>In fact, the command above works, in case you want to test it right now. I uploaded the image to my Docker Hub account.</p>
<p>And if you hadn&rsquo;t already implemented it this way, this also forces you to add configuration options as environment variables (like <code>TVCLIPBOARD_SESSION_TIMEOUT</code>) or via command-line arguments (like <code>--expires</code>), exposing what is configurable.</p>
<blockquote>
  <p>The goal here is to remember how important it is to automate builds and document how to use it for other people.</p>

</blockquote>
<h3>4. Organizing Javascript and adding Cache Busting<span class="hx:absolute hx:-mt-20" id="4-organizing-javascript-and-adding-cache-busting"></span>
    <a href="#4-organizing-javascript-and-adding-cache-busting" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>To &ldquo;just work&rdquo;, way back at the beginning, all you had to do was make an &ldquo;index.html&rdquo; and go writing <code>if host</code> or <code>if client</code> in the middle of the HTML and JS. But at least GLM started out by duplicating a <code>host.html</code> and a <code>client.html</code>. But the javascript was all inline between <code>&lt;script&gt;...&lt;/script&gt;</code> tags and duplicated in both files, so another refactoring step was to ask it to separate <code>common.js</code>, <code>client.js</code>, <code>host.js</code>.</p>
<p>This is the bare minimum of maintainability. Again: refactoring doesn&rsquo;t end in a single step.</p>
<p>But I could run into a problem. Being a very simple little system, I had no <strong>ETAGs</strong> implemented and no asset pipeline like WebPack or similar.</p>
<p>In a dynamic web framework, most will support generating CSS and JS files dynamically, changing their names to things like <code>/assets/host-20260128123456.js</code>, and every time we edit some JS, the pipeline regenerates that file with a different name.</p>
<p>When we open it in a web browser, the browser always tries to &ldquo;cache&rdquo; static assets like images, JS, CSS, so it doesn&rsquo;t keep asking for the same thing all the time. If it already downloaded <code>host.js</code>, on the next &ldquo;reload&rdquo;, if the same file is requested, it just pulls from the cache instead of asking the server again.</p>
<p>The problem with that is if you change the content of the file and keep the same name. The browser will think it&rsquo;s the same thing and you&rsquo;ll spend hours scratching your head thinking <em>&ldquo;damn it, I already fixed that, why isn&rsquo;t it showing up??&rdquo;</em> - and it might be cache.</p>
<p>To avoid this, there are <strong>Cache Busting</strong> techniques, like ETAG headers. But the most common is just to add some kind of timestamp indicator in the URL, for example <code>/static/js/host.js?v=20260128120431</code>. This way, every time my server restarts, the timestamp will change (it&rsquo;s not the best rule, but for now it works). The URL will be different and the browser will be forced to download the newest version, making sure I don&rsquo;t waste time hunting down cache issues.</p>
<p>Again, any minimally competent web framework with HTML template support will have this, but I asked to do it from scratch:</p>
<p><a href="https://github.com/akitaonrails/tvclipboard/commit/4f98f8364bbe8b4832270c5817e0ee85a4dbc547"target="_blank" rel="noopener"><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20260128103739_screenshot-2026-01-28_10-37-00.png" alt="cache busting"  loading="lazy" /></a>
Since I serve static HTML pages, my server code needs to modify the URLs before sending them to the browser, and that&rsquo;s what the commit above does.</p>
<p>While writing this post, I noticed it added cache busting for &ldquo;.js&rdquo; files but forgot about &ldquo;style.css&rdquo;, which was left without it. I didn&rsquo;t notice because I barely touched the original CSS it made. But again, to show that LLMs are never perfect and you have to keep constantly checking if it didn&rsquo;t forget anything or leave anything behind. I had it fix this <a href="https://github.com/akitaonrails/tvclipboard/commit/2338999c505f1466c4913f41c8c7cbea72b369c9"target="_blank" rel="noopener">in this last commit</a></p>
<blockquote>
  <p>The goal here is to remember that you can&rsquo;t just drop files on the server without knowing how they&rsquo;re being served. Every front-end dev needs to know how a browser works. &ldquo;Knowing HTML&rdquo; isn&rsquo;t enough.</p>

</blockquote>
<h3>5. Adding Command-Line Arguments and doing more cleanup<span class="hx:absolute hx:-mt-20" id="5-adding-command-line-arguments-and-doing-more-cleanup"></span>
    <a href="#5-adding-command-line-arguments-and-doing-more-cleanup" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>The <a href="https://github.com/akitaonrails/tvclipboard/commit/278604425edf52ae682a0e11e2952780f2783fd7"target="_blank" rel="noopener">next</a> <a href="https://github.com/akitaonrails/tvclipboard/commit/59de9fb135c4b9e0f38343f7296527f0b1c5a31c"target="_blank" rel="noopener">three</a> <a href="https://github.com/akitaonrails/tvclipboard/commit/ce0c25f37284f923568417c2543c66923fe449ec"target="_blank" rel="noopener">commits</a> are nonsense I should have squashed into a single commit.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20260128104202_screenshot-2026-01-28_10-40-53.png" alt="cleanup commits"  loading="lazy" /></p>
<p>This is more of a reminder that it&rsquo;s not entirely bad to let an agent like Crush do git commits automatically, but you need to pay attention because you might have to tell it to undo (git has ways to redo commits, with options like <code>-amend</code>).</p>
<p>I just added command-line options. Now the binary has this support:</p>
<p><a href="https://github.com/akitaonrails/tvclipboard/commit/278604425edf52ae682a0e11e2952780f2783fd7"target="_blank" rel="noopener"><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20260128104735_screenshot-2026-01-28_10-47-27.png" alt="cli args"  loading="lazy" /></a></p>
<p>And since it forgot to document it in the README, the other commit is for that. This is why I said the right thing to do was to squash into a single commit, because they&rsquo;re the same task.</p>
<h3>6. Dev vs Production environment configuration<span class="hx:absolute hx:-mt-20" id="6-dev-vs-production-environment-configuration"></span>
    <a href="#6-dev-vs-production-environment-configuration" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>This is another point every beginner ignores. The classic <em>&ldquo;ah, but it works on my machine&rdquo;</em>.</p>
<p>On the web, especially, &ldquo;your machine&rdquo; is always going to be a <code>localhost</code>, or <code>127.0.0.1</code> or <code>0.0.0.0</code> (watch my <a href="https://www.youtube.com/watch?v=0TndL-Nh6Ok&amp;list=PLdsnXVqbHDUcTGjNZuRYCVj3AZtdt6oG7"target="_blank" rel="noopener">Networking playlist</a> to understand the difference).</p>
<p>The problem: if I run this app locally on my PC, I access it via <code>http://localhost:3333</code>. When the QR Code gets generated, it&rsquo;ll use &ldquo;localhost&rdquo;. Now imagine your phone trying to load a &ldquo;localhost&rdquo; link. <strong>Obviously, it won&rsquo;t work.</strong> Because your phone doesn&rsquo;t have the app running on that port on localhost. I need my PC&rsquo;s IP. So the first thing I have to ask to fix is that, if it&rsquo;s localhost, it should look up my PC&rsquo;s IP - which will be something like <code>192.168.0.xxx</code>. With my PC and my phone on the same DHCP, on the same network, they can find each other via that private IP.</p>
<p>But what about when I run it in Docker? The internal IP of a Docker container is different from the external IP (the container is always either on NAT or on Bridge). The container&rsquo;s &ldquo;external&rdquo; IP will be something like <code>192.168.xxx</code>, but the internal IP will be something like <code>172.xxx</code>. Again, if the QR Code uses the internal IP, there will be problems.</p>
<p>So I need a new configuration, usually called <strong>PUBLIC URL</strong> or <strong>PUBLIC HOST</strong> or just <strong>HOST</strong>. Where I <strong>explicitly declare it</strong>.</p>
<p>For example, when I&rsquo;m done, as I said, I&rsquo;ll leave my service exposed on the internet using a real personal domain. Something like <code>https://clip.example.com</code> with TLS support and all. So I need my <code>docker-compose.yml</code> to have an option like:</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="w">  </span>- <span class="l">TVCLIPBOARD_PUBLIC_URL = https://clip.example.com</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 that way the QR Code is generated with this name instead of having to guess. But at the same time, if nothing is passed, it assumes &ldquo;localhost&rdquo;.</p>
<p>In this <a href="https://github.com/akitaonrails/tvclipboard/commit/b129e728101fd9fca400d48c0082b2f3dd679cf3"target="_blank" rel="noopener">first commit</a> it doesn&rsquo;t yet do all of this - I built it little by little. But that&rsquo;s how you start this kind of feature.</p>
<blockquote>
  <p>The goal here is to remember that &ldquo;running on my machine&rdquo; (localhost) means nothing. We don&rsquo;t put &ldquo;your machine&rdquo; into production. &ldquo;Working&rdquo; means &ldquo;working OUTSIDE your machine&rdquo;.</p>

</blockquote>
<h3>7. More code cleanup<span class="hx:absolute hx:-mt-20" id="7-more-code-cleanup"></span>
    <a href="#7-more-code-cleanup" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>I&rsquo;m deliberately mentioning &ldquo;code cleanup&rdquo; all the time so it&rsquo;s very clear that a considerable part of the work of programming is &ldquo;cleaning up&rdquo; code that has already been written.</p>
<p>The <a href="https://github.com/akitaonrails/tvclipboard/commit/c9505acd840bbcbead456e3823ecd4a8d6e022a6"target="_blank" rel="noopener">next</a> <a href="https://github.com/akitaonrails/tvclipboard/commit/67c5a01f4791257c8f4b19388c9c39bf1ccabb33"target="_blank" rel="noopener">four</a> <a href="https://github.com/akitaonrails/tvclipboard/commit/899fef6232c5b8f7081c0ad8681014a681de78ce"target="_blank" rel="noopener">commits</a> <a href="https://github.com/akitaonrails/tvclipboard/commit/63d7fbfaca798dec1eca48ff214561e01b22e670"target="_blank" rel="noopener">are</a> just maintenance.</p>
<p>For example, the LLM inadvertently left a duplicated snippet inside the code. It didn&rsquo;t break anything because it was in the Docker Compose configuration:</p>
<p><a href="https://github.com/akitaonrails/tvclipboard/commit/67c5a01f4791257c8f4b19388c9c39bf1ccabb33"target="_blank" rel="noopener"><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20260128151806_screenshot-2026-01-28_10-59-40.png" alt="duplicate"  loading="lazy" /></a>
On top of that, there&rsquo;s an <strong>accidental</strong> behavior in the way this app works that I decided to keep as a &ldquo;feature&rdquo;. There always has to be a browser that opens first and automatically becomes the Host and displays a QR Code. The next browser to open needs the link from that QR Code and becomes the &ldquo;client&rdquo;.</p>
<p>But when the host closes, the client loses the connection and is automatically <strong>promoted</strong> to host. I didn&rsquo;t ask for that but it ended up working out, so I <a href="https://github.com/akitaonrails/tvclipboard/commit/63d7fbfaca798dec1eca48ff214561e01b22e670"target="_blank" rel="noopener">asked to keep it and document it</a>.</p>
<blockquote>
  <p>The goal here is to remember that LLMs aren&rsquo;t perfect, they&rsquo;ll leave leftovers behind, and you need to keep monitoring all the time.</p>

</blockquote>
<h3>8. Optimizing Javascript<span class="hx:absolute hx:-mt-20" id="8-optimizing-javascript"></span>
    <a href="#8-optimizing-javascript" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>The <a href="https://github.com/akitaonrails/tvclipboard/commit/8db01bb053f8e045708b60293f569655a4cf2311"target="_blank" rel="noopener">next</a> <a href="https://github.com/akitaonrails/tvclipboard/commit/8f7080cfce86257d5fc0e594f8098c115858a963"target="_blank" rel="noopener">three</a> <a href="https://github.com/akitaonrails/tvclipboard/commit/97df6c5b125de522c4ca65d276df353a97670ae6"target="_blank" rel="noopener">commits</a> are about cleaning up the Javascript.</p>
<p>Last time we touched it, I had only asked it to pull the inline Javascript out of the HTML and separate it into reusable &ldquo;.js&rdquo; files. But the time came to take a look at them.</p>
<p>Right off the bat, I could see there was duplication between client and host, so I asked <a href="https://github.com/akitaonrails/tvclipboard/commit/8db01bb053f8e045708b60293f569655a4cf2311"target="_blank" rel="noopener">to deduplicate</a> and move things into &ldquo;common.js&rdquo;.</p>
<p>On top of that there were several small issues:</p>
<ul>
<li>a bunch of variables and functions in the global scope (global pollution: bad practice)</li>
<li>websockets that open but with nothing checking the close</li>
<li>timers being started but never cleared (leaks)</li>
</ul>
<p>Javascript code that &ldquo;just works&rdquo; is always like this: everything global, everything open, nothing checked, multiple leaks. That&rsquo;s normal and every LLM will do it this way.</p>
<p>I also don&rsquo;t think you need to start from the beginning demanding SOLID and everything else. First make it work, then make it work properly, finally make it work fast. It&rsquo;s always in that order, especially in a simple scope like this.</p>
<p>That&rsquo;s why now it&rsquo;s time to ask it to refactor. And the first thing to do is always to isolate the scope, for example, in a block 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-javascript" data-lang="javascript"><span class="line"><span class="cl"><span class="p">(</span><span class="kd">function</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="s1">&#39;use strict&#39;</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="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>The problem with this is that every function the HTML was calling, like &ldquo;onclick&rdquo;, will fail because there&rsquo;s nothing in the global scope anymore. Once everything is hidden, now you have to <a href="https://github.com/akitaonrails/tvclipboard/commit/97df6c5b125de522c4ca65d276df353a97670ae7"target="_blank" rel="noopener">expose</a> only what actually needs to be public.</p>
<p>This is called <strong>IIFE Encapsulation</strong> (<em>Immediately Invoked Function Expression</em>). There are thousands of practices like this that Javascript needs to get &ldquo;cleaner&rdquo;. I know, I should be using modules, Typescript, blah blah, but this is a simple example.</p>
<blockquote>
  <p>The goal here is to remember that, just because it &ldquo;works&rdquo; doesn&rsquo;t mean it&rsquo;s &ldquo;good&rdquo;, especially with Javascript.</p>

</blockquote>
<h3>9. Optimizing the Golang<span class="hx:absolute hx:-mt-20" id="9-optimizing-the-golang"></span>
    <a href="#9-optimizing-the-golang" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>This is the longest section. The Golang code already &ldquo;works&rdquo;, but it&rsquo;s not good yet. I know it has race conditions in channels, I know the error handling is bad, I know there are still micro security issues, so I want to fix what I can.</p>
<p>The next 6 commits deal with:</p>
<ul>
<li><a href="https://github.com/akitaonrails/tvclipboard/commit/8d28a12569bcf49fc5f4816b5c964299ff0dbc71"target="_blank" rel="noopener">Refactor: Improve error handling, race conditions, and graceful shutdown</a></li>
<li><a href="https://github.com/akitaonrails/tvclipboard/commit/5bd7e0a8f1c14a9a3c59cbddd1437cfd193f9b6b"target="_blank" rel="noopener">Fix: Critical race conditions, memory leaks, and security issues</a></li>
<li><a href="https://github.com/akitaonrails/tvclipboard/commit/480cc36f93f30369044dfe291477329174566e38"target="_blank" rel="noopener">Security: Add message size validation and rate limiting to prevent abuse</a></li>
<li><a href="https://github.com/akitaonrails/tvclipboard/commit/0c456a203903167182722138c2c11ee63b09ddf3"target="_blank" rel="noopener">Config: Tighten security limits and add origin validation</a></li>
<li><a href="https://github.com/akitaonrails/tvclipboard/commit/5678b0a1768db27bb802a56c13c1498ab4f82040"target="_blank" rel="noopener">Fix: CORS origins, Web Crypto API fallback, and clipboard issues</a></li>
<li><a href="https://github.com/akitaonrails/tvclipboard/commit/5dd813d9ca58324a12b8e6ebf8fcaf8c7be99e9b"target="_blank" rel="noopener">Fix: QR code uses correct port from PublicURL and document Web Crypto API HTTPS requirement</a></li>
</ul>
<p><a href="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20260128164221_screenshot-2026-01-28_16-41-43.png"target="_blank" rel="noopener">6 commits</a></p>
<p>GoLang itself has a tool to check for <strong>race conditions</strong>, the <a href="https://go.dev/doc/articles/race_detector"target="_blank" rel="noopener"><strong>Data Race Detector</strong></a>. Just pass the <code>--race</code> option to commands like <code>go build</code> or <code>go test</code>. And luckily GLM was smart and immediately used this tool when I asked it to check for these issues.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20260128112743_screenshot-2026-01-28_11-24-16.png" alt="race condition"  loading="lazy" /></p>
<p><strong>Race conditions</strong> happen when two asynchronous tasks try to modify the same data structure, for example. One way to &ldquo;fix&rdquo; them is to put Locks or Mutexes on structures that can only be modified synchronously. Any application with anything asynchronous will eventually have race condition issues. It&rsquo;s not &ldquo;maybe&rdquo;, it&rsquo;s &ldquo;when&rdquo;.</p>
<p>A server has to be &ldquo;well-behaved&rdquo;, it can&rsquo;t just &ldquo;crash&rdquo; or &ldquo;panic&rdquo;. But the first &ldquo;working&rdquo; code GLM wrote had several snippets like this:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20260128112136_screenshot-2026-01-28_11-21-28.png" alt="panic"  loading="lazy" /></p>
<p>This is wrong. Besides that, we need the server to correctly accept system signals like SIGINT, SIGTERM, etc. This is when we hit &ldquo;Ctrl-C&rdquo;, for example, or when Docker tells the container to shut down. It won&rsquo;t simply crash the program, first it asks the program to shut itself down, giving it a chance to do cleanup, like flushing files, closing ports and things like that.</p>
<p>This is called <strong>Graceful Shutdown</strong>.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20260128112344_screenshot-2026-01-28_11-23-37.png" alt="graceful shutdown"  loading="lazy" /></p>
<p>I didn&rsquo;t stop to check how right GLM was but according to itself, some of the issues it itself found in the code it itself wrote included things like:</p>
<p><a href="https://github.com/akitaonrails/tvclipboard/commit/5bd7e0a8f1c14a9a3c59cbddd1437cfd193f9b6b"target="_blank" rel="noopener"><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20260128113944_screenshot-2026-01-28_11-39-23.png" alt="fixing errors 1"  loading="lazy" /></a></p>
<p><a href="https://github.com/akitaonrails/tvclipboard/commit/5bd7e0a8f1c14a9a3c59cbddd1437cfd193f9b6b"target="_blank" rel="noopener"><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20260128114028_screenshot-2026-01-28_11-39-38.png" alt="fixing errors 2"  loading="lazy" /></a></p>
<p>As you can see, just because it &ldquo;compiles&rdquo; doesn&rsquo;t mean it&rsquo;s ok. Just because it &ldquo;runs&rdquo; doesn&rsquo;t mean it&rsquo;s ok. Even asking it to check <strong>MULTIPLE TIMES</strong>, the LLM doesn&rsquo;t catch all the errors in one go. Each of these commits took hours to resolve and I had to intervene multiple times, and even so I&rsquo;m still not sure it caught everything (I&rsquo;d have to review more carefully - but I wanted to publish this article quickly 😂).</p>
<h3>10. Adding Security Systems<span class="hx:absolute hx:-mt-20" id="10-adding-security-systems"></span>
    <a href="#10-adding-security-systems" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Again, none of this is necessary in a system that only I will use on my local network, but you as a developer have to take responsibility.</p>
<blockquote>
  <p>ALWAYS assume every user of your system is MALICIOUS! That&rsquo;s the RULE, not the exception.</p>

</blockquote>
<p>A silly example: the user can paste and transmit any text over websockets to my server. The 3 most obvious things that immediately crossed my mind were:</p>
<p><strong>a) When the host receives the text, it renders it into the HTML in the &ldquo;Received&rdquo; box</strong></p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20260128090907_20260127_195954.jpg" alt="tvclipboard host received screen"  loading="lazy" />
What if the user sends a <code>&lt;script&gt;....&lt;/script&gt;</code>? That will render into the HTML and the browser will EXECUTE it. That&rsquo;s how <strong>Code Injection</strong> works. That&rsquo;s how <strong>XSS</strong> (<em>Cross-Site Scripting</em>) works. This was very common in old forums or comment systems. To avoid it you must <strong>SANITIZE</strong> everything coming from the user.</p>
<p>Luckily, the way GLM did it is already sanitized because it writes the content with <code>textContent</code> instead of <code>innerHTML</code>, so the text is written literally without being executed:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20260128140317_screenshot-2026-01-28_14-03-09.png" alt="textContent"  loading="lazy" />
It happened to get this right on the first try, but you need to check because it could have done it wrong.</p>
<p><strong>b) There was no message size check</strong></p>
<p>A user should never be able to send text of any arbitrary size. They can always try to POST a gigabyte file directly to see if it blows up some <strong>Overflow</strong> error on the server, exposing some security exploit.</p>
<p>EVERY user field needs limits and it needs to be checked not only on the client/front-end, but more importantly, on the backend:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20260128140558_screenshot-2026-01-28_14-05-51.png" alt="message max size"  loading="lazy" />
<strong>c) You have to reject Spammers!!</strong></p>
<p>Again, you can&rsquo;t assume everyone will use your web app nicely. The rule is to expect everyone to try to take it down or break in. And one way to do that is to write a script to keep making thousands of POSTs per second to try some kind of <strong>Denial of Service</strong>, where the server queues up so many requests it blows up.</p>
<p>The right thing is to configure this in another service that sits in front of yours, like <a href="https://www.cloudflare.com/ddos/"target="_blank" rel="noopener"><strong>Cloudflare</strong></a>.</p>
<p>But it doesn&rsquo;t hurt to implement a <em>fallback</em> in your application itself. A way to configure how many requests a single user can make at a time (e.g. 4 requests per second - no one is going to paste text that many times per second in an app like this).</p>
<p>If they try more than that, the app has to immediately <strong>REJECT</strong> and drop the connection, without wasting time processing any more, freeing resources for other real requests. This is called <strong>RATE LIMITING</strong>.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20260128141010_screenshot-2026-01-28_14-10-02.png" alt="rate limiting"  loading="lazy" />
<strong>d) Another issue beginners don&rsquo;t stop to think about is CORS and Origin Check.</strong></p>
<p>As I said before, we have Javascripts like <code>/static/js/common.js</code>. It&rsquo;s not a huge problem if someone else uses them because they don&rsquo;t have any big secrets in them. But at the very least I don&rsquo;t want some site like &ldquo;<a href="http://malware.pk/index.html"target="_blank" rel="noopener">http://malware.pk/index.html</a>&rdquo; to include <code>https://clip.example.com/static/js/common.js</code> inside it and for me to end up paying for its traffic.</p>
<p>I want only the <strong>SAME ORIGIN</strong>, in this case &ldquo;clip.example.com&rdquo;, to be able to load files from the same server. No one from outside is welcome and that&rsquo;s the <strong>BEST PRACTICE</strong>. Study <strong>CORS</strong> (Cross-Origin Resource Sharing). This is more important if your system spans multiple domains where one depends on the other, then you want - in a controlled way - to allow only certain other domains that are under your control.</p>
<blockquote>
  <p>The rule for everything on networks is <strong>DENY ALL</strong> and then <strong>ALLOW [specific]</strong>. It should NEVER be <strong>ALLOW ALL</strong>.</p>

</blockquote>
<p>Implementing this took a while, because GLM kept tying itself in knots over how to allow &ldquo;localhost&rdquo;, but also &ldquo;192.168.xxx:3333&rdquo;, but also &ldquo;clip.example.com&rdquo; (via port 443), etc. It&rsquo;s not hard, GLM just got itself all tangled up and I had to intervene several times with prompts to steer it in the right direction. It ended up taking multiple commits for it to get it right.</p>
<p><strong>e) Another little thing beginners don&rsquo;t think about: browser resource PERMISSIONS.</strong></p>
<p>Every time you load this system on &ldquo;localhost&rdquo; and it receives a message to drop into the clipboard, this alert pops up asking for permission:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20260128114848_screenshot-2026-01-28_11-48-38.png" alt="clipboard permission"  loading="lazy" /></p>
<p>And that&rsquo;s normal and correct: nothing should have access to things outside the browser. That&rsquo;s why the Javascript asks <em>&ldquo;send this to the clipboard&rdquo;</em>, the browser intercepts it, checks the permission and, if it doesn&rsquo;t have it, asks for it. Every time it will ask. But if you use a real domain (e.g. clip.example.com), it asks once and then remembers per domain. That&rsquo;s one of the reasons I&rsquo;m going to expose it on the internet: to have a real domain.</p>
<blockquote>
  <p>Exposing services on the internet is DANGEROUS. That&rsquo;s why I wrote <a href="https://akitaonrails.com/en/2025/09/09/accessing-my-home-server-with-a-real-domain/"target="_blank" rel="noopener">this article explaining how to use real domains</a> and <a href="https://akitaonrails.com/en/2025/09/10/protecting-your-home-server-with-cloudflare-zero-trust/"target="_blank" rel="noopener">this other article about using Cloudflare Zero Trust</a>.</p>

</blockquote>
<p><strong>f) There was another security factor which is &ldquo;overkill&rdquo; but I wanted to implement anyway.</strong></p>
<p>Let&rsquo;s say I want to run only on localhost. Let&rsquo;s say it&rsquo;s a private but shared network (guests, roommate, office, etc). Running on localhost there&rsquo;s no TLS, meaning HTTP packets travel in plain text.</p>
<p>Anyone with <a href="https://www.wireshark.org/"target="_blank" rel="noopener"><strong>Wireshark</strong></a> can &ldquo;sniff&rdquo; the packets on the network and intercept mine. And if I happen to be passing sensitive data, like a password, it will be visible in the clear.</p>
<p>The right thing is to run with TLS (HTTPS). But just as a &ldquo;stopgap&rdquo;, I asked GLM to implement a simple obfuscation (it&rsquo;s not a fully secure process, but it uses AES-GCM), which is enough to not be obvious with a sniffer. For this it uses the <a href="https://w3c.github.io/webcrypto/"target="_blank" rel="noopener"><strong>Web Crypto API</strong></a>.</p>
<p>But there&rsquo;s a catch: Web Crypto API is only available if the site opens over HTTPS or if it&rsquo;s localhost. But if I try <code>http://192.168.xxx:3333</code>, it won&rsquo;t be available. For that there&rsquo;s <a href="https://github.com/akitaonrails/tvclipboard/commit/5dd813d9ca58324a12b8e6ebf8fcaf8c7be99e9b"target="_blank" rel="noopener">this commit</a> that deals with this and documents it.</p>
<blockquote>
  <p>The goal here is to show that security, optimization, and maintainability aspects are much more than a beginner thinks. In fact, they&rsquo;ll never think about them, because you need to have studied beforehand to even know they exist.</p>

</blockquote>
<h3>11. Open Source Licenses<span class="hx:absolute hx:-mt-20" id="11-open-source-licenses"></span>
    <a href="#11-open-source-licenses" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>This topic alone would be material for a long article, but in practice I tend to think simply:</p>
<ul>
<li>if it&rsquo;s a project where I don&rsquo;t care whether anyone contributes or not, I just open it with PERMISSIVE licenses, like BSD, MIT</li>
<li>if it&rsquo;s a project where I&rsquo;d like contributions and that no one uses commercially, I just put a RESTRICTIVE license on it, like GPL</li>
<li>if it&rsquo;s restrictive and it&rsquo;s an app that could turn into a SaaS (so the code isn&rsquo;t distributed, so there would be no obligation to contribute), then I add AGPL (Affero)</li>
</ul>
<p>That&rsquo;s what I did here: I licensed it under <a href="https://github.com/akitaonrails/tvclipboard/blob/master/LICENSE"target="_blank" rel="noopener">AGPL 3.0</a>.</p>
<p>The warning is to not let LLMs write the license file. Always take texts on laws, regulations and rules from the official site. The LLM always produces a shortened or incomplete or even wrong version. Legal documents need to be EXACT, they can&rsquo;t have wrong words and ambiguous meanings or incomplete statements.</p>
<blockquote>
  <p>LLMs are not good at producing correct legal text on the first try, there will always be errors. Always assume such text is wrong: there&rsquo;s no &ldquo;compiler&rdquo; for laws.</p>

</blockquote>
<h3>12. Fixing the problem of overly long tokens<span class="hx:absolute hx:-mt-20" id="12-fixing-the-problem-of-overly-long-tokens"></span>
    <a href="#12-fixing-the-problem-of-overly-long-tokens" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Way back I said that the tokens GLM generated for me are too big (over 100 characters). This results in super gigantic links.</p>
<p>It&rsquo;s not a problem because no one has to type this and it&rsquo;s not meant to be bookmarked. This is more of a personal choice: I like short tokens in URLs.</p>
<p>The advantage of the long token is that I don&rsquo;t need to implement any kind of &ldquo;database&rdquo; (key-value store, or KV Store - Memcached, Redis). That&rsquo;s because all the session info (ID and expiration timestamp) is encoded inside that token, which is why it&rsquo;s long. It&rsquo;s a json struct, encrypted with AES, converted to Hex.</p>
<p>Trying to keep the same concept, <a href="https://github.com/akitaonrails/tvclipboard/commit/eb274968d8ec83c28abce7ffd7352ad6eebd86bc"target="_blank" rel="noopener">in this first commit</a> I only asked it to try to optimize the size of the structure. So GLM on its own chose to try to change from UUID (36 chars) to a random 24-char Hex ID. Swap standard ISO timestamp (24 chars) for UNIX (10 chars). This went from 130-140 chars down to around 112 chars. A reduction on the order of 20% to 25%.</p>
<p>It wasn&rsquo;t a bad attempt. But in the end I wasn&rsquo;t really swallowing tokens that big (it could be a cookie too, I know, but I wanted to keep everything in the URL).</p>
<p>In the end I decided I wanted really short tokens (8 chars, base62) and so I&rsquo;d need to keep the Session in some &ldquo;storage&rdquo;. I decided it would be &ldquo;in-memory&rdquo; (process-affinity, but I&rsquo;m only going to run 1 instance, no load balancer, so it&rsquo;s fine). And that would be a FIFO with rotation (older keys get discarded to make room for newer ones, and they expire that way).</p>
<p><a href="https://github.com/akitaonrails/tvclipboard/commit/a34b265b585a5661c2595fe474b4613a5aba13c0"target="_blank" rel="noopener">This is the commit for that feature</a> and I still had to intervene several times. For some reason it thought it would be a good idea to have a background goroutine cleaning up expired tokens from this storage. But that&rsquo;s completely unnecessary if it&rsquo;s a FIFO. It did it anyway, I had to tell it to undo. That&rsquo;s the routine.</p>
<p><a href="https://github.com/akitaonrails/tvclipboard/commit/a34b265b585a5661c2595fe474b4613a5aba13c0"target="_blank" rel="noopener"><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20260128131103_screenshot-2026-01-28_13-10-53.png" alt="FIFO"  loading="lazy" /></a></p>
<h3>13. More refactoring and cleanup (almost done!)<span class="hx:absolute hx:-mt-20" id="13-more-refactoring-and-cleanup-almost-done"></span>
    <a href="#13-more-refactoring-and-cleanup-almost-done" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>The last <strong>6 commits</strong> are <strong>MORE MAINTENANCE!!</strong></p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20260128162905_screenshot-2026-01-28_16-28-52.png" alt="6 commits"  loading="lazy" /></p>
<p>Sounds like a joke, but the amount of maintenance I did is actually small. In a real project this is part of the routine. Every time you add something new, you break something that was already there, and you need to adjust it to avoid accumulating <strong>technical debt</strong>.</p>
<p>Remember we ran the <strong>race condition</strong> check? We added new code, so it has to be checked again. And it found more issues fixed <a href="https://github.com/akitaonrails/tvclipboard/commit/df348dbe170451c38b9c9fdf703b57ce6c253578"target="_blank" rel="noopener">in this commit</a>. I don&rsquo;t remember now whether these were already there way back and it skipped them without fixing, or whether the last edits ended up creating these new bugs.</p>
<p>After adding so many things and refactoring, I decided to bump up Go code coverage a bit more. The <code>go test</code> command has the <code>-coverprofile</code> option to help check which parts of your code haven&rsquo;t been tested yet. So I asked GLM to add tests for new features like Rate Limiting, message size check, origin check and <a href="https://github.com/akitaonrails/tvclipboard/commit/fc32a575c53d61b7fce5ab941d069dd45d66fc81"target="_blank" rel="noopener">other things</a>.</p>
<p>I asked for a trivial thing which is a <a href="https://github.com/akitaonrails/tvclipboard/commit/2867f8df052f89febc095644ed28aaaefd0b198a"target="_blank" rel="noopener">Close button</a> on the client side, to not keep piling up tabs on my smartphone.</p>
<p>After I had it swap the token system, there&rsquo;s no more need to encrypt the timestamp structure. But it left behind unit tests about that. So I <a href="https://github.com/akitaonrails/tvclipboard/commit/9b5d9f5d3b6504788e73426cc3dc2c6a2a9fe358"target="_blank" rel="noopener">told it to remove them</a>. Again: LLMs will leave things incomplete behind. A lot of times it doesn&rsquo;t know whether to keep code so as not to break the test, or to drop the test because the code is no longer needed.</p>
<p>Then I asked Go to run the <a href="https://github.com/akitaonrails/tvclipboard/commit/1b0e2efd0dfaedf96b2c54eea62438cc7b081924"target="_blank" rel="noopener">linter</a> and support <a href="https://github.com/akitaonrails/tvclipboard/commit/128cf14feff3f1d36a2568c9af8b9665c6b6377a"target="_blank" rel="noopener">golangci-lint</a> for last-pass checks. A Linter is a sort of formatting, syntax and good-practice check for a particular language. But most of the adjustments were extra whitespace, a few bad indentations and stuff like that. Nothing major.</p>
<p>One last thing that was my own negligence is that I was forgetting to add Javascript tests. A considerable part of the code is front-end dealing with WebSockets and everything. I had to add them, and that was <a href="https://github.com/akitaonrails/tvclipboard/commit/bb0d4895f1cfb5b62c0133930b017c93db5d60a8"target="_blank" rel="noopener">in this commit</a>.</p>
<p>Since I was doing it all in one go, I also asked it to add ESLint, and to prioritize testing everything only unit-wise, with mocks and stubs so everything is headless and doesn&rsquo;t need to load Chromium. Just test the publicly exposed functions. This raised front-end test coverage from zero to 73%, which is great coverage. See the <a href="https://github.com/akitaonrails/tvclipboard/commit/bb0d4895f1cfb5b62c0133930b017c93db5d60a8"target="_blank" rel="noopener">commit</a> to see how it turned out.</p>
<h3>14. Last thing: I18n<span class="hx:absolute hx:-mt-20" id="14-last-thing-i18n"></span>
    <a href="#14-last-thing-i18n" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>This is something I remembered while I was already writing this article. Completely unnecessary, but I wanted to see if GLM could do it. And <a href="https://github.com/akitaonrails/tvclipboard/commit/065c52046551b2d8ff426a7206366455e19638c4"target="_blank" rel="noopener">it &ldquo;did&rdquo;</a>. I didn&rsquo;t detail how I wanted it and I let it come up with the solution. I would have had the Go server swap the strings before sending the HTML to the client.</p>
<p>But GLM preferred to add this on the front-end side and swap the strings via Javascript. It&rsquo;s not wrong, but it&rsquo;s not the best way.</p>
<p>Language translation is exactly one of the things AIs were originally built to be good at: Natural Language Processing (NLP). I had it extract all the English strings and then make a Brazilian Portuguese version, and I can say it did it well:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20260128153748_screenshot-2026-01-28_15-36-56.png" alt="pt-BR"  loading="lazy" /></p>
<p>In a real web app this is something we always need to worry about: not leaving user-facing messages &ldquo;hardcoded&rdquo; and always extracting and testing them. Which brings me to the last part:</p>
<h2>Wouldn&rsquo;t it have been better to use Framework X or Library Y?<span class="hx:absolute hx:-mt-20" id="wouldnt-it-have-been-better-to-use-framework-x-or-library-y"></span>
    <a href="#wouldnt-it-have-been-better-to-use-framework-x-or-library-y" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The Go crowd might not like that I didn&rsquo;t use Gin, Fiber or another web framework. But really in this case the app is too simple. There are no more than 3 endpoints. The most complicated part would be the WebSockets, which wouldn&rsquo;t look much different using a framework. Or bigger checks like CORS, which also wouldn&rsquo;t have much less LOC.</p>
<p>On the Javascript side, maybe I18n would have been worth importing a ready-made package, but again, it&rsquo;s very simple. There was no major feature I had to reinvent.</p>
<p>Of course, this is because this app is miserably small, as I said at the beginning. For a real larger app, I would indeed recommend using a popular framework with good community support, well documented and so on. But things the size of a ToDo List really don&rsquo;t make a difference unless the exercise was about learning a particular framework.</p>
<p>Thinking about that, you have to be careful because no LLM will pick for you. If you start with small prompts, one feature at a time (which is the right way), it tends to write things all from scratch, without importing any library.</p>
<blockquote>
  <p>You have to know beforehand which library to ask for each thing!</p>

</blockquote>
<p>Again, a beginner wouldn&rsquo;t know, so they&rsquo;d end up reinventing the wheel dozens of times instead of using popular libraries.</p>
<p>Remember I said, way back at the beginning, to save the structure of the project in version <strong>v0.1</strong> that &ldquo;already worked&rdquo;? Here&rsquo;s how it looks at the end in a real project:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20260128162157_screenshot-2026-01-28_16-20-40.png" alt="final tree"  loading="lazy" /></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>Phew! That was long! Congrats if you made it this far!</p>
<p>My experience on this little project was like having a <strong>super motivated intern</strong> that I needed to spend the <strong>whole day</strong> looking over the shoulder of, and every other minute saying:</p>
<p><em>&ldquo;Whoa! Stop everything, this is wrong!&rdquo;</em>
<em>&ldquo;Whoa! Undo it and do it again but this other way!&rdquo;</em>
<em>&ldquo;Whoa! No, you already did this wrong before and you&rsquo;re repeating it!&rdquo;</em></p>
<p>I&rsquo;m not kidding when I said it was more than 250 prompts. And that&rsquo;s a low estimate, I think I wrote MANY more than that.</p>
<p>As soon as I finished the Vibe Coding process, I pushed to GitHub and started writing this article, so there are still hours of <strong>REVIEW</strong> of the whole code, from beginning to end, to see if I missed anything. You couldn&rsquo;t put it in production without doing that! If anyone sees weird things in the code, feel free to post in the comments.</p>
<p>It really felt like I was mentoring an intern. But with one problem: unlike a real intern, the LLM has already forgotten everything I taught it.</p>
<p>When I do a new project with GLM, it will make practically ALL the same mistakes and mix-ups. It&rsquo;s like I was mentoring a blind intern with the memory of a goldfish.</p>
<p><strong>It&rsquo;s extremely exhausting!!</strong></p>
<blockquote>
  <p>In the end it took around 12 hours to code everything, and I have the impression that a senior Go developer would have done it on their own in half the time. A junior would have taken this long over 2 or 3 days (stopping to research).</p>

</blockquote>
<p>I already knew this, I&rsquo;ve repeated it dozens of times, but the notion of:</p>
<p><em>&ldquo;I don&rsquo;t need to hire programmers anymore, AI does everything by itself.&rdquo;</em> - is a <strong>GIANT FALLACY</strong>.</p>
<p>And it&rsquo;s not going to get much better than this. We&rsquo;re already at the top of the S curve I&rsquo;ve explained in every podcast I&rsquo;ve been on and in videos on my channel. It costs orders of magnitude more energy to train the next version than it took for all previous versions combined, for <strong>incremental</strong> progress.</p>
<p>While the architecture stays the same, it&rsquo;s not going to get past this. There needs to be some NEW BREAKTHROUGH (which hasn&rsquo;t happened to date, since 2021 when Transformers came out).</p>
<p>A Claude Code will be a bit faster than GLM. The next version of GLM will be a bit faster. Maybe the 12 hours I spent will turn into 6 hours (for this miserable app). But the <strong>PROCESS</strong> will be the same: it does it wrong, I tell it to fix it, and it does this <em>ad eternum</em>.</p>
<p>There will never be such a thing as writing a single prompt and out comes a perfect, secure, optimized app. It will ALWAYS come out buggy on the first try. That&rsquo;s why someone who is not a programmer should not build software to put in production with only Vibe Coding: it <strong>must</strong> go through review by a real experienced programmer.</p>
<p>This is not optional.</p>
<h2>Post-Mortem<span class="hx:absolute hx:-mt-20" id="post-mortem"></span>
    <a href="#post-mortem" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>This section was written the day after publishing the original article. Once I was done, I asked <strong>Claude Code</strong> to do a deep quality analysis, focused on security and tests. And it found <strong>SEVERAL</strong> errors. Remember when I said you couldn&rsquo;t put it in production without reviewing carefully first?</p>
<p><a href="https://github.com/akitaonrails/tvclipboard/commit/d3e2b1b8dcbe1f0451995623df17b38ad236dbb7"target="_blank" rel="noopener"><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20260129095637_screenshot-2026-01-29_09-56-25.png" alt="claude code analysis"  loading="lazy" /></a></p>
<p><a href="https://github.com/akitaonrails/tvclipboard/commit/d3e2b1b8dcbe1f0451995623df17b38ad236dbb7"target="_blank" rel="noopener">This is the commit</a> with the fixes, take a look.</p>
<p>So it &ldquo;already worked&rdquo; in Phase 1, with a ton of problems. I intervened and spent 90% of the time in Phase 2 adding the foundation. I didn&rsquo;t review it properly and I published the file, now I came back and there are still problems.</p>
<blockquote>
  <p>Software is NEVER &ldquo;done&rdquo;.</p>

</blockquote>
<p>Even what Claude Code found doesn&rsquo;t mean it fixes EVERYTHING. It means it fixes SOME THINGS. No software ever has &ldquo;a guarantee&rdquo; of being perfect. That doesn&rsquo;t exist.</p>
<p>Even if I had started with Claude Code, as I said before, it would indeed have been faster. But it all depends on what you ask it. If you don&rsquo;t know exactly the requirements and specs for security, or performance, or scalability, or maintainability BEFORE asking, it will do SOME THINGS. Not necessarily the PERFECT THINGS, just SOME THINGS.</p>
<p>Then someone might say <em>&ldquo;See? Now that you&rsquo;ve reviewed it with Claude Code, it&rsquo;ll be perfect&rdquo;</em></p>
<p>So, afterwards I also asked <a href="https://github.com/akitaonrails/tvclipboard/commit/78dcfb11e58d026e2bc34476d849561724879fa8"target="_blank" rel="noopener"><strong>GPT 5.2 Codex</strong></a> to do the same extensive analysis and guess what: it also found more little issues. Far fewer, of course. But look at it: after GLM 4.7 and Opus 4.5, GPT 5.2 still found more things. IT NEVER ENDS!</p>
<p>In particular, remember how earlier I had tokens encrypted, then I changed the requirement asking for them not to be and to stay in-memory on the server? Old code from that feature was left behind. GPT 5.2 thought the intention was to finish implementing the encryption and started putting it back. I had to interrupt it and tell it to stop and remove it. Not even Opus 4.5 had removed this <strong>dead code</strong>.</p>
<p><a href="https://github.com/akitaonrails/tvclipboard/commit/78dcfb11e58d026e2bc34476d849561724879fa8"target="_blank" rel="noopener"><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20260129102239_screenshot-2026-01-29_10-22-30.png" alt="GPT 5.2"  loading="lazy" /></a></p>
<p>Always, you who ask are the one responsible for asking exactly and checking afterwards.</p>
]]></content:encoded><category>vibe-coding</category><category>software-engineering</category></item><item><title>Rant - Will LLMs Evolve Forever? Demystifying LLMs in Programming</title><link>https://akitaonrails.github.io/en/2025/05/01/rant-llms-will-evolve-forever-demystifying-llms-in-programming/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2025/05/01/rant-llms-will-evolve-forever-demystifying-llms-in-programming/</guid><pubDate>Thu, 01 May 2025 02:30:00 -0300</pubDate><description>&lt;p&gt;Let me recap my latest articles:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.akitaonrails.com/en/2025/04/25/hello-world-de-llm-criando-seu-proprio-chat-de-i-a-que-roda-local"target="_blank" rel="noopener"&gt;Hello World for LLMs&lt;/a&gt; - in this article I explain how to build a simple (educational) little chat program loading an LLM (Qwen2.5) and even how to use prompts so it can call scripts/agents and run a few simple tasks like loading local files. I pushed the code to GitHub as &lt;a href="https://github.com/akitaonrails/Tiny-Qwen-CLI"target="_blank" rel="noopener"&gt;Tiny-Qwen-CLI&lt;/a&gt; and I left the code &amp;ldquo;dirty&amp;rdquo; on purpose so I could test how LLMs manage (or fail) to adjust that code.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.akitaonrails.com/en/2025/04/25/seu-proprio-co-pilot-gratuito-universal-que-funciona-local-aider-ollama-qwen"target="_blank" rel="noopener"&gt;Your Own Free Co-Pilot&lt;/a&gt; - here I explain more about the Aider tool, which is like a Co-Pilot or Cursor, but free and open-source. It does not require plugins or IDEs. It runs in the terminal and is easy to use. I also show how to integrate it with Ollama and spin up your own local LLMs.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.akitaonrails.com/en/2025/04/29/dissecando-um-modelfile-de-ollama-ajustando-qwen3-pra-codigo"target="_blank" rel="noopener"&gt;Dissecting an Ollama Modelfile&lt;/a&gt; - in this one I dig deeper into LLM theory and explain what Key Sampling is and how various parameters affect an LLM&amp;rsquo;s text generation, like Temperature or Top_P. This is to demystify even further that there is no &amp;ldquo;magic&amp;rdquo; in how an LLM manages to generate text.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Over the last few days I have been testing and posting on X my impressions about the most varied commercial and open source models like Claude Sonnet, Gemini 2.5, OpenAI O4, Deepseek-R1, the new Qwen3 and more. There are more posts on the blog beyond the ones I mentioned above, but these are the main ones.&lt;/p&gt;</description><content:encoded><![CDATA[<p>Let me recap my latest articles:</p>
<ul>
<li><a href="https://www.akitaonrails.com/en/2025/04/25/hello-world-de-llm-criando-seu-proprio-chat-de-i-a-que-roda-local"target="_blank" rel="noopener">Hello World for LLMs</a> - in this article I explain how to build a simple (educational) little chat program loading an LLM (Qwen2.5) and even how to use prompts so it can call scripts/agents and run a few simple tasks like loading local files. I pushed the code to GitHub as <a href="https://github.com/akitaonrails/Tiny-Qwen-CLI"target="_blank" rel="noopener">Tiny-Qwen-CLI</a> and I left the code &ldquo;dirty&rdquo; on purpose so I could test how LLMs manage (or fail) to adjust that code.</li>
<li><a href="https://www.akitaonrails.com/en/2025/04/25/seu-proprio-co-pilot-gratuito-universal-que-funciona-local-aider-ollama-qwen"target="_blank" rel="noopener">Your Own Free Co-Pilot</a> - here I explain more about the Aider tool, which is like a Co-Pilot or Cursor, but free and open-source. It does not require plugins or IDEs. It runs in the terminal and is easy to use. I also show how to integrate it with Ollama and spin up your own local LLMs.</li>
<li><a href="https://www.akitaonrails.com/en/2025/04/29/dissecando-um-modelfile-de-ollama-ajustando-qwen3-pra-codigo"target="_blank" rel="noopener">Dissecting an Ollama Modelfile</a> - in this one I dig deeper into LLM theory and explain what Key Sampling is and how various parameters affect an LLM&rsquo;s text generation, like Temperature or Top_P. This is to demystify even further that there is no &ldquo;magic&rdquo; in how an LLM manages to generate text.</li>
</ul>
<p>Over the last few days I have been testing and posting on X my impressions about the most varied commercial and open source models like Claude Sonnet, Gemini 2.5, OpenAI O4, Deepseek-R1, the new Qwen3 and more. There are more posts on the blog beyond the ones I mentioned above, but these are the main ones.</p>
<p>The goal of this post is to demystify LLMs even more and talk more about my experience testing most of the most popular LLMs.</p>
<h3>Demystifying LLMs - 1. Benchmarks and Rankings<span class="hx:absolute hx:-mt-20" id="demystifying-llms---1-benchmarks-and-rankings"></span>
    <a href="#demystifying-llms---1-benchmarks-and-rankings" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Anyone who has followed my talks and videos for years is sick of hearing me talk about the book <a href="https://www.amazon.com.br/Como-Mentir-Estat%C3%ADstica-Darrell-Huff/dp/858057952X"target="_blank" rel="noopener">&ldquo;How to Lie with Statistics&rdquo;</a>. And what I see most in the hundreds of posts about LLMs are the rankings based on benchmarks. Here is an example that went viral yesterday, at the launch of Alibaba&rsquo;s new model, Qwen3:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/1kflvwk5uq9lkt5yh8knngwxwfi9" alt="Qwen3 benchmarks"  loading="lazy" /></p>
<p>The problem: the rankings are not wrong. What they mean is this:</p>
<p><strong>&ldquo;Qwen 3 beats Qwen 2.5 and also other famous ones like OpenAI o1, DeepSeek-R1, Grok 3 Beta, Gemini 2.5, etc &hellip;. IN THE BENCHMARKS THAT WERE RUN - and ONLY in the benchmarks that were run.&rdquo;</strong></p>
<p>That&rsquo;s all. But 99% of posts publish it like this:</p>
<p><strong>&ldquo;Qwen 3 IS BETTER AT WRITING CODE than Qwen 2.5 and also other famous ones like OpenAI o1, DeepSeek-R1, Grok 3 Beta, Gemini 2.5, etc&rdquo;</strong>*</p>
<p>The benchmarks are not wrong, what is wrong is whoever interpreted that to mean being better at everything. Let&rsquo;s go to another analogy:</p>
<p><strong>&ldquo;Student So-and-So got 99% right on every college entrance mock exam&rdquo;</strong></p>
<p>People&rsquo;s conclusion:</p>
<p><strong>&ldquo;Student So-and-So is 99% better than everyone at everything&rdquo;</strong></p>
<p>I hope that sounds obviously wrong. Being better at 1 thing makes nobody better at everything, but in AI this is how every news item about a new LLM version is published in personal posts and journalistic outlets. It is an obvious <strong>CLICKBAIT</strong>. And you fall for it.</p>
<p>And if you are used to hearing it this way, it makes it seem that with every new LLM version - that is &ldquo;better at everything&rdquo; than the previous one - we are very close, or have already reached, the point where yes, human programmers are no longer needed, nor mathematicians, nor engineers, nor doctors, since after all, the LLM is &ldquo;GOOD AT EVERYTHING&rdquo;.</p>
<p>And nothing could be further from the truth than this. These statistical ranking studies have a testing METHODOLOGY and a fixed benchmark suite. The tests are SIMPLE, think things at the level of &ldquo;write a function that computes a factorial&rdquo;, &ldquo;write a function that sorts a list of words&rdquo;, &ldquo;given a list of words, find a specific one in logarithmic time&rdquo;, etc. Things you see in introductory computer science courses or on Leet Code.</p>
<p>These tests are run MULTIPLE TIMES and the &ldquo;outliers&rdquo; (tests where it got way too much wrong, or tests where it got way too much right, or anything that falls out of the average) are discarded, a statistical distribution is built, and a generic overall &ldquo;number&rdquo; is compiled like &ldquo;got 99% right&rdquo; or something like that.</p>
<p>It got a lot wrong, but that data point is an outlier, and it was removed from the tally. It is statistically irrelevant within the methodology. But here is a fact: no LLM gets it right 100% of the time, and none ever will. I demonstrated this in my post about Modelfiles where I explain that there is a controlled random factor (temperature, top_p, top_k, min_p, key sampling). And there are also normalizations and random factors during training (softmax, relu). And there are also later optimization factors that &ldquo;round&rdquo; or &ldquo;truncate&rdquo; the probabilities like quantization (fp8, Q4, etc). In other words, there are several &ldquo;micro error&rdquo; factors and randomness baked into the entire process. It will never be a &ldquo;correct&rdquo; answer, only &ldquo;possibly one of the most correct&rdquo; and this &ldquo;possibly&rdquo; can vary A LOT.</p>
<h3>Dissecting AI Myths - 2. Exponential Evolution<span class="hx:absolute hx:-mt-20" id="dissecting-ai-myths---2-exponential-evolution"></span>
    <a href="#dissecting-ai-myths---2-exponential-evolution" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Whoever just arrived and is seeing frequent news about releases of new LLM versions like Alibaba&rsquo;s Qwen3 now, Xiaomi&rsquo;s new MiMo, Gemini version 2.5, ChatGPT version 4.1, etc has the impression that we are evolving by leaps and bounds.</p>
<p>But the truth is that yes, 2022 was a historical milestone with the original ChatGPT, mainly GPT 2. But from then until now, evolution has slowed down. Each new version is no longer visibly &ldquo;twice as good&rdquo; as the previous one, except in certain benchmarks, under certain specific conditions - that do not match reality. I will demonstrate this shortly.</p>
<p>A friend of mine reminded me about the evolution of deep learning technologies in image recognition, years earlier:</p>
<ul>
<li>
<p>2012 – AlexNet: First major victory on ImageNet with 8 layers, reaching 84.7% top-5 accuracy.​</p>
</li>
<li>
<p>2014 – VGG: Networks with 16–19 layers, incremental improvements, but with a significant increase in parameters.​</p>
</li>
<li>
<p>2015 – ResNet: Introduction of residual connections, allowing networks with more than 100 layers and surpassing human performance in top-5 accuracy.​</p>
</li>
<li>
<p>2016–2017 – Inception, ResNeXt, SENet: More complex models with marginal gains.​</p>
</li>
<li>
<p>Post-2018: Top-1 accuracy stabilizes around 85–88%, with marginal gains even in models like EfficientNet and NasNet.</p>
</li>
</ul>
<p>AI has been studied since literally the beginning of modern computing in the late 1930s, by none other than Alan Turing himself or John Von Neumann. I talked about them here:</p>


<div class="embed-container">
  <iframe
    src="https://www.youtube.com/embed/G4MvFT8TGII"
    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>Think of a &ldquo;slow beginning&rdquo;: from 1930 until around 2012 when ImageNet and things like AlphaGo, GANs and such started to pick up speed. We left the bottom leg of the &ldquo;S&rdquo; for the middle starting in the 2010s and have been accelerating until now. I &ldquo;guess&rdquo; that we are either already at, or heading toward, the top of the &ldquo;S&rdquo;, where the curve stabilizes and slows down. That is what happened with deep learning for images:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/lgr7uodnbmqfujjw39h69mnybi8t" alt="ImageNet"  loading="lazy" /></p>
<p>Remember AlphaGo? I followed it live. As a hobbyist amateur in Go, I watched live the games with the South Korean masters like Lee Sedol, at the time it happened, I think 2016. You probably just watched the <a href="https://www.netflix.com/br-en/title/80190844"target="_blank" rel="noopener">documentary on Netflix</a> only now, but this has been going on for years. And now, where is AlphaGo evolving exponentially? Everyone has forgotten already, because the hot thing of the moment is LLMs, which is just one more category in the AI world.</p>
<p>The vast majority of optimizations have already been done or are being done. If I had to summarize just some of the most important milestones of this part of the generation, it would go roughly like this:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/z14qvijztqvy6prvbpokk02fqrjt" alt="timeline llm"  loading="lazy" /></p>
<p>Every engineer and scientist knows this: when you have nothing, anything is a quantum leap. When you only have 1 real, 2 reais is already &ldquo;double&rdquo;. When you have 10 reais, 20 is already a lot. But when you get to 20 thousand, another 100 no longer adds as much value as when you only had 1 (there it would be 100x).</p>
<p>Every new drop of optimization now COSTS A LOT. Even in lossy optimization (quantization) we already went from fp32, moved to fp16, fp8, already reached 1-bit. There is nowhere further down to go after that. We are at the point where we are trading precision for performance.</p>
<p>Doubling parameters no longer results in doubling result quality. Need to double to get 5%? Maybe? And it is no use to keep increasing, the result starts getting worse instead of better. That is why we already jumped to things like specialist LLMs, MoE (Mixture of Experts).</p>
<p>Deep thinking/Chain-of-Thought/Reasoning was a big leap, but just increasing more thinking no longer delivers as much either, in fact, in many cases it makes the result worse with &ldquo;over-thinking&rdquo;. Thinking too much is not linear either.</p>
<p>Increasing context does not help improve the answers either. On the contrary, we already see that past a certain point, more context hurts the result, because the attention architecture (the great differentiator of LLMs), has limits. Yes, a Deepseek or Gemini claim to support &ldquo;1 million tokens&rdquo;. But I already explained in previous articles that this is <strong>Sliding Window Attention</strong> like SDPA or Flash where the attention is focused only on a PART of that large context: it cannot pay attention to everything all the time.</p>
<p>Every spot where you could put the obvious things, like caching, whether in training or in generation, has already been put there. We are running out of options here. And yes, new discoveries continue to happen, but as I said, none has yet managed to push past the limits we know exist.</p>
<h3>Demystifying AI Myths - 3. Ending Programmers<span class="hx:absolute hx:-mt-20" id="demystifying-ai-myths---3-ending-programmers"></span>
    <a href="#demystifying-ai-myths---3-ending-programmers" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>What I hate most about the current hype are the false promises of &ldquo;AGI&rdquo;, &ldquo;Vibe Coding&rdquo;, &ldquo;replacing employees with MCPs&rdquo;. Let me state it again: it is all propaganda.</p>
<p>Lying propaganda and false snake oil promises (something that always existed and will always exist in history) DEVALUES the reality of the innovations we have today.</p>
<p><em>&ldquo;But Anthropic&rsquo;s CEO and uncle Zuck said they will replace programmers. Microsoft and Google said that 30% of their code already comes out of AI.&rdquo;</em></p>
<p>As I said before about statistics. Yes: a lot of stuff IS going to be done with AI indeed. You don&rsquo;t need AI to optimize work in several areas:</p>
<ul>
<li>
<p>a stick and a clay block are enough to replace people&rsquo;s memory. Nobody remembers details, especially numerical ones, permanently. For millennia we have been evolving in helping our memory with some kind of external storage. Whether a clay block, whether paper, whether a post-it, whether a .txt file on your machine.</p>
</li>
<li>
<p>the first photographic camera already removed the need to hire a painter to have a &ldquo;selfie&rdquo;.</p>
</li>
<li>
<p>the first primitive computer already eliminated the need for an entire building of mathematicians doing and redoing calculations on paper. Even so, this is how the first rockets went to space. Have you watched the movie <a href="https://www.netflix.com/title/80123775"target="_blank" rel="noopener">Hidden Figures</a>? I recommend it.</p>
</li>
<li>
<p>cars have been assembled almost entirely by robots for years now. Robotics is nothing new in industry. These days, a tablet has already replaced supermarket and McDonald&rsquo;s cashiers. All of this without needing AI. IVRs and little bots have already replaced a lot of telemarketing and support.</p>
</li>
</ul>
<p>So yes, in practice, AI will also replace an entire class of programmers: the low-value-added ones. The ones who don&rsquo;t know how to do much more than copy and paste, or whose work routine is repetitive. If your job involves repetitive tasks, it is obvious that it is a matter of time until they get automated, whether by AI or mere scripts. Something we already do today.</p>
<p>A programmer&rsquo;s job has always been to replace other people&rsquo;s work. You, who work building an e-commerce system: you helped replace the work of salespeople, window dressers, store decorators, cashiers. You, who build ticketing and support systems, you already replaced human attendants. You, who build delivery systems, already replaced telephone operators. You, who build financial systems, already replaced bank clerks, the people who build bank branches, etc.</p>
<p>You, programmer, have always worked replacing other people&rsquo;s work. It was a matter of time until programmers replaced the work of programmers. Call it <strong>poetic justice</strong>. It is just evolution. It happens. Nothing in the world is made to be static nor guaranteed.</p>
<p>Here is the good news: as always happens. There are still painters, even though there are photographers. There are still salespeople, even though there is e-commerce. There is still construction, even though there is not as much need for stores or even offices (with home office). There still exist and will continue to exist, doctors, lawyers, accountants, several professions. The difference is that before, an accounting firm needed a building full of people who knew how to use abacuses and write spreadsheets on paper. Today a single accountant who knows how to use Excel replaces an entire floor. In the old days, an advertising agency was an entire building, today it is one person using Canva, CapCut and Photoshop.</p>
<p>Programmer is the same thing. Before it was an entire building with a purple ball pit. Tomorrow it will be one, GOOD, programmer with Cursor and Aider. Who disappears in this game? Whoever did the low-value-added grunt work.</p>
<p>I warned this was going to happen. For 5 years, before this generation of LLMs and AI even existed. Because it is the natural course of things. The weekend online course programmer. That is the first one who is going to disappear.</p>
<p>Do you know calculus? Do you know Algebra? Do you know Linear Algebra? Do you know Probability Statistics? Do you know what a statistical distribution is? Do you know what vectors or tensors are? Do you know what a Hilbert space is? No? I would take precautions if I were you. If all the articles I published this month of April/2025 were very difficult, you already know what you need to study.</p>
<h3>My Experience with LLMs<span class="hx:absolute hx:-mt-20" id="my-experience-with-llms"></span>
    <a href="#my-experience-with-llms" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>I have been using LLMs for 2 years. Every kind of research or code I do passes through some LLM in some capacity. I use it heavily, really. My ChatGPT history is gigantic.</p>
<p>And after doing another intensive round to make sure. Yes, o4-mini-high, Sonnet 3.7, Gemini 2.5, Qwen3, Deepseek-R1, etc are all extremely useful and none of them, absolutely none, can satisfy me.</p>
<p>Vibe Coding is enormous nonsense. Nobody without programming knowledge can build more than super simple software (and full of bugs) using only LLMs and prompts or MCPs. That is a fact.</p>
<p><em>&ldquo;But evolution is exponential, next year it will be perfect.&rdquo;</em></p>
<p>It won&rsquo;t. I already argued that point above.</p>
<p><em>&ldquo;But the benchmarks say they already manage to produce 99% perfect code.&rdquo;</em></p>
<p>I already argued that point too, your interpretation is mistaken.</p>
<p>We are near or already at the beginning of the top of the &ldquo;S&rdquo; curve. And I see no problem at all with that. I understand how LLMs are made, I understand how they are optimized, I understand what they can do and, more importantly, I understand what they are incapable of doing.</p>
<p>Look at the <a href="https://github.com/akitaonrails/Tiny-Qwen-CLI/pulls"target="_blank" rel="noopener">pull requests</a> of every attempt I made to do code refactoring and unit tests with the main LLMs on the market, open and closed. What I see:</p>
<ul>
<li>it needs LOTS of prompting and lots of instruction to get them to start spitting out code that works.</li>
<li>they look like an intern, the famous &ldquo;it works on my machine&rdquo;. They come out spitting, a bunch of copy-pasted code, and don&rsquo;t re-check whether it makes sense or whether it was needed. The amount of useless code they generate is scary.</li>
<li>they repeat the same mistakes, even when warned not to commit them.</li>
<li>even with &ldquo;agents&rdquo;, &ldquo;MCP&rdquo;, even running the code that was generated and giving the error stacktrace, they easily lose focus, lose attention and cannot fix it. Even retrying multiple times to fix the same error. Even giving hints. Even writing at length what is wrong. They quickly enter a loop. Especially if the so-called &ldquo;thinking&rdquo; is enabled. They think too much and don&rsquo;t solve it.</li>
<li>I&rsquo;m tired of seeing attempts to randomly go around tweaking the code, in parts I didn&rsquo;t even ask them to touch, and breaking more than fixing.</li>
<li>none of them was able to generate unit tests that work on the first try. Some managed to write a few simple ones, after giving them the error and asking them to fix it. Some get stuck in a thinking loop, confused, and go around changing things without fixing. In the end they fail.</li>
<li>Out of nowhere they start hallucinating. Qwen 3 itself, if I try to raise temperature just a bit, from 0.1 to 0.2, out of nowhere Chinese characters start coming out in the middle of the code, like this:</li>
</ul>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/j0rnlba6loxmo32pyyjxy8diteor" alt="chinese char"  loading="lazy" /></p>
<ul>
<li>Every now and then, more than one LLM pulled off the feat of generating a unit test that gave a recursive error. This filled the stacktrace so much that if I tried to pass it to the model (over 12 thousand tokens), it would blow the context window and from there it couldn&rsquo;t pay attention to anything else:</li>
</ul>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/eqh6ytb84mvcpzr92dk7kge2exaj" alt="recursive error"  loading="lazy" /></p>
<ul>
<li>
<p>since they mess up a lot, I repeatedly and exhaustively give it chances to fix things. I run the code it generated, send the error stacktrace, ask it to fix. It touches another part of the code. I run again, it errors and I repeat. If you do this more than half a dozen times, the context starts filling up, sliding window makes it split attention, it &ldquo;forgets&rdquo; what came before, starts repeating the same error and stays there forever.</p>
</li>
<li>
<p>it does not matter whether it supports 8 thousand tokens, 30 thousand tokens, 100 thousand tokens or 1 million tokens of context. That did not influence the quality of the code nor improved this routine of error repetition. It never manages to pay attention to everything.</p>
</li>
<li>
<p>there is no point trying to tune key sampling. whether with temperature 0, 0.1, 0.6. It only makes it err a little later or a little earlier, but it quickly errs and quickly becomes unable to fix its own mistakes. Tools like Aider take the code it suggested and write it over the real code file. Several times, from filling the context too much, it starts to &ldquo;forget&rdquo; and delete chunks of code. Thank goodness GIT exists, because several times I needed to run <code>git checkout</code> to go back to the beginning. If you don&rsquo;t check, be careful, it deletes code or overwrites it with nonsense, and this is frequent.</p>
</li>
<li>
<p>deep thinking/reasoning/chain-of-thought, whatever you prefer to call it, just means it spends a lot of time thinking in excess and filling the context with filler, eating up your tokens. If it&rsquo;s your commercial API, be careful: you will burn through your credits very fast and the result is not significantly better. There are isolated cases where it helps. I recommend leaving it off by default and turning it on only in a few special cases.</p>
</li>
</ul>
<p>And look: my example project is ridiculous: it is 5 files. 4 of them have fewer than 100 lines of code, the biggest one has 400-something lines. A real, large project is MILLIONS of lines of code. It does not fit in any context window of any Gemini or Deepseek. And even if it did fit, it would only make the model more confused. The good practice in any tool is to give it the minimum number of files. And that restricts the use cases to local and isolated refactorings, never things like renaming a class or function that is used in hundreds of files. For that, keep using your IntelliJ.</p>
<p>No model I tested is perfect. Some models do a bit better in some cases. On the first attempt I made with Gemini 2.5, it actually managed to perform the tasks I asked. It did not produce marvelous code, but at least it didn&rsquo;t break anything.</p>
<p>The second time, I ran <code>git reset --hard</code> and started over, with the same files and the same prompts. This time it was extremely disappointing. It broke the main code during the refactoring and was unable to finish a unit test that works, even though I forced it several times. I gave up out of exhaustion.</p>
<p>The third time, it gave a different result, improved the code a bit and managed to finish the same test. But it also required a few rounds of broken tests, giving stacktraces and asking it to fix obvious errors. And when I say &ldquo;obvious&rdquo; it is for it to fix a mock that it itself had placed without needing to.</p>
<p>And this is consistent across all the other models. If I run it only once, I get an impression. For example, that Claude is better than Qwen3. But if I run it again, the same test, now Claude disappoints me and Qwen3 jumps ahead. There is never an objectively better winner. They are all more or less at the same position and keep shuffling in the ranking depending on how many times you repeat exactly the same test, with the same code and the same prompts.</p>
<p>And who keeps repeating the same thing? Nobody. Everybody accepts the first answer. And the first answer can be QUITE wrong. At an obvious level of wrong. If you accept it automatically, you will insert garbage into your repo. And that is my problem with things like MCP or agents: if you leave everything on autopilot and ask things like &ldquo;take all the code files in this directory and fix every bug&rdquo;. It will, with full certainty, generate more bugs, delete code, vanish files and leave the final repository in a worse state than before.</p>
<p>Pure Vibe Coding, with zero interaction from a programmer, does not work. I tested all the main models. None was able to pass simple tests. None will be able to pass real larger tests. Not without a lot of interaction from a good programmer to reject errors and demand it try again, or manually fix what came out wrong.</p>
<p>And guess what: a coder who only knows how to copy and paste from stackoverflow (and now from ChatGPT), is unable to recognize many of these errors. So instead of helping, he may make things worse.</p>
<p>For me, who knows exactly what I want, the errors are obvious and I automatically reject them. And I reject what every LLM does more than I accept. Many times I just grab a snippet of what it suggested and discard all the rest. That is the real day-to-day. Everyone who says otherwise has not written code more complicated than a Hello World.</p>
<p>Why do you think every example of <em>&ldquo;wow, look at the LLM writing code on its own&rdquo;</em> is always writing a factorial function? Or a crappy web page? Because that is all it actually manages to do. No MCP example I have seen so far has impressed me. It was all smoke. And you got fooled by the propaganda.</p>
<p>Now you know.</p>
<p>How do you learn more and avoid getting fooled? Start by learning the fundamentals. 1 year ago I made several videos explaining how LLMs work. You should already have watched them:</p>
<p><a href="https://www.youtube.com/playlist?list=PLdsnXVqbHDUeowsAO0sChHDY4D65T5s1U"target="_blank" rel="noopener"><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/7tshjfwbly7wudczbni2pa863f11" alt="Playlist I.A."  loading="lazy" /></a></p>
<p>And how to become a programmer who will not be replaced by AI? That is what I made <a href="https://www.youtube.com/@Akitando"target="_blank" rel="noopener">my channel</a> for. When everything I said becomes common knowledge for you, congratulations, you have a good chance of surviving.</p>
]]></content:encoded><category>llms</category><category>vibe-coding</category><category>llm-benchmarks</category><category>off-topic</category></item></channel></rss>