<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Frank | AkitaOnRails.com</title><link>https://akitaonrails.github.io/en/tags/frank/</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:51 +0000</lastBuildDate><atom:link href="https://akitaonrails.github.io/en/tags/frank/index.xml" rel="self" type="application/rss+xml"/><item><title>Frank GO: Playing Go with AI</title><link>https://akitaonrails.github.io/en/2026/07/06/frank-go-playing-go-with-ai/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2026/07/06/frank-go-playing-go-with-ai/</guid><pubDate>Mon, 06 Jul 2026 12:00:00 -0300</pubDate><description>&lt;p&gt;&lt;img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/07/06/frank-go/key-art-hikaru-no-go.webp" alt="Official Hikaru no Go anime key art: Hikaru at the center holding a Go stone out to the camera, with the ghost Sai in Heian-era robes and fan behind him, surrounded by the cast (Akira, Waya, Isumi, Akari and company) and the Shonen Jump logo." loading="lazy" /&gt;&lt;/p&gt;
&lt;p&gt;I got into the ancient game of Go because of the manga &lt;strong&gt;Hikaru no Go&lt;/strong&gt;. I even wrote about it &lt;a href="https://akitaonrails.github.io/en/2008/07/15/off-topic-por-que-programadores-devem-jogar-go/"&gt;back in 2008, in a post that&amp;rsquo;s still up&lt;/a&gt;, arguing why programmers should play Go. Since then, my use case has barely moved: I own a physical goban at home, but I never had the slightest intention of joining a study group, a federation, or a tournament. I just like playing solo, solving tsumego (Go&amp;rsquo;s life-and-death puzzles), and loading famous games to study, like the legendary Lee Sedol vs AlphaGo games from 2016.&lt;/p&gt;</description><content:encoded><![CDATA[<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/07/06/frank-go/key-art-hikaru-no-go.webp" alt="Official Hikaru no Go anime key art: Hikaru at the center holding a Go stone out to the camera, with the ghost Sai in Heian-era robes and fan behind him, surrounded by the cast (Akira, Waya, Isumi, Akari and company) and the Shonen Jump logo."  loading="lazy" /></p>
<p>I got into the ancient game of Go because of the manga <strong>Hikaru no Go</strong>. I even wrote about it <a href="/en/2008/07/15/off-topic-por-que-programadores-devem-jogar-go/">back in 2008, in a post that&rsquo;s still up</a>, arguing why programmers should play Go. Since then, my use case has barely moved: I own a physical goban at home, but I never had the slightest intention of joining a study group, a federation, or a tournament. I just like playing solo, solving tsumego (Go&rsquo;s life-and-death puzzles), and loading famous games to study, like the legendary Lee Sedol vs AlphaGo games from 2016.</p>
<p>For anyone who doesn&rsquo;t know the manga: Hikaru no Go (written by Yumi Hotta, drawn by Takeshi Obata, the same artist as Death Note) ran in Shonen Jump from 1998 to 2003 and set off a Go boom across all of Japan. And here&rsquo;s a detail few people know: practically every game shown in the manga is based on a real historical game, recreated panel by panel. Hold onto that, because it comes back in a minute.</p>
<h2>SGF and the scattered-material problem<span class="hx:absolute hx:-mt-20" id="sgf-and-the-scattered-material-problem"></span>
    <a href="#sgf-and-the-scattered-material-problem" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>There are several very good open source programs for loading Go games or even playing online. Go games are recorded in a format called <strong>SGF</strong> (Smart Game Format), a plain-text format that&rsquo;s been around since the late 80s: each move becomes a coordinate (<code>;B[pd]</code> is a black stone on the 4-4 point), and the format supports variation trees, comments, and game metadata. It&rsquo;s the de facto standard of the Go world: every client reads SGF, every server exports SGF, and there&rsquo;s a gigantic archive of historical games and problems in this format accumulated over decades.</p>
<p>And that&rsquo;s where the problem lives: that archive is <strong>scattered across the entire internet</strong>. One site has the classic tsumego collections, another has the historical games, one GitHub repo has the commented problems, another has the professional kifu (game records). I was in no mood to download everything by hand, organize it folder by folder, and configure client by client. I wanted an app good enough, pretty enough, with everything built in.</p>
<h2>2008: the GNU Go era<span class="hx:absolute hx:-mt-20" id="2008-the-gnu-go-era"></span>
    <a href="#2008-the-gnu-go-era" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Here&rsquo;s where the historical context earns its keep, because it&rsquo;s the fun part. In 2008, when I wrote that post, the only accessible Go &ldquo;AI&rdquo; was <a href="https://www.gnu.org/software/gnugo/"target="_blank" rel="noopener">GNU Go</a>. And look, you couldn&rsquo;t even call that thing an AI: it was a pile of stacked heuristics. And it was terrible. Even an amateur like me could see it made moves with no flow, no sense of direction, loose stones that didn&rsquo;t talk to each other. It beat beginners on pure local tactics and nothing else.</p>
<p>What happened over the next 18 years is one of the most beautiful stories in computing. First came the Monte Carlo Tree Search methods, which already raised the level considerably.</p>
<p>Then in March 2016, DeepMind&rsquo;s <strong>AlphaGo</strong> beat Lee Sedol, one of the greatest players in history, 4 to 1. That was the series of the famous move 37 in game 2 (the shoulder hit no human would play) and Lee Sedol&rsquo;s answer in kind: move 78 in game 4, the &ldquo;hand of God&rdquo; that earned him his only win. If you&rsquo;ve never seen it, there&rsquo;s an <a href="https://www.netflix.com/title/80190844"target="_blank" rel="noopener">excellent documentary about that series on Netflix</a>, worth watching even if you know nothing about Go.</p>
<p>And after DeepMind published the papers, the open source community chased it down: first Leela Zero reimplementing AlphaZero, then KataGo going beyond it. Today anyone has a superhuman Go player running for free on their own computer. I wanted to see up close how much these AIs matured in 20 years.</p>
<p>That itch is what my new toy project was born from: <strong><a href="https://github.com/akitaonrails/frank_go"target="_blank" rel="noopener">Frank GO</a></strong>.</p>
<h2>Sabaki + KataGo<span class="hx:absolute hx:-mt-20" id="sabaki--katago"></span>
    <a href="#sabaki--katago" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>I asked Claude Code to research the most popular open source Go apps and engines (the whole survey is documented <a href="https://github.com/akitaonrails/frank_go/tree/main/docs/research"target="_blank" rel="noopener">in the repository itself</a>, as it should be). In the end I picked two projects as the foundation:</p>
<p><strong><a href="https://sabaki.yichuanshen.de/"target="_blank" rel="noopener">Sabaki</a></strong>, by Yichuan Shen, is the front-end. It&rsquo;s probably the most beautiful, best-kept open source SGF editor and Go board out there, built on Electron, with variation trees, analysis, and engine support via the GTP protocol. Elegant and minimalist.</p>
<p><strong><a href="https://github.com/lightvector/KataGo"target="_blank" rel="noopener">KataGo</a></strong>, by David Wu, is the AI. It&rsquo;s the strongest open source engine in the world, superhuman, heir to the AlphaZero lineage but with one difference that matters enormously for my case: beyond win rate, KataGo estimates <strong>score and territory ownership point by point</strong>. It answers &ldquo;who&rsquo;s winning, by how many points, and which areas of the board belong to whom.&rdquo; That&rsquo;s exactly the kind of information a beginner needs and that the old engines kept hidden.</p>
<p>Frank GO is a fork of Sabaki that wires in KataGo for solo play sessions, and bundles everything I wanted to have in one place:</p>
<ul>
<li><strong>Over 4,700 tsumego</strong> from the classic collections (Cho Chikun&rsquo;s Encyclopedia, the Gokyo Shumyo, the Xuanxuan Qijing), transcribed by Vít Brunner at <a href="https://tsumego.tasuki.org/"target="_blank" rel="noopener">tsumego.tasuki.org</a> and converted to SGF by the <a href="https://github.com/Seon82/tasuki2sgf"target="_blank" rel="noopener">tasuki2sgf</a> project, plus the 420 commented problems from <a href="https://github.com/gogameguru/go-problems"target="_blank" rel="noopener">Go Game Guru&rsquo;s go-problems</a> (by An Younggil, 8-dan professional), organized into 10 difficulty levels. Solve 5 in a row and you level up. A focus picker filters by life &amp; death, tesuji, ko, or semeai (capturing races).</li>
<li><strong>The puzzles fight back</strong>: with KataGo installed, it answers your moves inside the problem, and when it gives up the area the app judges the result and marks the problem solved automatically.</li>
<li><strong>Famous historical games to study</strong>, from the Ear-Reddening Game (1846, by the legendary Honinbo Shusaku) to AlphaGo vs Lee Sedol, curated from <a href="https://homepages.cwi.nl/~aeb/go/games/"target="_blank" rel="noopener">Andries Brouwer&rsquo;s game database at CWI</a>, each with the story behind it, and the AlphaGo ones with move-by-move commentary. You can watch them in auto-play or turn on &ldquo;guess the moves&rdquo; and try to find the pro&rsquo;s play.</li>
<li><strong>The Hikaru no Go games</strong>: remember how the manga&rsquo;s games are real? Well, 15 of them are bundled with chapter references and trivia, using the <a href="https://senseis.xmp.net/?HikaruNoGoGames"target="_blank" rel="noopener">chapter-to-game mapping the community compiled on Sensei&rsquo;s Library</a>. Sai&rsquo;s internet game against Toya Meijin, for instance, is a real game decided by half a point.</li>
<li><strong>A joseki dictionary</strong> (<a href="https://waterfire.us/joseki.htm"target="_blank" rel="noopener">Kogo&rsquo;s Joseki Dictionary</a>, the classic corner-opening reference), ladder drills, a &ldquo;who is winning?&rdquo; drill, and a ten-problem rank test that estimates your level and drops you straight into the right practice.</li>
</ul>
<p>Remember the scattered-material problem? Those collections above are exactly it: each one lived in its own corner of the internet. The provenance and licensing of every set is documented in the repo&rsquo;s <a href="https://github.com/akitaonrails/frank_go/blob/main/data/SOURCES.md"target="_blank" rel="noopener">data/SOURCES.md</a>, because you use other people&rsquo;s archives by giving proper credit.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/07/06/frank-go/tsumego-practice.jpg" alt="Tsumego practice in Frank GO: a corner problem on the board with the area painting overlay on, and the side panel showing level 1, focus, and the note that KataGo answers your moves."  loading="lazy" /></p>
<h2>Overlays for people just starting out<span class="hx:absolute hx:-mt-20" id="overlays-for-people-just-starting-out"></span>
    <a href="#overlays-for-people-just-starting-out" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The part of Sabaki I changed the most was building beginner-friendly visual overlays. The main one is <strong>area painting</strong>: an overlay that paints each player&rsquo;s influence as a soft gradient and settled territory in solid color, so you literally <strong>see</strong> what the stones are doing, which areas are dead and which are contested.</p>
<p>Alongside it comes the <strong>real-time score</strong> accounting for komi (White&rsquo;s point compensation for playing second), which warns you when you&rsquo;re hopelessly behind or when the game is already settled. And hovering over the board shows the move names (&ldquo;Hane&rdquo;, &ldquo;One-Point Jump&rdquo;), the vocabulary the books use and that beginners take forever to absorb.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/07/06/frank-go/study-mode.jpg" alt="Frank GO’s study mode: Honinbo Shusai’s retirement game (1938) loaded on the board, with area painting dimming the dead stones and the side panel telling the story of the game immortalized in Kawabata’s novel “The Master of Go”."  loading="lazy" /></p>
<p>That was my problem with every Go software I ever tried: they&rsquo;re tools built by strong players for strong players. A winrate percentage says nothing to someone who&rsquo;s learning. &ldquo;You&rsquo;re 12 points behind and the lower left corner just died&rdquo; says everything. KataGo always knew how to compute this; someone just had to put it on screen in a way a beginner understands.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/07/06/frank-go/practice-panel.jpg" alt="Frank GO’s practice panel greeting you and suggesting the ten-problem rank test to find your level."  loading="lazy" /></p>
<p>The idea is to be a one-stop shop, zero configuration, for beginners and students to learn Go at their own pace. And there&rsquo;s a build for everywhere. On Arch Linux it&rsquo;s <code>yay -S frank-go</code> via AUR. On <strong>Windows</strong> there&rsquo;s an installer and a portable build on the <a href="https://github.com/akitaonrails/frank_go/releases"target="_blank" rel="noopener">Releases page</a>; the installer has no code signing, so SmartScreen complains on first run — just click &ldquo;More info → Run anyway&rdquo; (<a href="/en/2026/05/30/open-source-best-practices-llm-the-minimum/">I explain why I don&rsquo;t sign Windows builds in the best-practices post</a>). On <strong>macOS</strong> it&rsquo;s <code>brew install --cask akitaonrails/tap/frank-go</code>, or the <code>.dmg</code> for Apple Silicon and Intel; that build is signed with a Developer ID and notarized by Apple, opens with zero drama. And on any platform you can run from source with <code>npm install</code>.</p>
<p>In every case the app downloads a small CPU engine with one click, no GPU, no account, no server, fully offline. The exception is macOS, where KataGo has no official binary: a <code>brew install katago</code> unlocks play-vs-AI, and everything else (tsumego, study, drills, area painting) works without it. Your progress stays on your machine.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/07/06/frank-go/study-and-drills.jpg" alt="Frank GO’s study and drills menu: study a famous game, Hikaru no Go games, joseki dictionary, who-is-winning drill, ladder drill, and rank test."  loading="lazy" /></p>
<h2>And when you want to play against people<span class="hx:absolute hx:-mt-20" id="and-when-you-want-to-play-against-people"></span>
    <a href="#and-when-you-want-to-play-against-people" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Frank GO is deliberately a solo study app. When you&rsquo;ve learned and want to play against humans, the natural path is the online servers, and there the more advanced apps serve you better. <a href="https://online-go.com/"target="_blank" rel="noopener">OGS (Online Go Server)</a> is the friendliest today, runs right in the browser. <a href="https://www.gokgs.com/"target="_blank" rel="noopener">KGS</a> and <a href="https://www.pandanet-igs.com/"target="_blank" rel="noopener">Pandanet IGS</a> (the original Internet Go Server, running since the early 90s) are the veterans, and the Asian servers like Fox and Tygem concentrate the strongest players. Organized Go revolves around the <a href="https://www.intergofed.org/"target="_blank" rel="noopener">IGF (International Go Federation)</a> and the national associations, with rankings and tournaments for anyone who wants to take it seriously.</p>
<p>I don&rsquo;t. And that&rsquo;s the whole point of the project: like I said up top, I have zero desire to compete. What I wanted, for literally years, was a way to play and have fun with Go at my own pace, with decent study material in one place, against an AI that plays beautifully instead of spitting out senseless stones like the GNU Go of 2008. I think I finally pulled it off.</p>
<p>The code is MIT, the repo is <a href="https://github.com/akitaonrails/frank_go"target="_blank" rel="noopener">github.com/akitaonrails/frank_go</a>, and contributions are welcome. There&rsquo;s even an <a href="https://github.com/akitaonrails/frank_go/issues/6"target="_blank" rel="noopener">open issue asking for character portraits</a> for the Hikaru no Go mode, if anyone feels like drawing. Sai thanks you.</p>
]]></content:encoded><category>frank</category><category>llms</category><category>artificial-intelligence</category><category>gaming</category></item><item><title>Complete Manga Solution: Frank Manga+, Frank Yomik, and the Prettify-Manga Extension</title><link>https://akitaonrails.github.io/en/2026/05/30/manga-plus-shueisha-on-the-desktop-frank-manga-plus/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2026/05/30/manga-plus-shueisha-on-the-desktop-frank-manga-plus/</guid><pubDate>Sat, 30 May 2026 09:00:00 -0300</pubDate><description>&lt;p&gt;&lt;img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/05/30/manga-plus/logo.png" alt="Frank Manga&amp;#43; logo: the name stylized in red and white over a black background." loading="lazy" /&gt;&lt;/p&gt;
&lt;p&gt;I&amp;rsquo;m a subscriber to the &lt;strong&gt;MANGA Plus by Shueisha&lt;/strong&gt; app on Android&amp;rsquo;s Google Play Store. For those who don&amp;rsquo;t know, this is Shueisha&amp;rsquo;s official channel (the Japanese publisher behind Shonen Jump) for reading their manga legally, with chapters releasing weekly nearly the same day they&amp;rsquo;re published in Japan. Two dollars a month gets you the entire catalog with an official English translation.&lt;/p&gt;</description><content:encoded><![CDATA[<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/05/30/manga-plus/logo.png" alt="Frank Manga&#43; logo: the name stylized in red and white over a black background."  loading="lazy" /></p>
<p>I&rsquo;m a subscriber to the <strong>MANGA Plus by Shueisha</strong> app on Android&rsquo;s Google Play Store. For those who don&rsquo;t know, this is Shueisha&rsquo;s official channel (the Japanese publisher behind Shonen Jump) for reading their manga legally, with chapters releasing weekly nearly the same day they&rsquo;re published in Japan. Two dollars a month gets you the entire catalog with an official English translation.</p>
<p>I own a Samsung ZFold 7, one of the few phones that&rsquo;s actually good for reading manga and Kindle (I talked about this and about <a href="/en/2026/05/14/wrapping-up-my-ai-marathon-success-or-failure/">Frank Yomik</a> in the AI marathon final ranking). The Fold opened gives you a bigger screen than a normal phone, which helps a lot. But there are days when my eyes are already tired of tiny screens, and what I really wanted was to read on a 32-inch OLED monitor that sits right in front of me, perfect for long sessions.</p>
<p>The problem is that <strong>MANGA Plus has no official website for reading</strong>. The subscription is locked to Android. There&rsquo;s an iOS version too, but no web. A tablet helps, but nothing beats the comfort of a big, static screen when you want to read a whole One Piece arc in one go.</p>
<p>For context, I caught up on a lot recently: I finally finished Chainsaw Man, Dan Da Dan, Jujutsu Kaisen, Kagurabachi, Akane-banashi, Sakamoto Days and, of course, One Piece. There&rsquo;s a lot more I want to follow, and the comfort of the big monitor became a real problem worth solving.</p>
<p>The main act here is <strong>Frank Manga+</strong>, which fixes Shueisha&rsquo;s desktop gap. At the end I also cover two pieces of my setup: <a href="#prettify-manga">Prettify Manga Reader</a>, for turning fansub sites and Kindle Web into tolerable readers, and <a href="#frank-yomik">Frank Yomik in the browser</a>, for furigana/translation on Kindle Japan and Naver.</p>
<h2>Decompiling the app<span class="hx:absolute hx:-mt-20" id="decompiling-the-app"></span>
    <a href="#decompiling-the-app" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>I decided to dig in. I sat down with Claude Code and we downloaded the official MANGA Plus xapk. We decompiled it to Java source with <a href="https://github.com/skylot/jadx"target="_blank" rel="noopener">jadx</a> and navigated around until we understood how the app authenticates with Shueisha&rsquo;s API.</p>
<p>The conclusion is straightforward: the user&rsquo;s &ldquo;subscription,&rdquo; from the app&rsquo;s point of view, is a 32-character hexadecimal <strong>deviceSecret</strong> stored in a plain XML file inside Android&rsquo;s shared preferences. The app generates this secret once when you log in, and from then on every API call (<code>jumpg-api.tokyo-cdn.com</code>) is signed with it. No elaborate DRM, no rotating token, none of that. A single static secret per device.</p>
<p>The catch is that <strong>extracting that secret from the app is a hassle</strong>, because by default you don&rsquo;t have access to <code>/data/data/jp.co.shueisha.mangaplus/shared_prefs/config.xml</code> on a non-rooted Android. Three paths exist:</p>
<ol>
<li><strong>Rooted phone</strong>: run <code>adb shell su -c &quot;cat /data/data/jp.co.shueisha.mangaplus/shared_prefs/config.xml&quot;</code> and look for <code>&lt;string name=&quot;secret&quot;&gt;...&lt;/string&gt;</code>.</li>
<li><strong>Rooted emulator</strong>: install Android Studio, create an AVD with Google Play x86_64, apply <a href="https://gitlab.com/newbit/rootAVD"target="_blank" rel="noopener">rootAVD</a> for Magisk root, log into the Google account that holds your subscription, install MANGA Plus from the Play Store, then run the same <code>adb shell</code> above.</li>
<li><strong>Network capture with mitmproxy</strong> (more advanced): run the rooted emulator, inject the mitmproxy CA via Conscrypt APEX, and intercept the secret on the app&rsquo;s first authenticated call.</li>
</ol>
<p>All three paths are documented in detail in the repo&rsquo;s <a href="https://github.com/akitaonrails/frank_mangaplus/blob/main/docs/install.md"target="_blank" rel="noopener">docs/install.md</a> and <a href="https://github.com/akitaonrails/frank_mangaplus/blob/main/docs/debugging.md"target="_blank" rel="noopener">docs/debugging.md</a>. I won&rsquo;t repeat them step by step here because it&rsquo;s more about tooling than technique, and anyone willing to put in the work will want to read the original. The important thing is that it <strong>works</strong>, and you only have to do it <strong>once</strong>: once extracted, the secret keeps the desktop app working for as long as your subscription lasts.</p>
<p>Yes, it&rsquo;s annoying. But it&rsquo;s the price for having a single configuration point that unlocks the rest.</p>
<h2>The desktop app<span class="hx:absolute hx:-mt-20" id="the-desktop-app"></span>
    <a href="#the-desktop-app" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>With the secret in hand, the rest was easy. I built <strong>Frank Manga+</strong>, a native app in <strong>Rust + Tauri</strong>, with a SvelteKit WebView on the frontend and a Rust client using <code>reqwest</code> on the backend talking directly to Shueisha&rsquo;s API. To load chapter images without leaking URLs or fighting CORS, I created a custom <code>mpimg://</code> scheme intercepted by Tauri, which downloads, decrypts (the API serves pages with a simple XOR), and hands the result back to the WebView.</p>
<p>The app opens on a library view with search:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/05/30/manga-plus/library.png" alt="Frank Manga&#43; open on the Library/Search tab showing a grid of manga covers: One Piece, Sakamoto Days, Kagurabachi, Jujutsu Kaisen, Akane-banashi, Chainsaw Man, Dan Da Dan, among others."  loading="lazy" /></p>
<p>Clicking a title takes you to a detail page with the full chapter list and a &ldquo;Continue&rdquo; button that picks up from the last chapter you read:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/05/30/manga-plus/title-detail.png" alt="Kagurabachi detail page in Frank Manga&#43;: large cover on the left, synopsis, and a virtualized list of numbered chapters on the right with a Continue button at the top."  loading="lazy" /></p>
<p>The reader opens in single-page, with snap-per-page and manga-style click zones: click the <strong>left half</strong> of the page to advance and the <strong>right half</strong> to go back (Japanese right-to-left reading direction). If you&rsquo;d rather use the keyboard, arrows, Space, <code>j</code>/<code>k</code>, and PageUp/PageDown all work. When you hit the end of a chapter, the next one prefetches and gets appended to the scroll, so you don&rsquo;t have to bounce back to the chapter list every time. And resume is per-chapter: leave mid-read and the next time you open that chapter you land on the exact page you stopped at. For huge series like One Piece (1100+ chapters), the list is virtualized, so it opens instantly:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/05/30/manga-plus/reader.png" alt="Frank Manga&#43; reader in single-page showing page 12 of 19 of a Kagurabachi chapter: dramatic black-and-white scene of Chihiro wielding his sword, with a page indicator at the top and a progress bar at the bottom."  loading="lazy" /></p>
<p>On a 32-inch monitor, single-page wastes half the screen. That&rsquo;s why there&rsquo;s <strong>double-page mode</strong>: hit <code>D</code> (or click the layout icon in the header) and the reader starts pairing facing pages, so the whole spread fills the screen the way the author drew it. It&rsquo;s how manga was meant to be read.</p>
<p>You can see why that matters when you compare it with cheap binding. Bad paperbacks swallow the middle of the fold, exactly where the art is supposed to connect. In digital, when the reader joins it properly, the scene gets room to breathe:</p>
<table>
  <thead>
      <tr>
          <th>Frank Manga+ joining the spread</th>
          <th>Cheap paperback swallowing the fold</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/05/30/manga-plus/reader-double-page.png" alt="Frank Manga&#43; in double-page mode on a widescreen monitor: a dramatic black-and-white scene fills the entire screen, with a pigtailed character crying and the speech bubbles “I….” and “…WANT TO GET STRONGER.”."  loading="lazy" /></td>
          <td><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/05/30/manga-plus/kikoru-paperback-spread.jpeg" alt="A Kaiju No. 8 paperback opened on a Kikoru two-page spread, with the art swallowed and distorted by the spine in the center."  loading="lazy" /></td>
      </tr>
  </tbody>
</table>
<p><code>D</code> cycles through three layouts: <strong>single</strong> (one page at a time), <strong>double</strong> (sequential pairs starting from page 1), and <strong>double-cover</strong> (the first page of each chapter solo, the rest in pairs, the way printed manga binds the cover singly before the first spread). The choice persists across chapters and sessions, so you set it once and forget it.</p>
<p>There&rsquo;s also a night-reading filter. Hit <code>F</code> (or click the crescent-moon icon in the header) and the page whites warm toward sepia. LCD white is harsh on the eyes in the dark, and this softens it without flattening the contrast:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/05/30/manga-plus/reader-eye-protection.png" alt="Frank Manga&#43; with the night-reading filter active: a black-and-white double-page spread takes on a warm amber tone, with several panels and English dialogue bubbles."  loading="lazy" /></p>
<p><code>F</code> cycles through four levels: <strong>off → low → med → high → off</strong>. The button tints amber and shows one to three dots for the active level, and the choice is saved in <code>localStorage</code> like the reader&rsquo;s other prefs. Under the hood it&rsquo;s a CSS <code>sepia + brightness + saturate</code> filter on the whole page-stack: the sepia pushes the hue toward amber but preserves the luminance range, so blacks stay black and the artwork&rsquo;s contrast is intact.</p>
<p>Reading state (last chapter, current page) lives in the WebView&rsquo;s <code>localStorage</code>. Image cache at <code>~/.cache/mangaplus-reader/</code>. The extracted <code>deviceSecret</code> lives in <code>~/.config/mangaplus-reader/secret</code> (Linux/macOS) or <code>%APPDATA%\mangaplus-reader\secret</code> (Windows). You can override it with the <code>MANGAPLUS_SECRET</code> environment variable if you prefer.</p>
<h2>Install<span class="hx:absolute hx:-mt-20" id="install"></span>
    <a href="#install" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>There&rsquo;s a build ready for all the main platforms:</p>
<ul>
<li><strong>Linux</strong>: AppImage, <code>.deb</code>, <code>.rpm</code>, or via AUR (<code>yay -S mangaplus-reader-bin</code>).</li>
<li><strong>macOS</strong>: separate <code>.dmg</code> for Apple Silicon (aarch64) and Intel (x64).</li>
<li><strong>Windows</strong>: <code>.exe</code> installer. It&rsquo;s not signed, so Windows SmartScreen will complain, click &ldquo;More info → Run anyway&rdquo;.</li>
</ul>
<p>All artifacts come out on the <a href="https://github.com/akitaonrails/frank_mangaplus/releases"target="_blank" rel="noopener">GitHub Releases</a>.</p>
<h2>Status and contributions<span class="hx:absolute hx:-mt-20" id="status-and-contributions"></span>
    <a href="#status-and-contributions" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>This is <strong>beta</strong>. It works fine for my use (I read several series a week with no headaches), but it&rsquo;s missing features: genre filters in search, real offline mode (today it only caches what you&rsquo;ve already read), and a smoother secret-extraction path for less technical users.</p>
<p>If you subscribe to MANGA Plus and want to test it, send an issue or PR. The code is Rust + Svelte, MIT, contributions welcome: <a href="https://github.com/akitaonrails/frank_mangaplus"target="_blank" rel="noopener">github.com/akitaonrails/frank_mangaplus</a>.</p>
<p>Now I finally read One Piece on the big monitor, without having to hold a phone or twist my neck. The work paid off.</p>
<p><a id="prettify-manga"></a></p>
<h2>Bonus: Prettify Manga Reader for fansub sites<span class="hx:absolute hx:-mt-20" id="bonus-prettify-manga-reader-for-fansub-sites"></span>
    <a href="#bonus-prettify-manga-reader-for-fansub-sites" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Besides the desktop app, I keep a simpler extension around: <a href="https://github.com/akitaonrails/prettify-manga"target="_blank" rel="noopener">Prettify Manga Reader</a>. It does not translate anything. Its job is to fix the reading experience.</p>
<p>Most fansub sites are ugly, packed with banners, and obviously built for phones: one page under the next, endless vertical scroll, good luck. That&rsquo;s practical on a phone, but manga was not drawn like an Instagram feed. Manga was drawn for page turns. Two pages open at once. The reading rhythm, the reveal, the pause between panels, all of it assumes that pair.</p>
<p>And many authors use that to draw two-page spreads. <strong>Kaiju No. 8</strong> does it several times. If the site stacks one page under the other, you can still read the speech bubbles, but the full composition is gone. You&rsquo;re seeing half the art at a time and thinking you saw the whole thing.</p>
<p>The usual site looks like this:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/05/30/manga-plus/prettify-manga-reader-before.webp" alt="A typical fansub page before enabling the extension, with the manga page surrounded by the original site layout."  loading="lazy" /></p>
<p>Prettify opens a dark overlay, fits the art to the screen, and joins pages at the center seam when <strong>Book</strong> or <strong>Double</strong> mode is active. The spread looks like a real two-page spread again:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/05/30/manga-plus/prettify-manga-reader-spread.webp" alt="Prettify Manga Reader showing a two-page spread correctly joined at the center seam, full-screen on a dark background."  loading="lazy" /></p>
<p>It has three modes: <strong>Single</strong> (one page per screen), <strong>Double</strong> (pairs from the start), and <strong>Book</strong> (first page alone, then pairs, like binding). It opens in Book by default. If an image is already horizontal, it stays as a full-width spread. It has scroll snap so every page lands in the right place, keyboard shortcuts, small mouse controls, help with <code>?</code>, and an end-of-chapter card that tries to find previous/next chapter links when the site makes them obvious.</p>
<p>It also has a three-level night filter. Hit <code>N</code> and it warms the page with sepia, brightness, and contrast without turning the art into dead gray. A white manga page on a big monitor at night is a flashlight to the face. This fixes it.</p>
<p>To install it, go to the <a href="https://github.com/akitaonrails/prettify-manga"target="_blank" rel="noopener">Prettify Manga Reader GitHub repo</a>, download the zip from <strong>Releases</strong>, unzip it into a permanent directory, open <code>chrome://extensions</code> or <code>brave://extensions</code>, enable <strong>Developer mode</strong>, click <strong>Load unpacked</strong>, and select the unzipped folder. If you want to build from source, clone the repo, run <code>npm test</code>, then <code>npm run package</code>, unzip the package generated under <code>dist/</code>, and load that folder.</p>
<p>On Kindle Web Reader, Prettify deliberately does less. On URLs like <code>read.amazon.com/manga/...</code>, <code>read.amazon.co.jp/manga/...</code>, <code>read.amazon.co.uk/manga/...</code>, and <code>read.kindle.*</code> domains, it doesn&rsquo;t try to rebuild the book inside its own overlay. Amazon&rsquo;s reader already handles account, pages, and DRM; the extension only adds a discreet bottom-right toolbar and keyboard shortcuts on top of the native reader.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/05/30/manga-plus/prettify-manga-reader-kindle.webp" alt="Kindle Web Reader with Prettify Manga Reader’s discreet toolbar in the bottom-right corner, showing the Night button active to soften the manga page."  loading="lazy" /></p>
<p>Space, PageDown, and arrows advance the page; Shift+Space, PageUp, and the opposite arrows go back; Home/End jump to the beginning or end when the native reader accepts them, with a scroll fallback; <code>N</code> cycles the night filter. It doesn&rsquo;t store Amazon credentials, doesn&rsquo;t touch sessions, and needs no token in the code. It&rsquo;s just visual comfort and keyboard control on top of the Kindle Web that already exists.</p>
<p><a id="frank-yomik"></a></p>
<h2>Bonus 2: Frank Yomik now on the desktop too via extension<span class="hx:absolute hx:-mt-20" id="bonus-2-frank-yomik-now-on-the-desktop-too-via-extension"></span>
    <a href="#bonus-2-frank-yomik-now-on-the-desktop-too-via-extension" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Anyone who read the <a href="/en/2026/05/14/wrapping-up-my-ai-marathon-success-or-failure/">AI marathon recap</a> remembers <a href="https://github.com/akitaonrails/FrankYomik"target="_blank" rel="noopener">Frank Yomik</a>, my self-hosted project that takes a manga or webtoon page, detects the bubbles with RT-DETR-v2, runs OCR, sends it to a local Ollama (<code>qwen3:14b</code>) for translation, and returns the page with the translated text in place. I already covered the story and the technical details in <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>. The point here is the new desktop piece.</p>
<p>Originally it had only a Flutter client for Android and Linux, which opened Kindle Japan and Naver Webtoon inside a WebView. The thing is, running a Flutter app inside a WebView on the desktop just to read Kindle is using a sledgehammer to crack a nut. On the desktop you already have Chromium open. What was missing was a browser extension.</p>
<p>Over the past few weeks I added exactly that. A Manifest V3 extension for Chromium/Brave/Edge, talking to the same self-hosted Yomik server. It runs on <a href="https://read.amazon.co.jp"target="_blank" rel="noopener">read.amazon.co.jp</a>, <a href="https://read.kindle.co.jp"target="_blank" rel="noopener">read.kindle.co.jp</a>, <a href="https://comic.naver.com"target="_blank" rel="noopener">comic.naver.com</a>, and Naver&rsquo;s mobile version. The page stays visually identical to the original, with no injected buttons or HUD in the reader&rsquo;s face. All configuration lives in the popup:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/05/30/manga-plus/yomik-extension-popup.png" alt="Frank Yomik popup open over a Kindle Japan page showing Initial D in Japanese: fields for API base URL, auth token, checkboxes for Kindle reader and Naver Webtoon, manga pipeline dropdown set to Furigana, target language English, and a webtoon prefetch block."  loading="lazy" /></p>
<p>The content script grabs the current page image and sends it to the server. The bearer token sits in the service worker, so it never leaks to the page&rsquo;s JS. When the translation comes back, the image gets swapped in place of the original. There&rsquo;s a local cache to avoid re-translating pages you&rsquo;ve already seen, a &ldquo;force reprocess&rdquo; button for when you need to file a bug report, and autosave for the config. Details in the <a href="https://github.com/akitaonrails/FrankYomik/tree/main/extension"target="_blank" rel="noopener">extension README</a>, and the Load Unpacked zip ships with the repo&rsquo;s Releases.</p>
<p>In practice, the 32&quot; monitor is now my reading desk. Frank Manga+ opens Shueisha&rsquo;s official catalog in English (One Piece, Chainsaw Man, and whatever else Shonen Jump publishes). When it&rsquo;s fansub or Kindle Web and all I want is visual comfort, Prettify handles the reader, keyboard, and night filter. When I want to read something Japanese with no official translation, I open Kindle Japan in another tab and Yomik renders furigana or an LLM translation on top of the page. For Korean Naver webtoons, same story, same extension.</p>
<p>The nice part is that each project solves a different piece and they complement each other. Frank Manga+ exists because Shueisha has no website. Prettify makes browser readers less miserable. Yomik exists because the rest of the manga I read doesn&rsquo;t have a decent official translation in any reasonable timeframe. For a manga addict like me, it&rsquo;s the setup I&rsquo;d been wanting for years.</p>
]]></content:encoded><category>frank-yomik</category><category>frank</category><category>llms</category><category>artificial-intelligence</category></item><item><title>Turning YouTube into a Karaoke App | Frank Karaoke</title><link>https://akitaonrails.github.io/en/2026/04/05/turning-youtube-into-a-karaoke-app-frank-karaoke/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2026/04/05/turning-youtube-into-a-karaoke-app-frank-karaoke/</guid><pubDate>Sun, 05 Apr 2026 12:00:00 -0300</pubDate><description>&lt;p&gt;Project on GitHub: &lt;a href="https://github.com/akitaonrails/frank_karaoke"target="_blank" rel="noopener"&gt;github.com/akitaonrails/frank_karaoke&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/05/frank-karaoke/scoring-in-action.jpg" alt="Real-time scoring overlaid on a YouTube karaoke video" loading="lazy" /&gt;&lt;/p&gt;
&lt;p&gt;I&amp;rsquo;ve always loved karaoke. I go out to sing with family or friends every now and then. In São Paulo there are good places in Liberdade and Bom Retiro, for instance, with private Japanese-style booths. If you&amp;rsquo;ve never been to a karaoke like that: you rent a private room by the hour, there&amp;rsquo;s a huge song catalog, two microphones, and a scoring system that grades your singing in real time. The best systems are Japanese, like &lt;a href="https://www.joysound.com/"target="_blank" rel="noopener"&gt;Joysound&lt;/a&gt; and &lt;a href="https://www.clubdam.com/"target="_blank" rel="noopener"&gt;DAM&lt;/a&gt;. A score above 90 (out of 100) is considered advanced. DAM, in the LIVE DAM Ai series, even uses AI to give scores that feel more &amp;ldquo;human.&amp;rdquo;&lt;/p&gt;</description><content:encoded><![CDATA[<p>Project on GitHub: <a href="https://github.com/akitaonrails/frank_karaoke"target="_blank" rel="noopener">github.com/akitaonrails/frank_karaoke</a></p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/05/frank-karaoke/scoring-in-action.jpg" alt="Real-time scoring overlaid on a YouTube karaoke video"  loading="lazy" /></p>
<p>I&rsquo;ve always loved karaoke. I go out to sing with family or friends every now and then. In São Paulo there are good places in Liberdade and Bom Retiro, for instance, with private Japanese-style booths. If you&rsquo;ve never been to a karaoke like that: you rent a private room by the hour, there&rsquo;s a huge song catalog, two microphones, and a scoring system that grades your singing in real time. The best systems are Japanese, like <a href="https://www.joysound.com/"target="_blank" rel="noopener">Joysound</a> and <a href="https://www.clubdam.com/"target="_blank" rel="noopener">DAM</a>. A score above 90 (out of 100) is considered advanced. DAM, in the LIVE DAM Ai series, even uses AI to give scores that feel more &ldquo;human.&rdquo;</p>
<p>But not every place has that level.</p>
<h2>The problem with karaoke in Brazil<span class="hx:absolute hx:-mt-20" id="the-problem-with-karaoke-in-brazil"></span>
    <a href="#the-problem-with-karaoke-in-brazil" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>In Brazil we grew up with <a href="https://www.videoland.com.br/wwwroot/historia.asp"target="_blank" rel="noopener">Videokê</a>, the brand the Korean Seok Ha Hwang brought to the country in 1996, importing equipment from Korea. It became a craze in the 90s and 2000s, showed up in every bar, barbecue and birthday party. The problem is that those machines stopped in time. The current models, like the VSK 5.0, ship with around 12-13 thousand songs in the catalog, which you expand by buying cartridges or song packs. In practice, the repertoire is old, the interface is straight out of the 2000s, and if the song you want to sing came out after 2015, good luck.</p>
<p>The workaround a lot of bars adopted was to allow Chromecast or screen mirroring so that customers can search for songs directly on YouTube. Makes sense: on YouTube you can find karaoke for any song. With-lyrics version, instrumental version, vocal guide version.</p>
<p>But there&rsquo;s a downgrade: you lose the scoring. One of the most fun parts of karaoke is the competition. Watching your score climb, comparing with friends, trying to beat the night&rsquo;s record. If you&rsquo;re just singing on top of a YouTube video, you get no feedback. It&rsquo;s like bowling without a scoreboard.</p>
<p>And buying a professional system for home? Importing a Joysound F1 runs north of US$ 2,000 just for the hardware, not counting the monthly catalog subscription. For casual use it makes no sense.</p>
<h2>The idea: YouTube with real-time scoring<span class="hx:absolute hx:-mt-20" id="the-idea-youtube-with-real-time-scoring"></span>
    <a href="#the-idea-youtube-with-real-time-scoring" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p><a href="https://github.com/akitaonrails/frank_karaoke"target="_blank" rel="noopener">Frank Karaoke</a> came out of that frustration. If YouTube already has every song, why not build an app that works as a YouTube wrapper with a real-time scoring overlay? You search for any karaoke video, sing along, and the app analyzes your voice through the mic and shows a live score.</p>
<p>It&rsquo;s a Flutter app for Android. Internally it loads YouTube into a webview and injects an overlay in HTML/CSS/JavaScript right into the page. The score display, the pitch trail, the settings panel, the mode selector — all of it rendered inside the webview through JS injection.</p>
<picture>
  <source media="(prefers-color-scheme: dark)" srcset="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/05/frank-karaoke/karaoke-full-dark.png">
  <img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/05/frank-karaoke/karaoke-full-light.png" alt="Frank Karaoke">
</picture>
<h2>Scoring without a reference<span class="hx:absolute hx:-mt-20" id="scoring-without-a-reference"></span>
    <a href="#scoring-without-a-reference" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Now, the real problem. Every professional karaoke system depends on prebuilt reference files for each song. Every single one.</p>
<p>Sony&rsquo;s <a href="https://en.wikipedia.org/wiki/SingStar"target="_blank" rel="noopener">SingStar</a>, which sold over 12 million copies between 2004 and the end of the PS3 era, had a hand-crafted note track for every song. Every note, every syllable, all mapped manually. The mechanism compared the singer&rsquo;s pitch via FFT against that reference in real time. A detail I thought was clever: octave was ignored. If the right note was a C, it didn&rsquo;t matter if you sang C3 or C4. Men sing women&rsquo;s songs no problem.</p>
<p>Joysound and DAM in Japan go further and evaluate three separate dimensions: pitch accuracy (音感), rhythm/timing (リズム感) and expressiveness/dynamic volume (表現力). All based on MIDI data from the operator&rsquo;s server. The open source equivalent format is UltraStar, where each song has a <code>.txt</code> file like:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

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

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">git clone https://github.com/akitaonrails/frank_karaoke.git
</span></span><span class="line"><span class="cl"><span class="nb">cd</span> frank_karaoke
</span></span><span class="line"><span class="cl">flutter pub get
</span></span><span class="line"><span class="cl">flutter run -d &lt;device_id&gt;</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The README has the rest.</p>
<p>Stack: Flutter + Riverpod for state management, <code>webview_flutter</code> for YouTube, <code>youtube_explode_dart</code> for audio extraction, <code>record</code> for PCM mic capture, <code>audio_decoder</code> for reference decoding via Android MediaCodec, and the YIN algorithm implemented in pure Dart.</p>
<p>The technical documentation for the scoring system is in <a href="https://github.com/akitaonrails/frank_karaoke/blob/main/docs/scoring.md"target="_blank" rel="noopener"><code>docs/scoring.md</code></a> in the repository. It covers how SingStar, Joysound and DAM work, the academic papers, the pitch oracle architecture, the voice isolation problems on Android, and the roadmap.</p>
<h2>The scoring is experimental<span class="hx:absolute hx:-mt-20" id="the-scoring-is-experimental"></span>
    <a href="#the-scoring-is-experimental" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>I have to be straight: the scoring system is experimental. Without per-song reference files, the evaluation is approximate. The app measures whether you&rsquo;re in tune, whether you follow a melodic contour, whether your intervals are musical, whether you&rsquo;re consistent. But it doesn&rsquo;t tell you whether you&rsquo;re singing the correct melody for this specific song (unless the pitch oracle manages to download the audio, and that doesn&rsquo;t always work).</p>
<p>If you have experience with audio processing, pitch detection, or music evaluation, the repository is open and the research documentation in <a href="https://github.com/akitaonrails/frank_karaoke/blob/main/docs/scoring.md"target="_blank" rel="noopener"><code>docs/scoring.md</code></a> details what was tried, what works and what doesn&rsquo;t. In particular: tuning the modes&rsquo; thresholds, improving voice isolation, and integrating with <a href="https://github.com/rakuri255/UltraSinger"target="_blank" rel="noopener">UltraSinger</a> (which generates reference files from songs using Demucs + basic-pitch + WhisperX) are areas where contribution from people who know the subject would make a real difference. I&rsquo;d appreciate any help from specialists on calibrating these systems.</p>
<p>Oh, and the name. Frank Karaoke. It&rsquo;s a tribute to Sinatra. Who else?</p>
<p>Project on GitHub: <a href="https://github.com/akitaonrails/frank_karaoke"target="_blank" rel="noopener">github.com/akitaonrails/frank_karaoke</a></p>
]]></content:encoded><category>frank</category><category>llms</category><category>artificial-intelligence</category><category>audio-and-video</category></item><item><title>Teaching People to Question the News | Frank Investigator</title><link>https://akitaonrails.github.io/en/2026/03/27/teaching-people-to-question-the-news-frank-investigator/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2026/03/27/teaching-people-to-question-the-news-frank-investigator/</guid><pubDate>Fri, 27 Mar 2026 10:00:00 -0300</pubDate><description>&lt;p&gt;Heads up: &lt;a href="https://github.com/akitaonrails/frank_investigator"target="_blank" rel="noopener"&gt;Frank Investigator&lt;/a&gt; is an experimental project, in active development, and isn&amp;rsquo;t meant to be the final word on any article it analyzes. It doesn&amp;rsquo;t tell you what&amp;rsquo;s true or false. What it does is ask the questions the article refused to ask, identify known rhetorical patterns, and search for external sources the author left out. If you want to help, contribute on &lt;a href="https://github.com/akitaonrails/frank_investigator"target="_blank" rel="noopener"&gt;GitHub&lt;/a&gt; or send feedback. If you want to follow the results, &lt;a href="https://themakitachronicles.com/"target="_blank" rel="noopener"&gt;The Makita Chronicles&lt;/a&gt; newsletter is going to have a new section called &amp;ldquo;Notícias Duvidosas&amp;rdquo; (Dubious News) where I&amp;rsquo;ll publish the investigator&amp;rsquo;s summary and a link to the full report.&lt;/p&gt;</description><content:encoded><![CDATA[<p>Heads up: <a href="https://github.com/akitaonrails/frank_investigator"target="_blank" rel="noopener">Frank Investigator</a> is an experimental project, in active development, and isn&rsquo;t meant to be the final word on any article it analyzes. It doesn&rsquo;t tell you what&rsquo;s true or false. What it does is ask the questions the article refused to ask, identify known rhetorical patterns, and search for external sources the author left out. If you want to help, contribute on <a href="https://github.com/akitaonrails/frank_investigator"target="_blank" rel="noopener">GitHub</a> or send feedback. If you want to follow the results, <a href="https://themakitachronicles.com/"target="_blank" rel="noopener">The Makita Chronicles</a> newsletter is going to have a new section called &ldquo;Notícias Duvidosas&rdquo; (Dubious News) where I&rsquo;ll publish the investigator&rsquo;s summary and a link to the full report.</p>
<p>With that out of the way, let me explain why I built this.</p>
<h2>The problem with the Brazilian press<span class="hx:absolute hx:-mt-20" id="the-problem-with-the-brazilian-press"></span>
    <a href="#the-problem-with-the-brazilian-press" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>I&rsquo;m fed up.</p>
<p>Fed up of opening the newspaper and having to do mental gymnastics to separate information from narrative. Fed up of outlets like Folha de São Paulo, UOL, Carta Capital, Brasil 247, O Globo and several others that use misleading headlines, omit context on purpose, transpose evidence from other countries with no caveat, and create the appearance of consensus among outlets that are all saying the same thing because they&rsquo;re following the same coordinated agenda.</p>
<p>This isn&rsquo;t conspiracy theory. It&rsquo;s a verifiable editorial pattern. And the worst part: most readers don&rsquo;t have the time or the tools to notice. You read the headline, read the first two paragraphs, and walk away with the impression the article planted in your head.</p>
<h2>What Frank Investigator does<span class="hx:absolute hx:-mt-20" id="what-frank-investigator-does"></span>
    <a href="#what-frank-investigator-does" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>You give it a news article URL. The system fetches the article with a headless Chromium (to get past paywalls and cookie walls), extracts the content while filtering out ads and sidebars, and decomposes the text into verifiable claims. Then the real analysis starts: divergence between headline and body, rhetorical fallacies (false causality, appeal to authority, strawman, bait-and-pivot), source distortion, temporal manipulation, selective citation, authority laundering. It expands the links cited in the article to verify whether the sources actually say what the author claims. It evaluates each claim with consensus from 3 AI models (Claude Sonnet 4.6, GPT-5.4, Gemini 3.1 Pro) through OpenRouter. It detects contextual gaps, coordinated campaigns across outlets, and measures the ratio between passion and evidence. 15 stages in total.</p>
<p>The central principle is &ldquo;Truth Above Consensus&rdquo;: a primary source (official data, government document, original academic study) vetoes any number of secondary sources repeating the same information. Ten newspapers repeating the same thing without a primary source still adds up to zero.</p>
<p>Let me show you five real examples.</p>
<h2>Example 1: The Noelia Castillo case (BBC)<span class="hx:absolute hx:-mt-20" id="example-1-the-noelia-castillo-case-bbc"></span>
    <a href="#example-1-the-noelia-castillo-case-bbc" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/blog/2026/03/27/frank-investigator/noelia-original.png" alt="Original article on the BBC"  loading="lazy" /></p>
<p>The <a href="https://www.bbc.com/portuguese/articles/clyxedlekleo"target="_blank" rel="noopener">BBC published</a> a story with the headline &ldquo;Noelia Castillo dies: a 25-year-old&rsquo;s fight in the Spanish courts against her father to receive euthanasia.&rdquo; At first glance it looks like a story about the right to euthanasia and a family dispute. A young quadriplegic woman who fought against her religious father&rsquo;s opposition to exercise her right to die.</p>
<p>Except when you compare it with other outlets, like <a href="https://veja.abril.com.br/comportamento/a-decisao-extrema-tomada-por-espabhola-que-ficou-paraplegica-apos-agressao-sexual/"target="_blank" rel="noopener">Veja</a>, facts come up that the BBC almost completely omitted. And those facts change everything.</p>
<p>Noelia was taken from her family by the Spanish government at age 13 and placed under state custody. While she was under that custody, she suffered multiple gang rapes. The sexual violence resulted in serious psychiatric damage and a mental health history that already added up to 67% disability rating before the events of 2022. When she attempted suicide in October 2022 by jumping from the fifth floor of a building, she was left paraplegic. Her disability rating rose to 74%.</p>
<p>The euthanasia request was approved by Catalonia&rsquo;s Guarantee and Evaluation Commission. The procedure was scheduled for August 2, 2024, but was suspended for over 600 days because of the father&rsquo;s appeals. Five judicial instances ruled on it. The Constitutional Court dismissed any violation of fundamental rights. Spain&rsquo;s Supreme Court denied the appeal. The European Court of Human Rights rejected the suspension request. On Friday, March 26, 2026, Noelia underwent euthanasia at the Sant Camil Residential Hospital, in Catalonia&rsquo;s Garraf region.</p>
<p>But there&rsquo;s a detail that Veja mentions that&rsquo;s disturbing: Noelia reportedly expressed doubts before the procedure. And the hospital allegedly accelerated the process because her organs were already committed for donation.</p>
<p>The <a href="https://investigator.themakitachronicles.com/investigations/e5a27e016c"target="_blank" rel="noopener">Frank Investigator report</a> compared the coverage from several outlets.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/blog/2026/03/27/frank-investigator/noelia-contexto.png" alt="Event context and omitted facts"  loading="lazy" /></p>
<p>The cross-analysis of the articles showed that some outlets, like the BBC, omitted facts that change the interpretation of the entire case. Others, like Veja, included the full context. The episode of gang sexual violence in October 2022, the criminal investigation, the psychiatric history since age 13, all of that appears in some coverage but is completely absent in others. And among the outlets that omitted these facts, none touched on the ethical question of whether the physical basis for the euthanasia request derives from a suicide attempt.</p>
<p>The convergent framing across outlets is one of &ldquo;judicial battle,&rdquo; &ldquo;death she asked for,&rdquo; &ldquo;to stop suffering,&rdquo; &ldquo;to leave in peace,&rdquo; softening the definitive nature of the procedure and positioning Noelia as the heroic protagonist and the father as the obstructive antagonist. The father, Gerônimo Castillo, and his Christian Lawyers are labeled &ldquo;ultra-Catholics&rdquo; or &ldquo;ultra-conservatives&rdquo; without any independent source backing that editorial classification.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/blog/2026/03/27/frank-investigator/noelia-coordenada.png" alt="Coordinated narrative analysis - Noelia"  loading="lazy" /></p>
<p>Narrative coordination came in at 55%. It doesn&rsquo;t seem to be active coordination between newsrooms, but thematic editorial alignment: everyone bought the autonomy-of-the-individual narrative without questioning it. What raises the score is the convergence of omissions. No outlet explains the legal distinction between the ECHR &ldquo;actively authorizing&rdquo; the euthanasia and simply refusing the provisional protective measures the father requested, which is what actually happened. The medical and bioethical implications of approving euthanasia in a case stemming from a suicide attempt show up nowhere. And any voice critical of the procedure is automatically framed as religious or ideological, never as medical or legal.</p>
<p>It&rsquo;s the kind of case where the omission is the manipulation. The outlets that omitted these facts didn&rsquo;t lie at any point. But by framing it as a &ldquo;family dispute over euthanasia rights&rdquo; and omitting the causal chain (state custody → gang rapes → psychiatric damage → suicide attempt → paraplegia → euthanasia), the reader walks away with an impression that&rsquo;s radically different from reality. Comparing coverage is exactly the kind of thing the investigator does well: exposing what each outlet chose to show and what it chose to hide.</p>
<h2>Example 2: &ldquo;Government cuts taxes on nearly a thousand imports&rdquo; (UOL)<span class="hx:absolute hx:-mt-20" id="example-2-government-cuts-taxes-on-nearly-a-thousand-imports-uol"></span>
    <a href="#example-2-government-cuts-taxes-on-nearly-a-thousand-imports-uol" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/blog/2026/03/27/frank-investigator/corte-original.png" alt="Original article on UOL"  loading="lazy" /></p>
<p><a href="https://economia.uol.com.br/noticias/redacao/2026/03/26/de-remedios-a-lupulo-governo-corta-imposto-de-mil-importados.ghtm"target="_blank" rel="noopener">UOL published</a> that the government cut import taxes on nearly a thousand products, from medicine to hops. Positive headline, framed as a benefit to the consumer. Several other outlets ran the same thing with similar framing: the government did something good, prices are going down.</p>
<p>Except <a href="https://www.gazetadopovo.com.br/economia/governo-aumenta-imposto-importacao-recua-fake-news/"target="_blank" rel="noopener">Gazeta do Povo</a> tells the other half of the story. The government didn&rsquo;t cut old taxes. What actually happened was: at some point before February 2025, the government raised import tariffs on more than 1,200 items, a measure that would have generated an estimated R$ 14 billion in revenue. Then, under social-media and public pressure, it partially backed down. Tariffs on around 970 capital goods, computing and telecommunications items were dropped to zero. Taxes on 120 IT products were reduced. And now they&rsquo;re calling that a &ldquo;tax cut.&rdquo;</p>
<p>In other words: they raised taxes, took public pressure, partially walked it back, and rebranded it as a generous concession. The majority of the 1,200+ items that had tariffs raised still have higher tariffs than before. No final consumer prices went down. Prices went back to where they were for some products, and remain higher for most.</p>
<p>The <a href="https://investigator.themakitachronicles.com/investigations/f35bfe0176"target="_blank" rel="noopener">Frank Investigator report</a> cross-checked the articles and exposed what was omitted.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/blog/2026/03/27/frank-investigator/corte-contexto.png" alt="Context and omitted facts - tax cut"  loading="lazy" /></p>
<p>What the comparison between articles exposes: none of the outlets with the positive framing mentions that most of the 1,200+ items with raised tariffs still have higher tariffs after the two rounds of &ldquo;cuts.&rdquo; The fiscal impact on the R$ 14 billion revenue target from the original increases — nobody calculates it. Voices from the domestic industry that may be hurt by the tariff reduction on imported competitors — none. The Gecex&rsquo;s objective criteria for defining &ldquo;insufficient supply in the internal market&rdquo; — they don&rsquo;t show up.</p>
<p>The two articles analyzed build the same positive framing for the government. The paradox that the &ldquo;cuts&rdquo; are a partial reversal of increases made by the same government the previous year stays buried or simply absent.</p>
<p>It&rsquo;s the classic kind of manipulation through reframing. Nobody lied. But &ldquo;government cuts taxes&rdquo; and &ldquo;government backs down on tax hike after public pressure&rdquo; describe the same event with opposite impressions. The editorial choice of which version to publish IS the manipulation.</p>
<h2>Example 3: &ldquo;Globo apologizes for PowerPoint&rdquo; (Brasil 247)<span class="hx:absolute hx:-mt-20" id="example-3-globo-apologizes-for-powerpoint-brasil-247"></span>
    <a href="#example-3-globo-apologizes-for-powerpoint-brasil-247" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/blog/2026/03/27/frank-investigator/globo-original.png" alt="Original article on Brasil 247"  loading="lazy" /></p>
<p>This case blew up over the past few days. GloboNews showed a diagram on the Estúdio I program connecting President Lula, his ministers and Daniel Vorcaro, owner of Banco Master, who&rsquo;s at the center of documented fraud. Globo later issued a public retraction, called the material &ldquo;erroneous and incomplete,&rdquo; and fired an editor.</p>
<p><a href="https://www.brasil247.com/brasil/globo-se-desculpa-por-powerpoint-que-tentou-jogar-o-caso-master-no-colo-de-lula"target="_blank" rel="noopener">Brasil 247</a> published a story with the headline &ldquo;Globo apologizes for PowerPoint that tried to dump the Master case on Lula&rsquo;s lap.&rdquo;</p>
<p>The <a href="https://investigator.themakitachronicles.com/investigations/752d80653a"target="_blank" rel="noopener">Frank Investigator report</a> exposed what&rsquo;s going on under the hood:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/blog/2026/03/27/frank-investigator/globo-resumo.png" alt="Investigation summary - Globo"  loading="lazy" /></p>
<p>The central fact is real: Globo apologized and fired someone. But Brasil 247&rsquo;s framing goes way beyond what the facts support. The headline saying &ldquo;tried to dump it on Lula&rdquo; attributes deliberate intent where the documents point to an editorial mistake. Globo&rsquo;s retraction described the material as &ldquo;erroneous and incomplete,&rdquo; not as an attempt to incriminate anyone.</p>
<p>What stands out in this case is the coordinated campaign. The investigator gave it 62% narrative coordination.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/blog/2026/03/27/frank-investigator/globo-coordenada.png" alt="Coordinated narrative analysis - Globo"  loading="lazy" /></p>
<p>Several outlets editorially aligned with the government used the phrase &ldquo;without evidence&rdquo; in a convergent way to describe the association between Lula and the Master case. All of them focused on Globo&rsquo;s mistake as the central narrative point, instead of investigating the actual connections. No outlet mentioned which other political names were excluded from the original PowerPoint. None investigated Vorcaro&rsquo;s documented connections with different spheres of power. The focus is meta-journalistic: they criticize the broadcaster instead of covering the scandal.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/blog/2026/03/27/frank-investigator/globo-retorica.png" alt="Rhetorical analysis - Globo"  loading="lazy" /></p>
<p>The fallacies detected: loaded language (&ldquo;tried to dump,&rdquo; &ldquo;without evidence&rdquo; used to frame an editorial mistake as a deliberate political attack), false causality (Globo&rsquo;s retraction doesn&rsquo;t prove the connections are false), cherry-picking (highlights the omission of names tied to the Lula government without contextualizing which other names were omitted), and bait-and-pivot (uses Globo&rsquo;s apology as a hook to minimize the Banco Master scandal).</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/blog/2026/03/27/frank-investigator/globo-lacunas.png" alt="Contextual gaps - Globo"  loading="lazy" /></p>
<p>And the questions none of these outlets asked: which Supreme Court justices and politicians from other parties were also excluded from the PowerPoint? Does the Master case have documented connections to Lula government figures, or are they limited to previous administrations? Does Brasil 247, which is publishing this article, have a declared editorial alignment with the Lula government? What was the journalism Ethics Council&rsquo;s reaction?</p>
<p>Overall confidence: 13%. The article doesn&rsquo;t fabricate facts. But it selects, frames and omits in a way that builds a narrative the data doesn&rsquo;t support.</p>
<h2>Example 4: &ldquo;Why expensive fuel is good&rdquo; (Folha)<span class="hx:absolute hx:-mt-20" id="example-4-why-expensive-fuel-is-good-folha"></span>
    <a href="#example-4-why-expensive-fuel-is-good-folha" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/blog/2026/03/27/frank-investigator/combustivel-original.png" alt="Original article on Folha"  loading="lazy" /></p>
<p>The economist Bernardo Guimarães published a <a href="https://www1.folha.uol.com.br/colunas/bernardo-guimaraes/2026/03/por-que-combustivel-caro-e-bom.shtml"target="_blank" rel="noopener">column on Folha</a> arguing that expensive fuel is good for society because it stimulates innovation in clean energy. He cites real academic articles (Popp 2002, NBER) and has verifiable academic credentials (PhD from Yale, professor at FGV EESP). It looks solid.</p>
<p>The <a href="https://investigator.themakitachronicles.com/investigations/e6cd2ac867"target="_blank" rel="noopener">full Frank Investigator report</a> shows another picture.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/blog/2026/03/27/frank-investigator/combustivel-summary.png" alt="Investigation summary - fuel"  loading="lazy" /></p>
<p>The article is right to cite real studies. But it omits who pays the bill. The column completely ignores the distributive impact: low-income populations, residents of peripheral and rural areas, who depend more on personal vehicles and have less access to clean alternatives. For an economist, ignoring distributive effects is either incompetence or editorial choice.</p>
<p>There&rsquo;s a worse problem. The empirical evidence he cites (US patents, electric vehicle data in California between 2014-2017) is transposed to Brazil with no caveat at all. Brazil has an ethanol matrix and flex-fuel infrastructure that completely changes the causal mechanism. The article treats it as if the Brazilian consumer were in the same situation as the Californian one, which is false.</p>
<p>And there&rsquo;s the context the article mentions in passing but doesn&rsquo;t develop: the war in Iran is making fuel prices rise around the world. Brazil should be in a privileged position thanks to the pre-salt and to ethanol. But decades of mismanagement and corruption at Petrobras mean we&rsquo;re paying the same price as the rest of the world. Instead of questioning that, the column sells the idea that &ldquo;at least it&rsquo;ll stimulate clean energy.&rdquo; It&rsquo;s a rationalization of a problem that shouldn&rsquo;t exist.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/blog/2026/03/27/frank-investigator/combustivel-lacunas.png" alt="Contextual gaps - fuel"  loading="lazy" /></p>
<p>The investigator identified 5 questions the article refused to address, with 35% contextual completeness. The fallacies detected include false dilemma (presents expensive fuel as the only viable climate policy), cherry-picking (acknowledges short-term inelasticity but emphasizes only long-term effects), and loaded language (describes alternatives as &ldquo;playing at planting a little sapling&rdquo;).</p>
<p>Overall confidence: 25%. It isn&rsquo;t fabricated disinformation. It&rsquo;s opinion with cherry-picked evidence in favor of the thesis and omission of relevant counterpoints.</p>
<h2>Example 5: &ldquo;There&rsquo;s no strong cinema without streaming regulation&rdquo; (O Globo)<span class="hx:absolute hx:-mt-20" id="example-5-theres-no-strong-cinema-without-streaming-regulation-o-globo"></span>
    <a href="#example-5-theres-no-strong-cinema-without-streaming-regulation-o-globo" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/blog/2026/03/27/frank-investigator/cinema-original.png" alt="Original article on O Globo"  loading="lazy" /></p>
<p>Renata Magalhães, president of the Brazilian Academy of Cinema, gave <a href="https://oglobo.globo.com/blogs/miriam-leitao/post/2026/03/renata-magalhaes-nao-existe-cinema-forte-sem-regulamentacao-do-streaming.ghtml"target="_blank" rel="noopener">an interview in Miriam Leitão&rsquo;s column on O Globo</a> arguing that regulating streaming is a necessary condition for strengthening Brazilian cinema.</p>
<p>The <a href="https://investigator.themakitachronicles.com/investigations/7e4f5605c5"target="_blank" rel="noopener">Frank Investigator report</a> found serious problems.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/blog/2026/03/27/frank-investigator/cinema-resumo.png" alt="Investigation summary - cinema"  loading="lazy" /></p>
<p>First: the central claim that &ldquo;many films have low audiences&rdquo; appears with no empirical data. No numbers, no historical series. It&rsquo;s a pure authority claim with no analytical basis.</p>
<p>Second: there&rsquo;s an internal contradiction the article doesn&rsquo;t resolve. The text opens by saying that Brazilian cinema is &ldquo;in international spotlight&rdquo; (awards, festivals). And then immediately argues that the absence of regulation prevents the industry from being strengthened. But if Brazilian cinema is already winning international awards without that regulation, the argument that the regulation is a necessary condition collapses. The article doesn&rsquo;t address that contradiction.</p>
<p>And here&rsquo;s the elephant in the room that none of these articles mentions: people simply don&rsquo;t want to watch most of these films. Internationally awarded Brazilian cinema is made to compete in Cannes and at the Oscars, not to fill movie theaters in Brazil. Instead of asking why the Brazilian audience isn&rsquo;t interested, the industry would rather ask for streaming regulation to force platforms to fund and screen content that has no spontaneous audience. It&rsquo;s the classic playbook: use public money and regulation to keep alive an industry that doesn&rsquo;t sustain itself in the market.</p>
<p>The interviewee is the president of the Brazilian Academy of Cinema. She has a direct institutional interest in the regulation. The article doesn&rsquo;t present any opposing voice and doesn&rsquo;t discuss the costs to consumers: subscription price increases, catalog reduction. A single source, with a declared conflict of interest, with no counterpoint.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/blog/2026/03/27/frank-investigator/cinema-coordenada.png" alt="Coordinated narrative analysis - cinema"  loading="lazy" /></p>
<p>And here comes the coordinated campaign, with 55% narrative coordination. Multiple outlets reproduce the same emotional framing: cinema &ldquo;at risk,&rdquo; regulation as &ldquo;essential.&rdquo; None of the identified sources discusses comparable empirical international evidence on the effectiveness of content quotas (Europe has experiences with contradictory results). None mentions the Brazilian Academy of Cinema&rsquo;s conflicts of interest. The fact that the internationally awarded Brazilian productions were made without the proposed regulation is omitted convergently across all outlets. Only one isolated site (targethd.net) mentioned negative impacts on consumers.</p>
<p>Overall confidence: 9%. The article is legitimate editorial advocacy, but with analytical flaws that limit its informational value to nearly zero.</p>
<h2>What the investigator analyzes<span class="hx:absolute hx:-mt-20" id="what-the-investigator-analyzes"></span>
    <a href="#what-the-investigator-analyzes" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The 5 examples above show different patterns, but the analysis criteria are the same.</p>
<p>The strongest signal of manipulation is omission. It isn&rsquo;t what the article says that misleads, it&rsquo;s what it leaves out. The contextual gap analysis identifies the questions the article should have answered and didn&rsquo;t, and searches for counter-evidence on each one. In the examples above, articles that omit most of the relevant context aren&rsquo;t informing anyone. And when cross-comparison between outlets shows that some covered the facts and others didn&rsquo;t, like in the Noelia case or the tax cut, it&rsquo;s hard to argue that the omission was accidental.</p>
<p>Then comes the detection of coordinated campaigns. Several newspapers covering the same subject is normal. All of them using the same loaded language, focusing on the same points and omitting the same counterpoints at the same time isn&rsquo;t. The strongest signal of coordination isn&rsquo;t what outlets say in common, but what they omit in common.</p>
<p>There&rsquo;s also reframing, which is more subtle. In the tax case, the government raised tariffs, backed down under pressure, and the outlets called it a &ldquo;cut.&rdquo; Nobody lied technically, but the choice of framing completely changes the interpretation. This kind of manipulation is harder to detect because each individual statement is defensible.</p>
<p>The rhetorical fallacies catch specific constructions: false dilemma (&ldquo;either you regulate or cinema dies&rdquo;), bait-and-pivot (open with a positive fact and pivot to a crisis narrative), loaded language (&ldquo;without evidence&rdquo; used to attribute intent). Each detected fallacy comes with the exact citation of the passage and the explanation of why that construction is problematic.</p>
<p>And there&rsquo;s the principle that ties it all together: if 10 outlets repeat the same claim citing each other, the LLM consensus has to reflect that the chain of evidence is circular, not that the claim is well supported. Volume of coverage is not a proxy for truth.</p>
<h2>Why you can&rsquo;t &ldquo;just ask ChatGPT&rdquo;<span class="hx:absolute hx:-mt-20" id="why-you-cant-just-ask-chatgpt"></span>
    <a href="#why-you-cant-just-ask-chatgpt" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The first reaction many people will have is: &ldquo;why do I need this if I can just paste the article into ChatGPT and ask it to analyze?&rdquo;</p>
<p>Try it. Take a political article and ask ChatGPT to criticize it. It&rsquo;ll criticize. Take the same article and ask it to confirm. It&rsquo;ll find arguments to confirm. The LLM isn&rsquo;t searching for truth. It&rsquo;s predicting which response you most likely want to hear given the framing of your question. If you ask &ldquo;analyze the problems with this article,&rdquo; the model will find problems. If you ask &ldquo;is this article correct?&rdquo;, it&rsquo;ll find merits. It&rsquo;s automated confirmation bias.</p>
<p>There&rsquo;s another problem. General-purpose LLMs are trained to be agreeable. The sycophantic tendency (agreeing with the user) is documented in every large model. If your conversation history indicates you&rsquo;re left-leaning, the model tends to frame answers in a way that pleases that profile. If you&rsquo;re right-leaning, same thing in the other direction. It&rsquo;s not lying on purpose. It&rsquo;s optimizing for user satisfaction, which is literally the metric it was trained for via RLHF.</p>
<p>And worse: LLMs hallucinate. If they don&rsquo;t have enough evidence to support the answer they think you want to hear, they invent it. They fabricate plausible citations and data, attribute statements to people who never made them. If you ask it to criticize an article about fuel, it might invent a fictional study that &ldquo;proves&rdquo; the opposite of the article. It sounds convincing. But it doesn&rsquo;t exist.</p>
<p>Frank Investigator was built precisely to avoid these problems. The first design decision is that no human asks the LLM a question. There&rsquo;s no open-ended prompt like &ldquo;analyze this article.&rdquo; Each step in the pipeline has structured prompts that ask for specific analyses: &ldquo;list the rhetorical fallacies in this passage,&rdquo; &ldquo;identify which contextual information is missing,&rdquo; &ldquo;compare the headline with the body.&rdquo; The model doesn&rsquo;t know whether the operator agrees or disagrees with the article, because the operator never expresses an opinion. That eliminates confirmation bias at the root.</p>
<p>To deal with hallucination, every analyzer that uses an LLM includes the instruction &ldquo;CRITICAL — NO HALLUCINATION: Only reference URLs, sources, claims, quotes, and data that are EXPLICITLY present in the input provided to you. Do not invent, guess, or fabricate any URL, source name, statistic, quote, or claim. If you cannot verify something from the provided text, mark it as unverifiable — never fill in details.&rdquo; It doesn&rsquo;t eliminate hallucination completely, but it cuts it down a lot. And since 3 models from different companies (Anthropic, OpenAI, Google) answer the same questions, when one hallucinates the other two usually disagree. The consensus is weighted by confidence, not by simple majority. If two models say &ldquo;supported&rdquo; with 70% confidence and one says &ldquo;mixed&rdquo; with 95%, the &ldquo;mixed&rdquo; weighs more. The further apart the disagreement, the bigger the penalty on final confidence. If one model starts giving inconsistent answers, it gets put in quarantine and the other two carry on.</p>
<p>But the safeguard I consider most important is the primary source veto. If a primary source (IBGE data, court ruling, original study) contradicts a claim, confidence is capped at 60% and the verdict is forced to &ldquo;mixed,&rdquo; even if all 3 LLMs say &ldquo;supported.&rdquo; Ten newspaper articles repeating a claim don&rsquo;t override one official datum that contradicts it. Along the same lines, if 5 stories &ldquo;confirm&rdquo; a claim but they all come from the same editorial group (Folha/UOL, Globo/G1/Valor), the system knows they&rsquo;re the same voice and reduces the weight. Volume doesn&rsquo;t replace independence.</p>
<p>None of this makes the system perfect. But it&rsquo;s categorically different from pasting text into ChatGPT and asking &ldquo;what do you think?&rdquo;.</p>
<h2>The numbers<span class="hx:absolute hx:-mt-20" id="the-numbers"></span>
    <a href="#the-numbers" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><table>
  <thead>
      <tr>
          <th>Metric</th>
          <th>Value</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Commits</td>
          <td>129</td>
      </tr>
      <tr>
          <td>Active days of work</td>
          <td>~9</td>
      </tr>
      <tr>
          <td>Lines of Ruby (code)</td>
          <td>19,444</td>
      </tr>
      <tr>
          <td>Lines of test code</td>
          <td>9,190</td>
      </tr>
      <tr>
          <td>Test files</td>
          <td>108</td>
      </tr>
      <tr>
          <td>Total lines (code)</td>
          <td>24,301</td>
      </tr>
      <tr>
          <td>Services (analyzers + services)</td>
          <td>80</td>
      </tr>
      <tr>
          <td>Misinformation analyzers</td>
          <td>15</td>
      </tr>
      <tr>
          <td>ActiveRecord models</td>
          <td>14</td>
      </tr>
      <tr>
          <td>Background jobs</td>
          <td>19</td>
      </tr>
      <tr>
          <td>Database migrations</td>
          <td>31</td>
      </tr>
      <tr>
          <td>Pipeline stages</td>
          <td>15</td>
      </tr>
      <tr>
          <td>LLM models in consensus</td>
          <td>3</td>
      </tr>
      <tr>
          <td>Locales</td>
          <td>2 (en, pt-BR)</td>
      </tr>
  </tbody>
</table>
<p>Stack: Ruby 4.0.1, Rails 8.1.2, SQLite with WAL mode, Solid Queue (jobs inside Puma), Solid Cable (WebSockets), Tailwind CSS v4, headless Chromium via Ferrum CDP, deploy with Kamal to GitHub Container Registry. AGPL-3.0.</p>
<h2>The development process<span class="hx:absolute hx:-mt-20" id="the-development-process"></span>
    <a href="#the-development-process" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The project has 129 commits in 9 active days of work (March 11-16 and 25-27). The first day was the heaviest: more than 60 commits on March 11 alone, going from zero to a working system with content extraction, claim decomposition, LLM evaluation, and a web interface with live updates via Turbo Streams.</p>
<p>The commits tell the story. It started with the foundation:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

<div><pre><code>9430c63 - Harden risky attachment analysis and warnings
bdc503d - Harden screenshot capture with failure recovery and pipeline timeout
125e73a - Separate suspect content from submitter signature in forwarded emails
6f7a522 - Handle forwarded message fidelity</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Malicious attachments that could exploit the parser. Site screenshots that could lock up headless Chrome. Sender signatures that were being confused with the content of the suspicious email. Forwarded emails that lost fidelity in the forward. Each of these fixes a different attack vector or edge case.</p>
<p>Security isn&rsquo;t a feature you implement once. It&rsquo;s a continuous process of &ldquo;what didn&rsquo;t I think could go wrong?&rdquo;</p>
<h2>The numbers<span class="hx:absolute hx:-mt-20" id="the-numbers"></span>
    <a href="#the-numbers" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><table>
  <thead>
      <tr>
          <th>Metric</th>
          <th>Value</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Commits</td>
          <td>38</td>
      </tr>
      <tr>
          <td>Hours of work (~)</td>
          <td>17</td>
      </tr>
      <tr>
          <td>Lines of Ruby</td>
          <td>14,616</td>
      </tr>
      <tr>
          <td>Ruby files</td>
          <td>161</td>
      </tr>
      <tr>
          <td>Application code (app/)</td>
          <td>8,217 lines</td>
      </tr>
      <tr>
          <td>Test code (test/)</td>
          <td>5,312 lines</td>
      </tr>
      <tr>
          <td>Test/code ratio</td>
          <td>0.65</td>
      </tr>
      <tr>
          <td>Analysis layers</td>
          <td>6</td>
      </tr>
      <tr>
          <td>Async jobs</td>
          <td>20 classes</td>
      </tr>
      <tr>
          <td>Data models</td>
          <td>9 tables</td>
      </tr>
      <tr>
          <td>Commits/hour</td>
          <td>~2.2</td>
      </tr>
      <tr>
          <td>Lines/hour</td>
          <td>~860</td>
      </tr>
  </tbody>
</table>
<p>860 lines per hour. Obviously AI-assisted development. But look at the hardening commits: none of them came from the LLM suggesting &ldquo;hey, let&rsquo;s protect against spoofing.&rdquo; That was me stopping and thinking &ldquo;wait, what if someone spoofs the authorized sender?&rdquo;, &ldquo;what if an attacker uses IOC poisoning against my reporting system?&rdquo;. The LLM doesn&rsquo;t ask those questions on its own. It implements them when you ask, but the one who has to spot the hole is you.</p>
<h2>A serious warning: DO NOT offer this as a service<span class="hx:absolute hx:-mt-20" id="a-serious-warning-do-not-offer-this-as-a-service"></span>
    <a href="#a-serious-warning-do-not-offer-this-as-a-service" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>If you looked at Frank FBI and thought &ldquo;cool, I&rsquo;m going to offer this as a SaaS to other people,&rdquo; I have one piece of advice: <strong>DON&rsquo;T do that</strong>.</p>
<p>I can think of several ways to exploit a service like this offered to the public. The operator would have access to every email forwarded by users — personal information, corporate data, sensitive correspondence. A centralized service becomes a high-value target: compromise the server and you have access to a continuous stream of confidential emails from people who are already in a vulnerable situation.</p>
<p>I know enough people to know that whoever would deploy this as a service wouldn&rsquo;t worry about encryption at rest or about destroying the emails after analysis. And who guarantees that the operator won&rsquo;t read users&rsquo; emails? Nobody. It&rsquo;s the kind of thing that looks like a useful service but in practice creates a honeypot of sensitive data managed by someone with no incentive to protect it.</p>
<p>Frank FBI was built to be self-hosted. To run on your server, under your control, with your data staying with you. Or in your company&rsquo;s infrastructure, managed by your IT team.</p>
<p>And the project is licensed under AGPL-3.0. If you use my code and offer it as a network service, you&rsquo;re legally required to release all the derived code. No exceptions. I picked AGPL for that reason — to make sure nobody takes the project, adds tracking and telemetry, and offers it as a &ldquo;free email verification service&rdquo; while collecting user data behind the scenes.</p>
<p>The <a href="https://github.com/akitaonrails/frank_fbi"target="_blank" rel="noopener">repository is here</a>. AGPL-3.0.</p>
]]></content:encoded><category>frank</category><category>llms</category><category>artificial-intelligence</category><category>security</category></item><item><title>My First Vibe Code Failure and How I Fixed It | Frank Yomik</title><link>https://akitaonrails.github.io/en/2026/03/05/my-first-vibe-code-failure-frank-yomik/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2026/03/05/my-first-vibe-code-failure-frank-yomik/</guid><pubDate>Thu, 05 Mar 2026 08:00:00 -0300</pubDate><description>&lt;p&gt;Before anyone asks, all the code is in &lt;a href="https://github.com/akitaonrails/FrankYomik/"target="_blank" rel="noopener"&gt;this repository&lt;/a&gt;. And there are pre-built client binaries on the &lt;a href="https://github.com/akitaonrails/FrankYomik/releases"target="_blank" rel="noopener"&gt;releases page&lt;/a&gt;. But the app alone isn&amp;rsquo;t enough because it needs the server component, which runs on a machine of yours (local or cloud) with a GPU of at least 16GB of VRAM, and that you need to configure — I&amp;rsquo;m not going to maintain a public server, just a personal one for my own private use.&lt;/p&gt;</description><content:encoded><![CDATA[<p>Before anyone asks, all the code is in <a href="https://github.com/akitaonrails/FrankYomik/"target="_blank" rel="noopener">this repository</a>. And there are pre-built client binaries on the <a href="https://github.com/akitaonrails/FrankYomik/releases"target="_blank" rel="noopener">releases page</a>. But the app alone isn&rsquo;t enough because it needs the server component, which runs on a machine of yours (local or cloud) with a GPU of at least 16GB of VRAM, and that you need to configure — I&rsquo;m not going to maintain a public server, just a personal one for my own private use.</p>
<p>&ndash;</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/03/screenshot-2026-03-05_07-52-11.png" alt="kindle library"  loading="lazy" /></p>
<p>I have a huge collection of manga bought on Amazon.co.jp that I read through <a href="read.amazon.co.jp">Kindle web</a>. Shounen manga normally has furigana — that small text in hiragana next to the kanji — that helps me read, because, despite having studied Japanese, I was never formally trained. But manga aimed at adult audiences (seinen, not porn) usually comes without furigana. It&rsquo;s pure kanji and my reading speed crawls.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/03/screenshot-2026-03-05_07-53-26.png" alt="furigana"  loading="lazy" /></p>
<p>For years I&rsquo;ve wanted a tool that solves this. The idea is simple: detect the speech bubbles on a manga page, extract the text with OCR, and either add furigana to the kanji, or translate directly to English and render it back into the bubble. Sounds easy, right?</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/03/screenshot-2026-03-05_08-07-43.png" alt="no-furigana"  loading="lazy" />
<em>(kanji with no caption/furigana)</em></p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/03/screenshot-2026-03-05_08-11-21.png" alt="com furigana"  loading="lazy" />
<em>(with furigana injected in real time)</em></p>
<p>Yeah, it sounded easy. And because I knew it sounded easy but wouldn&rsquo;t be, I never had the patience to actually do it. I know how to build the first 80% of any project. The problem is the last 20% — that phase of experimentation, tweaking, fine-tuning, handling edge cases — that consumes more time than all the rest combined. And in a computer vision project, that 20% is especially treacherous.</p>
<p>But then the vibe coding era arrived. And I thought: maybe now the 20% is feasible. I started the project on February 24, 2026, at 23:10 at night. And it became an example of how easy it is to produce massive volumes of <strong>useless</strong> code.</p>
<h2>The original idea: OpenCV and heuristics<span class="hx:absolute hx:-mt-20" id="the-original-idea-opencv-and-heuristics"></span>
    <a href="#the-original-idea-opencv-and-heuristics" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>My original plan — the concept I&rsquo;d imagined for years — was: use <a href="https://opencv.org/"target="_blank" rel="noopener">OpenCV</a> — which is a famous and old computer vision library — to detect the speech bubbles. Manga bubbles are typically white areas with a black contour inside the panels. In theory, you just threshold the image to grab white regions, find contours, filter by size and shape, and you&rsquo;re done.</p>
<p>In 24 hours I already had a working proof of concept: bubble detection, OCR with manga-ocr (a model trained specifically on Japanese manga text), furigana with MeCab for morphological analysis, translation with Ollama running Qwen3:14b locally, and rendering the text back into the bubble. The initial commit (<code>9169d73</code>) on Feb 24 already did all of that.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/03/shounen6-debug.jpg" alt="shounen6 debug"  loading="lazy" /></p>
<p>The next day, Feb 25, I was already extending the pipeline with a Korean webtoon translation flow. 27 commits that day. Everything seemed to be flowing.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/03/057-debug.jpg" alt="webtoon debug"  loading="lazy" /></p>
<p>And then the hell started.</p>
<h2>The hell of false positives<span class="hx:absolute hx:-mt-20" id="the-hell-of-false-positives"></span>
    <a href="#the-hell-of-false-positives" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The problem with OpenCV bubble detection is that manga isn&rsquo;t a standardized document. Every artist has their own line, scan quality varies enormously between print eras, and color pages need completely different parameters from black-and-white pages. And the thing that looks the most like a white speech bubble on a manga page is&hellip; <strong>a face</strong>.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/03/screenshot-2026-03-05_09-15-17.png" alt="false positive face"  loading="lazy" /></p>
<p>Character faces are light areas, relatively rounded, with a dark contour. Exactly like speech bubbles. And no matter how many filters you stack, there&rsquo;s always a case where the face of a character with blue hair sails through every filter, or where a legitimate bubble gets rejected because it has an unusual shape.</p>
<p>Look at how my bubble detector ended up at peak complexity — 551 lines with 7 layers of false-positive filters (abridged version):</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

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

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

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

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

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

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

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

</blockquote>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/03/shounen8-en.jpg" alt="shounen8 en"  loading="lazy" /></p>
<p>I generated code very fast with Claude, but the problem wasn&rsquo;t writing speed, it was the approach. No prompt in the world turns 7 layers of heuristic filters into a robust solution. The right solution was to change the approach completely.</p>
<p>But vibe coding made the failure cheap. In the pre-AI days, those 5 days of OpenCV would have been maybe 2-3 weeks. The cost of being wrong would have been high enough to make the decision to throw it out really hard. With vibe coding, 5 days were discarded without pain because I knew I could rebuild fast. And in fact, the RT-DETR-v2 integration and the restructuring of the entire project were done in <strong>a single day</strong> (March 4, 15 commits).</p>
<p>The question that remains: if I had done the <code>yolo_bubble_detection_plan.md</code> research on day 1 instead of day 5, I&rsquo;d probably have reached the current state in 2 days. The difference between weeks of work and a weekend was a HuggingFace search. Researching before implementing seems obvious in hindsight, but in the heat of the moment the temptation to solve it by hand is strong.</p>
<p>The project is now open source. I initially didn&rsquo;t know if I wanted to open the code, there was a lot of kludge and that 551-line detector that I was embarrassed about. But after the refactoring, the code became clean enough to share. It&rsquo;s the version I would have liked to have built from the start, but that I could only build because I screwed up first.</p>
<p>The bigger headache was the bubble detection and replacement model, but there are several other points I didn&rsquo;t detail: did you notice that webtoons are colored and the bubbles themselves have art? I had to use an image model to do <strong>in-painting</strong> and have the AI redraw the bubble before placing the text on top.</p>
<p>Another headache I don&rsquo;t think I&rsquo;ll solve: <strong>translation coherence</strong>. Today it translates each bubble in isolation, with no context of the story before or after. In Japanese, there&rsquo;s no gender distinction in words. So the captain was talking about Nami, but the bubble says &ldquo;He&rdquo; instead of &ldquo;She.&rdquo; There&rsquo;s no way to know without reading the previous text. For this to work better, like in a GPT chat, you have to add part of the previous text to know when to use the correct gender or, worse, when there are puns that appeared volumes ago and are referenced in the future (something an Oda loves to do). All those subtleties get lost if you only translate one bubble at a time.</p>
<p>I imagine that&rsquo;s why nobody has done something like this yet, translating in near real time, because for it to really be good, the translation work would be exponential for each chapter further along in the story.</p>
<p>Anyway, the name <strong>Frank Yomik</strong> comes from &ldquo;yomi&rdquo; (読み, reading in Japanese) and &ldquo;ik-da&rdquo; (읽다, to read in Korean). Frank is a nod to a frank, direct translation. The app reads in both languages.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/03/shounen9-en.jpg" alt="shounen9 en"  loading="lazy" /></p>
<p>For anyone who wants to try it: the <a href="https://github.com/akitaonrails/FrankYomik"target="_blank" rel="noopener">repository is on GitHub</a>. The server needs a GPU with at least 8GB of VRAM for the detection model + OCR + translation. The Flutter client runs on Android and Linux desktop. And if you, like me, have a stack of Japanese manga you&rsquo;d like to read more fluently — now you can.</p>
]]></content:encoded><category>frank-yomik</category><category>frank</category><category>llms</category><category>artificial-intelligence</category></item><item><title>Software Is Never 'Done' — 4 Projects, Life After Deploy, and Why One-Shot Prompting Is a Myth</title><link>https://akitaonrails.github.io/en/2026/03/01/software-is-never-done-4-projects-life-after-deploy-one-shot-prompt-myth/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2026/03/01/software-is-never-done-4-projects-life-after-deploy-one-shot-prompt-myth/</guid><pubDate>Sun, 01 Mar 2026 12:00:00 -0300</pubDate><description>&lt;p&gt;And don&amp;rsquo;t forget to subscribe to my newsletter &lt;a href="https://themakitachronicles.com/"target="_blank" rel="noopener"&gt;The M.Akita Chronicles&lt;/a&gt;!&lt;/p&gt;
&lt;p&gt;&amp;ndash;&lt;/p&gt;
&lt;p&gt;I published the &amp;ldquo;final post&amp;rdquo; of the &lt;a href="https://akitaonrails.github.io/en/2026/02/20/zero-to-post-production-in-1-week-using-ai-on-real-projects-behind-the-m-akita-chronicles/"&gt;Behind The M.Akita Chronicles&lt;/a&gt; series 10 days ago. 274 commits, 1,323 tests, deployed to production. I wrote down the lessons, did the conclusion, dropped the quote at the end. Done.&lt;/p&gt;
&lt;p&gt;125 post-production commits later, I can confirm: &lt;strong&gt;software &lt;em&gt;&amp;ldquo;done&amp;rdquo;&lt;/em&gt; doesn&amp;rsquo;t exist.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Today the M.Akita Chronicles repo has 335 commits and 1,422 tests. Tomorrow, Monday, subscribers get the 3rd consecutive newsletter — generated, reviewed, and sent by a system that won&amp;rsquo;t stop evolving. Meanwhile, &lt;a href="https://akitaonrails.github.io/en/2026/02/23/vibe-code-built-a-smart-image-indexer-with-ai-in-2-days-frank-sherlock/"&gt;Frank Sherlock&lt;/a&gt; went from 50 commits and v0.1 to 103 commits and v0.7 with face detection. &lt;a href="https://akitaonrails.github.io/en/2026/02/01/frankmd-markdown-editor-vibe-code-part-1/"&gt;FrankMD&lt;/a&gt; got 3 external contributors and shipped v0.2.0. And even &lt;a href="https://akitaonrails.github.io/en/2026/02/21/vibe-code-built-a-mega-clone-in-rails-in-1-day-frankmega/"&gt;FrankMega&lt;/a&gt; — a project built in 1 day — needed fixes when real users showed up.&lt;/p&gt;</description><content:encoded><![CDATA[<p>And don&rsquo;t forget to subscribe to my newsletter <a href="https://themakitachronicles.com/"target="_blank" rel="noopener">The M.Akita Chronicles</a>!</p>
<p>&ndash;</p>
<p>I published the &ldquo;final post&rdquo; of the <a href="/en/2026/02/20/zero-to-post-production-in-1-week-using-ai-on-real-projects-behind-the-m-akita-chronicles/">Behind The M.Akita Chronicles</a> series 10 days ago. 274 commits, 1,323 tests, deployed to production. I wrote down the lessons, did the conclusion, dropped the quote at the end. Done.</p>
<p>125 post-production commits later, I can confirm: <strong>software <em>&ldquo;done&rdquo;</em> doesn&rsquo;t exist.</strong></p>
<p>Today the M.Akita Chronicles repo has 335 commits and 1,422 tests. Tomorrow, Monday, subscribers get the 3rd consecutive newsletter — generated, reviewed, and sent by a system that won&rsquo;t stop evolving. Meanwhile, <a href="/en/2026/02/23/vibe-code-built-a-smart-image-indexer-with-ai-in-2-days-frank-sherlock/">Frank Sherlock</a> went from 50 commits and v0.1 to 103 commits and v0.7 with face detection. <a href="/en/2026/02/01/frankmd-markdown-editor-vibe-code-part-1/">FrankMD</a> got 3 external contributors and shipped v0.2.0. And even <a href="/en/2026/02/21/vibe-code-built-a-mega-clone-in-rails-in-1-day-frankmega/">FrankMega</a> — a project built in 1 day — needed fixes when real users showed up.</p>
<p>Throughout February, I published more than a dozen posts detailing the build of each project. This post is different. It isn&rsquo;t about building from scratch, I covered that already. It&rsquo;s about what happens <strong>after</strong>. And what happens after destroys the one-shot prompt narrative. Software needs an experienced human at the wheel. Iterative development is the only thing that works. Anyone who disagrees hasn&rsquo;t put a system in production yet.</p>
<p>I&rsquo;ll prove it with <code>git log</code>.</p>
<h2>Life After Deploy<span class="hx:absolute hx:-mt-20" id="life-after-deploy"></span>
    <a href="#life-after-deploy" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><table>
  <thead>
      <tr>
          <th>Project</th>
          <th>Post-publication commits</th>
          <th>Highlight</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>The M.Akita Chronicles</td>
          <td>56</td>
          <td>3rd week in production, new features, 13 bug fixes, prompt tuning</td>
      </tr>
      <tr>
          <td>Frank Sherlock</td>
          <td>53</td>
          <td>From v0.3 to v0.7: video support, face detection, AUR publish</td>
      </tr>
      <tr>
          <td>FrankMD</td>
          <td>14</td>
          <td>3 external contributors, 4 PRs merged, v0.2.0</td>
      </tr>
      <tr>
          <td>FrankMega</td>
          <td>2</td>
          <td>MIME types nobody saw coming</td>
      </tr>
      <tr>
          <td><strong>Total</strong></td>
          <td><strong>125</strong></td>
          <td></td>
      </tr>
  </tbody>
</table>
<p>Let&rsquo;s get into the details.</p>
<h2>The M.Akita Chronicles: 3 Weeks in Production<span class="hx:absolute hx:-mt-20" id="the-makita-chronicles-3-weeks-in-production"></span>
    <a href="#the-makita-chronicles-3-weeks-in-production" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The newsletter system has been live since February 16. It&rsquo;s already sent 2 newsletters and tomorrow it sends the third. 56 commits have happened since the &ldquo;final post&rdquo;:</p>
<table>
  <thead>
      <tr>
          <th>Date</th>
          <th>Commits</th>
          <th>What happened</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Feb 20 (Thu)</td>
          <td>16</td>
          <td>Steam Gaming (whole new section), bug fixes in paywall detection</td>
      </tr>
      <tr>
          <td>Feb 21 (Fri)</td>
          <td>12</td>
          <td>/preview command, YouTube collage, /rerun notifications</td>
      </tr>
      <tr>
          <td>Feb 22 (Sat)</td>
          <td>1</td>
          <td>Fix: /preflight sending the result to the wrong channel</td>
      </tr>
      <tr>
          <td>Feb 23 (Sun)</td>
          <td>10</td>
          <td>2nd newsletter publication, Gmail clipping warning, TTS revert</td>
      </tr>
      <tr>
          <td>Feb 24 (Mon)</td>
          <td>1</td>
          <td>Podcast prompt tweak</td>
      </tr>
      <tr>
          <td>Feb 28 (Sat)</td>
          <td>7</td>
          <td>Marvin prompt tuning, rate limiting, /rerun comments</td>
      </tr>
      <tr>
          <td>Mar 1 (Sun)</td>
          <td>9</td>
          <td>Mood system, switch to Grok-4, QA pipeline, config centralization</td>
      </tr>
  </tbody>
</table>
<h3>Features Nobody Saw Coming<span class="hx:absolute hx:-mt-20" id="features-nobody-saw-coming"></span>
    <a href="#features-nobody-saw-coming" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p><strong>Steam Gaming.</strong> Wasn&rsquo;t in the original plan. It was born because the entertainment section only had anime — and gamer readers were left out. Result: 6 commits just on launch day. The first commit (<code>bf41e25</code>) added the whole section — Steam API service, generation job, tests, Hugo shortcode. The next 5 fixed things: Portuguese date parsing, ranking offset so the top 10 didn&rsquo;t repeat, wishlist filter for releases, dark theme. No spec in the world predicts that the Steam API returns dates with Portuguese abbreviations (<code>&quot;fev&quot;</code>, <code>&quot;mar&quot;</code>, <code>&quot;abr&quot;</code>) when you ask for <code>l=brazilian</code>. You find that out when the parser breaks in production.</p>
<p><strong>Preview system.</strong> <code>/preview</code> was born because I needed to see how the auto-generated content looked <strong>before</strong> publishing the entire newsletter. Seems obvious in retrospect, but in v1 the only way to validate was to generate everything and look at the final result. 5 commits to build a preview system with 9 sections, aliases (<code>hn</code> for hacker_news, <code>steam</code> for steam_gaming), separate comment-preview mode, and rescue when one section fails without taking the others down (<code>d23c725</code> — because a section with an error was killing the entire preview).</p>
<p><strong>Marvin&rsquo;s moods.</strong> Marvin (the bot&rsquo;s sarcastic persona) suffers from a chronic LLM problem: smoothing. Without intervention, every comment ends up the same lukewarm tone. The iterative fix was a mood system — 9 modes the operator picks per story:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

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

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

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

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

</blockquote>
]]></content:encoded><category>frank</category><category>llms</category><category>software-engineering</category><category>off-topic</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>From Zero to Post-Production in 1 Week - How to Use AI on Real Projects | Behind The M.Akita Chronicles</title><link>https://akitaonrails.github.io/en/2026/02/20/zero-to-post-production-in-1-week-using-ai-on-real-projects-behind-the-m-akita-chronicles/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2026/02/20/zero-to-post-production-in-1-week-using-ai-on-real-projects-behind-the-m-akita-chronicles/</guid><pubDate>Fri, 20 Feb 2026 12:49:58 +0000</pubDate><description>&lt;p&gt;This post is part of a series; follow along via the tag &lt;a href="https://akitaonrails.github.io/en/tags/the-m-akita-chronicles/"&gt;the-m-akita-chronicles&lt;/a&gt;. This is part 9 and the Finale!&lt;/p&gt;
&lt;p&gt;And make sure you subscribe to my new newsletter &lt;a href="https://themakitachronicles.com/"target="_blank" rel="noopener"&gt;The M.Akita Chronicles&lt;/a&gt;!&lt;/p&gt;
&lt;p&gt;&amp;ndash;&lt;/p&gt;
&lt;p&gt;274 commits. 8 days. 4 applications. 1,323 tests. A complete system in production — with real subscribers receiving a newsletter every Monday at 7am, an AI-generated podcast on Spotify, a static blog on Netlify, and a Discord bot that&amp;rsquo;s my operational interface.&lt;/p&gt;</description><content:encoded><![CDATA[<p>This post is part of a series; follow along via the tag <a href="/en/tags/the-m-akita-chronicles/">the-m-akita-chronicles</a>. This is part 9 and the Finale!</p>
<p>And make sure you subscribe to my new newsletter <a href="https://themakitachronicles.com/"target="_blank" rel="noopener">The M.Akita Chronicles</a>!</p>
<p>&ndash;</p>
<p>274 commits. 8 days. 4 applications. 1,323 tests. A complete system in production — with real subscribers receiving a newsletter every Monday at 7am, an AI-generated podcast on Spotify, a static blog on Netlify, and a Discord bot that&rsquo;s my operational interface.</p>
<p>I did this with a partner: Claude Code, an AI agent running in the terminal. And the most important lesson I took away is not that &ldquo;AI programs by itself&rdquo; — <strong>AI programming by itself is a recipe for disaster</strong>. What works is something that already has a name and decades of history: Extreme Programming. Except now the pair in pair programming isn&rsquo;t human.</p>
<blockquote>
  <p>I need all of you to go through an experience: a <strong>1-WEEK IMMERSION</strong> completely <strong>DETACHED</strong> from the code, using AI as pair programming, going from zero to production on any small project. Trust me, until you do it at least once, you won&rsquo;t get it. But after that, everything changes.</p>

</blockquote>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/pair-programming.jpg" alt="pair programming"  loading="lazy" /></p>
<h2>The &ldquo;One-Shot Prompt&rdquo; Myth<span class="hx:absolute hx:-mt-20" id="the-one-shot-prompt-myth"></span>
    <a href="#the-one-shot-prompt-myth" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>There&rsquo;s a growing narrative that &ldquo;vibe coding&rdquo; means writing a detailed spec, handing it to the AI agent, and it delivers the system ready. Like those &ldquo;I built a SaaS in 10 minutes with Cursor&rdquo; videos. It looks magical until you look at what was actually delivered: a prototype with no tests, no security, no error handling, no deploy, nothing that makes a system survive contact with real users.</p>
<p>The reality of my 274 commits tells a different story:</p>
<table>
  <thead>
      <tr>
          <th>Category</th>
          <th>Commits</th>
          <th>%</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>New features (<code>Add ...</code>)</td>
          <td>101</td>
          <td>37%</td>
      </tr>
      <tr>
          <td>Bug fixes (<code>Fix ...</code>)</td>
          <td>45</td>
          <td>16%</td>
      </tr>
      <tr>
          <td>Refactoring/hardening (<code>Harden</code>, <code>Extract</code>, <code>DRY</code>, <code>Rework</code>, <code>Replace</code>)</td>
          <td>27</td>
          <td>10%</td>
      </tr>
      <tr>
          <td>Security</td>
          <td>21</td>
          <td>8%</td>
      </tr>
      <tr>
          <td>Deploy/infra</td>
          <td>30</td>
          <td>11%</td>
      </tr>
      <tr>
          <td>Tests/CI</td>
          <td>44</td>
          <td>16%</td>
      </tr>
      <tr>
          <td>Documentation</td>
          <td>27</td>
          <td>10%</td>
      </tr>
  </tbody>
</table>
<p><strong>Only 37% of the commits are features.</strong> The rest is the work that makes real software: fixing bugs, hardening against failures, protecting against attacks, deploying, testing, documenting. Anyone showing only the features part is selling an illusion.</p>
<p>More than that: many of the features emerged <strong>because</strong> iteration revealed they were needed. <code>ContentPreflight</code> wasn&rsquo;t in the original plan — it was born when the second newsletter went out with an empty section because a job failed silently. <code>RecoverStaleDeliveriesJob</code> was born when I realized a crash in the middle of sending left emails in eternal limbo. <code>StealthBrowser</code> was born when Morningstar blocked HTTParty. No spec in the world predicts this. <strong>The correct system emerges from iteration, not from specification.</strong></p>
<h2>Pair Programming With an AI Agent<span class="hx:absolute hx:-mt-20" id="pair-programming-with-an-ai-agent"></span>
    <a href="#pair-programming-with-an-ai-agent" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>I don&rsquo;t use Claude Code as a code generator. I use it as a pair programming partner.</p>
<p>The difference is fundamental. A code generator takes a spec and returns output. A pair programming partner <strong>talks</strong>. I interrupt when it takes the wrong path. I give it context it doesn&rsquo;t have. I question decisions. I ask for alternatives. Sometimes I say &ldquo;stop, this is getting too complicated&rdquo; and it simplifies.</p>
<p>Some real examples from the 274 commits:</p>
<p><strong>Interruption for over-engineering.</strong> When I asked for the email delivery system, the first proposal had a state machine with 8 states, separate retry queues, and dead letter handling. I interrupted: &ldquo;Simplify. Four states: pending, sending, sent, unknown. When in doubt, don&rsquo;t resend.&rdquo; The result was <code>SendSingleEmailJob</code> with the <code>ses_accepted</code> variable as the pivot — 40 lines that solve the whole problem.</p>
<p><strong>Context the agent doesn&rsquo;t have.</strong> When we were on Yahoo Finance, Claude tried to use HTTParty. I explained: &ldquo;Yahoo does TLS fingerprinting, it&rsquo;ll block any HTTP client that isn&rsquo;t a real browser.&rdquo; Result: headless Chromium with crumb authentication. That&rsquo;s in no tutorial — it&rsquo;s the kind of knowledge that comes from trying and failing.</p>
<p><strong>Joint architecture decision.</strong> The podcast pipeline could have been a Python monolith. I suggested: &ldquo;Rails orchestrates, Python serves TTS over HTTP, content integrates by filesystem.&rdquo; The result — <code>QwenTtsClient</code> in Ruby calling <code>podcast-tts</code> in Python — keeps each language doing what it does best.</p>
<p><strong>Iterative prompt refinement.</strong> The first newsletters came out generic. The Akita and Marvin comments sounded the same. I tuned prompts with it: &ldquo;Akita never says &lsquo;maybe&rsquo;. Marvin uses &lsquo;well&hellip;&rsquo; and &lsquo;anyway&rsquo;. The LLM will soften opinions if you&rsquo;re not explicit.&rdquo; Each tweak was a prompt commit, tested with real data.</p>
<p>The pattern is: I bring the <strong>what</strong> and the <strong>why</strong>. The agent brings the <strong>how</strong> — and the how is often better than I&rsquo;d do alone. But how without what produces correct code that solves the wrong problem. And what without how produces specs that never leave paper.</p>
<h2>Extreme Programming Isn&rsquo;t a 2000s Fad<span class="hx:absolute hx:-mt-20" id="extreme-programming-isnt-a-2000s-fad"></span>
    <a href="#extreme-programming-isnt-a-2000s-fad" 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/agile-lifecycle-development-process-diagram-vector-31188796.jpg" alt="agile lifecycle development process diagram"  loading="lazy" /></p>
<p>Everything we did has a name in XP. Not because I planned it — because these are the practices that naturally emerge when you program with fast feedback:</p>
<h3>Pair Programming<span class="hx:absolute hx:-mt-20" id="pair-programming"></span>
    <a href="#pair-programming" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Already explained. The pair is the agent. The dynamic is the same: one drives, the other navigates. I navigate more than drive — I define direction, question decisions, correct course. The agent drives more than navigates — writes code, runs tests, proposes solutions. When it inverts (me dictating exact code, it just typing), the result always gets worse.</p>
<h3>Small Releases<span class="hx:absolute hx:-mt-20" id="small-releases"></span>
    <a href="#small-releases" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Out of 274 commits, <strong>every single one passes CI</strong>. No exceptions. There&rsquo;s no &ldquo;broken commit that&rsquo;ll be fixed in the next one&rdquo;. Every commit on master is production-ready.</p>
<p>CI runs on every commit: RuboCop (style) → bundler-audit (gem vulnerabilities) → Brakeman (static security) → full tests. It&rsquo;s ~22 seconds. Sounds like nothing, but multiplied by 274 commits that&rsquo;s 100+ minutes of automated validation I didn&rsquo;t have to do manually.</p>
<p>The distribution of commits by day shows the rhythm:</p>
<table>
  <thead>
      <tr>
          <th>Date</th>
          <th>Commits</th>
          <th>Highlight</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Feb 10 (Mon)</td>
          <td>4</td>
          <td>Initial scaffold, Discord bot, blog</td>
      </tr>
      <tr>
          <td>Feb 11 (Tue)</td>
          <td>41</td>
          <td>Feature explosion: 7 newsletter sections, themes, X polling</td>
      </tr>
      <tr>
          <td>Feb 12 (Wed)</td>
          <td>49</td>
          <td>Podcast pipeline, deploy, security, real market data</td>
      </tr>
      <tr>
          <td>Feb 13 (Thu)</td>
          <td>29</td>
          <td>Daily digest, resilient scraping, hardening</td>
      </tr>
      <tr>
          <td>Feb 14 (Fri)</td>
          <td>30</td>
          <td>End-to-end integration, email hardening, blog deploy</td>
      </tr>
      <tr>
          <td>Feb 15 (Sat)</td>
          <td>40</td>
          <td>Podcast fine-tuning, cross-app, story management</td>
      </tr>
      <tr>
          <td>Feb 16 (Sun)</td>
          <td>26</td>
          <td>First real newsletter sent to subscribers</td>
      </tr>
      <tr>
          <td>Feb 17 (Mon)</td>
          <td>31</td>
          <td>Refactoring, Application Commands, anime fix</td>
      </tr>
      <tr>
          <td>Feb 18 (Tue)</td>
          <td>24</td>
          <td>DRY shortcodes, backup job, consolidation</td>
      </tr>
  </tbody>
</table>
<p>The day-2 &ldquo;explosion&rdquo; isn&rsquo;t chaos — it&rsquo;s small releases stacked up. Each one adds a feature, runs CI, is ready to deploy. It isn&rsquo;t a 2-week waterfall followed by a big-bang merge. It&rsquo;s real continuous integration.</p>
<h3>Test-Driven Development<span class="hx:absolute hx:-mt-20" id="test-driven-development"></span>
    <a href="#test-driven-development" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>1,323 tests. 970 in marvin-bot (6 seconds in parallel), 353 in newsletter (1 second in parallel). Ratio:</p>
<table>
  <thead>
      <tr>
          <th>App</th>
          <th>Code (lines)</th>
          <th>Tests (lines)</th>
          <th>Test/code ratio</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>marvin-bot</td>
          <td>9,348</td>
          <td>13,929</td>
          <td>1.49x</td>
      </tr>
      <tr>
          <td>newsletter</td>
          <td>3,397</td>
          <td>5,391</td>
          <td>1.59x</td>
      </tr>
      <tr>
          <td><strong>Total</strong></td>
          <td><strong>12,745</strong></td>
          <td><strong>19,320</strong></td>
          <td><strong>1.52x</strong></td>
      </tr>
  </tbody>
</table>
<p><strong>More lines of test than of code.</strong> In both applications. And this isn&rsquo;t vanity — it&rsquo;s the infrastructure that enables speed.</p>
<p>Coverage from SimpleCov:</p>
<table>
  <thead>
      <tr>
          <th>App</th>
          <th>Line Coverage</th>
          <th>Branch Coverage</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>marvin-bot</td>
          <td>82.4%</td>
          <td>73.1%</td>
      </tr>
      <tr>
          <td>newsletter</td>
          <td>86.8%</td>
          <td>70.5%</td>
      </tr>
  </tbody>
</table>
<p>It&rsquo;s not 100% — and never will be. The 17-18% uncovered is mostly integration with external APIs (Discord, SES, OpenRouter) that are mocked in tests. Real coverage on business logic is above 95%.</p>
<p>The question that matters: <strong>are these tests useful?</strong> The empirical proof: over 274 commits, I saw CI fail and catch a real bug more than 50 times. Without those tests, every one of those bugs would have gone to production. The agent proposes a change, the test catches the regression, the change is fixed before committing. Without tests, the agent hands you plausible code that silently breaks something that worked yesterday.</p>
<p>TDD with an AI agent is multiplicative. The agent is good at generating tests — it knows the patterns, knows the common edge cases, knows the Minitest structure. And the tests it generates become the safety net for the changes it makes later. It&rsquo;s a virtuous cycle: <strong>tests enable speed, speed generates more tests</strong>.</p>
<h3>Continuous Refactoring<span class="hx:absolute hx:-mt-20" id="continuous-refactoring"></span>
    <a href="#continuous-refactoring" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>27 explicit refactoring commits. Some examples:</p>
<ul>
<li><strong><code>Extract StealthBrowser wrapper</code></strong> — The headless browser code with stealth patches was duplicated in 4 clients. Extracted into a service, the 4 clients were left with 5 lines each.</li>
<li><strong><code>DRY section rendering: collapse 5 bubble methods + 7 Hugo shortcodes</code></strong> — 5 bubble rendering methods in <code>MarkdownToHtml</code> that were copy-paste with different colors, collapsed into 1 parameterized method. 7 Hugo shortcodes delegating to 1 partial.</li>
<li><strong><code>Replace market recap LLM generation with Yahoo Finance real data</code></strong> — The LLM invented market numbers. Replaced with real data from Yahoo Finance + LLM commentary. Better result, lower cost.</li>
<li><strong><code>Replace DuckDuckGo with Brave Search API</code></strong> — DuckDuckGo doesn&rsquo;t have a stable API. Brave Search has an official API with 2,000 queries/month free. Clean swap.</li>
<li><strong><code>Rework ArticleFetcher: browser-first with anti-detection</code></strong> — Two full rewrites. The first tried HTTP-first with fallback to browser. The second inverted: browser-first because most reader links are SPAs. Each rewrite kept tests passing.</li>
</ul>
<p>Refactoring is where the pair programming partner shines. I say &ldquo;this code is duplicated, extract a concern&rdquo; and the agent does in 2 minutes what would take me 20. But <strong>I</strong> decide what to extract and <strong>how</strong> the interface should look. The agent alone doesn&rsquo;t refactor — it piles new code on top of existing code.</p>
<h3>Continuous Integration<span class="hx:absolute hx:-mt-20" id="continuous-integration"></span>
    <a href="#continuous-integration" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>The CI already described above: rubocop + bundler-audit + brakeman + tests. Runs on every commit, in both apps. Brakeman deserves a mention:</p>
<table>
  <thead>
      <tr>
          <th>App</th>
          <th>Controllers</th>
          <th>Models</th>
          <th>Warnings</th>
          <th>Ignored</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>marvin-bot</td>
          <td>6</td>
          <td>8</td>
          <td>0</td>
          <td>0</td>
      </tr>
      <tr>
          <td>newsletter</td>
          <td>10</td>
          <td>5</td>
          <td>0</td>
          <td>2</td>
      </tr>
  </tbody>
</table>
<p>Zero security warnings in both. The 2 ignored in newsletter are documented false positives (SQL interpolation with config paths, not user input).</p>
<p>Brakeman caught real problems during development: SQL injection in a search query, path traversal in the images controller, open redirect in unsubscribe. Each one fixed before going to production, in the same commit where CI flagged it.</p>
<h2>The Anatomy of a Feature: From Request to Deploy<span class="hx:absolute hx:-mt-20" id="the-anatomy-of-a-feature-from-request-to-deploy"></span>
    <a href="#the-anatomy-of-a-feature-from-request-to-deploy" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Let me take a concrete example to illustrate how the flow works in practice. <code>List-Unsubscribe</code> (commit <code>5950ccc</code>).</p>
<p><strong>Context</strong>: confirmation emails weren&rsquo;t reaching Gmail. Everything green — DKIM, SPF, DMARC, SES accepting. But Gmail was silently dropping them into spam.</p>
<p><strong>Investigation</strong> (me + agent): we researched. Since February 2024, Gmail requires <code>List-Unsubscribe</code> + <code>List-Unsubscribe-Post</code> on every bulk sender email. Without them, it penalizes the <strong>entire domain</strong> — including transactional emails.</p>
<p><strong>Implementation</strong> (agent executes, I validate): header in <code>SesMailer</code>, RFC 8058 endpoint in <code>UnsubscriptionsController</code> (skip CSRF for email clients, returns 200 instead of redirect), tests covering both flows.</p>
<p><strong>CI</strong>: rubocop OK, brakeman flagged the <code>skip_forgery_protection</code> as a warning (correct — we validated it&rsquo;s conditional). Tests pass.</p>
<p><strong>Deploy</strong>: <code>kamal deploy</code>. In 2 minutes, the fix is in production. Domain reputation takes 1-2 days to recover. The next day, confirmation emails start arriving again.</p>
<p><strong>Total time</strong>: ~25 minutes from &ldquo;why is Gmail rejecting this?&rdquo; to production deploy. Without the agent, it would be hours of RFC reading, manual implementation, and hoping I didn&rsquo;t forget anything.</p>
<h2>What AI Does Well (And What It Does Badly)<span class="hx:absolute hx:-mt-20" id="what-ai-does-well-and-what-it-does-badly"></span>
    <a href="#what-ai-does-well-and-what-it-does-badly" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>After 274 commits with an AI agent, I have a pretty clear sense of the strengths and weaknesses:</p>
<h3>Does well:<span class="hx:absolute hx:-mt-20" id="does-well"></span>
    <a href="#does-well" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><ul>
<li><strong>Boilerplate and scaffolding</strong>: jobs, services, tests, migrations — the agent produces at typing speed</li>
<li><strong>Tests</strong>: surprisingly good at identifying edge cases and generating comprehensive tests</li>
<li><strong>Mechanical refactoring</strong>: renaming, extracting methods, moving code between files — fast and precise</li>
<li><strong>Contextual research</strong>: &ldquo;how does RFC 8058 work?&rdquo; produces an actionable answer in seconds</li>
<li><strong>Consistency</strong>: follows patterns established in the project (concerns, naming conventions, test structure) without forgetting</li>
</ul>
<h3>Does badly:<span class="hx:absolute hx:-mt-20" id="does-badly"></span>
    <a href="#does-badly" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><ul>
<li><strong>Architecture decisions</strong>: tends to over-engineer. Needs constant human braking</li>
<li><strong>Specific domain knowledge</strong>: doesn&rsquo;t know that Yahoo does TLS fingerprinting or that Qwen&rsquo;s VoiceDesign produces a European accent</li>
<li><strong>Opinions</strong>: softens everything. Personality prompts become generic mush without explicit instructions</li>
<li><strong>Proactive security</strong>: implements what you ask for, but rarely suggests protections you didn&rsquo;t ask for (SSRF, rate limiting, encryption at rest)</li>
<li><strong>Prioritization</strong>: executes anything with equal enthusiasm. Never says &ldquo;this is a waste of time&rdquo; or &ldquo;do X before Y&rdquo;</li>
</ul>
<p>AI doesn&rsquo;t program by itself, and it isn&rsquo;t useless. <strong>It&rsquo;s a programmer multiplier, not a substitute</strong>. A mediocre programmer with AI produces mediocre code faster. An experienced programmer with AI produces good code at a speed that was previously only possible with a team.</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>Let&rsquo;s look at the real size of what was built in 8 days:</p>
<h3>Code<span class="hx:absolute hx:-mt-20" id="code"></span>
    <a href="#code" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><table>
  <thead>
      <tr>
          <th>Component</th>
          <th>Lines</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>marvin-bot app (Ruby)</td>
          <td>9,348</td>
      </tr>
      <tr>
          <td>marvin-bot tests (Ruby)</td>
          <td>13,929</td>
      </tr>
      <tr>
          <td>newsletter app (Ruby)</td>
          <td>3,397</td>
      </tr>
      <tr>
          <td>newsletter tests (Ruby)</td>
          <td>5,391</td>
      </tr>
      <tr>
          <td>podcast-tts (Python)</td>
          <td>1,770</td>
      </tr>
      <tr>
          <td>Hugo templates (HTML)</td>
          <td>193</td>
      </tr>
      <tr>
          <td>Blog CSS</td>
          <td>261</td>
      </tr>
      <tr>
          <td>AI prompts (Markdown)</td>
          <td>795</td>
      </tr>
      <tr>
          <td>CLAUDE.md (project guide)</td>
          <td>702</td>
      </tr>
  </tbody>
</table>
<p><strong>Total functional code: ~16,000 lines. Total with tests: ~36,000 lines.</strong></p>
<h3>Components<span class="hx:absolute hx:-mt-20" id="components"></span>
    <a href="#components" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><ul>
<li><strong>43 services</strong> in marvin-bot (scrapers, AI, parsers, validators, uploaders)</li>
<li><strong>38 jobs</strong> in marvin-bot (content generation, daily digest, podcast, infrastructure)</li>
<li><strong>12 services</strong> in newsletter (email delivery, publishing, assembly)</li>
<li><strong>11 jobs</strong> in newsletter (sending, recovery, backup, blog publishing)</li>
<li><strong>23 AI prompt templates</strong> (personality, generation, podcast, tools)</li>
<li><strong>17 Hugo shortcodes</strong> (section rendering on the blog)</li>
<li><strong>3 RubyLLM tools</strong> (image generation, web search, web fetch)</li>
</ul>
<h3>Pipeline<span class="hx:absolute hx:-mt-20" id="pipeline"></span>
    <a href="#pipeline" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><ul>
<li><strong>10 content sections</strong> generated per week (anime, hacker news, youtube, market, world events, history x3, holidays, closing remarks)</li>
<li><strong>10 daily digest jobs</strong> posting to Discord at 8am</li>
<li><strong>1 podcast</strong> of ~30 minutes generated per week (script + TTS + assembly + S3)</li>
<li><strong>1 newsletter</strong> assembled, published to the blog, and sent by email every Monday</li>
</ul>
<h2>Is This 200 User Stories?<span class="hx:absolute hx:-mt-20" id="is-this-200-user-stories"></span>
    <a href="#is-this-200-user-stories" 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/66d7f28718285e260f53fe6e_018b4d67589e6f4d2fdf9130502016fb4a75e73a-1200x674.jpg" alt="sprint planning"  loading="lazy" /></p>
<p>I looked at the 274 commits and tried to map them to user stories in the agile sense. Not every commit is a story — many are fixes inside the same feature, prompt tweaks, or CI cleanup. But the conservative count of distinct features, each one with implicit acceptance criteria (works, has tests, passes CI, deploys):</p>
<blockquote>
  <p><strong>Estimate: ~180-200 user stories.</strong></p>

</blockquote>
<p>That includes:</p>
<ul>
<li>Each newsletter section (10 distinct generators with scraping, AI, validation, fallback)</li>
<li>The email pipeline (delivery tracking, atomic claiming, recovery, List-Unsubscribe)</li>
<li>The podcast pipeline (LLM script, dual-model TTS, assembly, S3, RSS)</li>
<li>The Discord bot (15+ commands, embeds, context, multi-turn /ask, daily digest)</li>
<li>The blog (Hugo + Hextra, 17 shortcodes, RSS podcast, SEO, GA)</li>
<li>The subscription page (confirmation, themes, LGPD, terms of service)</li>
<li>Infrastructure (Kamal deploy, Cloudflare, SES config, RunPod, backups)</li>
<li>21 security commits (SSRF, CSP, rate limiting, encryption, path traversal)</li>
</ul>
<p>In traditional Scrum with a team of 2-3 devs, a well-done story (with code review, QA, deploy) takes on average 1-2 days. 200 stories × 1.5 days / 2 devs = <strong>~150 days of work, or ~30 weeks in 2-week sprints</strong> (counting planning, review, retro, and the inevitable overhead of people coordinating with each other).</p>
<p>Being generous — senior team, well-defined stories, no impediments — maybe 10-15 weeks.</p>
<blockquote>
  <p>We did it in 8 days. One person and one AI agent.</p>

</blockquote>
<p>But — and this is crucial — <strong>I&rsquo;m not just any dev and this isn&rsquo;t just any project</strong>. I have 30 years of software experience. I know which decisions matter and which don&rsquo;t. I know when to stop engineering and ship. I know when the agent is wrong before it finishes generating the code. And the project is solo-developer, without external code review, without separate QA, without coordination between teams.</p>
<p>The agent&rsquo;s real multiplier isn&rsquo;t &ldquo;10x more code&rdquo;. It&rsquo;s <strong>eliminating dead time</strong>. Without an agent, 70% of my time would be typing boilerplate, reading API docs, writing mechanical tests, debugging typos. With an agent, that time goes to zero and I focus 100% on architecture, domain, and quality decisions.</p>
<h2>The CLAUDE.md: The Spec That Evolves<span class="hx:absolute hx:-mt-20" id="the-claudemd-the-spec-that-evolves"></span>
    <a href="#the-claudemd-the-spec-that-evolves" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>One thing that works exceptionally well with AI agents is having a living document that describes the project. The project&rsquo;s <code>CLAUDE.md</code> has 702 lines and covers:</p>
<ul>
<li>Architecture overview</li>
<li>Full tech stack</li>
<li>All environment variables</li>
<li>Content directory structure</li>
<li>Services, jobs, and models of each app</li>
<li>12 &ldquo;common hurdles&rdquo; with documented solutions</li>
<li>14 project design patterns</li>
<li>Full weekly pipeline with schedules</li>
<li>Post-implementation checklist</li>
</ul>
<p>This document wasn&rsquo;t written all at once. It <strong>evolved with the project</strong>. Every time we found a hurdle (Yahoo Finance needs Chromium, HN RSS changes format, LLM softens opinions), we documented it in CLAUDE.md. In the next session, the agent already knows.</p>
<p>It&rsquo;s the equivalent of a team&rsquo;s onboarding doc — except the &ldquo;new team member&rdquo; is the agent, and it reads the whole doc in 2 seconds before every interaction. Investing in documentation pays back exponentially with AI agents because <strong>they actually read it</strong>.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-20_12-59-38.jpg" alt="claude.md"  loading="lazy" /></p>
<h2>The Lessons<span class="hx:absolute hx:-mt-20" id="the-lessons"></span>
    <a href="#the-lessons" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p><strong>1. Vibe coding without discipline is a throwaway prototype.</strong> 274 commits with CI, tests, security scanning, and small releases is the opposite of vibe coding. It&rsquo;s software engineering with a copilot that types fast.</p>
<p><strong>2. TDD is more important with AI, not less.</strong> The agent modifies code with confidence because it has 1,323 tests as a safety net. Without tests, every change is a gamble.</p>
<p><strong>3. The human decides the what. The agent decides the how.</strong> Flip it and the result drops dramatically.</p>
<p><strong>4. Continuous refactoring is mandatory.</strong> The agent piles code. If you don&rsquo;t prune regularly — extract concerns, DRY duplications, simplify interfaces — the codebase becomes a monolith of 500 lines per file.</p>
<p><strong>5. Documentation is an investment with immediate return.</strong> Every hour documenting in CLAUDE.md saves hours of lost context in future sessions.</p>
<p><strong>6. Small releases are the key.</strong> Every commit is production-ready. If something goes wrong, revert one commit. If the feature was a bad idea, it never depended on another. This only works with automated CI.</p>
<p><strong>7. Security isn&rsquo;t a phase — it&rsquo;s a habit.</strong> 21 security commits spread across 8 days, not concentrated in a &ldquo;security sprint&rdquo; at the end. Brakeman runs on every commit. Vulnerabilities are fixed the moment they appear.</p>
<p><strong>8. The agent never says &ldquo;no&rdquo;.</strong> That&rsquo;s a bug, not a feature. If you ask for something over-engineered, it implements with enthusiasm. If you ask for something insecure, it implements without complaining. <strong>You</strong> are the brake. <strong>You</strong> are the code review. <strong>You</strong> are the adult in the room.</p>
<h2>The Counter-Example: FrankMD<span class="hx:absolute hx:-mt-20" id="the-counter-example-frankmd"></span>
    <a href="#the-counter-example-frankmd" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Everything I described above may sound theoretical. <em>&ldquo;Of course TDD and continuous refactoring are important&rdquo;</em> — everyone agrees in theory. So let me prove it empirically with a counter-example from <strong>the same developer, with the same AI agent, two weeks earlier</strong>.</p>
<p>Before the M.Akita Chronicles, I built <a href="https://github.com/akitaonrails/FrankMD"target="_blank" rel="noopener">FrankMD</a> — a Markdown editor in Rails, Obsidian/VSCode style. 212 commits in 19 days. Same setup: me + Claude Code as a pair programming partner.</p>
<p><img src="https://camo.githubusercontent.com/4d7ffcd26d22eb57334017e3b946a10eeb59cacf609edcc8caebf68d03d528b4/68747470733a2f2f6e65772d75706c6f6164732d616b6974616f6e7261696c732e73332e75732d656173742d322e616d617a6f6e6177732e636f6d2f6672616e6b6d642f323032362f30322f73637265656e73686f742d323032362d30322d30315f31342d31332d32392e6a7067" alt="frankmd"  loading="lazy" /></p>
<p>The difference: on FrankMD, I did what any developer does when &ldquo;it&rsquo;s just a little personal project&rdquo;. I focused on features. Tests? Later. Refactoring? When necessary. CI? Runs when I remember.</p>
<h3>The <code>app_controller.js</code> Spiral<span class="hx:absolute hx:-mt-20" id="the-app_controllerjs-spiral"></span>
    <a href="#the-app_controllerjs-spiral" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>FrankMD is a SPA-like with Stimulus. In the beginning, all the JavaScript lived in a single file: <code>app_controller.js</code>. Here&rsquo;s what happened to it across commits:</p>
<table>
  <thead>
      <tr>
          <th>Moment</th>
          <th>LOC</th>
          <th>What happened</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Commit #1 (initial)</td>
          <td>506</td>
          <td>Clean scaffold, basic editor</td>
      </tr>
      <tr>
          <td>Commit #44 (~10 features later)</td>
          <td>~4,372</td>
          <td>File finder, themes, typewriter, image picker, emoji&hellip;</td>
      </tr>
      <tr>
          <td>Commit #54 (peak)</td>
          <td><strong>4,990</strong></td>
          <td>Everything inside. AI grammar, video embed, content search&hellip;</td>
      </tr>
      <tr>
          <td><strong>Refactor 1</strong>: Extract utilities + image picker</td>
          <td>3,249</td>
          <td>-35%, 6 modules + 1 controller extracted</td>
      </tr>
      <tr>
          <td><strong>Refactor 2</strong>: Extract 10 Stimulus controllers</td>
          <td>1,803</td>
          <td>-45%, 10 controllers with tests</td>
      </tr>
      <tr>
          <td>Features grow again&hellip;</td>
          <td>2,739</td>
          <td>More code piled on</td>
      </tr>
      <tr>
          <td><strong>Refactor 3</strong>: Extract utility modules</td>
          <td>2,684</td>
          <td>-2%, small</td>
      </tr>
      <tr>
          <td><strong>Refactor 4</strong>: Extract domain controllers</td>
          <td>2,074</td>
          <td>-23%, 4 new controllers</td>
      </tr>
      <tr>
          <td>Features grow&hellip;</td>
          <td>2,367</td>
          <td>Growing again</td>
      </tr>
      <tr>
          <td><strong>Refactor 5</strong>: Extract autosave/scroll-sync</td>
          <td>1,837</td>
          <td>-22%</td>
      </tr>
      <tr>
          <td><strong>Refactor 6</strong>: Replace custom JS with Rails 8 built-ins</td>
          <td>1,511</td>
          <td>-17%, -383 net lines</td>
      </tr>
      <tr>
          <td>Final state</td>
          <td>1,576</td>
          <td>39 controllers + 29 lib modules</td>
      </tr>
  </tbody>
</table>
<p>The pattern is clear: <strong>build-build-build-STOP-refactor</strong>. The file grew 10x in 54 commits, hit almost 5,000 lines, and then I needed <strong>6 rounds of refactoring</strong> to bring it back to acceptable. Each stop was a big commit (hundreds of lines moved, dozens of files created), not a surgical tweak.</p>
<p>The CSS followed the same pattern: 1,053 lines in a single <code>application.css</code>, until the refactoring commit that exploded it into 10 theme files + 9 component files.</p>
<h3>Tests: Before vs. After<span class="hx:absolute hx:-mt-20" id="tests-before-vs-after"></span>
    <a href="#tests-before-vs-after" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>On FrankMD, the 2nd commit was &ldquo;Add comprehensive test suite&rdquo; — system tests (browser integration). Sounds responsible, but those tests covered <strong>the happy path and nothing else</strong>. The JavaScript unit tests only came in commit #52, <strong>after app_controller.js had already hit 5,000 lines</strong> — because I needed tests to refactor safely. Retroactive tests.</p>
<p>Ruby coverage reflects this: it had to be pushed from 77% to 89% in an explicit &ldquo;Add tests for untested success paths&rdquo; commit. That wasn&rsquo;t TDD — it was <strong>filling holes afterward</strong>.</p>
<h3>The Numbers Side by Side<span class="hx:absolute hx:-mt-20" id="the-numbers-side-by-side"></span>
    <a href="#the-numbers-side-by-side" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><table>
  <thead>
      <tr>
          <th>Metric</th>
          <th>FrankMD</th>
          <th>M.Akita Chronicles</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Commits</td>
          <td>212</td>
          <td>274</td>
      </tr>
      <tr>
          <td>Days</td>
          <td>19</td>
          <td>8</td>
      </tr>
      <tr>
          <td>Commits/day</td>
          <td>11</td>
          <td>34</td>
      </tr>
      <tr>
          <td>Tests</td>
          <td>1,364 JS + ~490 Ruby</td>
          <td>1,323 Ruby</td>
      </tr>
      <tr>
          <td>Ruby coverage</td>
          <td>~89% (retroactive)</td>
          <td>82-87% (organic)</td>
      </tr>
      <tr>
          <td>Test/code ratio</td>
          <td>~1.2x</td>
          <td>1.52x</td>
      </tr>
      <tr>
          <td>&ldquo;Stop everything&rdquo; refactors</td>
          <td>6 big ones</td>
          <td>0</td>
      </tr>
      <tr>
          <td>Incremental refactors</td>
          <td>16</td>
          <td>27</td>
      </tr>
      <tr>
          <td>Largest file (peak)</td>
          <td>4,990 LOC</td>
          <td>—</td>
      </tr>
      <tr>
          <td>CI on every commit</td>
          <td>No</td>
          <td>Yes</td>
      </tr>
  </tbody>
</table>
<p>The number that matters: <strong>commits per day</strong>. The M.Akita Chronicles had 3x more throughput — with TDD, CI, and continuous refactoring — because <strong>it never had to stop</strong>. No &ldquo;stop everything and refactor 5,000 lines into 10 files&rdquo; commit. No &ldquo;I need to add retroactive tests so I can touch this code without fear&rdquo; moment.</p>
<h3>The Concrete Lesson<span class="hx:absolute hx:-mt-20" id="the-concrete-lesson"></span>
    <a href="#the-concrete-lesson" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>FrankMD&rsquo;s 6 big refactors weren&rsquo;t &ldquo;continuous refactoring&rdquo;. They were <strong>emergency surgery on accumulated technical debt</strong>. Each one took hours — not the 2 minutes of the &ldquo;extract a concern&rdquo; described above. And each one introduced risk: when you move 1,500 lines from one file to 10 new files, the chance of breaking something is proportional to the size of the change.</p>
<p>With TDD and continuous refactoring, the M.Akita Chronicles never accumulated that debt. The 27 refactors are small commits — &ldquo;Extract StealthBrowser&rdquo;, &ldquo;DRY bubble methods&rdquo; — that take minutes and are protected by the 1,323 tests that already existed when the refactoring was done.</p>
<p><strong>Same developer. Same AI agent. Two different approaches. Result: the disciplined project delivered more, in less time, with less pain.</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><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/41MD3fXWOXL.jpg" alt="xp book"  loading="lazy" /></p>
<p>This project isn&rsquo;t an AI showcase. It&rsquo;s a showcase of software engineering accelerated by AI. And the strongest proof is that <strong>the same developer with the same agent</strong> produced radically different results depending on the process.</p>
<p>On FrankMD, without discipline, the result was predictable: a 5,000-line file, 6 emergency surgeries, retroactive tests, and 11 commits per day. On the M.Akita Chronicles, with XP, the result was 34 commits per day, zero forced stops, 1,323 organic tests, and a production system in 8 days. The variable wasn&rsquo;t the AI — it was the process.</p>
<p>274 commits in 8 days don&rsquo;t happen because the agent is magical. They happen because Extreme Programming — pair programming, TDD, small releases, continuous refactoring, continuous integration — works as well with an AI pair as it does with a human pair. Maybe better in some ways: the agent never gets tired, never argues from ego, never says &ldquo;but we&rsquo;ve always done it this way&rdquo;, and reads 700 lines of documentation before every interaction without complaining.</p>
<p>The future of development isn&rsquo;t &ldquo;AI replacing programmers&rdquo;. It&rsquo;s programmers who know how to use AI as a tool — with the same discipline they&rsquo;ve always used with any other tool — producing in a week what entire teams take quarters to deliver.</p>
<p>Not because the AI is brilliant. Because <strong>the process</strong> is brilliant. XP was right from the start. It was just missing a pair that never gets tired and types at 1,000 words per minute.</p>
<p>Remember:</p>
<blockquote>
  <p>&ldquo;AI is your mirror, it reveals faster who you are. If you&rsquo;re incompetent, it&rsquo;ll produce bad things faster. If you&rsquo;re competent, it&rsquo;ll produce good things faster.&rdquo; - by Akita</p>

</blockquote>
]]></content:encoded><category>frank</category><category>llms</category><category>artificial-intelligence</category><category>the-m-akita-chronicles</category></item><item><title>Vibe Code: I Built a Markdown Editor From Scratch With Claude Code (FrankMD) PART 2</title><link>https://akitaonrails.github.io/en/2026/02/01/frankmd-markdown-editor-vibe-code-part-2/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2026/02/01/frankmd-markdown-editor-vibe-code-part-2/</guid><pubDate>Sun, 01 Feb 2026 19:29:18 +0000</pubDate><description>&lt;p&gt;If you haven&amp;rsquo;t read it yet, check out &lt;a href="https://akitaonrails.github.io/en/2026/02/01/frankmd-markdown-editor-vibe-code-part-1/"&gt;Part 1&lt;/a&gt; to understand the app I built. &lt;strong&gt;FrankMD&lt;/strong&gt; is a Markdown editor that I customized to be effective for my personal use case: writing blog posts. It wasn&amp;rsquo;t meant to be a replacement for Obsidian or VS Code.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://github.com/akitaonrails/FrankMD"target="_blank" rel="noopener"&gt;&lt;img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-01_18-49-26.jpg" alt="FrankMD" loading="lazy" /&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;The &lt;strong&gt;TL;DR&lt;/strong&gt; is this: in the old days, if I were to build this web app from scratch, it would have cost me around &lt;strong&gt;200 hours&lt;/strong&gt;, spread over 1 or 2 months, going slow, back and forth. And it probably wouldn&amp;rsquo;t have ended up as complete as this one.&lt;/p&gt;</description><content:encoded><![CDATA[<p>If you haven&rsquo;t read it yet, check out <a href="/en/2026/02/01/frankmd-markdown-editor-vibe-code-part-1/">Part 1</a> to understand the app I built. <strong>FrankMD</strong> is a Markdown editor that I customized to be effective for my personal use case: writing blog posts. It wasn&rsquo;t meant to be a replacement for Obsidian or VS Code.</p>
<p><a href="https://github.com/akitaonrails/FrankMD"target="_blank" rel="noopener"><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-01_18-49-26.jpg" alt="FrankMD"  loading="lazy" /></a></p>
<p>The <strong>TL;DR</strong> is this: in the old days, if I were to build this web app from scratch, it would have cost me around <strong>200 hours</strong>, spread over 1 or 2 months, going slow, back and forth. And it probably wouldn&rsquo;t have ended up as complete as this one.</p>
<blockquote>
  <p>With Claude, I did it all in a little over <strong>30 hours</strong>, spread across 3 days of super hyper focus (I don&rsquo;t recommend it; I almost got sick doing it). In the end, this produced a project with roughly 130 pull requests. A total of <strong>18 thousand lines of code</strong> (test coverage around 70%). It was all done in Ruby on Rails 8 and JavaScript (HotWire/Stimulus).</p>

</blockquote>
<p>There are several advantages to this particular app: it&rsquo;s a very well-known and well-documented scope. There are hundreds of text editors of all types and complexities. Surely every LLM has been trained with tons of material on this. So when I ask it to search by filename, it already knows to reach for something like the Levenshtein algorithm for fuzzy matching, for example.</p>
<p>Searching and listing images in a grid, uploading to S3, find and replace, HTML preview, etc. All of this is super well documented and trivial to do. Even without LLMs, any intern would find it in the first Google link and on Stack Overflow. So I&rsquo;d say more than 90% of the scope was easy. An app with a less well-known scope — say, a scientific application — would have been much harder. So this example of mine is only slightly above a Hello World: repeating a well-known scope that already exists.</p>
<p>Unlike what I did in my first Vibe Coding article, this time I won&rsquo;t comment commit by commit; there are more than 130! We&rsquo;d be here all week. Instead, I&rsquo;ll comment on a few themes that catch my attention and that might be relevant to other projects.</p>
<h2>Choose Your LLM Plans Carefully<span class="hx:absolute hx:-mt-20" id="choose-your-llm-plans-carefully"></span>
    <a href="#choose-your-llm-plans-carefully" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Over those 3 days, I was interrupted by Claude Code about 2 or 3 times and had to use something else for 2 or 3 hours. That&rsquo;s because it has some daily token limit and tells me to stop after I use too many tokens.</p>
<p>I&rsquo;m using the Max Plan (5x Pro), which I think costs around USD 100, meaning around USD 6 a day. In this case it was cheap, because 3 days would only cost USD 18.</p>
<p>But during one of those breaks, I switched to using Claude Opus via OpenRouter, and that was WAY more expensive. Outside the Claude Code exclusive Max plan, the per-token cost is absurdly higher. In about 2 or 3 hours, it already burned through more than USD 100 (equivalent to the whole monthly subscription!)</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20260201165118_screenshot-2026-01-31_10-19-11.png" alt="open router billing"  loading="lazy" /></p>
<p>So, in total, by using the Max plan outside its bounds, I ended up spending more than USD 120. But if I had just stopped to rest between the daily limits, I would have spent one extra day (wouldn&rsquo;t have gotten sick &#x1f605;) and wouldn&rsquo;t have gone much beyond USD 20. So you need to research the plans for each platform carefully before jumping in without thinking, like I did. &#x1f602;</p>
<p>As I&rsquo;ve said over and over: when in doubt, use Claude Code with the Max plan.</p>
<h2>Choose Your Architecture Carefully<span class="hx:absolute hx:-mt-20" id="choose-your-architecture-carefully"></span>
    <a href="#choose-your-architecture-carefully" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>When I started coding (every time I say &ldquo;I coded&rdquo;, read it as &ldquo;I vibe coded&rdquo;, of course), the start was very fast. I decided only a few technical things: I wanted it to be Ruby on Rails 8, I wanted it to use Tailwind CSS, I wanted it to use no database and implement everything based on the filesystem, I wanted it to have no authentication, since it would be self-hosted for my use only.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-01_17-01-45.jpg" alt="first commits"  loading="lazy" /></p>
<p>The first 10 commits took a little over an hour and a half. I kept adding features bit by bit to give &ldquo;shape&rdquo; to the editor, and at this point I already had a minimal, good-looking editor, with a dark theme, that edited and displayed an HTML preview. Not bad for a prototype.</p>
<p>As I said in the other article, this would be one of the &ldquo;it already works&rdquo; milestones.</p>
<p>But of course, there are still more than 120 commits to really work, and nearly 30 hours to reach the &ldquo;really works&rdquo; stage.</p>
<p>I kept adding various small features, in this order:</p>
<ul>
<li>Add a Markdown cheat-sheet (help)</li>
<li>Dialog box to add fenced code blocks with language autocomplete</li>
<li>Theme system, already adding around 10 themes (but not yet tuned for Omarchy)</li>
<li>Dialog box to customize fonts</li>
<li>Zoom control on the Preview</li>
<li>First attempt at &ldquo;typewriter mode&rdquo; (this will cause plenty of headaches later)</li>
<li>Dialog box to search files by path with a fuzzy finder</li>
<li>First attempt to sync the editor scroll with the preview (this will also cause plenty of headaches)</li>
<li>Dialog box to search files by content with regex support</li>
<li>Dialog box to search and embed YouTube videos</li>
<li>Dialog box to search images, upload and insert</li>
<li>Initial Docker support</li>
</ul>
<p>That completes the first <a href="https://github.com/akitaonrails/FrankMD/commit/ba42c81b47a6343ba42df8705c6973d9d45506a0"target="_blank" rel="noopener">34 commits</a> and about 4 hours of work. Again, very close to &ldquo;it already works&rdquo;.</p>
<p>But the fun begins at <a href="https://github.com/akitaonrails/FrankMD/commit/7bbf03a4b594203ed1a36c6735f9d23c5ffc2c91"target="_blank" rel="noopener">commit 7bbf03a</a>, titled: &ldquo;Refactor to Restful Architecture&rdquo;.</p>
<blockquote>
  <p>This is a Rails-specific thing, and an architecture thing in general. Claude, without me instructing it, did exactly what I&rsquo;d expect a Rails intern to do: it crammed all the logic for managing directories and files, image uploads and everything else into 2 or 3 service modules.</p>

</blockquote>
<p>It also wrote all the routes manually. That is, it didn&rsquo;t use the most basic thing in Rails: the concept of <a href="https://guides.rubyonrails.org/routing.html"target="_blank" rel="noopener">Restful Resources</a>. Read the linked guide; I won&rsquo;t explain what that is here, just that any experienced Rails person knows it with their eyes closed.</p>
<p>So it created routes like &ldquo;<a href="http://localhost:3000/notes"target="_blank" rel="noopener">http://localhost:3000/notes</a>&rdquo; but didn&rsquo;t enable things like &ldquo;<a href="http://localhost:3000/notes/hello"target="_blank" rel="noopener">http://localhost:3000/notes/hello</a>&rdquo; automatically opening &ldquo;hello.md&rdquo;, and HTTP verbs like PATCH to update the file or DELETE to remove the file. And also, without the GET option, I wouldn&rsquo;t have the option to bookmark a file directly. All of this comes for free if you use RESTful. So that commit was the refactor where I instructed it to redo everything in that architecture. Just that alone was almost <strong>1000 lines</strong> added.</p>
<blockquote>
  <p>The &ldquo;correct&rdquo; thing would have been to set up, right at the start, a CLAUDE.MD or CONTRIBUTING.MD file or something similar with the specific architecture rules and code conventions — but I didn&rsquo;t, so here&rsquo;s a reminder for you. &#x1f605;</p>

</blockquote>
<h3>Refactoring and Fixes<span class="hx:absolute hx:-mt-20" id="refactoring-and-fixes"></span>
    <a href="#refactoring-and-fixes" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>After that first big refactor, I went back to making adjustments (fixes) and kept adding new features:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-01_17-17-55.jpg" alt="screenshot 2026 02 01 17 17 55"  loading="lazy" /></p>
<p>At this point we already crossed <strong>47 commits</strong> and more than <strong>8 hours</strong> non-stop. And yes, I didn&rsquo;t take my eyes off the monitor. You can&rsquo;t read 100% of everything Claude is thinking and doing; it&rsquo;s a lot, but overall I&rsquo;ve noticed you need to keep glancing at it and, most importantly, <strong>pay very close attention</strong>!</p>
<p>The processing time for multiple agents in parallel, many in <em>reasoning/thinking</em> mode, is really slow. Claude is one of the fastest compared to the others, but in practice I find it painfully slow.</p>
<p>After lunch, I decided to open YouTube on the side while waiting for Claude to finish the tasks. That means I wasn&rsquo;t paying attention and only glanced over to see if it finished with &ldquo;success&rdquo; or asked me for some permission to execute something.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20260201185351_screenshot-2026-01-30_19-32-51.png" alt="don’t do this lol"  loading="lazy" /></p>
<p>I don&rsquo;t remember if it was at this point, but I went to look at what it had generated, and of course, by this stage it already had an <code>application.css</code> with more than 1000 lines, which I asked it to fix in <a href="https://github.com/akitaonrails/FrankMD/commit/4d3d6a07e572567dbf4ddb59faf54ba5f30aa259#diff-824082aab0a95adb8b632a5c383dd6ffb733b0899d4ba594a8380882467eba82"target="_blank" rel="noopener">this commit</a>, and it broke one huge file into all of these:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-01_17-25-55.jpg" alt="application.js refactoring"  loading="lazy" /></p>
<p>Even the themes: everything was in a single file!! This is the level I&rsquo;d call &ldquo;intern&rdquo;. Since everything &ldquo;works&rdquo;, it just keeps piling up code, all in the same place, and leaves worrying about it for later. Every intern does this at the start if nobody teaches them, and apparently the LLM does the same. It won&rsquo;t proactively do anything you didn&rsquo;t explicitly ask for. In fact, I never said in the prompt <em>&ldquo;remember to keep breaking things into small files&rdquo;</em>, but I should have.</p>
<p>But that wasn&rsquo;t the worst. No sir, <a href="https://github.com/akitaonrails/FrankMD/commit/e35f6eb738b218caba1340723d016633d8ead99f"target="_blank" rel="noopener">this commit</a> has all the JavaScript in A SINGLE FILE. I think it was already past <strong>4 or 5 thousand lines</strong>. Look at how many files it broke it into, it doesn&rsquo;t even fit in this picture:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-01_17-29-19.jpg" alt="app_controller.js refactoring"  loading="lazy" /></p>
<p>The problem is that I myself fell into what I&rsquo;m criticizing here. I&rsquo;d ask for a new feature, hit &ldquo;reload&rdquo; on the browser and <em>voilà</em>, there was the new feature! &ldquo;WORKING&rdquo;!! (Ruby on Rails is very productive!)</p>
<p>And that&rsquo;s how it went, adding &ldquo;it already works&rdquo;, adding, &ldquo;it already works&rdquo;, and when I finally looked, <strong>horror show</strong>!!</p>
<p>At the last refactoring we were at around 9 hours of work. This is where we wrap up the day; we&rsquo;re on the next day and, not counting the time I slept, we&rsquo;re already at <strong>17 hours</strong>, 70 commits. Roughly HALFWAY through the project.</p>
<blockquote>
  <p>All this to say the following: the BIGGEST part of Vibe Coding will always be REFACTORING, fixing bugs, and adding TESTS!</p>

</blockquote>
<h3>I18n - Internationalization<span class="hx:absolute hx:-mt-20" id="i18n---internationalization"></span>
    <a href="#i18n---internationalization" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>I got a little ahead of myself; before the JavaScript mega-refactoring, I had done another mega-refactoring: I asked it to extract all the English strings and already translate a Brazilian Portuguese option for me. Another <a href="https://github.com/akitaonrails/FrankMD/commit/9aee048bcaaafbf83480c5c272032b5bb2941ac5"target="_blank" rel="noopener">gigantic commit</a> with more than <strong>1600 lines</strong> modified.</p>
<p>I wanted to make a separate section just to comment on this because this is one of the points where I think LLMs shine. They were originally built precisely to deal with natural language and translations. LLMs are very good with languages. In the old days I&rsquo;d have to go through Google Translate, which was always pretty bad, and then find a native speaker to adjust the terms Google got wrong.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-01_17-38-24.jpg" alt="i18n"  loading="lazy" /></p>
<p>As expected, after extracting everything, asking it to adjust the tests to take i18n into account, and seeing that Portuguese was working, I started adding others just for fun and was very pleased with the result. I18n is definitely something that doesn&rsquo;t make sense to do without an LLM these days. It&rsquo;s orders of magnitude better than the old way.</p>
<h2>The Last HALF<span class="hx:absolute hx:-mt-20" id="the-last-half"></span>
    <a href="#the-last-half" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>From the last big refactor to the end, there are about another 70 commits and roughly 14 hours, the end of the second day and the third day, with a roughly 15-hour break in the middle where I got a fever and had to throw myself into bed and stuff myself with medicine… but all good, the next morning I felt a bit better and finished it!</p>
<p>From there on, I kept adding the rest of the features, like emoji, emoticons, find and replace dialog, jump to line dialog, adjustments to the configuration system (via envs, via .fed), and Docker.</p>
<p>One thing I didn&rsquo;t mention before, but that came before the I18n effort, was a <strong>REBRANDING</strong>. This is another thing LLMs are very good at solving: text substitution, knowing how to tell apart plain text, variables and code in general. I wasn&rsquo;t very inspired and, at the start, I was going to make a MUCH simpler little web editor than what it ended up becoming, and I was just going to call it &ldquo;WebNotes&rdquo;.</p>
<p>But when it grew and I decided it was worth sharing publicly, I decided to give it a real name. That&rsquo;s how &ldquo;FrankMD&rdquo; was born. But it&rsquo;s not enough to just give it a name; now you have to go rename EVERYTHING inside the code, strings, documentation, etc. It was in <a href="https://github.com/akitaonrails/FrankMD/commit/4d370f7cbae145fe6c28c6173c723fccfb818ffe"target="_blank" rel="noopener">this commit</a>: a solid <strong>700 lines</strong> of modifications.</p>
<h3>Now &ldquo;does it work??&rdquo;<span class="hx:absolute hx:-mt-20" id="now-does-it-work"></span>
    <a href="#now-does-it-work" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>I&rsquo;m writing this article at 5 PM on Sunday. At 7:25 PM on Saturday, I sent it to some friends on Discord to test, innocently thinking I&rsquo;d already reached version 1.0.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-01_17-50-30.jpg" alt="discord"  loading="lazy" /></p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-01_17-54-03.jpg" alt="issues"  loading="lazy" /></p>
<p>And there I went, with a fever and needing to lie down, but wanting to reach 1.0!!</p>
<p>The issues themselves I killed quickly, but <strong>Syntax Highlight</strong> I didn&rsquo;t know was going to become hell in my life. The first version came out quickly, in <a href="https://github.com/akitaonrails/FrankMD/commit/e063a0bb3b6f3a1ab927ca335d06fe8eb7946d25"target="_blank" rel="noopener">this commit</a></p>
<h3>Don&rsquo;t Reinvent the Wheel (duh)<span class="hx:absolute hx:-mt-20" id="dont-reinvent-the-wheel-duh"></span>
    <a href="#dont-reinvent-the-wheel-duh" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Yes, it&rsquo;s the most obvious cliché of all, but even I make this mistake. As I said at the start, my expectation for this project was to be little more than a Web &ldquo;Notepad&rdquo; for my notes, and it ended up becoming the main editor for my blog.</p>
<p>The text editor part started with the first thing that works first: just a plain textarea.</p>
<p>But on Saturday I wanted to add a line counter column that takes line wrapping into account, control over that wrapping, typewriter mode always centered in the middle of the screen, and sync between the text editor scroll and the preview.</p>
<p>Not counting all the dialog boxes that add things to the text, like images, videos, code and tables. Not counting the shortcuts for things like bold, italic. Not counting auto-save routines, line count stats, current line, etc.</p>
<p>For those who don&rsquo;t know, the problem with syntax highlight is that you can&rsquo;t edit directly on the colored text (because it has hidden tags that format the text). One way to solve this is to duplicate the text block: one in the background, which is the raw text and you can edit however you want, and another formatted and colored block with HTML exactly on top, which you don&rsquo;t touch, but it updates with whatever is edited. That&rsquo;s how Claude did it.</p>
<p>But there are a LOT of &ldquo;edge cases&rdquo;: alignment problems due to wrapping, problems with size and position to adjust the auto-scroll sync. I spent hours and hours fighting with this. Look at the kind of problem I was dealing with:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20260201183500_screenshot-2026-02-01_12-14-45.png" alt="syntax offset bug"  loading="lazy" /></p>
<p>Everything got resolved when I decided: enough. I&rsquo;m reinventing the wheel. Obviously this problem has already been solved. So I asked Claude to remove all the logic tied to the custom textarea and replace it with <a href="https://codemirror.net/"target="_blank" rel="noopener"><strong>Code Mirror</strong></a>, which is an open source component for a code editor, with support for line counting, syntax highlight, and much more!</p>
<p><a href="https://github.com/akitaonrails/FrankMD/commit/d0d42c9b57a9d19d904023951e6b46d9a71fcba8"target="_blank" rel="noopener"><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-01_18-03-47.jpg" alt="codemirror refactoring"  loading="lazy" /></a></p>
<p>It wasn&rsquo;t just those 5 commits; those were the main ones. They took a solid <strong>3 hours</strong> or more to stabilize that code, plus multiple small adjustments over more commits. A good <strong>6,000 lines of code modified</strong>. It&rsquo;s the kind of refactoring that, in a real project, would have taken the whole week and still would have come out with multiple bugs and side effects. But for this, the LLM is also very good. Big refactorings, <strong>especially if you&rsquo;ve prepared a good automated test suite</strong>, go quite well with LLMs.</p>
<blockquote>
  <p>This is the kind of thing that happens in real projects. I can&rsquo;t blame the LLM, it&rsquo;s entirely my fault for changing the project requirements: originally there wasn&rsquo;t going to be syntax highlight!</p>

</blockquote>
<h3>Tests, tests, tests<span class="hx:absolute hx:-mt-20" id="tests-tests-tests"></span>
    <a href="#tests-tests-tests" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>The main recommendations for how to do vibe coding are the same as how to program the old way:</p>
<ul>
<li>Be careful with architecture choices (yes, it takes experience)</li>
<li>Build small features one at a time, each with the right abstractions and immediately with tests</li>
<li>Refactor from time to time to consolidate or abstract code into the right domains, adjusting and adding more tests</li>
</ul>
<p>If you follow those 3 items, it already helps a lot. It&rsquo;s not magic: all of these are tools that give feedback to the LLM so it knows what to do.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20260201183748_screenshot-2026-01-31_03-33-13.png" alt="tests"  loading="lazy" /></p>
<p>If you&rsquo;re the kind of dumb customer who only says <em>&ldquo;oh, it stopped working&rdquo;</em> — you&rsquo;re no good at talking to a programmer, and I&rsquo;m sorry to say that LLMs won&rsquo;t help you either:</p>
<ul>
<li>WHAT stopped working?</li>
<li>Did everything stop opening?</li>
<li>Did just one element disappear?</li>
<li>Does the element appear but clicking does nothing?</li>
<li>Can you click, but an error message appears?</li>
<li>What message exactly?</li>
<li>What did you do before that?</li>
<li>Did you change anything from the previous test to this one?</li>
<li>etc</li>
</ul>
<p>ALL OF THIS and more has to be in the next prompt for fixes. Including server logs, command lines to reproduce the error, and whatever evidence can be produced, to make it easier to understand the error.</p>
<blockquote>
  <p>There&rsquo;s no such thing as <em>&ldquo;it stopped working&rdquo;</em> — your head is the thing that never worked.</p>

</blockquote>
<p>One thing I really liked about Claude was when a bug appeared that hadn&rsquo;t existed before, but I didn&rsquo;t know if it was from the last commit or from 3 commits back. And instead of just guessing at random things, Claude itself offered to manually do the equivalent of a <a href="https://git-scm.com/docs/git-bisect"target="_blank" rel="noopener">git bisect</a> and kept offering me one commit at a time to test and keep going until we found which one introduced the bug:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20260201183240_screenshot-2026-02-01_15-05-30.png" alt="bisect"  loading="lazy" /></p>
<h3>Optimizations and Checks<span class="hx:absolute hx:-mt-20" id="optimizations-and-checks"></span>
    <a href="#optimizations-and-checks" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Throughout the project, 2 or 3 times I specifically stopped to check things like memory leaks and security holes. Even being a personal project, I didn&rsquo;t want very obvious holes.</p>
<p>Be warned that Claude is a champion at scattering setTimeout calls in JavaScript and never calling <a href="https://github.com/akitaonrails/FrankMD/commit/039f3f8d72b0bf8facd5a1f85f54523bbfc12b53"target="_blank" rel="noopener">clearTimeout</a> on anything!</p>
<p>It&rsquo;s not much, but I had more than <a href="https://github.com/akitaonrails/FrankMD/commit/afb669f9b96baef05bae02cd76a9731353f6eac4"target="_blank" rel="noopener">one commit</a> to delete <strong>&ldquo;Dead Code&rdquo;</strong>: code that, during a refactor, was moved to another module but forgotten at the origin. LLMs will always leave dead code behind, because they have no way to look at 100% of every file 100% of the time. Eventually it misses one thing or another. And it won&rsquo;t cause any obvious bug. You have to prune it gradually, after each refactoring.</p>
<p>When I started really testing with long texts, I noticed the interface had &ldquo;lag&rdquo;, kind of slow. Two things seemed to be causing it: the statistical count of how many words and characters the text has was very aggressive.</p>
<blockquote>
  <p>Again, &ldquo;it already works&rdquo; … but slow …</p>

</blockquote>
<p>Worse: when I first asked it to create the text editor, I didn&rsquo;t say exactly how I wanted the &ldquo;auto-save&rdquo; to behave. So it did it in the most conservative, aggressive and slow way of all: save everything on every keystroke!!!</p>
<p>There were <a href="https://github.com/akitaonrails/FrankMD/commit/76406744a3e268d7b96648a2a41c415e3781c1a4"target="_blank" rel="noopener">several other things</a> like not updating the HTML Preview if it wasn&rsquo;t open and things like that.</p>
<blockquote>
  <p>LLMs won&rsquo;t make performant code right off the bat; you need to tell them how!</p>

</blockquote>
<p>Performance is always a trade-off. For example, if I go as fast as possible, the rest may end up super insecure. If I crank up security too much, usability collapses. If I over-optimize, maintainability collapses. There&rsquo;s no way to make everything perfect: one will always negatively affect the other, and only an experienced programmer knows how to choose (or goes through trial and error until getting it right for the specific case).</p>
<blockquote>
  <p>Again, it&rsquo;s not the LLM&rsquo;s &ldquo;fault&rdquo;; this is normal in any project and we have to keep adjusting these things all the time.</p>

</blockquote>
<h3>Now really, reaching the end<span class="hx:absolute hx:-mt-20" id="now-really-reaching-the-end"></span>
    <a href="#now-really-reaching-the-end" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>There&rsquo;s still plenty of technical debt left. The <a href="https://github.com/akitaonrails/FrankMD/blob/master/app/javascript/controllers/app_controller.js"target="_blank" rel="noopener">app_controller.js</a> file still has more than <strong>2,000 lines</strong>. And this is AFTER I spent a solid 3 hours refactoring just this one. Now there are roughly 28 JavaScript controllers, but before there was only a single file.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20260201183730_screenshot-2026-01-31_11-05-30.png" alt="app controller"  loading="lazy" /></p>
<p>Besides that, LLMs can&rsquo;t be consistent all the time, especially when the project code grows a lot. There was one type of feature it always did server-side, but later decided to do client-side. I had to interrupt and say: <em>&ldquo;hey, why?&rdquo;</em>, and then it redid it the right way:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20260201183531_screenshot-2026-02-01_10-49-54.png" alt="interrupt"  loading="lazy" /></p>
<p>Any code written with zero human assistance ALWAYS comes out at the worst possible code quality. With my assistance, I think the code is at least minimally presentable. You can check for yourselves at <a href="https://github.com/akitaonrails/FrankMD"target="_blank" rel="noopener">my GitHub repository</a>.</p>
<p>I recommend digging through the commits: at least one thing LLMs do well is document the history of what was modified in each commit, which makes it much easier to search later.</p>
<p>There was one bug where Claude got stuck in a loop, trying and failing, to figure out when my CSS broke out of nowhere. It changed something and asked me to test; I restarted the server, did a force reload, and nothing: broken.</p>
<p>It was Saturday night; I was tired and a bit feverish, but then the penny dropped. I had done something dumb earlier. I put &ldquo;notes/&rdquo; in the <code>.gitignore</code>, which was the directory where I was creating test Markdown files in the editor and didn&rsquo;t want to commit.</p>
<p>However, that also started ignoring directories like <code>app/views/notes/...</code>, for example. And neither I nor Claude knew this, but when I asked it to search, voilà:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20260201184244_screenshot-2026-01-30_19-55-28.png" alt="tailwind error"  loading="lazy" /></p>
<blockquote>
  <p>Tailwind build obeys <code>.gitignore</code>, so it was screwing up the CSS compilation. &#x1f92c;</p>

</blockquote>
<p>When we fixed that, it started working, but by then 2 hours had gone by!</p>
<p>As I said, you have to stay sharp on the errors, watch why the LLM is doing so much trial and error, doing and undoing in the same place and getting nowhere. This isn&rsquo;t uncommon and can eat up half your day if you&rsquo;re not paying attention.</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>When I started this Friday morning, I imagined:</p>
<blockquote>
  <p><em>&ldquo;Ah, a full day of work should be enough.&rdquo;</em></p>

</blockquote>
<p>And as always, every estimate is an underestimate. It&rsquo;s already Sunday, end of the day, and only now do I think I&rsquo;ve gotten close to version 1.0. It still isn&rsquo;t perfect, but I think it&rsquo;s at least minimally presentable.</p>
<p>Remember I did this at my pace of <strong>extreme hyperfocus</strong>, when I barely blink, my eyes dry out and I start to see everything blurry, and even my head starts to hurt a bit from staying so tense, concentrated on a single thing. It&rsquo;s not a state I recommend.</p>
<p>Even those 30 hours over 3 days, the right thing would be 30 hours spread over at least 5 days.</p>
<p>If I had let Claude do everything on its own, without checking anything, it would have taken half the time and, in the end, I&rsquo;d have code &ldquo;that works&rdquo;, but bizarre, with a single CSS file of around 2000 lines, a single JS file of around 8 thousand lines, everything with ZERO tests, and everything looking like a house of cards where you touch one small thing and break 10 other things without knowing why.</p>
<p>Overall, it was a good experiment. Knowing how and where to use them, LLMs are exceptional. As I said at the start, without the LLM I would have taken more than 200 hours to do it alone. Even going through several rough patches along the way, I only took 30 hours. So the LLM really did make me work at least 6 to 7 times faster, maybe more.</p>
<p>Finally I feel we&rsquo;ve reached the elusive <strong>&ldquo;10x Developer&rdquo;</strong>. All you need is to be a senior first.</p>
<p>To wrap up, a mini-report from <a href="https://github.com/XAMPPRocky/tokei"target="_blank" rel="noopener">Tokei</a>:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>Breakdown by language:
┌─────────────────┬───────┬────────┬────────┐
│    Language     │ Files │ Lines  │  Code  │
├─────────────────┼───────┼────────┼────────┤
│ JavaScript      │ 137   │ 26,621 │ 19,922 │
├─────────────────┼───────┼────────┼────────┤
│ Ruby            │ 60    │ 8,449  │ 5,883  │
├─────────────────┼───────┼────────┼────────┤
│ YAML            │ 10    │ 3,477  │ 2,867  │
├─────────────────┼───────┼────────┼────────┤
│ Ruby HTML (ERB) │ 33    │ 2,927  │ 2,634  │
├─────────────────┼───────┼────────┼────────┤
│ CSS             │ 29    │ 1,619  │ 1,297  │
├─────────────────┼───────┼────────┼────────┤
│ Markdown        │ 1     │ 716    │ -      │
├─────────────────┼───────┼────────┼────────┤
│ HTML            │ 5     │ 369    │ 329    │
├─────────────────┼───────┼────────┼────────┤
│ Dockerfile      │ 1     │ 88     │ 46     │
├─────────────────┼───────┼────────┼────────┤
│ Other           │ 6     │ 145    │ 76     │
└─────────────────┴───────┴────────┴────────┘
The codebase is primarily JavaScript (60%) and Ruby (17%) by code volume.</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>So, roughly <strong>33 thousand lines of code</strong>. This is a SMALL project! Big projects are 10 times bigger, with more than 300 thousand lines of code. I&rsquo;d guess that at an iFood or Mercado Livre, with all the subsystems, it&rsquo;s more than 1 million lines of code. Adjust your expectations!</p>
]]></content:encoded><category>frankmd</category><category>frank</category><category>llms</category><category>artificial-intelligence</category></item><item><title>Vibe Code: I Built a Markdown Editor From Scratch With Claude Code (FrankMD) PART 1</title><link>https://akitaonrails.github.io/en/2026/02/01/frankmd-markdown-editor-vibe-code-part-1/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2026/02/01/frankmd-markdown-editor-vibe-code-part-1/</guid><pubDate>Sun, 01 Feb 2026 10:40:43 -0300</pubDate><description>&lt;p&gt;In the last &lt;a href="https://akitaonrails.com/en/2026/01/28/vibe-code-built-a-little-app-fully-with-glm-4-7-tv-clipboard/"target="_blank" rel="noopener"&gt;two&lt;/a&gt; &lt;a href="https://akitaonrails.com/2026/01/29/vibe-code-qual-llm-%c3%a9-a-melhor-vamos-falar-a-real/"target="_blank" rel="noopener"&gt;posts&lt;/a&gt; I just wanted to beat up on LLMs to show how hard it is to build something simple from scratch, let alone &lt;em&gt;&amp;ldquo;build GTA 6 from scratch&amp;rdquo;&lt;/em&gt;, as some idiots believe.&lt;/p&gt;
&lt;p&gt;I&amp;rsquo;ve been saying this for years: I LIKE LLMs, I use them a lot since they came out, and I&amp;rsquo;ve never had a problem with them. My problem is calibrating the UNREALISTIC expectations of &amp;ldquo;normies&amp;rdquo;, non-programmers: you&amp;rsquo;re never going to get an app like this with a handful of prompts without knowing exactly which components and techniques to use underneath.&lt;/p&gt;</description><content:encoded><![CDATA[<p>In the last <a href="https://akitaonrails.com/en/2026/01/28/vibe-code-built-a-little-app-fully-with-glm-4-7-tv-clipboard/"target="_blank" rel="noopener">two</a> <a href="https://akitaonrails.com/2026/01/29/vibe-code-qual-llm-%c3%a9-a-melhor-vamos-falar-a-real/"target="_blank" rel="noopener">posts</a> I just wanted to beat up on LLMs to show how hard it is to build something simple from scratch, let alone <em>&ldquo;build GTA 6 from scratch&rdquo;</em>, as some idiots believe.</p>
<p>I&rsquo;ve been saying this for years: I LIKE LLMs, I use them a lot since they came out, and I&rsquo;ve never had a problem with them. My problem is calibrating the UNREALISTIC expectations of &ldquo;normies&rdquo;, non-programmers: you&rsquo;re never going to get an app like this with a handful of prompts without knowing exactly which components and techniques to use underneath.</p>
<p>This time, I decided to tackle another little problem that had been sitting on my todo-list for a while: having a self-hosted note-taking web app (to put on my <a href="/en/tags/homelab/">home-server</a>. Over the years I&rsquo;ve used everything: Evernote, Notion, Obsidian, among others, but none of them really pleased me. So why not build my own exactly the way I want?</p>
<p>I&rsquo;m going to split this topic into 2 articles: in this first one, I&rsquo;ll talk exclusively about my application; <a href="/en/2026/02/01/frankmd-markdown-editor-vibe-code-part-2/">in the second one</a>, I&rsquo;ll talk about the process of vibe coding with Claude. Spoilers: it took 3 full days (roughly <strong>30 hours</strong>). 137 pull requests! Wait for the post-mortem.</p>
<h2>The Problem<span class="hx:absolute hx:-mt-20" id="the-problem"></span>
    <a href="#the-problem" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Overall, this is just me being fussy. Keeping notes shouldn&rsquo;t be complicated: create a directory on your machine, open LazyVim, VSCode or whatever, done: edit the Markdown as pure code and be happy.</p>
<p>Anyway, recently I was just using Obsidian on a shared directory in my Dropbox. That way I could access it from my phone if I needed to. But it&rsquo;s a pain because if I&rsquo;m on another PC, I need to have Obsidian or some other editor installed. If I have plugins, I need to install those too.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-01_11-43-09.jpg" alt="obsidian"  loading="lazy" /></p>
<p>In practice any other text editor would do: they&rsquo;re just text files, but the inconsistency of having something different in each place, all misconfigured, demotivates me from writing.</p>
<p>In particular, I was using Obsidian and, later, LazyVim to edit my blog posts, which are also in Markdown. And here I have a bigger set of problems:</p>
<ul>
<li>there&rsquo;s a specific path and Hugo frontMatter; I have to be careful with the title to generate the right slug, etc.</li>
<li>there are images/screenshots that I keep manually uploading to S3, grabbing the URL and pasting into the post</li>
<li>I avoid using tables because &ldquo;drawing&rdquo; tables directly in Markdown is a pain</li>
<li>I keep Google Images, YouTube, etc. tabs open so I can keep grabbing links, alt-tab, and paste</li>
<li>there&rsquo;s no good preview, so I let a lot of little errors slip through (duplicated image url, for example)</li>
<li>there&rsquo;s no good spellchecker, so lots of little typos get through that I then have to keep fixing</li>
</ul>
<p>I wanted to solve all these problems in one place: a beautiful editor like Obsidian that would fix all these inconveniences and let me focus only on my text.</p>
<h2>FrankMD<span class="hx:absolute hx:-mt-20" id="frankmd"></span>
    <a href="#frankmd" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Frank Rosenblatt was the creator of the Perceptron in 1957, which gave rise to modern neural networks. Since I built this editor entirely with AI, I decided to pay tribute to Frank.</p>
<p>So I named it Frank Markdown or Frank Editor (fed). This editor has all the basics you would expect in any editor:</p>
<ul>
<li>syntax highlighting</li>
<li>HTML preview</li>
<li>markdown formatting helpers (Ctrl-B for bold, for example)</li>
<li>customize font family and size</li>
</ul>
<p>But then I started adding some things specific to my personal taste and/or that would make my life easier for editing my blog posts. Let&rsquo;s go.</p>
<h3>Image Insertion<span class="hx:absolute hx:-mt-20" id="image-insertion"></span>
    <a href="#image-insertion" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>This is an important part for me. When I write posts about code, I&rsquo;m always taking screenshots of my terminal or editor to illustrate. The process is a print screen that sends it to <code>~/Pictures/Screenhots</code>. I open Nautilus (I&rsquo;m on Linux) and run a little script I made to upload that image to a bucket on my AWS S3. I copy the generated link and add it to my post.</p>
<p>It&rsquo;s &ldquo;a little annoying&rdquo; doing this every time. On top of that, sometimes I&rsquo;d like to resize the image to make it more optimized. But that means going to the terminal and running <code>magick screenshot.png -resize 50%</code>, which is another manual step.</p>
<p>So I decided to make a dialog box with several options for this.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-01_14-03-02.jpg" alt="image dialog"  loading="lazy" /></p>
<p>By default, I can configure a main images directory on my PC and it will display a grid limited to the 10 most recent. Or I can filter by name. There&rsquo;s an option to link directly (in case I want to upload the image along with the html) or to serve from S3 and it will copy the generated link automatically (which is what I prefer).</p>
<p>Same thing for images on the Web (Google, Pinterest), where I can choose to download, resize and upload to S3:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-01_14-06-07.jpg" alt="bing images example"  loading="lazy" /></p>
<p>And the link will be in the expected format <code>...amazonaws.com/frankmd/...jpg</code>. All pretty, easy and practical! For me, this helps a lot!</p>
<p>It&rsquo;s an extra, since I couldn&rsquo;t skip integrating an AI; the last tab is for <a href="https://gemini.google/br/overview/image-generation/?hl=pt-BR"target="_blank" rel="noopener">Nano Banana</a>:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-01_14-10-24.jpg" alt="nano banana generator"  loading="lazy" /></p>
<p>Nano Banana is really impressive. Look at what this prompt generated:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/ai_1769965759787.png" alt="nano banana generated image"  loading="lazy" /></p>
<p>Even more important: I can see the preview immediately (ctrl-shift-v) to know whether I inserted the right image (something I used to screw up a lot editing in NeoVim, without seeing it):</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-01_14-13-29.jpg" alt="real time preview"  loading="lazy" /></p>
<p>For me, these image insertion helpers are the ones that are going to help me the most when editing my blog posts.</p>
<h3>Markdown Helpers<span class="hx:absolute hx:-mt-20" id="markdown-helpers"></span>
    <a href="#markdown-helpers" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Simpler than an image is embedding a video player. I can either pass any link with a random .mp4 file, or I can directly search YouTube and embed it properly:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-01_14-12-34.jpg" alt="youtube searching"  loading="lazy" /></p>
<p>It will insert the correct HTML, <code>&lt;embed ...</code>, with the right URL and title.</p>
<p>I also built an easy way to add and edit tables:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-01_14-28-00.jpg" alt="spreadsheet editor"  loading="lazy" /></p>
<p>And it will generate the Markdown just right. Never again do I have to sit there editing that garbage by hand to readjust columns or rows.</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-markdown" data-lang="markdown"><span class="line"><span class="cl">| Cabeçalho 1 | Cabeçalho 2 |
</span></span><span class="line"><span class="cl">| ----------- | ----------- |
</span></span><span class="line"><span class="cl">| Linguagem   | Nota        |
</span></span><span class="line"><span class="cl">| Ruby        | 10          |
</span></span><span class="line"><span class="cl">| JS          | 5           |
</span></span><span class="line"><span class="cl">| Rust        | 6 😂        |</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>But if you want to do it manually, you can also do that:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-01_14-28-40.jpg" alt="code editor"  loading="lazy" /></p>
<p>I added this mostly because it would be weird to leave it out, but in general I prefer to write code blocks manually. But there it is for anyone who wants a visual mode.</p>
<p>Then, of course, it had to have an easy way to edit Emojis &#x1f606; (ctrl-shift-e):</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-01_14-30-19.jpg" alt="emoji panel"  loading="lazy" /></p>
<p>But what nobody would expect is that I also offer support for <strong>Emoticons</strong>!!
☆:.｡.o(≧▽≦)o.｡.:*☆</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-01_14-30-40.jpg" alt="emoticon panel"  loading="lazy" /></p>
<h3>Customization<span class="hx:absolute hx:-mt-20" id="customization"></span>
    <a href="#customization" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Speaking of Emoticons, that reminds me that I wanted this editor to be infinitely customizable. For example, of course I added internationalization and it has Japanese!!</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-01_14-34-30.jpg" alt="japanese locale"  loading="lazy" /></p>
<p>And other languages too; go poke around later. It has Portuguese too!</p>
<blockquote>
  <p>&ldquo;What&rsquo;s the best font for programming?&rdquo;</p>

</blockquote>
<p>Who knows! That&rsquo;s why I added several to choose from:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-01_15-22-35.jpg" alt="fonts"  loading="lazy" /></p>
<p>Since I use <a href="/en/tags/omarchy/">Omarchy</a>, I also wanted my editor to support exactly the same themes:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-01_14-37-05.jpg" alt="temas"  loading="lazy" /></p>
<p>In the <a href="https://github.com/akitaonrails/FrankMD/blob/master/README.md"target="_blank" rel="noopener">README</a> everything that can be customized is documented. You can customize many things through environment variables and also through a <code>.fed</code> file, automatically created in the directory where you open this editor. There&rsquo;s plenty, so I recommend poking around.</p>
<h3>Focus on the Text<span class="hx:absolute hx:-mt-20" id="focus-on-the-text"></span>
    <a href="#focus-on-the-text" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Obviously I included a <em>&ldquo;Typewriter Mode&rdquo;</em> that keeps my cursor closer to the middle of the screen and centers the editor, while hiding all distractions.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-01_14-17-37.jpg" alt="typewriter mode"  loading="lazy" /></p>
<p>I normally prefer TUIs, but I have to admit that using a browser has the advantage of giving me access to extensions. The most useful one, without a doubt, is <a href="https://app.grammarly.com/"target="_blank" rel="noopener">Grammarly</a>, for any language:</p>
<p><a href="https://app.grammarly.com/"target="_blank" rel="noopener"><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-01_14-20-04.jpg" alt="grammarly"  loading="lazy" /></a></p>
<p>But beyond that, I added an &ldquo;AI&rdquo; button in the toolbar (which is configurable via ENVs and a config file — read the <a href="https://github.com/akitaonrails/FrankMD/blob/master/README.md"target="_blank" rel="noopener">README</a>). And it does this:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-01_14-22-28.jpg" alt="AI grammar check"  loading="lazy" /></p>
<p>It sends to your LLM of choice (local Ollama, OpenAI, Anthropic, OpenRouter, etc.) with a prompt telling it not to rewrite your text, only to check grammar, spelling and errors of that kind. Then I return a &ldquo;diff&rdquo; interface to compare and accept, edit or cancel.</p>
<p>I think Grammarly alone is already enough, but it cost nothing to build this one, so I built it.</p>
<h3>Hugo Blogging<span class="hx:absolute hx:-mt-20" id="hugo-blogging"></span>
    <a href="#hugo-blogging" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>This blog is made with Hextra/Hugo. That&rsquo;s why I wanted to already bake in an easy way to create new articles in Hugo. It starts right from the option to create a new document:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-01_14-39-53.jpg" alt="new hugo note"  loading="lazy" /></p>
<p>And it already creates it with the frontMatter, automatic slug generation, timestamped subdirectories and all the rest:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-01_14-38-55.jpg" alt="hugo frontmatter"  loading="lazy" /></p>
<p>Like I said, this editor was built mainly to make my own workflow easier.</p>
<h2>Self-deploy vs CLI<span class="hx:absolute hx:-mt-20" id="self-deploy-vs-cli"></span>
    <a href="#self-deploy-vs-cli" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Originally I thought of this project as a Notion-light or something like that. A web editor that would deploy to my home server and serve only as an organizer, a place to put notes bigger than a post-it (like the history of my Linux configurations, step-by-step commands, etc.).</p>
<p>But as I kept building the app, more and more it grew and started looking like a desktop app. But by then it was too late. I wanted to use Ruby on Rails 8 (to shake off the rust and because I like it). Only halfway through did I think I should have done it in Node, because now I can&rsquo;t embed it in an Electron app anymore.</p>
<p>Fine, the idea is to be a Markdown editor for programmers. Every programmer has Docker. So I wrapped it in a little shell function and I can just land in any directory on my Linux and type:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nb">cd</span> ~/Projects/akitaonrails-hugo
</span></span><span class="line"><span class="cl">fed .</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>Read the <a href="https://github.com/akitaonrails/FrankMD/blob/master/README.md#2-add-the-fed-function"target="_blank" rel="noopener">README</a> of the project to learn how to add it to your <code>.bashrc</code>. And doing it this way, it will work on my Omarchy exactly like any other native app:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-01_15-16-29.jpg" alt="omarchy web app"  loading="lazy" /></p>
<p>In reality, I&rsquo;ll use both ways: as if it were a native app. And as self-hosted, to organize a central place with my notes — which will be separate from where I organize my blog posts. So it really does have multiple uses.</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&rsquo;ve summarized the features; there are several little things you&rsquo;ll discover as you start using them. Everything is clickable; if you click on the app title, there&rsquo;s an About:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-01_15-21-03.jpg" alt="about"  loading="lazy" /></p>
<p>Right-click on top of selected text has a formatting submenu; Ctrl-I works for italic; <code>Ctrl-H</code> has a basic &ldquo;find and replace&rdquo;; there are a bunch of keyboard combinations to access things.</p>
<p>Oh right, I didn&rsquo;t even mention that you can quickly search files by name or by content using <code>Ctrl-P</code> and <code>Ctrl-Shift-F</code>:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-01_15-24-36.jpg" alt="search filename"  loading="lazy" /></p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-01_15-25-59.jpg" alt="search file content"  loading="lazy" /></p>
<p>To make sure everything is working, this very article was written entirely with my new editor. And I&rsquo;d say that, if it isn&rsquo;t perfect (for me), it&rsquo;s at 99%. Feel free to open Issues with bugs or features that would make sense — remembering: the idea is to stay away from replacing VSCode and having EVERYTHING. I need it to have only ENOUGH.</p>
<p>Considering it cost me <strong>3 days of work</strong> and that I didn&rsquo;t write a single line of code, the result is impressive. Yes, text editors are very well documented applications with tons of training material — that&rsquo;s why Claude managed to build it without trouble. Don&rsquo;t get too impressed. That said, yes, I wanted a custom editor for my personal use, one that would be beautiful to use, and it managed to deliver, so I&rsquo;m satisfied.</p>
<p>Don&rsquo;t miss <a href="/en/2026/02/01/frankmd-markdown-editor-vibe-code-part-2/">PART 2</a> for the details of how this was built.</p>
]]></content:encoded><category>frankmd</category><category>frank</category><category>llms</category><category>artificial-intelligence</category></item></channel></rss>