<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>The-M-Akita-Chronicles | AkitaOnRails.com</title><link>https://akitaonrails.github.io/en/tags/the-m-akita-chronicles/</link><description>Fabio Akita's blog — tech, career, and assorted geek topics. English edition.</description><generator>Hugo -- gohugo.io</generator><language>en</language><lastBuildDate>Mon, 19 Oct 2026 23:20:52 +0000</lastBuildDate><atom:link href="https://akitaonrails.github.io/en/tags/the-m-akita-chronicles/index.xml" rel="self" type="application/rss+xml"/><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>Integration Tests in a Monorepo | Behind The M.Akita Chronicles</title><link>https://akitaonrails.github.io/en/2026/02/20/integration-tests-in-a-monorepo-behind-the-m-akita-chronicles/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2026/02/20/integration-tests-in-a-monorepo-behind-the-m-akita-chronicles/</guid><pubDate>Fri, 20 Feb 2026 12:48:45 +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;/themakitachronicles&lt;/a&gt;. This is part 8.&lt;/p&gt;
&lt;p&gt;And make sure to 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;Unit tests are every developer&amp;rsquo;s comfort zone. You mock everything, isolate everything, run it in milliseconds, and the green in the terminal gives you a false sense of security. Until the day you deploy and discover that service A writes in a format service B can&amp;rsquo;t read. Or that the Hacker News RSS switched from Atom to RSS and your parser blew up. Or that Yahoo Finance started returning 429 after the third ticker because the rate limit that worked in tests doesn&amp;rsquo;t exist in real life.&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/">/themakitachronicles</a>. This is part 8.</p>
<p>And make sure to subscribe to my new newsletter <a href="https://themakitachronicles.com/"target="_blank" rel="noopener">The M.Akita Chronicles</a>!</p>
<p>&ndash;</p>
<p>Unit tests are every developer&rsquo;s comfort zone. You mock everything, isolate everything, run it in milliseconds, and the green in the terminal gives you a false sense of security. Until the day you deploy and discover that service A writes in a format service B can&rsquo;t read. Or that the Hacker News RSS switched from Atom to RSS and your parser blew up. Or that Yahoo Finance started returning 429 after the third ticker because the rate limit that worked in tests doesn&rsquo;t exist in real life.</p>
<p>Unit tests prove the pieces work in isolation. Integration tests prove the <strong>system</strong> works. And in a project with multiple applications sharing data via the filesystem, the difference between the two is the difference between &ldquo;the code compiles&rdquo; and &ldquo;the newsletter lands in the inbox&rdquo;.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-20_12-22-50.jpg" alt="unit tests"  loading="lazy" /></p>
<h2>The Problem: Three Apps, One Filesystem, Zero Guarantees<span class="hx:absolute hx:-mt-20" id="the-problem-three-apps-one-filesystem-zero-guarantees"></span>
    <a href="#the-problem-three-apps-one-filesystem-zero-guarantees" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The project has an unusual architecture: three Rails/Hugo applications in a monorepo, integrated through a shared <code>content/</code> directory:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>akitando-news/
├── marvin-bot/    # Gera conteúdo (Discord bot, AI, scraping)
├── newsletter/    # Monta e envia a newsletter
├── blog/          # Hugo &#43; Hextra → Netlify
├── podcast-tts/   # Servidor TTS (Python/FastAPI)
└── content/       # Markdown &#43; YAML, a cola entre tudo</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>marvin-bot writes Markdown with YAML frontmatter into <code>content/anime/</code>, <code>content/hacker_news/</code>, etc. The newsletter reads those files to assemble the newsletter. The blog serves the same content as static pages. The podcast reads the assembled newsletter to generate the dialogue script.</p>
<p>The contract between these systems is <strong>implicit</strong>. There&rsquo;s no schema. No versioned API. No protobuf. The contract is: &ldquo;the Markdown file has this frontmatter, with these fields, in this structure&rdquo;. If marvin-bot changes the format of a field — say, switches <code>image</code> from string to array — the newsletter breaks silently. No unit test in either app detects it.</p>
<p>That&rsquo;s the fundamental flaw of testing distributed systems with unit tests: <strong>you&rsquo;re testing each side of the contract separately, but never the contract itself</strong>.</p>
<h2>Layer 1: Unit Tests With Paranoid Isolation<span class="hx:absolute hx:-mt-20" id="layer-1-unit-tests-with-paranoid-isolation"></span>
    <a href="#layer-1-unit-tests-with-paranoid-isolation" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Before talking about integration, I need to explain what the unit tests do in this project — because without that foundation, the integration tests would have nothing to stand on.</p>
<p>The 977 marvin-bot tests run in parallel using every core on the machine. Each test worker has its own directory:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="n">parallelize</span><span class="p">(</span><span class="ss">workers</span><span class="p">:</span> <span class="ss">:number_of_processors</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">parallelize_setup</span> <span class="k">do</span> <span class="o">|</span><span class="n">worker</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">  <span class="no">Rails</span><span class="o">.</span><span class="n">application</span><span class="o">.</span><span class="n">config</span><span class="o">.</span><span class="n">content_dir</span> <span class="o">=</span>
</span></span><span class="line"><span class="cl">    <span class="no">Rails</span><span class="o">.</span><span class="n">root</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="s2">&#34;tmp&#34;</span><span class="p">,</span> <span class="s2">&#34;test_content_w</span><span class="si">#{</span><span class="n">worker</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span><span class="o">.</span><span class="n">to_s</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>But worker-level isn&rsquo;t enough. Inside each worker, multiple tests run sequentially — and if one test leaves a file in <code>content/stories/</code>, the next test might find spurious data. The solution: <strong>one unique directory per test</strong>:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="n">setup</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="vi">@base_content_dir</span> <span class="o">=</span> <span class="no">Rails</span><span class="o">.</span><span class="n">application</span><span class="o">.</span><span class="n">config</span><span class="o">.</span><span class="n">content_dir</span>
</span></span><span class="line"><span class="cl">  <span class="vi">@test_content_dir</span> <span class="o">=</span> <span class="no">File</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="vi">@base_content_dir</span><span class="p">,</span> <span class="no">SecureRandom</span><span class="o">.</span><span class="n">hex</span><span class="p">(</span><span class="mi">8</span><span class="p">))</span>
</span></span><span class="line"><span class="cl">  <span class="no">Rails</span><span class="o">.</span><span class="n">application</span><span class="o">.</span><span class="n">config</span><span class="o">.</span><span class="n">content_dir</span> <span class="o">=</span> <span class="vi">@test_content_dir</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="no">FileUtils</span><span class="o">.</span><span class="n">mkdir_p</span><span class="p">(</span><span class="no">File</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="vi">@test_content_dir</span><span class="p">,</span> <span class="s2">&#34;stories&#34;</span><span class="p">))</span>
</span></span><span class="line"><span class="cl">  <span class="no">FileUtils</span><span class="o">.</span><span class="n">mkdir_p</span><span class="p">(</span><span class="no">File</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="vi">@test_content_dir</span><span class="p">,</span> <span class="s2">&#34;newsletters&#34;</span><span class="p">))</span>
</span></span><span class="line"><span class="cl">  <span class="no">FileUtils</span><span class="o">.</span><span class="n">mkdir_p</span><span class="p">(</span><span class="no">File</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="vi">@test_content_dir</span><span class="p">,</span> <span class="s2">&#34;images&#34;</span><span class="p">))</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">teardown</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="no">FileUtils</span><span class="o">.</span><span class="n">rm_rf</span><span class="p">(</span><span class="vi">@test_content_dir</span><span class="p">)</span> <span class="k">if</span> <span class="vi">@test_content_dir</span>
</span></span><span class="line"><span class="cl">  <span class="no">Rails</span><span class="o">.</span><span class="n">application</span><span class="o">.</span><span class="n">config</span><span class="o">.</span><span class="n">content_dir</span> <span class="o">=</span> <span class="vi">@base_content_dir</span> <span class="k">if</span> <span class="vi">@base_content_dir</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>Pay attention to the teardown line that restores <code>@base_content_dir</code>. Without it, the next test would create its directory <strong>inside</strong> the previous test&rsquo;s directory — <code>tmp/test_content_w3/abc123/def456/</code> — and by test 50 you&rsquo;d have a path 50 levels deep. I learned this burning an entire afternoon debugging why the tests kept getting slower as the suite progressed.</p>
<p>This pattern — worker-level base + per-test subdirectory + restore on teardown — is identical across both apps. And it&rsquo;s what lets you run 977 tests in 7 seconds with 32 parallel workers.</p>
<p>The golden rule: <strong>the test <code>content_dir</code> must NEVER point to the real <code>../content</code></strong>. If it does, the teardown&rsquo;s <code>rm_rf</code> wipes out your production prompts. This isn&rsquo;t hypothetical — it happened during development.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-20_12-24-08.jpg" alt="unit tests newsletter"  loading="lazy" /></p>
<h2>Layer 2: The Integration Environment<span class="hx:absolute hx:-mt-20" id="layer-2-the-integration-environment"></span>
    <a href="#layer-2-the-integration-environment" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Unit tests mock everything: external APIs, AI, Discord, SES. That&rsquo;s correct — you don&rsquo;t want to burn $2 in GPT-5.2 tokens every time you run <code>bin/rails test</code>. But it means the tests never exercise the real pipeline.</p>
<p>The project has a third environment beyond development and test: <strong>integration</strong>. It&rsquo;s not test. It&rsquo;s not staging. It&rsquo;s a specific mode for running the full pipeline with real data, using real APIs, but without sending actual emails:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="c1"># config/environments/integration.rb</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Jobs rodam inline (perform_later vira perform_now)</span>
</span></span><span class="line"><span class="cl"><span class="n">config</span><span class="o">.</span><span class="n">active_job</span><span class="o">.</span><span class="n">queue_adapter</span> <span class="o">=</span> <span class="ss">:inline</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Emails salvos em arquivo ao invés de enviados via SES</span>
</span></span><span class="line"><span class="cl"><span class="n">config</span><span class="o">.</span><span class="n">action_mailer</span><span class="o">.</span><span class="n">delivery_method</span> <span class="o">=</span> <span class="ss">:file</span>
</span></span><span class="line"><span class="cl"><span class="n">config</span><span class="o">.</span><span class="n">action_mailer</span><span class="o">.</span><span class="n">file_settings</span> <span class="o">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="ss">location</span><span class="p">:</span> <span class="no">Rails</span><span class="o">.</span><span class="n">root</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="s2">&#34;tmp/integration_emails&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The <code>queue_adapter = :inline</code> is crucial. In production, <code>perform_later</code> enqueues the job into SolidQueue and a separate worker executes it. In integration, it runs <strong>right there, in the same process</strong>. That turns the asynchronous production pipeline into a synchronous pipeline you can run, observe, and debug sequentially.</p>
<p>SES is swapped for writing to disk — every email becomes an HTML file in <code>tmp/integration_emails/</code>. After running the pipeline, you open those HTMLs in the browser to visually validate that the email renders correctly.</p>
<h2>Layer 3: DevCache — LLMs Aren&rsquo;t Idempotent<span class="hx:absolute hx:-mt-20" id="layer-3-devcache--llms-arent-idempotent"></span>
    <a href="#layer-3-devcache--llms-arent-idempotent" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Here&rsquo;s a problem anyone working with AI in production knows: calling GPT-5.2 with the same prompt twice <strong>doesn&rsquo;t return the same answer</strong>. And every call costs money. If you&rsquo;re iterating on the integration pipeline — running, tweaking a prompt, running again — spending $3 per iteration gets expensive fast.</p>
<p>The solution is <code>DevCache</code>: a file-based cache that only activates in the integration env:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">module</span> <span class="nn">DevCache</span>
</span></span><span class="line"><span class="cl">  <span class="no">CACHE_TTL</span> <span class="o">=</span> <span class="mi">1</span><span class="o">.</span><span class="n">day</span>
</span></span><span class="line"><span class="cl">  <span class="no">CACHE_ENVS</span> <span class="o">=</span> <span class="sx">%w[integration]</span><span class="o">.</span><span class="n">freeze</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">fetch</span><span class="p">(</span><span class="n">namespace</span><span class="p">,</span> <span class="n">key</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="k">yield</span> <span class="k">unless</span> <span class="n">enabled?</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">path</span> <span class="o">=</span> <span class="n">cache_path</span><span class="p">(</span><span class="n">namespace</span><span class="p">,</span> <span class="n">key</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">path</span><span class="o">.</span><span class="n">exist?</span> <span class="o">&amp;&amp;</span> <span class="no">File</span><span class="o">.</span><span class="n">mtime</span><span class="p">(</span><span class="n">path</span><span class="p">)</span> <span class="o">&gt;</span> <span class="no">CACHE_TTL</span><span class="o">.</span><span class="n">ago</span>
</span></span><span class="line"><span class="cl">      <span class="k">return</span> <span class="no">JSON</span><span class="o">.</span><span class="n">parse</span><span class="p">(</span><span class="n">path</span><span class="o">.</span><span class="n">read</span><span class="p">,</span> <span class="ss">symbolize_names</span><span class="p">:</span> <span class="kp">true</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">result</span> <span class="o">=</span> <span class="k">yield</span>
</span></span><span class="line"><span class="cl">    <span class="n">path</span><span class="o">.</span><span class="n">write</span><span class="p">(</span><span class="no">JSON</span><span class="o">.</span><span class="n">generate</span><span class="p">(</span><span class="n">result</span><span class="p">))</span>
</span></span><span class="line"><span class="cl">    <span class="n">result</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>In production and in test, <code>DevCache.fetch</code> runs the block directly. In integration, it saves the result as JSON and returns the cached value on the next run. TTL of 1 day — enough to iterate within the same day without recomputing, short enough to not serve stale data.</p>
<p>The generation code uses it like this:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="n">summary</span> <span class="o">=</span> <span class="no">DevCache</span><span class="o">.</span><span class="n">fetch</span><span class="p">(</span><span class="s2">&#34;summarize&#34;</span><span class="p">,</span> <span class="n">url_key</span><span class="p">)</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="no">AiChat</span><span class="o">.</span><span class="n">summarize</span><span class="p">(</span><span class="n">article</span><span class="o">[</span><span class="ss">:content</span><span class="o">]</span><span class="p">,</span> <span class="ss">source_url</span><span class="p">:</span> <span class="n">url</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="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>First run: calls GPT-5.2, pays $0.02, saves to <code>tmp/dev_cache/summarize/{md5}.json</code>. Second run: reads from the cache, zero cost. <code>FORCE=1</code> busts the cache when you want to force regeneration — as an explicit flag, not a default.</p>
<p>DevCache exists in both apps (marvin-bot and newsletter) with identical implementation. When you run <code>integration:clean</code>, both caches are wiped.</p>
<p>The point: <strong>LLM caching in a development environment is a requirement, not an optimization</strong>. Without it, iterating on a prompt that needs 8 API calls becomes financially unworkable.</p>
<h2>Layer 4: Real Data via Rsync<span class="hx:absolute hx:-mt-20" id="layer-4-real-data-via-rsync"></span>
    <a href="#layer-4-real-data-via-rsync" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>This is where most projects stop. They have unit tests, maybe a staging environment, and ship it. The problem: staging uses test data. Test data is <strong>too clean</strong>. It doesn&rsquo;t have the edge cases real data has:</p>
<ul>
<li>Articles with titles in Japanese (anime ranking)</li>
<li>URLs that redirect 3 times before reaching the destination</li>
<li>Images that return 200 with Content-Type <code>text/html</code> (CloudFlare error page)</li>
<li>Previous newsletters with slightly different frontmatter formats</li>
<li>Stories with <code>score: null</code> because they were created before the field existed</li>
</ul>
<p>The solution: <strong>rsync from production</strong>.</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># Limpa conteúdo gerado (preserva stories e imagens de produção)</span>
</span></span><span class="line"><span class="cl"><span class="nb">cd</span> marvin-bot <span class="o">&amp;&amp;</span> bin/rails integration:clean
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Sincroniza content/ de produção pra local</span>
</span></span><span class="line"><span class="cl">rsync -avz --delete production:/path/to/content/ ../content/
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Roda o pipeline completo com dados reais</span>
</span></span><span class="line"><span class="cl"><span class="nv">RAILS_ENV</span><span class="o">=</span>integration bin/rails integration:pipeline</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>integration:clean</code> is surgical: it removes generated directories (<code>anime/</code>, <code>hacker_news/</code>, <code>newsletters/</code>, <code>podcasts/</code>) but preserves <code>stories/</code> and <code>images/</code> — which are user-submitted data coming in via Discord. After the rsync, the local directory is an exact copy of production.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-20_12-25-37.jpg" alt="cleanup"  loading="lazy" /></p>
<p>The pipeline then generates every section from that real data. If a parser breaks on a Korean title, you find out here — not in production at 5pm on a Sunday when the 8 jobs run in parallel.</p>
<h2>Layer 5: The Full Pipeline<span class="hx:absolute hx:-mt-20" id="layer-5-the-full-pipeline"></span>
    <a href="#layer-5-the-full-pipeline" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The heart of the integration system is <code>integration:pipeline</code>. It simulates the entire production week in a single 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="nv">RAILS_ENV</span><span class="o">=</span>integration bin/rails integration:pipeline</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 pipeline runs in waves, honoring dependencies:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>Wave 1 (paralelo, 8 jobs):
  Book, Holidays, History, Geek History,
  Anime, Hacker News, YouTube, World Events

Wave 2 (depende de World Events):
  Market Recap

Wave 3 (depende de tudo):
  Closing Remarks

Preflight → Newsletter Assembly → Podcast</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><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-20_12-26-13.jpg" alt="waves"  loading="lazy" /></p>
<p>Each wave runs its jobs in parallel threads, exactly like production does with SolidQueue. Parallel execution isn&rsquo;t an implementation detail here — it&rsquo;s a test feature. If two jobs try to write to the same file, or if one job reads a file another is in the middle of writing, the bug shows up here.</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="n">threads</span> <span class="o">=</span> <span class="n">to_run</span><span class="o">.</span><span class="n">map</span> <span class="k">do</span> <span class="o">|</span><span class="nb">name</span><span class="p">,</span> <span class="n">klass</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">  <span class="no">Thread</span><span class="o">.</span><span class="n">new</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">    <span class="no">ActiveRecord</span><span class="o">::</span><span class="no">Base</span><span class="o">.</span><span class="n">connection_pool</span><span class="o">.</span><span class="n">with_connection</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">      <span class="n">run_single_job</span><span class="p">(</span><span class="nb">name</span><span class="p">,</span> <span class="n">klass</span><span class="p">,</span> <span class="n">target_date</span><span class="p">,</span> <span class="n">results</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">end</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="n">threads</span><span class="o">.</span><span class="n">each</span><span class="p">(</span><span class="o">&amp;</span><span class="ss">:join</span><span class="p">)</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p><code>with_connection</code> is necessary because each thread needs its own SQLite connection — without it, connections leak and the pool gets exhausted.</p>
<p>At the end, the pipeline prints a status table and a billing summary:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-20_12-34-56.jpg" alt="billing"  loading="lazy" /></p>
<p>That billing summary isn&rsquo;t vanity — it&rsquo;s cost control. If a refactored prompt triples the output tokens, you see it here before going to production and burning 10x on a Sunday with 200 stories.</p>
<h2>Layer 6: Preflight — Automated Structural Validation<span class="hx:absolute hx:-mt-20" id="layer-6-preflight--automated-structural-validation"></span>
    <a href="#layer-6-preflight--automated-structural-validation" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>After generating every section, the pipeline runs <code>ContentPreflight</code>. It doesn&rsquo;t validate the <em>content</em> — it validates the <em>structure</em>. Each section gets checked:</p>
<ul>
<li><strong>Does the file exist?</strong> If <code>content/anime/2026-02-16.md</code> doesn&rsquo;t exist, something failed.</li>
<li><strong>Minimum item count?</strong> The anime section needs at least 3 items. If it has 1, the generator had a problem.</li>
<li><strong>Required markers?</strong> The Markdown must contain <code>[COMMENTARY]</code> and <code>[AKITA]</code> — without them, newsletter assembly doesn&rsquo;t know where to cut.</li>
<li><strong>Specific data?</strong> Market recap needs at least 5 ticker rows.</li>
</ul>
<p>The result is a status per section: <code>pass</code>, <code>degraded</code>, <code>fail</code>, or <code>skip</code>. The pipeline uses this to decide whether the newsletter can be assembled automatically or needs human intervention:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-20_12-36-19.jpg" alt="preflight"  loading="lazy" /></p>
<p>In production, this result goes to Discord as an embed. In the integration pipeline, it&rsquo;s printed to the terminal. The logic is the same — the notification channel changes.</p>
<h2>Layer 7: Cross-App — Newsletter Assembly<span class="hx:absolute hx:-mt-20" id="layer-7-cross-app--newsletter-assembly"></span>
    <a href="#layer-7-cross-app--newsletter-assembly" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Here&rsquo;s where the monorepo pays dividends. marvin-bot needs to invoke the newsletter to assemble the final newsletter. In production, these are different servers. In the integration pipeline, it&rsquo;s a <code>system()</code> call:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="n">env_vars</span> <span class="o">=</span> <span class="s2">&#34;RAILS_ENV=integration CONTENT_DIR=</span><span class="si">#{</span><span class="no">Shellwords</span><span class="o">.</span><span class="n">escape</span><span class="p">(</span><span class="n">content_dir</span><span class="p">)</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="n">cmd</span> <span class="o">=</span> <span class="s2">&#34;cd </span><span class="si">#{</span><span class="n">newsletter_dir</span><span class="si">}</span><span class="s2"> &amp;&amp; </span><span class="si">#{</span><span class="n">env_vars</span><span class="si">}</span><span class="s2"> bin/rails integration:generate_all&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nb">system</span><span class="p">(</span><span class="n">cmd</span><span class="p">)</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-20_12-38-17.jpg" alt="assembly"  loading="lazy" /></p>
<p>The newsletter reads the files marvin-bot just generated in <code>content/</code>, assembles the newsletter, and saves the result in <code>content/newsletters/</code>. CONTENT_DIR is passed explicitly — both apps point to the same directory.</p>
<p>If the frontmatter format changed and the newsletter can&rsquo;t parse it, the error shows up here. If the newsletter&rsquo;s <code>SectionParser</code> has diverged from marvin-bot&rsquo;s <code>SectionParser</code> (they&rsquo;re identical copies, but maintained by hand), here&rsquo;s where you find out.</p>
<p>After the newsletter, the podcast: the pipeline invokes <code>podcast:integration</code>, which generates the dialogue script from the assembled newsletter, synthesizes audio via TTS, and builds the final MP3. It&rsquo;s the longest pipeline — from Markdown newsletter to Spotify-ready MP3, passing through a two-pass LLM and per-turn voice synthesis.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-20_12-47-52.jpg" alt="tts"  loading="lazy" /></p>
<h2>The Value of Testing With Production Data<span class="hx:absolute hx:-mt-20" id="the-value-of-testing-with-production-data"></span>
    <a href="#the-value-of-testing-with-production-data" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Let me give concrete examples of bugs that only showed up with real data:</p>
<p><strong>1. Japanese anime titles broke the slugify.</strong>
<code>ContentWriter.slugify</code> used Rails&rsquo; <code>parameterize</code>, which strips non-ASCII characters. Title: &ldquo;進撃の巨人 Season 4&rdquo; → slug: &ldquo;season-4&rdquo;. Two anime with the subtitle &ldquo;Season 4&rdquo; collided on the filesystem. It never showed up in tests because the fixtures used English titles.</p>
<p><strong>2. The HackerNews parser assumed RSS, but the feed switched to Atom.</strong>
<code>rss/channel/item</code> became <code>feed/entry</code>. Unit tests mocked the XML. The parser itself was never tested against the real feed. Running integration with the real rsynced feed, the parser returned zero items — caught by preflight as <code>fail</code>.</p>
<p><strong>3. Yahoo Finance returned 429 after the 5th ticker.</strong>
Rate limiting didn&rsquo;t exist in tests (HTTP was mocked). In the real integration, the first 5 tickers went through, then everything failed. The fix — sleeping between requests — would never have been implemented without real data exposing the problem.</p>
<p><strong>4. Images with a valid URL but the wrong Content-Type.</strong>
A CDN returned 200 for image URLs, but with an HTML body (Cloudflare error page). <code>UrlValidator.reachable?</code> returned <code>true</code>. <code>ImageProcessor.download_image</code> downloaded the HTML and tried to resize it. mini_magick crashed with an obscure error. The fix: validate Content-Type on the HEAD response — but this scenario would never surface with mocked URLs.</p>
<p><strong>5. Newsletter assembly failed on stories with <code>score: null</code>.</strong>
Old stories, created before the <code>score</code> field had a default, had <code>nil</code> in the frontmatter. Assembly tried to do <code>sort_by { |s| s[:frontmatter][&quot;score&quot;] }</code> — <code>nil &lt;=&gt; &quot;high&quot;</code> raises <code>ArgumentError</code>. It only appeared when I rsynced production data that had 3 months of accumulated stories.</p>
<p>None of these bugs would be caught by a unit test. All of them were caught by the integration pipeline with real data.</p>
<h2>Practical Tips<span class="hx:absolute hx:-mt-20" id="practical-tips"></span>
    <a href="#practical-tips" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><h3>1. Separate Cost From Fidelity<span class="hx:absolute hx:-mt-20" id="1-separate-cost-from-fidelity"></span>
    <a href="#1-separate-cost-from-fidelity" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>The full integration pipeline costs around $0.40 in API calls. Not much, but if you run it 10 times in a day while iterating, that&rsquo;s $4. DevCache solves this: the first run pays, the following runs are free.</p>
<p>But there are scenarios where you <strong>want</strong> to pay again — when you change a prompt and need to see the real result. <code>FORCE=1</code> busts the cache selectively. And <code>SKIP_EXISTING=1</code> skips sections that have already been generated, saving time when you only want to re-run a specific section.</p>
<h3>2. The Integration Environment Is Not Staging<span class="hx:absolute hx:-mt-20" id="2-the-integration-environment-is-not-staging"></span>
    <a href="#2-the-integration-environment-is-not-staging" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Staging is a server running 24/7 simulating production. The integration environment is a <strong>local execution mode</strong> you turn on when needed and turn off when you&rsquo;re done. No dedicated infrastructure required. No separate database required. Same code, same machine, different flags.</p>
<p>The difference is philosophical: staging tests &ldquo;does the system work on the staging server?&rdquo;. Integration tests &ldquo;does the system work <strong>with this specific data</strong>?&rdquo;. The first is environment-dependent. The second is data-dependent. And data bugs are more common than environment bugs.</p>
<h3>3. Clean Before Rsync, Not After<span class="hx:absolute hx:-mt-20" id="3-clean-before-rsync-not-after"></span>
    <a href="#3-clean-before-rsync-not-after" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Order matters: clean the generated content <strong>before</strong> syncing from production. If you do the opposite — rsync first, clean after — you lose the production data you just downloaded.</p>
<p><code>integration:clean</code> is surgical by design. It removes anime/, hacker_news/, newsletters/ (generated), but preserves stories/ and images/ (production). That enables an iterative workflow: rsync once, run the pipeline, tweak a prompt, run again — without re-downloading 500MB of content.</p>
<h3>4. Preflight Is Your Smoke Test<span class="hx:absolute hx:-mt-20" id="4-preflight-is-your-smoke-test"></span>
    <a href="#4-preflight-is-your-smoke-test" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Don&rsquo;t trust &ldquo;ran without exception = correct&rdquo;. A generation job can run, produce a Markdown with 2 items instead of 10, and return success. Preflight catches that by verifying minimum counts, required markers, and expected structure.</p>
<p>In practice, every LLM bug I&rsquo;ve found was caught by preflight: truncated responses, wrong format, missing sections. GPT-5.2 is good, but it isn&rsquo;t deterministic — and when it fails, it fails in creative ways no schema validation would catch.</p>
<h3>5. Billing Summary Is Not Optional<span class="hx:absolute hx:-mt-20" id="5-billing-summary-is-not-optional"></span>
    <a href="#5-billing-summary-is-not-optional" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>If you&rsquo;re using LLMs in production, <strong>every</strong> test pipeline needs to report cost. Not for accounting — for engineering. A prompt that works but costs 3x more than the previous one is a performance bug. Input and output tokens per provider, cost per job, total cost — all visible on every run.</p>
<h3>6. Monorepo Helps, But Doesn&rsquo;t Solve<span class="hx:absolute hx:-mt-20" id="6-monorepo-helps-but-doesnt-solve"></span>
    <a href="#6-monorepo-helps-but-doesnt-solve" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Having the three apps in the same repository helps: a <code>git bisect</code> covers changes on both sides of the contract. A PR that changes the frontmatter format in marvin-bot <strong>can</strong> also update the parser in newsletter — in the same commit.</p>
<p>But &ldquo;can&rdquo; isn&rsquo;t &ldquo;guarantees&rdquo;. The contract between the apps is file-based and implicit. There&rsquo;s no shared type, no common interface. <code>SectionParser</code> is copied manually between the two apps. If someone edits one and forgets the other, they diverge. The integration test is the <strong>only</strong> verification that both sides agree.</p>
<h3>7. Preview Before Sending<span class="hx:absolute hx:-mt-20" id="7-preview-before-sending"></span>
    <a href="#7-preview-before-sending" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>The pipeline doesn&rsquo;t end at generation. The newsletter has a <code>newsletter:preview</code> that renders the final result as HTML in light and dark mode:</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> newsletter
</span></span><span class="line"><span class="cl"><span class="nv">FILE</span><span class="o">=</span>../content/newsletters/2026-02-16.md bin/rails newsletter:preview
</span></span><span class="line"><span class="cl"><span class="c1"># → tmp/preview/newsletter_light.html</span>
</span></span><span class="line"><span class="cl"><span class="c1"># → tmp/preview/newsletter_dark.html</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>Opening those HTMLs in the browser is the visual integration test. The Markdown can be perfect, the frontmatter correct, every field present — but an anime image shows up stretched because the aspect ratio changed. Or Marvin&rsquo;s commentary block doesn&rsquo;t have enough contrast in dark mode. Those bugs are visual — no automated test catches them.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-20_12-48-32.jpg" alt="final assembly"  loading="lazy" /></p>
<h3>8. The CI Pipeline Is Different From the Integration Pipeline<span class="hx:absolute hx:-mt-20" id="8-the-ci-pipeline-is-different-from-the-integration-pipeline"></span>
    <a href="#8-the-ci-pipeline-is-different-from-the-integration-pipeline" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>These are different things. CI runs on every commit:</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">bin/ci  <span class="c1"># rubocop + bundler-audit + brakeman + tests (~22s)</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>Integration runs when you want to validate the pipeline end-to-end — before a deploy, after changing prompts, or after rsyncing fresh data from production. CI is fast and cheap. Integration is slow and costs money. Both are necessary.</p>
<h2>The Cost of Not Having Integration<span class="hx:absolute hx:-mt-20" id="the-cost-of-not-having-integration"></span>
    <a href="#the-cost-of-not-having-integration" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Let me be direct: if this project didn&rsquo;t have the integration pipeline, I would have shipped at least 3 broken newsletters.</p>
<p>One with the anime section missing because the XML parser assumed RSS and the feed was Atom. One with every YouTube image pointing to 404 because the thumbnail URL pattern changed. And one with the market recap showing last month&rsquo;s tickers because Yahoo Finance blocked the requests and the job failed silently.</p>
<p>Unit tests would have passed in all those scenarios. CI would have gone green. The deploy would have shipped. And at 7am on a Monday, 300 people would have received a newsletter full of holes.</p>
<p>The integration pipeline is expensive — in setup time, in API cost, in maintenance complexity. But it&rsquo;s orders of magnitude cheaper than sending broken content to real subscribers. Especially when you only get one shot a week.</p>
<h2>Conclusion<span class="hx:absolute hx:-mt-20" id="conclusion"></span>
    <a href="#conclusion" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The testing hierarchy in this project is:</p>
<ol>
<li><strong>Unit tests</strong> (1,330 across both apps, 7 seconds in parallel, $0): prove each piece works in isolation</li>
<li><strong>CI pipeline</strong> (rubocop + audit + brakeman + tests, ~22 seconds, $0): prove the code is healthy</li>
<li><strong>Integration pipeline</strong> (real data, real APIs, ~3 minutes, ~$0.40): proves the <strong>system</strong> works</li>
<li><strong>Visual preview</strong> (HTML in the browser, manual, $0): proves the final result is presentable</li>
</ol>
<p>Each layer catches a different type of bug. None of them is enough alone. And the one that surprises most — the one that catches the bugs you swore didn&rsquo;t exist — is the third: real data, real pipeline, no mocks.</p>
<p>The confidence you have in your system is proportional to how real the data in your tests is. Mock everything and you get 100% coverage and zero confidence. Test with production data and you get the truth — however inconvenient.</p>
]]></content:encoded><category>the-m-akita-chronicles</category><category>testing</category><category>software-engineering</category></item><item><title>SQLite + Kamal: Rails Deploy Without Drama | Behind The M.Akita Chronicles</title><link>https://akitaonrails.github.io/en/2026/02/20/sqlite-kamal-rails-deploy-without-drama-behind-the-m-akita-chronicles/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2026/02/20/sqlite-kamal-rails-deploy-without-drama-behind-the-m-akita-chronicles/</guid><pubDate>Fri, 20 Feb 2026 03:18:55 +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;/themakitachronicles&lt;/a&gt;. This is part 7.&lt;/p&gt;
&lt;p&gt;And make sure to 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;If I told you that you can run a full Rails application in production — with database, job queue, cache — on a single $12/month VPS without installing PostgreSQL, Redis, or any other external service, would you believe me?&lt;/p&gt;
&lt;p&gt;Well, you can. That&amp;rsquo;s Rails 8 with SQLite and Kamal.&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/">/themakitachronicles</a>. This is part 7.</p>
<p>And make sure to subscribe to my new newsletter <a href="https://themakitachronicles.com/"target="_blank" rel="noopener">The M.Akita Chronicles</a>!</p>
<p>&ndash;</p>
<p>If I told you that you can run a full Rails application in production — with database, job queue, cache — on a single $12/month VPS without installing PostgreSQL, Redis, or any other external service, would you believe me?</p>
<p>Well, you can. That&rsquo;s Rails 8 with SQLite and Kamal.</p>
<h2>SQLite on Rails 8: now it&rsquo;s for real<span class="hx:absolute hx:-mt-20" id="sqlite-on-rails-8-now-its-for-real"></span>
    <a href="#sqlite-on-rails-8-now-its-for-real" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Rails 8 brought SQLite as a real production option, not as a development toy. And when I say &ldquo;real&rdquo;, I mean <code>rails new</code> generates everything ready to go: main database in SQLite, SolidQueue (jobs) in SQLite, SolidCache (cache) in SQLite, SolidCable (WebSocket) in SQLite.</p>
<p><strong>Four services that used to require PostgreSQL + Redis + Memcached are now <code>.sqlite3</code> files on disk.</strong></p>
<p>WAL mode (Write-Ahead Logging) enables concurrent reads while a write is happening. For applications with tens of thousands of requests per day — which is the vast majority of Rails applications in the world — SQLite is more than enough. And read latency is measured in <strong>microseconds</strong>, not milliseconds. It&rsquo;s local disk access, not a network round-trip.</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="c"># database.yml — production with SQLite</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">production</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">primary</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">&lt;&lt;</span><span class="p">:</span><span class="w"> </span><span class="cp">*default</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">database</span><span class="p">:</span><span class="w"> </span><span class="l">storage/production.sqlite3</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">queue</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">&lt;&lt;</span><span class="p">:</span><span class="w"> </span><span class="cp">*default</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">database</span><span class="p">:</span><span class="w"> </span><span class="l">storage/production_queue.sqlite3</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">migrations_paths</span><span class="p">:</span><span class="w"> </span><span class="l">db/queue_migrate</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">cache</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">&lt;&lt;</span><span class="p">:</span><span class="w"> </span><span class="cp">*default</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">database</span><span class="p">:</span><span class="w"> </span><span class="l">storage/production_cache.sqlite3</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">migrations_paths</span><span class="p">:</span><span class="w"> </span><span class="l">db/cache_migrate</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>Three files. No TCP connection. No pooling. No &ldquo;is PostgreSQL accepting connections?&rdquo;. Backup is a <code>VACUUM INTO</code> and that&rsquo;s it — a single SQL command that produces an atomic copy. Restore? Paste the file back. Try doing that with a 2GB PostgreSQL dump.</p>
<h2>Kamal: Docker Deploy Without Kubernetes<span class="hx:absolute hx:-mt-20" id="kamal-docker-deploy-without-kubernetes"></span>
    <a href="#kamal-docker-deploy-without-kubernetes" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Kamal is the Rails 8 deployer. Think of it as &ldquo;SSH + Docker, but smart&rdquo;. No Kubernetes. No Helm charts. No 300-line YAML describing an Ingress Controller.</p>
<p>The entire configuration fits in one file:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="c"># config/deploy.yml</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">service</span><span class="p">:</span><span class="w"> </span><span class="l">minha-app</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">meuregistry/minha-app</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">servers</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">web</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">hosts</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="m">107.170.70.49</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">options</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">network</span><span class="p">:</span><span class="w"> </span><span class="l">minha-network</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">minha-app-storage:/rails/storage</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">minha-app-content:/rails/content</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">proxy</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">ssl</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">host</span><span class="p">:</span><span class="w"> </span><span class="l">app.meudominio.com</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">builder</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">arch</span><span class="p">:</span><span class="w"> </span><span class="l">amd64</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">env</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">clear</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">RAILS_LOG_LEVEL</span><span class="p">:</span><span class="w"> </span><span class="l">info</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">secret</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_MASTER_KEY</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="l">DATABASE_URL</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>What Kamal does when you run <code>kamal deploy</code>:</p>
<ol>
<li>Builds the Docker image locally (or in CI)</li>
<li>Pushes to the registry</li>
<li>SSHs into the server</li>
<li>Pulls the new image</li>
<li>Runs migrations</li>
<li>Restarts the container with <strong>zero downtime</strong> via kamal-proxy</li>
</ol>
<p>The kamal-proxy is the secret: it acts as a reverse proxy in front of your containers. When the deploy happens, it brings up the new container, waits for it to be healthy, redirects traffic, and then tears down the old one. Your users never notice a thing.</p>
<h2>Docker Volumes: The Secret of SQLite in Production<span class="hx:absolute hx:-mt-20" id="docker-volumes-the-secret-of-sqlite-in-production"></span>
    <a href="#docker-volumes-the-secret-of-sqlite-in-production" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The critical point of SQLite in Docker is: <strong>the data can&rsquo;t die with the container</strong>. Docker volumes solve this:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">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">minha-app-storage:/rails/storage</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The <code>storage/</code> directory contains all the SQLite databases. With the Docker volume, they persist across deploys. The container is ephemeral; the data is permanent.</p>
<p>But there&rsquo;s a detail that trips up a lot of people: if you run <strong>two different services</strong> that share data through files (not database), you need a shared volume:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="c"># Service A writes content</span><span class="w">
</span></span></span><span class="line"><span class="cl"><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">content-compartilhado:/rails/content</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="c"># Service B reads content</span><span class="w">
</span></span></span><span class="line"><span class="cl"><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">content-compartilhado:/rails/content</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The same Docker volume mounted on two containers. Simple, it works, and you don&rsquo;t need NFS, S3, or anything fancy. It&rsquo;s a directory on the server&rsquo;s disk.</p>
<h2>Multiple Services on the Same VPS<span class="hx:absolute hx:-mt-20" id="multiple-services-on-the-same-vps"></span>
    <a href="#multiple-services-on-the-same-vps" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>A rarely discussed advantage of Kamal: you can run <strong>multiple applications</strong> on the same server. The kamal-proxy routes by hostname:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>app.meudominio.com    → container-app
bot.meudominio.com    → container-bot</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>Each service has its own <code>deploy.yml</code>, its own Docker image, its own deploy cycle. But they all run on the same VPS, sharing the same Docker network for internal communication.</p>
<p>SSL is automatic via Let&rsquo;s Encrypt — kamal-proxy handles that. Each hostname gets its certificate without you configuring anything.</p>
<h2>Hooks: Deploy Automation<span class="hx:absolute hx:-mt-20" id="hooks-deploy-automation"></span>
    <a href="#hooks-deploy-automation" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Kamal supports hooks at various points of the deploy cycle. The most useful is <code>pre-deploy</code>, which runs <strong>before</strong> the new container replaces the old one:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="c1"># .kamal/hooks/pre-deploy</span>
</span></span><span class="line"><span class="cl"><span class="c1">#!/bin/sh</span>
</span></span><span class="line"><span class="cl"><span class="c1"># Run migrations before the container goes live</span>
</span></span><span class="line"><span class="cl"><span class="n">ssh</span> <span class="n">root</span><span class="err">@</span><span class="vg">$KAMAL_HOSTS</span> <span class="p">\</span>
</span></span><span class="line"><span class="cl">  <span class="s2">&#34;docker exec minha-app-web bin/rails db:migrate&#34;</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Other useful hooks:</p>
<ul>
<li><code>post-deploy</code> — notify the team, clear cache</li>
<li><code>pre-connect</code> — check server health</li>
<li><code>docker-setup</code> — install dependencies on the host</li>
</ul>
<h2>The Dockerfile Rails Generates<span class="hx:absolute hx:-mt-20" id="the-dockerfile-rails-generates"></span>
    <a href="#the-dockerfile-rails-generates" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Rails 8 generates an optimized production Dockerfile. Some highlights:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dockerfile" data-lang="dockerfile"><span class="line"><span class="cl"><span class="c"># Multi-stage build: large build stage, minimal runtime stage</span><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="k">FROM</span><span class="w"> </span><span class="s">ruby:3.3-slim</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="s">base</span><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="c"># Install only runtime dependencies</span><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="k">RUN</span> apt-get install -y libsqlite3-0<span class="err">
</span></span></span><span class="line"><span class="cl"><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="k">FROM</span><span class="w"> </span><span class="s">base</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="s">build</span><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="c"># Here install everything to compile native gems</span><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="k">RUN</span> apt-get install -y build-essential libsqlite3-dev<span class="err">
</span></span></span><span class="line"><span class="cl"><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="c"># Copy and install gems</span><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="k">COPY</span> Gemfile* ./<span class="err">
</span></span></span><span class="line"><span class="cl"><span class="k">RUN</span> bundle install --without development test<span class="err">
</span></span></span><span class="line"><span class="cl"><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="c"># Final stage: only what&#39;s needed to run</span><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="k">FROM</span><span class="w"> </span><span class="s">base</span><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="k">COPY</span> --from<span class="o">=</span>build /usr/local/bundle /usr/local/bundle<span class="err">
</span></span></span><span class="line"><span class="cl"><span class="k">COPY</span> . .<span class="err">
</span></span></span><span class="line"><span class="cl"><span class="k">RUN</span> bundle <span class="nb">exec</span> bootsnap precompile --gemfile app/ lib/</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>Multi-stage build means the final image has no compilers, no development headers, nothing unnecessary. The production image stays lean.</p>
<h2>Secrets: No .env in Production<span class="hx:absolute hx:-mt-20" id="secrets-no-env-in-production"></span>
    <a href="#secrets-no-env-in-production" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Kamal has a secrets system that reads from a local file (<code>.kamal/secrets</code>) and injects them as environment variables into the container. That file <strong>never</strong> goes to git:</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"># .kamal/secrets (gitignored)</span>
</span></span><span class="line"><span class="cl"><span class="nv">RAILS_MASTER_KEY</span><span class="o">=</span>abc123...
</span></span><span class="line"><span class="cl"><span class="nv">AWS_ACCESS_KEY_ID</span><span class="o">=</span>AKIA...</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>In <code>deploy.yml</code>, you reference:</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">env</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">secret</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_MASTER_KEY</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="l">AWS_ACCESS_KEY_ID</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>Kamal reads from the local file and configures it on the server. Simple, secure, no HashiCorp Vault or AWS Secrets Manager (unless you want — Kamal supports adapters).</p>
<h2>SQLite Backup: Absurdly Simple (If You Do It Right)<span class="hx:absolute hx:-mt-20" id="sqlite-backup-absurdly-simple-if-you-do-it-right"></span>
    <a href="#sqlite-backup-absurdly-simple-if-you-do-it-right" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Want a backup? One SQL command:</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-sql" data-lang="sql"><span class="line"><span class="cl"><span class="k">VACUUM</span><span class="w"> </span><span class="k">INTO</span><span class="w"> </span><span class="s1">&#39;/tmp/backup/newsletter.sqlite3&#39;</span><span class="p">;</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p><code>VACUUM INTO</code> is <strong>atomic</strong> — it produces a consistent, defragmented copy of the database, even with writes happening. It runs while the application is serving requests normally. And the resulting copy is a complete, functional SQLite database — you can open it, query it, restore it.</p>
<p><strong>Warning</strong>: copying the <code>.sqlite3</code> file directly (<code>cp</code>, <code>tar</code>, <code>rsync</code>) from a database in use <strong>can corrupt the backup</strong>. If a write is in progress at the moment of the copy, you end up with a half-written file. SQLite has WAL (Write-Ahead Log) and journaling that protect the active database — but the raw copy doesn&rsquo;t inherit that protection.</p>
<p>In practice, I automated this with a Rails job that runs every hour:</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">BackupDatabaseJob</span> <span class="o">&lt;</span> <span class="no">ApplicationJob</span>
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">perform</span>
</span></span><span class="line"><span class="cl">    <span class="n">backup_dir</span> <span class="o">=</span> <span class="no">File</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="no">Rails</span><span class="o">.</span><span class="n">configuration</span><span class="o">.</span><span class="n">content_dir</span><span class="p">,</span> <span class="s2">&#34;backups&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="no">FileUtils</span><span class="o">.</span><span class="n">mkdir_p</span><span class="p">(</span><span class="n">backup_dir</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">dest</span> <span class="o">=</span> <span class="no">File</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="n">backup_dir</span><span class="p">,</span> <span class="s2">&#34;newsletter.sqlite3&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="no">ActiveRecord</span><span class="o">::</span><span class="no">Base</span><span class="o">.</span><span class="n">connection</span><span class="o">.</span><span class="n">execute</span><span class="p">(</span><span class="s2">&#34;VACUUM INTO &#39;</span><span class="si">#{</span><span class="n">dest</span><span class="si">}</span><span class="s2">&#39;&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The backup goes to the shared content directory — the same one that&rsquo;s already synced with <code>rsync</code> to my local machine. No extra backup agent, no external service, no volume snapshot. One SQL, one file, one <code>rsync</code>.</p>
<p>Compare that with <code>pg_dump</code> of a production PostgreSQL database with dozens of tables and constraints. The SQLite backup is <strong>one command that produces a working file</strong>. Restore? Copy it back and restart.</p>
<h2>When NOT to Use SQLite<span class="hx:absolute hx:-mt-20" id="when-not-to-use-sqlite"></span>
    <a href="#when-not-to-use-sqlite" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>I&rsquo;ll be honest: SQLite isn&rsquo;t for everything.</p>
<ul>
<li><strong>Multiple web servers</strong> writing to the same database? No. SQLite is single-writer. If you need horizontal scaling with multiple nodes, go PostgreSQL.</li>
<li><strong>Massive datasets</strong> (hundreds of GB)? PostgreSQL has better query planning and parallelism.</li>
<li><strong>Replication and high availability</strong>? PostgreSQL with streaming replication.</li>
</ul>
<p>But the honest question is: <strong>how many applications really need that?</strong> The vast majority run on a single server and will never need more. And for those, SQLite + Kamal is the most productive combination out there today.</p>
<h2>The Real Cost<span class="hx:absolute hx:-mt-20" id="the-real-cost"></span>
    <a href="#the-real-cost" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Let&rsquo;s do the math:</p>
<table>
  <thead>
      <tr>
          <th>Component</th>
          <th>Before</th>
          <th>Now</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Server</td>
          <td>VPS $24/month</td>
          <td>VPS $12/month</td>
      </tr>
      <tr>
          <td>Database</td>
          <td>RDS PostgreSQL $30/month</td>
          <td>SQLite (included)</td>
      </tr>
      <tr>
          <td>Redis</td>
          <td>ElastiCache $15/month</td>
          <td>SolidQueue (included)</td>
      </tr>
      <tr>
          <td>Cache</td>
          <td>ElastiCache $15/month</td>
          <td>SolidCache (included)</td>
      </tr>
      <tr>
          <td>Deploy</td>
          <td>Complex CI/CD</td>
          <td><code>kamal deploy</code></td>
      </tr>
      <tr>
          <td>SSL</td>
          <td>Certbot + cron</td>
          <td>Automatic</td>
      </tr>
      <tr>
          <td><strong>Total</strong></td>
          <td><strong>~$84/month + headache</strong></td>
          <td><strong>$12/month</strong></td>
      </tr>
  </tbody>
</table>
<p>And the cognitive cost? Instead of debugging &ldquo;why did Redis lose my jobs?&rdquo;, &ldquo;why is PostgreSQL rejecting connections?&rdquo;, &ldquo;why did the cert expire?&rdquo;, you focus on what matters: <strong>the product</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>SQLite + Kamal is not a downgrade. The truth is that most applications never needed the complexity we thought they needed. Rails 8 embraced that and delivered a deploy experience that is, without exaggeration, the simplest that ever existed in the Ruby ecosystem.</p>
<p>One VPS. One command. Zero external services. And an application that runs as fast as any enterprise setup with 15 services in docker-compose.</p>
]]></content:encoded><category>the-m-akita-chronicles</category><category>rails</category><category>devops</category></item><item><title>Discord as an Admin Panel | Behind The M.Akita Chronicles</title><link>https://akitaonrails.github.io/en/2026/02/20/discord-as-an-admin-panel-behind-the-m-akita-chronicles/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2026/02/20/discord-as-an-admin-panel-behind-the-m-akita-chronicles/</guid><pubDate>Fri, 20 Feb 2026 03:18:24 +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;/themakitachronicles&lt;/a&gt;. This is part 6.&lt;/p&gt;
&lt;p&gt;And don&amp;rsquo;t forget to 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;Every Rails project starts the same way: you build an admin panel. Active Admin, Administrate, or some homegrown CRUD. Tables, forms, buttons. And every week, you end up using 5% of the screens you built.&lt;/p&gt;
&lt;p&gt;Now picture your admin panel as a conversation. You paste a link; the system processes it. You type &lt;code&gt;/week&lt;/code&gt; and see the summary. &lt;code&gt;/approve&lt;/code&gt; and the newsletter is scheduled. &lt;code&gt;/ask akita how does the podcast pipeline work?&lt;/code&gt; and an AI explains the architecture of your own system to you.&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/">/themakitachronicles</a>. This is part 6.</p>
<p>And don&rsquo;t forget to subscribe to my new newsletter <a href="https://themakitachronicles.com/"target="_blank" rel="noopener">The M.Akita Chronicles</a>!</p>
<p>&ndash;</p>
<p>Every Rails project starts the same way: you build an admin panel. Active Admin, Administrate, or some homegrown CRUD. Tables, forms, buttons. And every week, you end up using 5% of the screens you built.</p>
<p>Now picture your admin panel as a conversation. You paste a link; the system processes it. You type <code>/week</code> and see the summary. <code>/approve</code> and the newsletter is scheduled. <code>/ask akita how does the podcast pipeline work?</code> and an AI explains the architecture of your own system to you.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-20_00-40-53.jpg" alt="/ask akita"  loading="lazy" /></p>
<p>That&rsquo;s Discord as an operational interface. It&rsquo;s not a hack — it&rsquo;s <strong>the best CRUD interface there is for a team of one</strong>.</p>
<h2>Why Discord Instead of a Web Admin?<span class="hx:absolute hx:-mt-20" id="why-discord-instead-of-a-web-admin"></span>
    <a href="#why-discord-instead-of-a-web-admin" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Let&rsquo;s be honest about what an admin panel demands:</p>
<ol>
<li><strong>Authentication</strong>: login, session, CSRF, password reset</li>
<li><strong>Authorization</strong>: roles, permissions, who can do what</li>
<li><strong>UI</strong>: tables, pagination, forms, client-side validation</li>
<li><strong>Deployment</strong>: another route, another controller, another set of tests</li>
<li><strong>Mobile</strong>: responsive? App? PWA?</li>
</ol>
<p>And what Discord already gives you for free:</p>
<ol>
<li><strong>Auth</strong>: Discord handles it. You just check the <code>author_id</code></li>
<li><strong>Authorization</strong>: a list of admin IDs and you&rsquo;re done</li>
<li><strong>UI</strong>: embeds with colors, fields, thumbnails, reactions as status</li>
<li><strong>Deployment</strong>: zero. Uses what&rsquo;s already deployed</li>
<li><strong>Mobile</strong>: native iOS and Android app, push notifications</li>
</ol>
<p>The trade-off? It doesn&rsquo;t fit everything. A complex CRUD with 20-field forms? Better off with a web admin. But for command/response operations — which are 90% of what you do day to day — Discord is unbeatable.</p>
<h2>discordrb: A Gem That Works<span class="hx:absolute hx:-mt-20" id="discordrb-a-gem-that-works"></span>
    <a href="#discordrb-a-gem-that-works" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The <a href="https://github.com/shardlab/discordrb"target="_blank" rel="noopener"><code>discordrb</code></a> gem (shardlab fork, version 3.7.2) is the most mature one for Ruby. It&rsquo;s not perfect — it has its quirks. But it genuinely works in production.</p>
<p>The minimal setup:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="n">bot</span> <span class="o">=</span> <span class="no">Discordrb</span><span class="o">::</span><span class="no">Bot</span><span class="o">.</span><span class="n">new</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">  <span class="ss">token</span><span class="p">:</span> <span class="n">token</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="ss">intents</span><span class="p">:</span> <span class="no">Discordrb</span><span class="o">::</span><span class="no">INTENTS</span><span class="o">[</span><span class="ss">:server_messages</span><span class="o">]</span> <span class="o">|</span>
</span></span><span class="line"><span class="cl">           <span class="no">Discordrb</span><span class="o">::</span><span class="no">INTENTS</span><span class="o">[</span><span class="ss">:direct_messages</span><span class="o">]</span> <span class="o">|</span>
</span></span><span class="line"><span class="cl">           <span class="p">(</span><span class="mi">1</span> <span class="o">&lt;&lt;</span> <span class="mi">15</span><span class="p">)</span>  <span class="c1"># message_content intent</span>
</span></span><span class="line"><span class="cl"><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">bot</span><span class="o">.</span><span class="n">message</span> <span class="k">do</span> <span class="o">|</span><span class="n">event</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">  <span class="c1"># Every message flows through here</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">bot</span><span class="o">.</span><span class="n">run</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>That <code>(1 &lt;&lt; 15)</code> is the <em>Message Content Intent</em>. Without it, your bot receives events but <code>event.content</code> comes in empty. You need to enable it in the Discord Developer Portal &gt; Bot &gt; Privileged Gateway Intents. That&rsquo;s the first trap that catches everyone.</p>
<p><a href="https://discord.com/developers/"target="_blank" rel="noopener"><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-20_00-58-46.jpg" alt="discord dashboard"  loading="lazy" /></a></p>
<p>I won&rsquo;t walk through how to set up a Discord bot, there are <a href="https://www.ionos.com/digitalguide/server/know-how/creating-discord-bot/"target="_blank" rel="noopener">plenty of tutorials</a> for that already, easy to find online. I&rsquo;ll focus only on my use case as an example.</p>
<h2>Pattern 1: Parser + Dispatcher<span class="hx:absolute hx:-mt-20" id="pattern-1-parser--dispatcher"></span>
    <a href="#pattern-1-parser--dispatcher" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Mixing command parsing with business logic is a recipe for spaghetti. Split it into three layers:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>Discord Event → Parser → Dispatcher → Job/Service</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 runner (the thing that talks to the Discord API) is deliberately dumb:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="n">bot</span><span class="o">.</span><span class="n">message</span> <span class="k">do</span> <span class="o">|</span><span class="n">event</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">  <span class="k">next</span> <span class="k">if</span> <span class="n">event</span><span class="o">.</span><span class="n">author</span><span class="o">.</span><span class="n">bot_account?</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="n">parsed</span> <span class="o">=</span> <span class="no">SlashCommandParser</span><span class="o">.</span><span class="n">parse</span><span class="p">(</span><span class="n">event</span><span class="o">.</span><span class="n">content</span><span class="p">,</span> <span class="ss">attachments</span><span class="p">:</span> <span class="n">urls</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">case</span> <span class="n">parsed</span>
</span></span><span class="line"><span class="cl">  <span class="k">when</span> <span class="no">SlashCommandParser</span><span class="o">::</span><span class="no">Command</span>
</span></span><span class="line"><span class="cl">    <span class="no">CommandDispatcher</span><span class="o">.</span><span class="n">dispatch</span><span class="p">(</span><span class="n">parsed</span><span class="p">,</span> <span class="ss">channel_id</span><span class="p">:</span> <span class="n">channel_id</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">event</span><span class="o">.</span><span class="n">message</span><span class="o">.</span><span class="n">react</span><span class="p">(</span><span class="s2">&#34;\u{2705}&#34;</span><span class="p">)</span>  <span class="c1"># checkmark</span>
</span></span><span class="line"><span class="cl">  <span class="k">when</span> <span class="no">SlashCommandParser</span><span class="o">::</span><span class="no">Submission</span>
</span></span><span class="line"><span class="cl">    <span class="no">ProcessMessageJob</span><span class="o">.</span><span class="n">perform_later</span><span class="p">(</span><span class="ss">url</span><span class="p">:</span> <span class="n">parsed</span><span class="o">.</span><span class="n">url</span><span class="p">,</span> <span class="ss">tags</span><span class="p">:</span> <span class="n">parsed</span><span class="o">.</span><span class="n">tags</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">event</span><span class="o">.</span><span class="n">message</span><span class="o">.</span><span class="n">react</span><span class="p">(</span><span class="s2">&#34;\u{1F916}&#34;</span><span class="p">)</span>  <span class="c1"># robot</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">StandardError</span> <span class="o">=&gt;</span> <span class="n">e</span>
</span></span><span class="line"><span class="cl">  <span class="n">event</span><span class="o">.</span><span class="n">message</span><span class="o">.</span><span class="n">react</span><span class="p">(</span><span class="s2">&#34;\u{274C}&#34;</span><span class="p">)</span>  <span class="c1"># red X</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>Three instant visual results: checkmark (command accepted), robot (URL being processed), red X (error). The user knows what happened without waiting for any text response.</p>
<p>The parser returns typed objects, not strings. The dispatcher runs a giant <code>case</code> but each handler is an isolated method. Each handler calls a job or service. <strong>No business logic in the runner.</strong></p>
<h3>Evolution: Discord Application Commands<span class="hx:absolute hx:-mt-20" id="evolution-discord-application-commands"></span>
    <a href="#evolution-discord-application-commands" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>The initial version parsed raw text — the user typed <code>/week</code> and the parser interpreted it. It works, but it has limits: no autocomplete, no argument validation, no inline documentation.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-20_00-56-15.jpg" alt="help"  loading="lazy" /></p>
<p>Discord has a native <strong>Application Commands</strong> system — slash commands registered with the API that show up with autocomplete, description, and typed parameters. When the user types <code>/</code>, Discord displays every available command with its description.</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="n">bot</span><span class="o">.</span><span class="n">register_application_command</span><span class="p">(</span><span class="ss">:week</span><span class="p">,</span> <span class="s2">&#34;List this week&#39;s stories&#34;</span><span class="p">,</span> <span class="ss">server_id</span><span class="p">:</span> <span class="n">guild_id</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">bot</span><span class="o">.</span><span class="n">register_application_command</span><span class="p">(</span><span class="ss">:approve</span><span class="p">,</span> <span class="s2">&#34;Approve newsletter for sending&#34;</span><span class="p">,</span> <span class="ss">server_id</span><span class="p">:</span> <span class="n">guild_id</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">bot</span><span class="o">.</span><span class="n">register_application_command</span><span class="p">(</span><span class="ss">:ask</span><span class="p">,</span> <span class="s2">&#34;Ask AI a question&#34;</span><span class="p">,</span> <span class="ss">server_id</span><span class="p">:</span> <span class="n">guild_id</span><span class="p">)</span> <span class="k">do</span> <span class="o">|</span><span class="n">cmd</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">  <span class="n">cmd</span><span class="o">.</span><span class="n">string</span><span class="p">(</span><span class="s2">&#34;question&#34;</span><span class="p">,</span> <span class="s2">&#34;Your question&#34;</span><span class="p">,</span> <span class="ss">required</span><span class="p">:</span> <span class="kp">true</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">cmd</span><span class="o">.</span><span class="n">string</span><span class="p">(</span><span class="s2">&#34;personality&#34;</span><span class="p">,</span> <span class="s2">&#34;AI personality&#34;</span><span class="p">,</span> <span class="ss">choices</span><span class="p">:</span> <span class="p">{</span> <span class="s2">&#34;Akita&#34;</span> <span class="o">=&gt;</span> <span class="s2">&#34;akita&#34;</span><span class="p">,</span> <span class="s2">&#34;Marvin&#34;</span> <span class="o">=&gt;</span> <span class="s2">&#34;marvin&#34;</span> <span class="p">})</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The handler uses <code>bot.application_command</code> instead of parsing text:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="n">bot</span><span class="o">.</span><span class="n">application_command</span><span class="p">(</span><span class="ss">:week</span><span class="p">)</span> <span class="k">do</span> <span class="o">|</span><span class="n">event</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">  <span class="no">CommandDispatcher</span><span class="o">.</span><span class="n">dispatch_week</span><span class="p">(</span><span class="ss">channel_id</span><span class="p">:</span> <span class="n">event</span><span class="o">.</span><span class="n">channel</span><span class="o">.</span><span class="n">id</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">event</span><span class="o">.</span><span class="n">respond</span><span class="p">(</span><span class="ss">content</span><span class="p">:</span> <span class="s2">&#34;Listing stories...&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Both systems coexist: Application Commands for those who prefer autocomplete, raw text for those who prefer typing fast. The dispatcher is the same — the only difference is how the command arrives.</p>
<h2>Pattern 2: Reactions as Status<span class="hx:absolute hx:-mt-20" id="pattern-2-reactions-as-status"></span>
    <a href="#pattern-2-reactions-as-status" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-20_00-43-55.jpg" alt="reactions"  loading="lazy" /></p>
<p>The most underrated feature of Discord for bots is reactions. They&rsquo;re instant visual feedback that doesn&rsquo;t pollute the channel with messages:</p>
<ul>
<li><strong>Checkmark</strong> = command received and queued</li>
<li><strong>Robot</strong> = async processing started</li>
<li><strong>Red X</strong> = error (details in the embed that follows)</li>
</ul>
<p>The user pastes a link. Sees the robot appear. 30 seconds later, a rich embed shows up with title, summary, image and AI commentary. If something fails, a red embed appears with the error message.</p>
<p>That beats any loading spinner in a web UI.</p>
<h2>Pattern 3: Rich Embeds as Dashboard<span class="hx:absolute hx:-mt-20" id="pattern-3-rich-embeds-as-dashboard"></span>
    <a href="#pattern-3-rich-embeds-as-dashboard" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-20_00-44-36.jpg" alt="new story"  loading="lazy" /></p>
<p>Discord embeds are surprisingly powerful:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="n">embed</span> <span class="o">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="ss">title</span><span class="p">:</span> <span class="s2">&#34;Story Preview&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="ss">url</span><span class="p">:</span> <span class="n">source_url</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="ss">color</span><span class="p">:</span> <span class="mh">0xFF4500</span><span class="p">,</span>           <span class="c1"># orange-red for high priority</span>
</span></span><span class="line"><span class="cl">  <span class="ss">description</span><span class="p">:</span> <span class="n">summary</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="ss">thumbnail</span><span class="p">:</span> <span class="p">{</span> <span class="ss">url</span><span class="p">:</span> <span class="n">image</span> <span class="p">},</span>  <span class="c1"># article image</span>
</span></span><span class="line"><span class="cl">  <span class="ss">fields</span><span class="p">:</span> <span class="o">[</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span> <span class="nb">name</span><span class="p">:</span> <span class="s2">&#34;M.Akita diz&#34;</span><span class="p">,</span> <span class="ss">value</span><span class="p">:</span> <span class="n">akita_comment</span><span class="p">,</span> <span class="ss">inline</span><span class="p">:</span> <span class="kp">false</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span> <span class="nb">name</span><span class="p">:</span> <span class="s2">&#34;M.Arvin diz&#34;</span><span class="p">,</span> <span class="ss">value</span><span class="p">:</span> <span class="n">marvin_comment</span><span class="p">,</span> <span class="ss">inline</span><span class="p">:</span> <span class="kp">false</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span> <span class="nb">name</span><span class="p">:</span> <span class="s2">&#34;File&#34;</span><span class="p">,</span> <span class="ss">value</span><span class="p">:</span> <span class="s2">&#34;`stories/2026-02-16-titulo.md`&#34;</span><span class="p">,</span> <span class="ss">inline</span><span class="p">:</span> <span class="kp">false</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="o">]</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="ss">footer</span><span class="p">:</span> <span class="p">{</span> <span class="ss">text</span><span class="p">:</span> <span class="s2">&#34;fire high | tech_news | verified&#34;</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="no">Discordrb</span><span class="o">::</span><span class="no">API</span><span class="o">::</span><span class="no">Channel</span><span class="o">.</span><span class="n">create_message</span><span class="p">(</span><span class="n">token</span><span class="p">,</span> <span class="n">channel_id</span><span class="p">,</span> <span class="s2">&#34;&#34;</span><span class="p">,</span> <span class="kp">false</span><span class="p">,</span> <span class="o">[</span><span class="n">embed</span><span class="o">]</span><span class="p">)</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Colors signal priority (red = high, amber = medium, green = low). The footer shows score, section and fact-check status all on a single line. You glance at it and know the state of things.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-20_00-45-11.jpg" alt="week list"  loading="lazy" /></p>
<p>For reports, multiple embeds side by side (Discord accepts up to 10 per message) work as a mini-dashboard. The <code>/week</code> command lists every story with inline actions — <code>/load @a3f2b1</code> to see details, <code>/delete @a3f2b1</code> to remove it. The hash is stable (based on the file path), so it doesn&rsquo;t shift when you delete another story.</p>
<h2>Pattern 4: Context Per Channel<span class="hx:absolute hx:-mt-20" id="pattern-4-context-per-channel"></span>
    <a href="#pattern-4-context-per-channel" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-20_00-46-29.jpg" alt="load"  loading="lazy" /></p>
<p>When the user types <code>/load #3</code>, the #3 refers to the most recent listing. But what if two channels are being used at the same time?</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">ChannelContext</span> <span class="o">&lt;</span> <span class="no">ApplicationRecord</span>
</span></span><span class="line"><span class="cl">  <span class="no">EXPIRY_DURATION</span> <span class="o">=</span> <span class="mi">2</span><span class="o">.</span><span class="n">hours</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">for_channel</span><span class="p">(</span><span class="n">channel_id</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">find_or_create_by!</span><span class="p">(</span><span class="ss">channel_id</span><span class="p">:</span> <span class="n">channel_id</span><span class="o">.</span><span class="n">to_s</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">set_week_stories!</span><span class="p">(</span><span class="nb">hash</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">update!</span><span class="p">(</span><span class="ss">week_stories_json</span><span class="p">:</span> <span class="nb">hash</span><span class="o">.</span><span class="n">to_json</span><span class="p">,</span> <span class="ss">expires_at</span><span class="p">:</span> <span class="no">EXPIRY_DURATION</span><span class="o">.</span><span class="n">from_now</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">resolve_story_reference</span><span class="p">(</span><span class="n">ref</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">ref</span><span class="o">.</span><span class="n">match?</span><span class="p">(</span><span class="sr">/\A@[a-f0-9]+\z/i</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="c1"># Hash reference (stable across re-listings)</span>
</span></span><span class="line"><span class="cl">      <span class="nb">hash</span> <span class="o">=</span> <span class="n">ref</span><span class="o">.</span><span class="n">delete</span><span class="p">(</span><span class="s2">&#34;@&#34;</span><span class="p">)</span><span class="o">.</span><span class="n">downcase</span>
</span></span><span class="line"><span class="cl">      <span class="n">week_stories</span><span class="o">.</span><span class="n">values</span><span class="o">.</span><span class="n">find</span> <span class="p">{</span> <span class="o">|</span><span class="n">path</span><span class="o">|</span> <span class="n">md5_prefix</span><span class="p">(</span><span class="n">path</span><span class="p">)</span> <span class="o">==</span> <span class="nb">hash</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="k">elsif</span> <span class="n">ref</span><span class="o">.</span><span class="n">match?</span><span class="p">(</span><span class="sr">/\A#?\d+\z/</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="n">week_stories</span><span class="o">[</span><span class="n">ref</span><span class="o">.</span><span class="n">delete</span><span class="p">(</span><span class="s2">&#34;#&#34;</span><span class="p">)</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">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>Each channel has its own context with a 2-hour expiration. <code>/week</code> stores the number→file mapping. Then <code>/load #3</code> queries that context. If it expired, the bot says &ldquo;run <code>/week</code> first&rdquo;.</p>
<p>And the most useful detail: hash-based references (<code>@a3f2b1</code>) instead of numbers. When you delete story #2, #3 becomes #2 — but the hash <code>@a3f2b1</code> still points to the same file. That avoids the classic &ldquo;I deleted the wrong one because the list shifted&rdquo;.</p>
<h2>Pattern 5: Simple Admin Check<span class="hx:absolute hx:-mt-20" id="pattern-5-simple-admin-check"></span>
    <a href="#pattern-5-simple-admin-check" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>No complex RBAC. A list of IDs:</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">ADMIN_COMMANDS</span> <span class="o">=</span> <span class="o">%</span><span class="n">i</span><span class="o">[</span><span class="n">delete</span> <span class="n">update_score</span> <span class="n">approve</span> <span class="n">newsletter</span> <span class="n">publish_blog</span><span class="o">].</span><span class="n">freeze</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">dispatch</span><span class="p">(</span><span class="n">command</span><span class="p">,</span> <span class="ss">channel_id</span><span class="p">:,</span> <span class="ss">author_id</span><span class="p">:)</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="no">ADMIN_COMMANDS</span><span class="o">.</span><span class="n">include?</span><span class="p">(</span><span class="n">command</span><span class="o">.</span><span class="n">type</span><span class="p">)</span> <span class="o">&amp;&amp;</span> <span class="o">!</span><span class="n">admin?</span><span class="p">(</span><span class="n">author_id</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">send_error</span><span class="p">(</span><span class="n">channel_id</span><span class="p">,</span> <span class="s2">&#34;Permission denied. Admin privileges required.&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">  <span class="c1"># ...</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nc">self</span><span class="o">.</span><span class="nf">admin?</span><span class="p">(</span><span class="n">author_id</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">admin_ids</span> <span class="o">=</span> <span class="no">ENV</span><span class="o">[</span><span class="s2">&#34;DISCORD_ADMIN_USER_IDS&#34;</span><span class="o">].</span><span class="n">to_s</span><span class="o">.</span><span class="n">split</span><span class="p">(</span><span class="s2">&#34;,&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">return</span> <span class="kp">true</span> <span class="k">if</span> <span class="n">admin_ids</span><span class="o">.</span><span class="n">empty?</span>  <span class="c1"># dev: no IDs = everything allowed</span>
</span></span><span class="line"><span class="cl">  <span class="n">admin_ids</span><span class="o">.</span><span class="n">include?</span><span class="p">(</span><span class="n">author_id</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>In dev, without <code>DISCORD_ADMIN_USER_IDS</code> set, everything is allowed. In production, it&rsquo;s a list of Discord user IDs. No database, no roles table, no middleware. Ten lines handle authorization.</p>
<h2>Pattern 6: Async Operations with Feedback<span class="hx:absolute hx:-mt-20" id="pattern-6-async-operations-with-feedback"></span>
    <a href="#pattern-6-async-operations-with-feedback" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Slow operations (generating an image, processing an article, calling an AI) go to background jobs. Visual feedback comes in three beats:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">def</span> <span class="nc">self</span><span class="o">.</span><span class="nf">handle_ask</span><span class="p">(</span><span class="n">personality</span><span class="p">,</span> <span class="n">question</span><span class="p">,</span> <span class="n">attachments</span><span class="p">,</span> <span class="n">channel_id</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="c1"># 1. Immediate feedback: &#34;I&#39;m thinking&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="n">thinking_id</span> <span class="o">=</span> <span class="no">DiscordNotifier</span><span class="o">.</span><span class="n">send_message</span><span class="p">(</span><span class="n">channel_id</span><span class="p">,</span> <span class="ss">content</span><span class="p">:</span> <span class="s2">&#34;Thinking...&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="c1"># 2. Job does the heavy lifting in the background</span>
</span></span><span class="line"><span class="cl">  <span class="no">AskJob</span><span class="o">.</span><span class="n">perform_later</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">    <span class="n">question</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="ss">channel_id</span><span class="p">:</span> <span class="n">channel_id</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="ss">thinking_message_id</span><span class="p">:</span> <span class="n">thinking_id</span>
</span></span><span class="line"><span class="cl">  <span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># In AskJob:</span>
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">perform</span><span class="p">(</span><span class="n">question</span><span class="p">,</span> <span class="ss">channel_id</span><span class="p">:,</span> <span class="ss">thinking_message_id</span><span class="p">:</span> <span class="kp">nil</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">result</span> <span class="o">=</span> <span class="no">AiChat</span><span class="o">.</span><span class="n">ask_multimodal</span><span class="p">(</span><span class="n">question</span><span class="p">,</span> <span class="ss">tools</span><span class="p">:</span> <span class="o">[</span><span class="no">GenerateImageTool</span><span class="o">]</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="c1"># 3. Remove &#34;Thinking...&#34; and show result</span>
</span></span><span class="line"><span class="cl">  <span class="no">DiscordNotifier</span><span class="o">.</span><span class="n">delete_message</span><span class="p">(</span><span class="n">channel_id</span><span class="p">,</span> <span class="n">thinking_message_id</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="no">DiscordNotifier</span><span class="o">.</span><span class="n">send_message</span><span class="p">(</span><span class="n">channel_id</span><span class="p">,</span> <span class="ss">content</span><span class="p">:</span> <span class="s2">&#34;**Marvin:** </span><span class="si">#{</span><span class="n">result</span><span class="o">[</span><span class="ss">:text</span><span class="o">]</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-20_00-47-26.jpg" alt="thinking"  loading="lazy" /></p>
<p>The &ldquo;Thinking&hellip;&rdquo; shows up instantly, the job runs in the background, and when it finishes the temporary message disappears and the real result appears. If the job fails, &ldquo;Thinking&hellip;&rdquo; disappears and an error embed shows up.</p>
<p>Without this, the user sends a question and stares at the screen not knowing if the bot heard them. Terrible UX.</p>
<h2>Pattern 7: RubyLLM + Tool Calling on Discord<span class="hx:absolute hx:-mt-20" id="pattern-7-rubyllm--tool-calling-on-discord"></span>
    <a href="#pattern-7-rubyllm--tool-calling-on-discord" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The most powerful part: giving the LLM tools. <code>/ask</code> has three tools available: <code>GenerateImageTool</code> (generates images via Gemini), <code>WebSearchTool</code> (searches the web via Brave Search), and <code>WebFetchTool</code> (fetches and summarizes web pages). The LLM decides on its own which one to use based on the question. When the user asks &ldquo;generate an image of a cat programming&rdquo;, the LLM picks the right tool:</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">GenerateImageTool</span> <span class="o">&lt;</span> <span class="no">RubyLLM</span><span class="o">::</span><span class="no">Tool</span>
</span></span><span class="line"><span class="cl">  <span class="n">description</span> <span class="s2">&#34;Generate an image using Gemini. Use when the user asks to create or draw.&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="n">param</span> <span class="ss">:prompt</span><span class="p">,</span> <span class="ss">desc</span><span class="p">:</span> <span class="s2">&#34;Image description&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="n">param</span> <span class="ss">:aspect_ratio</span><span class="p">,</span> <span class="ss">desc</span><span class="p">:</span> <span class="s2">&#34;1:1 or 16:9&#34;</span><span class="p">,</span> <span class="ss">required</span><span class="p">:</span> <span class="kp">false</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">execute</span><span class="p">(</span><span class="ss">prompt</span><span class="p">:,</span> <span class="ss">aspect_ratio</span><span class="p">:</span> <span class="s2">&#34;1:1&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">image_data</span> <span class="o">=</span> <span class="no">ImageGenerator</span><span class="o">.</span><span class="n">generate</span><span class="p">(</span><span class="ss">prompt</span><span class="p">:</span> <span class="n">prompt</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="s2">&#34;Failed to generate image&#34;</span> <span class="k">unless</span> <span class="n">image_data</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">path</span> <span class="o">=</span> <span class="n">save_temp_file</span><span class="p">(</span><span class="n">image_data</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="no">Thread</span><span class="o">.</span><span class="n">current</span><span class="o">[</span><span class="ss">:generated_images</span><span class="o">]</span> <span class="o">&lt;&lt;</span> <span class="n">path</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;Image generated successfully. Tell the user it&#39;s ready.&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The <code>Thread.current[:generated_images]</code> is the trick to pass data back from the tool to the job. The LLM calls the tool during <code>chat.ask</code>, the tool saves the file and registers the path. After <code>chat.ask</code> returns, the job grabs the paths and sends them to Discord as file uploads.</p>
<p>Why thread-local and not a direct return? Because the LLM can call multiple tools within a single response, and the tool&rsquo;s return value goes back to the LLM (not to your code) — the LLM decides what to say to the user. Thread-local is the side channel for your code to know what happened.</p>
<h2>Pattern 8: Production Status Notifications<span class="hx:absolute hx:-mt-20" id="pattern-8-production-status-notifications"></span>
    <a href="#pattern-8-production-status-notifications" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Every content generation job notifies a dedicated channel:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">module</span> <span class="nn">DiscordStatus</span>
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">notify_start</span><span class="p">(</span><span class="n">label</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="no">DiscordNotifier</span><span class="o">.</span><span class="n">send_to_status_channel</span><span class="p">(</span><span class="s2">&#34;Starting </span><span class="si">#{</span><span class="n">label</span><span class="si">}</span><span class="s2">...&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">notify_done</span><span class="p">(</span><span class="n">label</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="no">DiscordNotifier</span><span class="o">.</span><span class="n">send_to_status_channel</span><span class="p">(</span><span class="s2">&#34;</span><span class="si">#{</span><span class="n">label</span><span class="si">}</span><span class="s2"> complete.&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">notify_error</span><span class="p">(</span><span class="n">label</span><span class="p">,</span> <span class="n">error</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="no">DiscordNotifier</span><span class="o">.</span><span class="n">send_to_status_channel</span><span class="p">(</span><span class="s2">&#34;</span><span class="si">#{</span><span class="n">label</span><span class="si">}</span><span class="s2"> FAILED: </span><span class="si">#{</span><span class="n">error</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The status channel is separate from the operations channel. When the 8 content generation jobs run on Sunday at 5pm, the status channel turns into a real-time log:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>Starting Anime Ranking...
Starting Hacker News...
Starting YouTube Digest...
Anime Ranking complete.
YouTube Digest complete.
Hacker News complete.
Starting Market Recap...  (wave 2, after world events)
...</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 morning, if something went wrong, you open Discord on your phone and you know exactly what — no SSH into the server, no grep in logs.</p>
<p>And when the bot restarts after a deploy:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="n">bot</span><span class="o">.</span><span class="n">ready</span> <span class="k">do</span> <span class="o">|</span><span class="n">_event</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">  <span class="n">status_channel</span> <span class="o">=</span> <span class="no">ENV</span><span class="o">[</span><span class="s2">&#34;DISCORD_STATUS_CHANNEL_ID&#34;</span><span class="o">]</span>
</span></span><span class="line"><span class="cl">  <span class="no">DiscordNotifier</span><span class="o">.</span><span class="n">send_message</span><span class="p">(</span><span class="n">status_channel</span><span class="p">,</span> <span class="ss">content</span><span class="p">:</span> <span class="s2">&#34;Bot reconnected and ready.&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-20_00-48-20.jpg" alt="log"  loading="lazy" /></p>
<p>Simple, but you know the deploy worked without checking anything.</p>
<h2>Pattern 9: Cross-App Communication<span class="hx:absolute hx:-mt-20" id="pattern-9-cross-app-communication"></span>
    <a href="#pattern-9-cross-app-communication" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The system has two Rails apps that need to talk to each other. The newsletter needs to notify Discord when it finishes assembling an edition. But the newsletter has no Discord connection — marvin-bot is the one that does.</p>
<p>Solution: HTTP relay.</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="c1"># In the newsletter — send it to marvin-bot</span>
</span></span><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">DiscordRelayClient</span>
</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">send_embed</span><span class="p">(</span><span class="ss">title</span><span class="p">:,</span> <span class="ss">description</span><span class="p">:,</span> <span class="ss">color</span><span class="p">:)</span>
</span></span><span class="line"><span class="cl">    <span class="n">post</span><span class="p">(</span><span class="s2">&#34;/api/discord_notify&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="ss">type</span><span class="p">:</span> <span class="s2">&#34;embed&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="ss">payload</span><span class="p">:</span> <span class="p">{</span> <span class="ss">title</span><span class="p">:</span> <span class="n">title</span><span class="p">,</span> <span class="ss">description</span><span class="p">:</span> <span class="n">description</span><span class="p">,</span> <span class="ss">color</span><span class="p">:</span> <span class="n">color</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># In marvin-bot — receive it and forward to Discord</span>
</span></span><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">Api</span><span class="o">::</span><span class="no">DiscordNotifyController</span> <span class="o">&lt;</span> <span class="no">ApplicationController</span>
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">create</span>
</span></span><span class="line"><span class="cl">    <span class="k">case</span> <span class="n">params</span><span class="o">[</span><span class="ss">:type</span><span class="o">]</span>
</span></span><span class="line"><span class="cl">    <span class="k">when</span> <span class="s2">&#34;embed&#34;</span>
</span></span><span class="line"><span class="cl">      <span class="no">DiscordNotifier</span><span class="o">.</span><span class="n">send_embed</span><span class="p">(</span><span class="n">status_channel</span><span class="p">,</span> <span class="n">params</span><span class="o">[</span><span class="ss">:payload</span><span class="o">]</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">when</span> <span class="s2">&#34;message&#34;</span>
</span></span><span class="line"><span class="cl">      <span class="no">DiscordNotifier</span><span class="o">.</span><span class="n">send_message</span><span class="p">(</span><span class="n">status_channel</span><span class="p">,</span> <span class="ss">content</span><span class="p">:</span> <span class="n">params</span><span class="o">[</span><span class="ss">:payload</span><span class="o">][</span><span class="ss">:content</span><span class="o">]</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">end</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The newsletter speaks HTTP. marvin-bot translates it to Discord. A shared <code>ADMIN_TOKEN</code> authenticates. Every method rescues errors and returns nil — if Discord is down, the newsletter doesn&rsquo;t lock up. Fire and forget.</p>
<p>But there&rsquo;s a second, more interesting cross-app flow: the podcast trigger. When the newsletter is approved, it needs to tell marvin-bot to generate the podcast:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="c1"># In the newsletter — trigger podcast generation on marvin-bot</span>
</span></span><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">MarvinBotClient</span>
</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">trigger_podcast</span><span class="p">(</span><span class="ss">date</span><span class="p">:)</span>
</span></span><span class="line"><span class="cl">    <span class="n">post</span><span class="p">(</span><span class="s2">&#34;/api/trigger/podcast&#34;</span><span class="p">,</span> <span class="ss">date</span><span class="p">:</span> <span class="n">date</span><span class="o">.</span><span class="n">to_s</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></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>That kicks off an entire pipeline on marvin-bot: two-pass LLM script → per-turn TTS on the GPU → assembly with loudnorm → S3 upload. And the newsletter&rsquo;s <code>PublishAndSendJob</code> waits (via <code>retry_on PodcastNotReady</code>) until the podcast is ready. Two apps, two backend languages (Ruby and Python), connected by fire-and-forget HTTP with result polling. It&rsquo;s not elegant — but it works every single week with no intervention.</p>
<h2>Pattern 10: Integrated Cost Tracking<span class="hx:absolute hx:-mt-20" id="pattern-10-integrated-cost-tracking"></span>
    <a href="#pattern-10-integrated-cost-tracking" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Every AI call is tracked with tokens and estimated cost:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">def</span> <span class="nc">self</span><span class="o">.</span><span class="nf">create_chat</span><span class="p">(</span><span class="nb">caller</span><span class="p">:,</span> <span class="ss">model</span><span class="p">:,</span> <span class="ss">provider</span><span class="p">:)</span>
</span></span><span class="line"><span class="cl">  <span class="n">chat</span> <span class="o">=</span> <span class="no">RubyLLM</span><span class="o">.</span><span class="n">chat</span><span class="p">(</span><span class="ss">model</span><span class="p">:</span> <span class="n">model</span><span class="p">,</span> <span class="ss">provider</span><span class="p">:</span> <span class="n">provider</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">chat</span><span class="o">.</span><span class="n">on_end_message</span> <span class="k">do</span> <span class="o">|</span><span class="n">message</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">    <span class="no">ApiUsage</span><span class="o">.</span><span class="n">create!</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">      <span class="ss">caller_name</span><span class="p">:</span> <span class="nb">caller</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="ss">input_tokens</span><span class="p">:</span> <span class="n">message</span><span class="o">.</span><span class="n">input_tokens</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="ss">output_tokens</span><span class="p">:</span> <span class="n">message</span><span class="o">.</span><span class="n">output_tokens</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="ss">cost_usd</span><span class="p">:</span> <span class="n">estimate_cost</span><span class="p">(</span><span class="n">model</span><span class="p">,</span> <span class="n">message</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">  <span class="n">chat</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>And on Discord: <code>/cost</code> shows spending by month, year, and by provider. No separate dashboard, no Grafana, nothing. One command and the information that matters.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-20_00-49-05.jpg" alt="cost"  loading="lazy" /></p>
<h2>Pattern 11: Never Fail Silently<span class="hx:absolute hx:-mt-20" id="pattern-11-never-fail-silently"></span>
    <a href="#pattern-11-never-fail-silently" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>This is the pattern I should have implemented from the start, but I only learned it the hard way — in production.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-20_00-50-46.jpg" alt="daily"  loading="lazy" /></p>
<p>The project has ~10 daily jobs that fetch data from external sources (Hacker News, GitHub Trending, Morningstar, etc.) and post embeds to Discord. Each client follows a &ldquo;defensive&rdquo; pattern:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="c1"># The pattern that LOOKS right:</span>
</span></span><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">DailyDevClient</span>
</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">fetch_popular</span><span class="p">(</span><span class="ss">limit</span><span class="p">:</span> <span class="mi">10</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">response</span> <span class="o">=</span> <span class="no">HTTParty</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="no">API_URL</span><span class="p">,</span> <span class="ss">headers</span><span class="p">:</span> <span class="p">{</span> <span class="s2">&#34;Authorization&#34;</span> <span class="o">=&gt;</span> <span class="s2">&#34;Bearer </span><span class="si">#{</span><span class="n">token</span><span class="si">}</span><span class="s2">&#34;</span> <span class="p">})</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="o">[]</span> <span class="k">unless</span> <span class="n">response</span><span class="o">.</span><span class="n">success?</span>
</span></span><span class="line"><span class="cl">    <span class="n">parse</span><span class="p">(</span><span class="n">response</span><span class="o">.</span><span class="n">body</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">rescue</span> <span class="no">StandardError</span> <span class="o">=&gt;</span> <span class="n">e</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;DailyDevClient failed: </span><span class="si">#{</span><span class="n">e</span><span class="o">.</span><span class="n">message</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="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><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># And in the job:</span>
</span></span><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">DailyDailyDevJob</span> <span class="o">&lt;</span> <span class="no">ApplicationJob</span>
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">perform</span>
</span></span><span class="line"><span class="cl">    <span class="n">posts</span> <span class="o">=</span> <span class="no">DailyDevClient</span><span class="o">.</span><span class="n">fetch_popular</span><span class="p">(</span><span class="ss">limit</span><span class="p">:</span> <span class="mi">10</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="k">if</span> <span class="n">posts</span><span class="o">.</span><span class="n">empty?</span>  <span class="c1"># ← THE PROBLEM</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># ... posts embed to Discord</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>This code never crashes. Never raises an exception. Never fills the log with errors. It&rsquo;s the most &ldquo;robust&rdquo; code possible.</p>
<p>It&rsquo;s also completely <strong>useless for diagnosis</strong>. When I deployed and ran the daily jobs manually, only one of ten worked. But there was <strong>no</strong> error in the logs. None. <code>return [] unless response.success?</code> swallowed the HTTP 401 without logging. <code>return if posts.empty?</code> discarded the result without telling anyone. All green, all silent, zero data.</p>
<p>The fix has two parts:</p>
<p><strong>Part 1: Clients log the HTTP code:</strong></p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">unless</span> <span class="n">response</span><span class="o">.</span><span class="n">success?</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;DailyDevClient: API returned HTTP </span><span class="si">#{</span><span class="n">response</span><span class="o">.</span><span class="n">code</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">return</span> <span class="o">[]</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>It sounds obvious, but it&rsquo;s easy to forget when you write <code>return []</code> as a defensive reflex. <code>response.code</code> is the information that distinguishes &ldquo;expired token&rdquo; (401) from &ldquo;rate limited&rdquo; (429) from &ldquo;API down&rdquo; (503). Without it, everything looks the same: empty array.</p>
<p><strong>Part 2: Jobs warn on Discord when they have no data:</strong></p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">module</span> <span class="nn">DailyDiscordEmbed</span>
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">report_empty</span><span class="p">(</span><span class="n">title</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="no">Rails</span><span class="o">.</span><span class="n">logger</span><span class="o">.</span><span class="n">warn</span><span class="p">(</span><span class="s2">&#34;</span><span class="si">#{</span><span class="nb">self</span><span class="o">.</span><span class="n">class</span><span class="o">.</span><span class="n">name</span><span class="si">}</span><span class="s2">: </span><span class="si">#{</span><span class="n">title</span><span class="si">}</span><span class="s2"> returned no results&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">channel_id</span> <span class="o">=</span> <span class="no">Rails</span><span class="o">.</span><span class="n">application</span><span class="o">.</span><span class="n">config</span><span class="o">.</span><span class="n">discord</span><span class="o">.</span><span class="n">channel_ids</span><span class="o">.</span><span class="n">first</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="k">unless</span> <span class="n">channel_id</span><span class="o">.</span><span class="n">present?</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">embed</span> <span class="o">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="ss">title</span><span class="p">:</span> <span class="s2">&#34;⚠️ </span><span class="si">#{</span><span class="n">title</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="ss">description</span><span class="p">:</span> <span class="s2">&#34;</span><span class="si">#{</span><span class="nb">self</span><span class="o">.</span><span class="n">class</span><span class="o">.</span><span class="n">name</span><span class="si">}</span><span class="s2"> returned no results. Check logs.&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="ss">color</span><span class="p">:</span> <span class="mh">0xFFA500</span><span class="p">,</span>  <span class="c1"># orange = warning, not red = error</span>
</span></span><span class="line"><span class="cl">      <span class="ss">timestamp</span><span class="p">:</span> <span class="no">Time</span><span class="o">.</span><span class="n">current</span><span class="o">.</span><span class="n">iso8601</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="no">DiscordNotifier</span><span class="o">.</span><span class="n">send_embed</span><span class="p">(</span><span class="n">channel_id</span><span class="p">,</span> <span class="n">embed</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">rescue</span> <span class="no">StandardError</span> <span class="o">=&gt;</span> <span class="n">e</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">error</span><span class="p">(</span><span class="s2">&#34;</span><span class="si">#{</span><span class="nb">self</span><span class="o">.</span><span class="n">class</span><span class="o">.</span><span class="n">name</span><span class="si">}</span><span class="s2"> empty report failed: </span><span class="si">#{</span><span class="n">e</span><span class="o">.</span><span class="n">message</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">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>Now when a daily job brings back no data:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">perform</span>
</span></span><span class="line"><span class="cl">  <span class="n">posts</span> <span class="o">=</span> <span class="no">DailyDevClient</span><span class="o">.</span><span class="n">fetch_popular</span><span class="p">(</span><span class="ss">limit</span><span class="p">:</span> <span class="mi">10</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="n">posts</span><span class="o">.</span><span class="n">empty?</span>
</span></span><span class="line"><span class="cl">    <span class="n">report_empty</span><span class="p">(</span><span class="s2">&#34;daily.dev Popular&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">  <span class="c1"># ...</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></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><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-20_00-50-02.jpg" alt="no results"  loading="lazy" /></p>
<p>An orange embed appears on Discord. In the morning, I open the channel and see: &ldquo;⚠️ daily.dev Popular returned no results&rdquo;. I check the logs and find: &ldquo;DailyDevClient: API returned HTTP 401&rdquo;. Expired token. Fixed in 2 minutes.</p>
<p>Without this pattern, I would have discovered it weeks later — when someone asked &ldquo;why are there never any daily.dev posts?&rdquo;.</p>
<p>The lesson: <strong><code>rescue =&gt; []</code> is not error handling. It&rsquo;s error hiding.</strong> Every failure point needs two signals: a log with the technical detail (HTTP code, exception message) and a visual notification in the channel where you&rsquo;re already looking (Discord). If the bot IS your operational interface, bot failures need to show up ON the interface.</p>
<h2>Pattern 12: Conversations With Memory (Multi-Turn <code>/ask</code>)<span class="hx:absolute hx:-mt-20" id="pattern-12-conversations-with-memory-multi-turn-ask"></span>
    <a href="#pattern-12-conversations-with-memory-multi-turn-ask" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The first version of <code>/ask</code> created a brand new <code>RubyLLM::Chat</code> on every invocation. It worked, but it was like talking to someone with amnesia:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>/ask what is Ruby?
→ Ruby is a programming language created by Matz in 1995...

/ask compare it with Python
→ Compare what with Python? Could you be more specific?</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>ruby_llm already keeps history internally — every <code>chat.ask()</code> appends to the message list and sends the full context to the model. What was missing was persisting the <code>Chat</code> object between invocations.</p>
<p>The solution is an in-memory store with three safeguards:</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">AskSessionStore</span>
</span></span><span class="line"><span class="cl">  <span class="no">MAX_SESSIONS</span> <span class="o">=</span> <span class="mi">100</span>       <span class="c1"># total across all users</span>
</span></span><span class="line"><span class="cl">  <span class="no">SESSION_TTL</span> <span class="o">=</span> <span class="mi">2</span><span class="o">.</span><span class="n">hours</span>    <span class="c1"># inactivity timeout</span>
</span></span><span class="line"><span class="cl">  <span class="no">MAX_MESSAGES</span> <span class="o">=</span> <span class="mi">50</span>        <span class="c1"># ~25 exchanges, then auto-reset</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="no">Session</span> <span class="o">=</span> <span class="no">Struct</span><span class="o">.</span><span class="n">new</span><span class="p">(</span><span class="ss">:chat</span><span class="p">,</span> <span class="ss">:last_used</span><span class="p">,</span> <span class="ss">keyword_init</span><span class="p">:</span> <span class="kp">true</span><span class="p">)</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">get_or_create</span><span class="p">(</span><span class="n">author_id</span><span class="p">,</span> <span class="n">personality</span><span class="p">,</span> <span class="ss">system_prompt</span><span class="p">:,</span> <span class="ss">tools</span><span class="p">:)</span>
</span></span><span class="line"><span class="cl">    <span class="n">synchronize</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">      <span class="n">cleanup_expired</span>
</span></span><span class="line"><span class="cl">      <span class="n">key</span> <span class="o">=</span> <span class="o">[</span><span class="n">author_id</span><span class="p">,</span> <span class="n">personality</span><span class="o">]</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">      <span class="k">if</span> <span class="p">(</span><span class="n">session</span> <span class="o">=</span> <span class="vi">@sessions</span><span class="o">[</span><span class="n">key</span><span class="o">]</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="n">session</span><span class="o">.</span><span class="n">last_used</span> <span class="o">=</span> <span class="no">Time</span><span class="o">.</span><span class="n">current</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="n">session</span><span class="o">.</span><span class="n">chat</span><span class="o">.</span><span class="n">messages</span><span class="o">.</span><span class="n">length</span> <span class="o">&gt;</span> <span class="no">MAX_MESSAGES</span>
</span></span><span class="line"><span class="cl">          <span class="vi">@sessions</span><span class="o">.</span><span class="n">delete</span><span class="p">(</span><span class="n">key</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">          <span class="k">return</span> <span class="n">create_session</span><span class="p">(</span><span class="n">key</span><span class="p">,</span> <span class="ss">system_prompt</span><span class="p">:</span> <span class="n">system_prompt</span><span class="p">,</span> <span class="ss">tools</span><span class="p">:</span> <span class="n">tools</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="k">end</span>
</span></span><span class="line"><span class="cl">        <span class="n">session</span><span class="o">.</span><span class="n">chat</span>
</span></span><span class="line"><span class="cl">      <span class="k">else</span>
</span></span><span class="line"><span class="cl">        <span class="n">evict_oldest</span> <span class="k">if</span> <span class="vi">@sessions</span><span class="o">.</span><span class="n">size</span> <span class="o">&gt;=</span> <span class="no">MAX_SESSIONS</span>
</span></span><span class="line"><span class="cl">        <span class="n">create_session</span><span class="p">(</span><span class="n">key</span><span class="p">,</span> <span class="ss">system_prompt</span><span class="p">:</span> <span class="n">system_prompt</span><span class="p">,</span> <span class="ss">tools</span><span class="p">:</span> <span class="n">tools</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="k">end</span>
</span></span><span class="line"><span class="cl">    <span class="k">end</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-20_00-54-32.jpg" alt="ask memory"  loading="lazy" /></p>
<p>The key is <code>(author_id, personality)</code> — each user has separate sessions for Marvin and for Akita. Volatile on purpose: restart the server, lose the history. No database, no serialization, no complexity. It&rsquo;s working memory, not persistence.</p>
<p><code>/ask clear</code> resets the session. Without it, there&rsquo;s no way to &ldquo;start over&rdquo; when the conversation gets tangled.</p>
<p>Now:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>/ask what is Ruby?
→ Ruby is a programming language created by Matz in 1995...

/ask compare it with Python
→ Ruby and Python have similar philosophies — both prioritize programmer
  happiness — but Ruby embraces blocks and metaprogramming while Python
  prefers explicitness...</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 LLM knows &ldquo;compare&rdquo; refers to Ruby because the session history includes the previous question. That&rsquo;s the difference between a chatbot and an assistant.</p>
<h2>Pattern 13: Registering Application Commands (Native Autocomplete)<span class="hx:absolute hx:-mt-20" id="pattern-13-registering-application-commands-native-autocomplete"></span>
    <a href="#pattern-13-registering-application-commands-native-autocomplete" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The Application Commands section above showed the simplified concept. In practice, registering 18 commands with subcommands, typed parameters and choices demands a dedicated structure. Here&rsquo;s how the project handles it.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-20_00-43-18.jpg" alt="auto complete"  loading="lazy" /></p>
<h3>The Problem With Raw Text<span class="hx:absolute hx:-mt-20" id="the-problem-with-raw-text"></span>
    <a href="#the-problem-with-raw-text" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>When the bot only parses text, the user has to memorize the syntax:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>/update score #1 #2 #3 high       ← works
/update score #1 #2 #3 alta       ← fails silently
/update scoree #1 high             ← ignored</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>No autocomplete, no parameter validation, no inline documentation. The user types <code>/</code> and has no idea what&rsquo;s available.</p>
<h3>The <code>DiscordCommands</code> Module<span class="hx:absolute hx:-mt-20" id="the-discordcommands-module"></span>
    <a href="#the-discordcommands-module" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>All command definitions live in a separate module with two public methods: <code>register!</code> (registers the commands with the Discord API) and <code>setup_handlers!</code> (wires the handlers to the bot):</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">module</span> <span class="nn">DiscordCommands</span>
</span></span><span class="line"><span class="cl">  <span class="no">SCORE_CHOICES</span>  <span class="o">=</span> <span class="p">{</span> <span class="s2">&#34;High&#34;</span> <span class="o">=&gt;</span> <span class="s2">&#34;high&#34;</span><span class="p">,</span> <span class="s2">&#34;Medium&#34;</span> <span class="o">=&gt;</span> <span class="s2">&#34;medium&#34;</span><span class="p">,</span> <span class="s2">&#34;Low&#34;</span> <span class="o">=&gt;</span> <span class="s2">&#34;low&#34;</span> <span class="p">}</span><span class="o">.</span><span class="n">freeze</span>
</span></span><span class="line"><span class="cl">  <span class="no">SECTION_CHOICES</span> <span class="o">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;Tech&#34;</span> <span class="o">=&gt;</span> <span class="s2">&#34;tech&#34;</span><span class="p">,</span> <span class="s2">&#34;Global&#34;</span> <span class="o">=&gt;</span> <span class="s2">&#34;global&#34;</span><span class="p">,</span> <span class="s2">&#34;Financial&#34;</span> <span class="o">=&gt;</span> <span class="s2">&#34;financial&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;Q&amp;A&#34;</span> <span class="o">=&gt;</span> <span class="s2">&#34;qa&#34;</span><span class="p">,</span> <span class="s2">&#34;Entertainment&#34;</span> <span class="o">=&gt;</span> <span class="s2">&#34;entertainment&#34;</span><span class="p">,</span> <span class="s2">&#34;Open Source&#34;</span> <span class="o">=&gt;</span> <span class="s2">&#34;opensource&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;Product&#34;</span> <span class="o">=&gt;</span> <span class="s2">&#34;product&#34;</span><span class="p">,</span> <span class="s2">&#34;Book&#34;</span> <span class="o">=&gt;</span> <span class="s2">&#34;book&#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><span class="line"><span class="cl">  <span class="no">PERSONALITY_CHOICES</span> <span class="o">=</span> <span class="p">{</span> <span class="s2">&#34;Marvin (default)&#34;</span> <span class="o">=&gt;</span> <span class="s2">&#34;marvin&#34;</span><span class="p">,</span> <span class="s2">&#34;Akita&#34;</span> <span class="o">=&gt;</span> <span class="s2">&#34;akita&#34;</span> <span class="p">}</span><span class="o">.</span><span class="n">freeze</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">register!</span><span class="p">(</span><span class="n">bot</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">server_id</span> <span class="o">=</span> <span class="no">Rails</span><span class="o">.</span><span class="n">application</span><span class="o">.</span><span class="n">config</span><span class="o">.</span><span class="n">discord</span><span class="o">.</span><span class="n">server_id</span>
</span></span><span class="line"><span class="cl">    <span class="n">sid</span> <span class="o">=</span> <span class="n">server_id</span><span class="o">.</span><span class="n">present?</span> <span class="p">?</span> <span class="n">server_id</span><span class="o">.</span><span class="n">to_i</span> <span class="p">:</span> <span class="kp">nil</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">register_week</span><span class="p">(</span><span class="n">bot</span><span class="p">,</span> <span class="n">sid</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">register_add</span><span class="p">(</span><span class="n">bot</span><span class="p">,</span> <span class="n">sid</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">register_update</span><span class="p">(</span><span class="n">bot</span><span class="p">,</span> <span class="n">sid</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">register_ask</span><span class="p">(</span><span class="n">bot</span><span class="p">,</span> <span class="n">sid</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># ... 14 more commands</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nc">self</span><span class="o">.</span><span class="nf">setup_handlers!</span><span class="p">(</span><span class="n">bot</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">setup_week_handlers</span><span class="p">(</span><span class="n">bot</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">setup_add_handler</span><span class="p">(</span><span class="n">bot</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">setup_update_handlers</span><span class="p">(</span><span class="n">bot</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">setup_ask_handlers</span><span class="p">(</span><span class="n">bot</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># ... 14 more handlers</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>
<h3>Guild vs Global<span class="hx:absolute hx:-mt-20" id="guild-vs-global"></span>
    <a href="#guild-vs-global" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>The <code>server_id</code> (or <code>sid</code>) controls the registration scope. With <code>server_id</code>, the command shows up instantly on the specified server. Without it, the command is global — available on every server, but can take up to an hour to propagate.</p>
<p>For a private bot doing internal operations, always go guild-scoped. The <code>DISCORD_SERVER_ID</code> env var is set at deploy time:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="n">sid</span> <span class="o">=</span> <span class="n">server_id</span><span class="o">.</span><span class="n">present?</span> <span class="p">?</span> <span class="n">server_id</span><span class="o">.</span><span class="n">to_i</span> <span class="p">:</span> <span class="kp">nil</span>
</span></span><span class="line"><span class="cl"><span class="n">bot</span><span class="o">.</span><span class="n">register_application_command</span><span class="p">(</span><span class="ss">:week</span><span class="p">,</span> <span class="s2">&#34;Story list and weekly report&#34;</span><span class="p">,</span> <span class="ss">server_id</span><span class="p">:</span> <span class="n">sid</span><span class="p">)</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>If <code>sid</code> is nil, discordrb registers globally. In dev it&rsquo;s convenient; in production, guild-scoped is faster and lets you have different commands per server.</p>
<h3>Anatomy of a Command<span class="hx:absolute hx:-mt-20" id="anatomy-of-a-command"></span>
    <a href="#anatomy-of-a-command" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>The simplest method — no parameters:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">def</span> <span class="nc">self</span><span class="o">.</span><span class="nf">register_count</span><span class="p">(</span><span class="n">bot</span><span class="p">,</span> <span class="n">sid</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">bot</span><span class="o">.</span><span class="n">register_application_command</span><span class="p">(</span><span class="ss">:count</span><span class="p">,</span> <span class="s2">&#34;Show story count for this week&#34;</span><span class="p">,</span> <span class="ss">server_id</span><span class="p">:</span> <span class="n">sid</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>With typed parameters:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">def</span> <span class="nc">self</span><span class="o">.</span><span class="nf">register_add</span><span class="p">(</span><span class="n">bot</span><span class="p">,</span> <span class="n">sid</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">bot</span><span class="o">.</span><span class="n">register_application_command</span><span class="p">(</span><span class="ss">:add</span><span class="p">,</span> <span class="s2">&#34;Submit a URL for the newsletter&#34;</span><span class="p">,</span> <span class="ss">server_id</span><span class="p">:</span> <span class="n">sid</span><span class="p">)</span> <span class="k">do</span> <span class="o">|</span><span class="n">cmd</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">    <span class="n">cmd</span><span class="o">.</span><span class="n">string</span><span class="p">(</span><span class="ss">:url</span><span class="p">,</span> <span class="s2">&#34;The URL to submit&#34;</span><span class="p">,</span> <span class="ss">required</span><span class="p">:</span> <span class="kp">true</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">cmd</span><span class="o">.</span><span class="n">string</span><span class="p">(</span><span class="ss">:tags</span><span class="p">,</span> <span class="s2">&#34;Tags (e.g. #tech #high)&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">cmd</span><span class="o">.</span><span class="n">string</span><span class="p">(</span><span class="ss">:comment</span><span class="p">,</span> <span class="s2">&#34;Optional comment about this link&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span 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>Discord renders this as an inline form: required <code>url</code> field, optional <code>tags</code> and <code>comment</code>. The user sees each parameter&rsquo;s description as they fill it in.</p>
<h3>Subcommands: Grouping Actions<span class="hx:absolute hx:-mt-20" id="subcommands-grouping-actions"></span>
    <a href="#subcommands-grouping-actions" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Complex commands use subcommands. <code>/update</code> on its own does nothing — the user has to pick what to update:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">def</span> <span class="nc">self</span><span class="o">.</span><span class="nf">register_update</span><span class="p">(</span><span class="n">bot</span><span class="p">,</span> <span class="n">sid</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">bot</span><span class="o">.</span><span class="n">register_application_command</span><span class="p">(</span><span class="ss">:update</span><span class="p">,</span> <span class="s2">&#34;Update story properties&#34;</span><span class="p">,</span> <span class="ss">server_id</span><span class="p">:</span> <span class="n">sid</span><span class="p">)</span> <span class="k">do</span> <span class="o">|</span><span class="n">cmd</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">    <span class="n">cmd</span><span class="o">.</span><span class="n">subcommand</span><span class="p">(</span><span class="ss">:score</span><span class="p">,</span> <span class="s2">&#34;Update story priority score&#34;</span><span class="p">)</span> <span class="k">do</span> <span class="o">|</span><span class="nb">sub</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">      <span class="nb">sub</span><span class="o">.</span><span class="n">string</span><span class="p">(</span><span class="ss">:stories</span><span class="p">,</span> <span class="s2">&#34;Story numbers (e.g. #1 #2 #3 or 1,2,3)&#34;</span><span class="p">,</span> <span class="ss">required</span><span class="p">:</span> <span class="kp">true</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="nb">sub</span><span class="o">.</span><span class="n">string</span><span class="p">(</span><span class="ss">:level</span><span class="p">,</span> <span class="s2">&#34;Priority level&#34;</span><span class="p">,</span> <span class="ss">required</span><span class="p">:</span> <span class="kp">true</span><span class="p">,</span> <span class="ss">choices</span><span class="p">:</span> <span class="no">SCORE_CHOICES</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">end</span>
</span></span><span class="line"><span class="cl">    <span class="n">cmd</span><span class="o">.</span><span class="n">subcommand</span><span class="p">(</span><span class="ss">:tag</span><span class="p">,</span> <span class="s2">&#34;Update story section tag&#34;</span><span class="p">)</span> <span class="k">do</span> <span class="o">|</span><span class="nb">sub</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">      <span class="nb">sub</span><span class="o">.</span><span class="n">string</span><span class="p">(</span><span class="ss">:stories</span><span class="p">,</span> <span class="s2">&#34;Story numbers (e.g. #1 #2 #3 or 1,2,3)&#34;</span><span class="p">,</span> <span class="ss">required</span><span class="p">:</span> <span class="kp">true</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="nb">sub</span><span class="o">.</span><span class="n">string</span><span class="p">(</span><span class="ss">:section</span><span class="p">,</span> <span class="s2">&#34;Section tag&#34;</span><span class="p">,</span> <span class="ss">required</span><span class="p">:</span> <span class="kp">true</span><span class="p">,</span> <span class="ss">choices</span><span class="p">:</span> <span class="no">SECTION_CHOICES</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">end</span>
</span></span><span class="line"><span class="cl">    <span class="n">cmd</span><span class="o">.</span><span class="n">subcommand</span><span class="p">(</span><span class="ss">:image</span><span class="p">,</span> <span class="s2">&#34;Update story image&#34;</span><span class="p">)</span> <span class="k">do</span> <span class="o">|</span><span class="nb">sub</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">      <span class="nb">sub</span><span class="o">.</span><span class="n">string</span><span class="p">(</span><span class="ss">:stories</span><span class="p">,</span> <span class="s2">&#34;Story numbers (e.g. #1 #2 #3 or 1,2,3)&#34;</span><span class="p">,</span> <span class="ss">required</span><span class="p">:</span> <span class="kp">true</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="nb">sub</span><span class="o">.</span><span class="n">attachment</span><span class="p">(</span><span class="ss">:file</span><span class="p">,</span> <span class="s2">&#34;Image file (omit for AI generation)&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">end</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># ... more subcommands: title, description, comment, source</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>When the user types <code>/update</code>, Discord shows a list of subcommands: score, tag, title, description, comment, image, source. Pick <code>score</code> and the <code>stories</code> field (free text) and <code>level</code> field (dropdown with High/Medium/Low) appear. Syntax errors become impossible.</p>
<h3>Choices: Native Dropdowns<span class="hx:absolute hx:-mt-20" id="choices-native-dropdowns"></span>
    <a href="#choices-native-dropdowns" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>The <code>SCORE_CHOICES</code> hash becomes a dropdown on Discord. The key is the visible label (&ldquo;High&rdquo;), the value is what arrives in the handler (&ldquo;high&rdquo;):</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">SCORE_CHOICES</span> <span class="o">=</span> <span class="p">{</span> <span class="s2">&#34;High&#34;</span> <span class="o">=&gt;</span> <span class="s2">&#34;high&#34;</span><span class="p">,</span> <span class="s2">&#34;Medium&#34;</span> <span class="o">=&gt;</span> <span class="s2">&#34;medium&#34;</span><span class="p">,</span> <span class="s2">&#34;Low&#34;</span> <span class="o">=&gt;</span> <span class="s2">&#34;low&#34;</span> <span class="p">}</span><span class="o">.</span><span class="n">freeze</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># In the registration:</span>
</span></span><span class="line"><span class="cl"><span class="nb">sub</span><span class="o">.</span><span class="n">string</span><span class="p">(</span><span class="ss">:level</span><span class="p">,</span> <span class="s2">&#34;Priority level&#34;</span><span class="p">,</span> <span class="ss">required</span><span class="p">:</span> <span class="kp">true</span><span class="p">,</span> <span class="ss">choices</span><span class="p">:</span> <span class="no">SCORE_CHOICES</span><span class="p">)</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The user doesn&rsquo;t have to remember whether it&rsquo;s &ldquo;high&rdquo;, &ldquo;HIGH&rdquo;, &ldquo;alta&rdquo;, or &ldquo;3&rdquo;. Pick it from the dropdown and done. That eliminates an entire category of input errors.</p>
<h3>Attachments: File Uploads<span class="hx:absolute hx:-mt-20" id="attachments-file-uploads"></span>
    <a href="#attachments-file-uploads" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Discord supports attachment-type parameters — the user can drag a file straight into the command:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="n">cmd</span><span class="o">.</span><span class="n">subcommand</span><span class="p">(</span><span class="ss">:image</span><span class="p">,</span> <span class="s2">&#34;Update story image&#34;</span><span class="p">)</span> <span class="k">do</span> <span class="o">|</span><span class="nb">sub</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">  <span class="nb">sub</span><span class="o">.</span><span class="n">string</span><span class="p">(</span><span class="ss">:stories</span><span class="p">,</span> <span class="s2">&#34;Story numbers (e.g. #1 #2 #3 or 1,2,3)&#34;</span><span class="p">,</span> <span class="ss">required</span><span class="p">:</span> <span class="kp">true</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="nb">sub</span><span class="o">.</span><span class="n">attachment</span><span class="p">(</span><span class="ss">:file</span><span class="p">,</span> <span class="s2">&#34;Image file (omit for AI generation)&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>In the handler, the resolved attachments come through as Discord CDN URLs:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">def</span> <span class="nc">self</span><span class="o">.</span><span class="nf">resolve_attachments</span><span class="p">(</span><span class="n">event</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">unless</span> <span class="n">event</span><span class="o">.</span><span class="n">resolved</span><span class="o">&amp;.</span><span class="n">attachments</span><span class="o">&amp;.</span><span class="n">any?</span>
</span></span><span class="line"><span class="cl">  <span class="n">event</span><span class="o">.</span><span class="n">resolved</span><span class="o">.</span><span class="n">attachments</span><span class="o">.</span><span class="n">values</span><span class="o">.</span><span class="n">map</span><span class="p">(</span><span class="o">&amp;</span><span class="ss">:url</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<h3>Lifecycle: Where to Register, Where to Wire Handlers<span class="hx:absolute hx:-mt-20" id="lifecycle-where-to-register-where-to-wire-handlers"></span>
    <a href="#lifecycle-where-to-register-where-to-wire-handlers" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>This is the point that confuses people most. The <strong>handlers</strong> have to be registered <strong>before</strong> <code>bot.run</code>. The <strong>registration with the Discord API</strong> can happen inside <code>bot.ready</code> (which fires after the WebSocket connection):</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">MarvinBotRunner</span>
</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">run!</span>
</span></span><span class="line"><span class="cl">    <span class="n">bot</span> <span class="o">=</span> <span class="no">Discordrb</span><span class="o">::</span><span class="no">Bot</span><span class="o">.</span><span class="n">new</span><span class="p">(</span><span class="ss">token</span><span class="p">:</span> <span class="n">token</span><span class="p">,</span> <span class="ss">intents</span><span class="p">:</span> <span class="o">...</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c1"># 1. Handlers BEFORE bot.run (otherwise the bot doesn&#39;t know how to respond)</span>
</span></span><span class="line"><span class="cl">    <span class="no">DiscordCommands</span><span class="o">.</span><span class="n">setup_handlers!</span><span class="p">(</span><span class="n">bot</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">bot</span><span class="o">.</span><span class="n">ready</span> <span class="k">do</span> <span class="o">|</span><span class="n">_event</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">      <span class="c1"># 2. API registration AFTER connection (idempotent, safe on reconnect)</span>
</span></span><span class="line"><span class="cl">      <span class="no">DiscordCommands</span><span class="o">.</span><span class="n">register!</span><span class="p">(</span><span class="n">bot</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">bot</span><span class="o">.</span><span class="n">run</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>Why inside <code>bot.ready</code>? Because the bot can reconnect (Discord closes WebSockets periodically). <code>register!</code> is idempotent — registering the same command twice doesn&rsquo;t duplicate it, it just updates. Putting it in <code>ready</code> makes sure the commands exist even after a reconnect.</p>
<h3>Bridge: Application Command → CommandDispatcher<span class="hx:absolute hx:-mt-20" id="bridge-application-command--commanddispatcher"></span>
    <a href="#bridge-application-command--commanddispatcher" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>The most elegant part of the architecture: the Application Command handler has no business logic. It translates Discord&rsquo;s parameters into the format the <code>CommandDispatcher</code> already expects:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">def</span> <span class="nc">self</span><span class="o">.</span><span class="nf">dispatch_interaction</span><span class="p">(</span><span class="n">event</span><span class="p">,</span> <span class="n">type</span><span class="p">,</span> <span class="ss">match</span><span class="p">:</span> <span class="o">[</span><span class="kp">nil</span><span class="o">]</span><span class="p">,</span> <span class="ss">attachments</span><span class="p">:</span> <span class="o">[]</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">event</span><span class="o">.</span><span class="n">respond</span><span class="p">(</span><span class="ss">content</span><span class="p">:</span> <span class="s2">&#34;\u{2705}&#34;</span><span class="p">,</span> <span class="ss">ephemeral</span><span class="p">:</span> <span class="kp">true</span><span class="p">)</span>  <span class="c1"># checkmark visible only to the author</span>
</span></span><span class="line"><span class="cl">  <span class="n">command</span> <span class="o">=</span> <span class="no">SlashCommandParser</span><span class="o">::</span><span class="no">Command</span><span class="o">.</span><span class="n">new</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">    <span class="ss">type</span><span class="p">:</span> <span class="n">type</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="ss">args</span><span class="p">:</span> <span class="p">{</span> <span class="ss">match</span><span class="p">:</span> <span class="n">match</span><span class="p">,</span> <span class="ss">attachments</span><span class="p">:</span> <span class="n">attachments</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="no">CommandDispatcher</span><span class="o">.</span><span class="n">dispatch</span><span class="p">(</span><span class="n">command</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="ss">channel_id</span><span class="p">:</span> <span class="n">event</span><span class="o">.</span><span class="n">channel</span><span class="o">.</span><span class="n">id</span><span class="o">.</span><span class="n">to_s</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="ss">message_id</span><span class="p">:</span> <span class="kp">nil</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="ss">author_id</span><span class="p">:</span> <span class="n">event</span><span class="o">.</span><span class="n">user</span><span class="o">.</span><span class="n">id</span><span class="o">.</span><span class="n">to_s</span>
</span></span><span class="line"><span class="cl">  <span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">rescue</span> <span class="no">StandardError</span> <span class="o">=&gt;</span> <span class="n">e</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">error</span><span class="p">(</span><span class="s2">&#34;Discord interaction error (</span><span class="si">#{</span><span class="n">type</span><span class="si">}</span><span class="s2">): </span><span class="si">#{</span><span class="n">e</span><span class="o">.</span><span class="n">message</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Each subcommand handler is a one-liner that assembles the args and calls <code>dispatch_interaction</code>:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="n">bot</span><span class="o">.</span><span class="n">application_command</span><span class="p">(</span><span class="ss">:update</span><span class="p">)</span><span class="o">.</span><span class="n">subcommand</span><span class="p">(</span><span class="ss">:score</span><span class="p">)</span> <span class="k">do</span> <span class="o">|</span><span class="n">event</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">  <span class="n">stories</span> <span class="o">=</span> <span class="n">event</span><span class="o">.</span><span class="n">options</span><span class="o">[</span><span class="s2">&#34;stories&#34;</span><span class="o">]</span>
</span></span><span class="line"><span class="cl">  <span class="n">level</span> <span class="o">=</span> <span class="n">event</span><span class="o">.</span><span class="n">options</span><span class="o">[</span><span class="s2">&#34;level&#34;</span><span class="o">]</span>
</span></span><span class="line"><span class="cl">  <span class="n">dispatch_interaction</span><span class="p">(</span><span class="n">event</span><span class="p">,</span> <span class="ss">:update_score</span><span class="p">,</span> <span class="ss">match</span><span class="p">:</span> <span class="o">[</span><span class="kp">nil</span><span class="p">,</span> <span class="n">stories</span><span class="p">,</span> <span class="kp">nil</span><span class="p">,</span> <span class="n">level</span><span class="o">]</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p><code>CommandDispatcher</code> doesn&rsquo;t know if the command came from raw text or from an Application Command. Same logic, same authorization, same jobs. No duplicated code.</p>
<h3>The Result<span class="hx:absolute hx:-mt-20" id="the-result"></span>
    <a href="#the-result" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>The project registers 18 Application Commands with combinations of subcommands, typed parameters, dropdowns and file uploads. The user types <code>/</code> and sees everything available with descriptions. Pick a command and Discord guides you field by field. Syntax errors are impossible — Discord validates before sending.</p>
<p>And both systems coexist: people who prefer autocomplete use Application Commands, people who prefer to type fast use raw text. <code>CommandDispatcher</code> receives both the same way. Adding a new command means adding a <code>register_*</code>, a <code>setup_*_handler</code>, and a dispatcher method — without touching the runner.</p>
<h2>The discordrb Pitfalls<span class="hx:absolute hx:-mt-20" id="the-discordrb-pitfalls"></span>
    <a href="#the-discordrb-pitfalls" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>It&rsquo;s not all roses. Some real pain points:</p>
<p><strong>Gateway disconnects</strong>: The WebSocket to Discord drops every once in a while. discordrb reconnects automatically, but your <code>bot.ready</code> and <code>bot.message</code> handlers need to be resilient to reconnects.</p>
<p><strong>Message Content Intent</strong>: Since 2022, bots in more than 75 servers need approval to read message content. For private bots (which is the case for internal operations), that&rsquo;s a non-issue. But if you plan to distribute the bot, get ready for paperwork.</p>
<p><strong>API Rate Limits</strong>: Discord rate-limits per route. Sending 10 embeds at once to the same channel? You&rsquo;ll hit the limit. discordrb handles rate limits internally, but calls that are too fast cause visible delays.</p>
<p><strong>Embed Limits</strong>: Title max 256 chars. Description 4096. Field value 1024. Exceeded them? Silent error. Always truncate before sending.</p>
<p><strong>File Uploads</strong>: 25MB limit on the free plan, 50MB with Nitro. For AI-generated images that&rsquo;s never an issue, but if you&rsquo;re shipping PDFs or audio, be careful.</p>
<h2>When NOT to Use Discord as Admin<span class="hx:absolute hx:-mt-20" id="when-not-to-use-discord-as-admin"></span>
    <a href="#when-not-to-use-discord-as-admin" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><ul>
<li><strong>Complex forms</strong>: editing 15 fields on a record? Web screen</li>
<li><strong>Bulk operations</strong>: importing a CSV with 1000 records? Script</li>
<li><strong>Data visualization</strong>: charts, analytical dashboards? Grafana/Metabase</li>
<li><strong>Multi-step approval</strong>: workflows with 5 approval stages? You need persisted state</li>
</ul>
<p>But for day-to-day operations — curating content, approving newsletters, monitoring jobs, checking costs, talking to AI — Discord is the most productive interface I&rsquo;ve ever used. And the best part: the subscribers will never know their weekly newsletter was curated by a guy typing commands into Discord on his phone while drinking coffee.</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/screenshot-2026-02-20_00-57-11.jpg" alt="hello"  loading="lazy" /></p>
<p>Using Discord as an admin panel isn&rsquo;t laziness — it&rsquo;s pragmatism. The best interface is the one you already use all day long. Auth, push notifications, mobile, rich formatting, file sharing — all free. You only build the business logic.</p>
<p>The <a href="https://rubyllm.com/"target="_blank" rel="noopener"><code>ruby_llm</code></a> gem with tool calling takes the bot from &ldquo;command executor&rdquo; to &ldquo;assistant that thinks&rdquo;. Add session memory and it thinks <strong>and remembers</strong>. When the LLM can generate images, search the web, keep context across questions, and answer questions about the architecture of its own system — that&rsquo;s when it stops being CRUD and becomes a co-pilot.</p>
<p>And the Parser → Dispatcher → Job pattern keeps everything testable. The parser knows nothing about Discord. The dispatcher knows nothing about AI. The jobs know nothing about the interface. Swap any one of those layers and the others don&rsquo;t even notice.</p>
<p>In the end, the best admin UI is the one that doesn&rsquo;t need a separate deploy. And the worst bug is the one that fails in silence — if the bot is your operational interface, the bot&rsquo;s failures need to show up <strong>on</strong> the interface, not hidden away in a log nobody reads.</p>
]]></content:encoded><category>the-m-akita-chronicles</category><category>automation</category><category>backend</category></item><item><title>Async Jobs That Survive Chaos | Behind the Scenes of The M.Akita Chronicles</title><link>https://akitaonrails.github.io/en/2026/02/19/async-jobs-that-survive-chaos-behind-the-m-akita-chronicles/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2026/02/19/async-jobs-that-survive-chaos-behind-the-m-akita-chronicles/</guid><pubDate>Thu, 19 Feb 2026 01:10:08 +0000</pubDate><description>&lt;p&gt;This post is part of a series; follow along through the tag &lt;a href="https://akitaonrails.github.io/en/tags/the-m-akita-chronicles/"&gt;/themakitachronicles&lt;/a&gt;. This is part 5.&lt;/p&gt;
&lt;p&gt;And make sure to 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;Let me be blunt: most developers treat background jobs as if they were scripts that run once and that&amp;rsquo;s it. &amp;ldquo;Ah, throw it in a Sidekiq and we&amp;rsquo;re good.&amp;rdquo; No, &lt;strong&gt;we&amp;rsquo;re not good&lt;/strong&gt;. Jobs that handle important things — sending emails, publishing content, calling external APIs — need to be treated as first-class citizens in your architecture.&lt;/p&gt;</description><content:encoded><![CDATA[<p>This post is part of a series; follow along through the tag <a href="/en/tags/the-m-akita-chronicles/">/themakitachronicles</a>. This is part 5.</p>
<p>And make sure to subscribe to my new newsletter <a href="https://themakitachronicles.com/"target="_blank" rel="noopener">The M.Akita Chronicles</a>!</p>
<p>&ndash;</p>
<p>Let me be blunt: most developers treat background jobs as if they were scripts that run once and that&rsquo;s it. &ldquo;Ah, throw it in a Sidekiq and we&rsquo;re good.&rdquo; No, <strong>we&rsquo;re not good</strong>. Jobs that handle important things — sending emails, publishing content, calling external APIs — need to be treated as first-class citizens in your architecture.</p>
<p>In this post I&rsquo;ll show how Rails 8 with ActiveJob and SolidQueue changed my perspective on async processing. This is not theory — these are patterns that emerged from a real system running in production every week.</p>
<h2>The Problem: Jobs Are Fragile by Nature<span class="hx:absolute hx:-mt-20" id="the-problem-jobs-are-fragile-by-nature"></span>
    <a href="#the-problem-jobs-are-fragile-by-nature" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Picture the scenario: you have a job that assembles a newsletter, publishes it on a blog through the GitHub API, waits for a podcast to be ready, and then fires off emails to hundreds of subscribers. Any step can fail. The API can time out. The podcast can take longer than expected. The server can restart in the middle of the delivery.</p>
<p>If you treat this as a linear script, you&rsquo;re going to suffer. The right question is not &ldquo;how do I make this job run?&rdquo; — it&rsquo;s <strong>&ldquo;how do I make this job recover when something goes wrong?&rdquo;</strong></p>
<h2>Pattern 1: retry_on With Specific Exceptions<span class="hx:absolute hx:-mt-20" id="pattern-1-retry_on-with-specific-exceptions"></span>
    <a href="#pattern-1-retry_on-with-specific-exceptions" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>ActiveJob&rsquo;s <code>retry_on</code> is absurdly powerful, but most people use it wrong. Look at the classic anti-pattern:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">MeuJob</span> <span class="o">&lt;</span> <span class="no">ApplicationJob</span>
</span></span><span class="line"><span class="cl">  <span class="n">retry_on</span> <span class="no">StandardError</span><span class="p">,</span> <span class="ss">wait</span><span class="p">:</span> <span class="mi">5</span><span class="o">.</span><span class="n">seconds</span><span class="p">,</span> <span class="ss">attempts</span><span class="p">:</span> <span class="mi">3</span>
</span></span><span class="line"><span class="cl"><span 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>This retries <strong>any</strong> error 3 times and gives up. It&rsquo;s useless for almost anything in the real world. The pattern that actually works is to create specific exceptions for transient states:</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">PodcastNotReady</span> <span class="o">&lt;</span> <span class="no">StandardError</span><span class="p">;</span> <span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">PublishAndSendJob</span> <span class="o">&lt;</span> <span class="no">ApplicationJob</span>
</span></span><span class="line"><span class="cl">  <span class="n">retry_on</span> <span class="no">PodcastNotReady</span><span class="p">,</span> <span class="ss">wait</span><span class="p">:</span> <span class="mi">15</span><span class="o">.</span><span class="n">minutes</span><span class="p">,</span> <span class="ss">attempts</span><span class="p">:</span> <span class="mi">16</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">perform</span><span class="p">(</span><span class="n">newsletter_id</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">metadata</span> <span class="o">=</span> <span class="n">check_podcast_metadata</span><span class="p">(</span><span class="n">newsletter_id</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">raise</span> <span class="no">PodcastNotReady</span><span class="p">,</span> <span class="s2">&#34;Aguardando podcast&#34;</span> <span class="k">unless</span> <span class="n">metadata</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># keep working...</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>See what&rsquo;s happening? The job waits up to <strong>4 hours</strong> (15min × 16) for the podcast to be ready, polling every 15 minutes. This is not an infinite loop — it has a clear limit. And when it hits that limit, you handle the timeout explicitly:</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">rescue</span> <span class="no">PodcastNotReady</span> <span class="o">=&gt;</span> <span class="n">e</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="n">executions</span> <span class="o">&gt;=</span> <span class="mi">16</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># Publish without podcast and notify the team</span>
</span></span><span class="line"><span class="cl">    <span class="n">publish_without_podcast</span><span class="p">(</span><span class="n">newsletter</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">notify_team</span><span class="p">(</span><span class="s2">&#34;Podcast não ficou pronto a tempo&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">else</span>
</span></span><span class="line"><span class="cl">    <span class="k">raise</span> <span class="c1"># re-raise so SolidQueue schedules the next retry</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>This is <strong>graceful degradation</strong>. The system doesn&rsquo;t stop because one component failed — it adapts and keeps going.</p>
<h2>Pattern 2: Distributed Locks<span class="hx:absolute hx:-mt-20" id="pattern-2-distributed-locks"></span>
    <a href="#pattern-2-distributed-locks" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>When you have multiple paths that can trigger the same job (a safety cron, a manual trigger, an API), you need to make sure only one instance runs at a time.</p>
<p>The concept is simple: a file-based (or database-based) lock that expires automatically:</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">DeployLock</span>
</span></span><span class="line"><span class="cl">  <span class="no">LOCK_DIR</span> <span class="o">=</span> <span class="no">Rails</span><span class="o">.</span><span class="n">root</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="s2">&#34;tmp/locks&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="no">DEFAULT_TTL</span> <span class="o">=</span> <span class="mi">30</span><span class="o">.</span><span class="n">minutes</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">with_lock</span><span class="p">(</span><span class="nb">name</span><span class="p">,</span> <span class="ss">ttl</span><span class="p">:</span> <span class="no">DEFAULT_TTL</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">acquire!</span><span class="p">(</span><span class="nb">name</span><span class="p">,</span> <span class="ss">ttl</span><span class="p">:</span> <span class="n">ttl</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">yield</span>
</span></span><span class="line"><span class="cl">  <span class="k">ensure</span>
</span></span><span class="line"><span class="cl">    <span class="n">release</span><span class="p">(</span><span class="nb">name</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></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>With SQLite, this is trivial — no Redis, no external coordination. The lock has a TTL so it doesn&rsquo;t hang forever if the process dies. And the <code>ensure</code> guarantees it releases even if the block raises an exception.</p>
<h2>Pattern 3: Atomic Email Delivery<span class="hx:absolute hx:-mt-20" id="pattern-3-atomic-email-delivery"></span>
    <a href="#pattern-3-atomic-email-delivery" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>This is where most newsletter systems fail spectacularly. The scenario: you&rsquo;re sending 500 emails and the server restarts on email 247. What happens when the job re-runs?</p>
<p>If you just iterated a list, you&rsquo;re going to resend the first 247. Your subscribers will love getting the newsletter twice.</p>
<p>The solution is <strong>atomic claiming</strong> per recipient:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="c1"># Before sending, create a record per subscriber</span>
</span></span><span class="line"><span class="cl"><span class="n">subscribers</span><span class="o">.</span><span class="n">each</span> <span class="k">do</span> <span class="o">|</span><span class="nb">sub</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">  <span class="no">EmailDelivery</span><span class="o">.</span><span class="n">create!</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">    <span class="ss">newsletter</span><span class="p">:</span> <span class="n">newsletter</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="ss">subscriber</span><span class="p">:</span> <span class="nb">sub</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="ss">status</span><span class="p">:</span> <span class="s2">&#34;pending&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># At send time, do an atomic claim</span>
</span></span><span class="line"><span class="cl"><span class="n">delivery</span> <span class="o">=</span> <span class="no">EmailDelivery</span>
</span></span><span class="line"><span class="cl">  <span class="o">.</span><span class="n">where</span><span class="p">(</span><span class="ss">status</span><span class="p">:</span> <span class="o">[</span><span class="s2">&#34;pending&#34;</span><span class="p">,</span> <span class="s2">&#34;failed&#34;</span><span class="o">]</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="o">.</span><span class="n">lock</span>
</span></span><span class="line"><span class="cl">  <span class="o">.</span><span class="n">first</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">delivery</span><span class="o">.</span><span class="n">update!</span><span class="p">(</span><span class="ss">status</span><span class="p">:</span> <span class="s2">&#34;sending&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">send_email</span><span class="p">(</span><span class="n">delivery</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">delivery</span><span class="o">.</span><span class="n">update!</span><span class="p">(</span><span class="ss">status</span><span class="p">:</span> <span class="s2">&#34;sent&#34;</span><span class="p">)</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>If the server dies between <code>sending</code> and <code>sent</code>, the record stays as &ldquo;sending&rdquo; — and a recovery job (<code>RecoverStaleDeliveriesJob</code>) periodically moves those records to &ldquo;unknown&rdquo; after a timeout, so they never get automatically resent. Ambiguous emails are <strong>never</strong> resent automatically. That&rsquo;s the kind of detail that separates a hobby system from a production system.</p>
<h2>Pattern 4: Orchestrator Jobs<span class="hx:absolute hx:-mt-20" id="pattern-4-orchestrator-jobs"></span>
    <a href="#pattern-4-orchestrator-jobs" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>A common mistake is cramming too much logic into a single job. The pattern that works is having <strong>orchestrator jobs</strong> that delegate to specialized jobs:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>PublishAndSendJob (orchestrator)
  ├── Waits for podcast (retry_on PodcastNotReady)
  ├── PublishToBlogJob.publish(newsletter)
  └── SendNewsletterJob.perform_now(newsletter.id)
        └── SendSingleEmailJob (per subscriber)</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 orchestrator coordinates the sequence. Each specialized job knows how to do exactly one thing and can be re-executed independently. If email sending fails, you can retrigger just the <code>SendNewsletterJob</code> without republishing the blog.</p>
<h2>Pattern 5: Safety Nets With Cron<span class="hx:absolute hx:-mt-20" id="pattern-5-safety-nets-with-cron"></span>
    <a href="#pattern-5-safety-nets-with-cron" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Don&rsquo;t trust a single execution chain. SolidQueue&rsquo;s cron (<code>config/recurring.yml</code>) acts as a safety net:</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">send_newsletter</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">class</span><span class="p">:</span><span class="w"> </span><span class="l">SendNewsletterJob</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">schedule</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;0 12 * * 1&#34;</span><span class="w">  </span><span class="c"># Monday 9am BRT (UTC-3)</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>If the 7am <code>PublishAndSendJob</code> failed catastrophically, the 9am cron fires the <code>SendNewsletterJob</code> as a fallback. The job checks whether the newsletter has already been sent and no-ops if so. <strong>Idempotency</strong> is the keyword here — the job can run as many times as you want and the result is always the same.</p>
<p>But watch out for the inverse: a job that reschedules itself infinitely when it finds no work is a ticking time bomb. If the job doesn&rsquo;t find a ready newsletter, it simply returns. The cron handles trying again the next week.</p>
<h2>Pattern 6: Status Notifications<span class="hx:absolute hx:-mt-20" id="pattern-6-status-notifications"></span>
    <a href="#pattern-6-status-notifications" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Every long-running job should communicate its progress. A simple concern handles it:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">module</span> <span class="nn">DiscordStatus</span>
</span></span><span class="line"><span class="cl">  <span class="kp">extend</span> <span class="no">ActiveSupport</span><span class="o">::</span><span class="no">Concern</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">notify_start</span><span class="p">(</span><span class="n">message</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="no">DiscordNotifier</span><span class="o">.</span><span class="n">send</span><span class="p">(</span><span class="ss">channel</span><span class="p">:</span> <span class="n">status_channel</span><span class="p">,</span> <span class="ss">text</span><span class="p">:</span> <span class="s2">&#34;▶️ </span><span class="si">#{</span><span class="n">message</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">notify_done</span><span class="p">(</span><span class="n">message</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="no">DiscordNotifier</span><span class="o">.</span><span class="n">send</span><span class="p">(</span><span class="ss">channel</span><span class="p">:</span> <span class="n">status_channel</span><span class="p">,</span> <span class="ss">text</span><span class="p">:</span> <span class="s2">&#34;✅ </span><span class="si">#{</span><span class="n">message</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">notify_error</span><span class="p">(</span><span class="n">message</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="no">DiscordNotifier</span><span class="o">.</span><span class="n">send</span><span class="p">(</span><span class="ss">channel</span><span class="p">:</span> <span class="n">status_channel</span><span class="p">,</span> <span class="ss">text</span><span class="p">:</span> <span class="s2">&#34;❌ </span><span class="si">#{</span><span class="n">message</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">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>Each job includes the concern and calls <code>notify_start</code> at the beginning, <code>notify_done</code> on success, <code>notify_error</code> on rescue. When something goes wrong at 3am, you wake up and see exactly where it stopped — no digging through logs required.</p>
<h2>SolidQueue: The End of Redis Dependency<span class="hx:absolute hx:-mt-20" id="solidqueue-the-end-of-redis-dependency"></span>
    <a href="#solidqueue-the-end-of-redis-dependency" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>A note on SolidQueue, which ships as default in Rails 8: using the same SQLite database for jobs and application data <strong>drastically</strong> simplifies operations. No need for a separate Redis running. No need to worry about Redis restarting and losing jobs that were in memory.</p>
<p>Jobs are persisted in the database. If the server restarts, they&rsquo;re there waiting. The retry state is preserved. It&rsquo;s absurdly simpler than the alternative, and for 99% of cases, the performance is more than enough.</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>Rails 8 with ActiveJob and SolidQueue didn&rsquo;t invent anything revolutionary. What it did was make it ridiculously easy to implement patterns that used to require heavy infrastructure:</p>
<ul>
<li><strong>retry_on</strong> with specific exceptions and clear limits</li>
<li><strong>Distributed locks</strong> with automatic TTL</li>
<li><strong>Atomic claiming</strong> for operations that can&rsquo;t duplicate</li>
<li><strong>Orchestrator jobs</strong> that delegate and recover</li>
<li><strong>Safety crons</strong> as fallback</li>
<li><strong>Notifications</strong> for operational visibility</li>
</ul>
<p>None of these patterns is complicated on its own. Put them together, though, and that&rsquo;s what makes the difference between a system that breaks at the first failure and one that holds up in production — and lets you sleep peacefully on Monday.</p>
]]></content:encoded><category>the-m-akita-chronicles</category><category>rails</category><category>distributed-systems</category></item><item><title>Frontend Without a Framework | Behind the Scenes of The M.Akita Chronicles</title><link>https://akitaonrails.github.io/en/2026/02/19/frontend-without-a-framework-behind-the-m-akita-chronicles/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2026/02/19/frontend-without-a-framework-behind-the-m-akita-chronicles/</guid><pubDate>Thu, 19 Feb 2026 01:08:23 +0000</pubDate><description>&lt;p&gt;This post is part of a series; follow along with the tag &lt;a href="https://akitaonrails.github.io/en/tags/the-m-akita-chronicles/"&gt;/themakitachronicles&lt;/a&gt;. This is part 4.&lt;/p&gt;
&lt;p&gt;And be sure to 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;There&amp;rsquo;s an obsession in the market that frontend = React. Or Vue. Or Svelte. Or the framework of the week. What if I told you it&amp;rsquo;s possible to have a modern, responsive frontend with dark mode, email-compatible, multi-platform — without a single line of JavaScript framework?&lt;/p&gt;</description><content:encoded><![CDATA[<p>This post is part of a series; follow along with the tag <a href="/en/tags/the-m-akita-chronicles/">/themakitachronicles</a>. This is part 4.</p>
<p>And be sure to subscribe to my new newsletter <a href="https://themakitachronicles.com/"target="_blank" rel="noopener">The M.Akita Chronicles</a>!</p>
<p>&ndash;</p>
<p>There&rsquo;s an obsession in the market that frontend = React. Or Vue. Or Svelte. Or the framework of the week. What if I told you it&rsquo;s possible to have a modern, responsive frontend with dark mode, email-compatible, multi-platform — without a single line of JavaScript framework?</p>
<p>I&rsquo;m not talking about static sites from the 2000s. I&rsquo;m talking about Tailwind CSS v4 on Rails, Hugo with the Hextra theme for the blog, and email templates that work from Gmail to Outlook 2016. All in the same project, with one developer.</p>
<h2>Tailwind CSS v4: The End of Handcrafted CSS<span class="hx:absolute hx:-mt-20" id="tailwind-css-v4-the-end-of-handcrafted-css"></span>
    <a href="#tailwind-css-v4-the-end-of-handcrafted-css" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>I have a confession: CSS has always been my kryptonite. Not because it&rsquo;s hard — but because it&rsquo;s <strong>unpredictable</strong>. You change a <code>margin-top</code> and break the layout of a page you never touched. Cascade, specificity, inheritance — it&rsquo;s a minefield.</p>
<p>Tailwind solves this in the most pragmatic way possible: utility classes right in the HTML.</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-html" data-lang="html"><span class="line"><span class="cl"><span class="p">&lt;</span><span class="nt">div</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;flex items-center gap-4 p-6 bg-white dark:bg-gray-800
</span></span></span><span class="line"><span class="cl"><span class="s">            rounded-xl shadow-sm border border-gray-200 dark:border-gray-700&#34;</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl">  <span class="p">&lt;</span><span class="nt">img</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;w-12 h-12 rounded-full&#34;</span> <span class="na">src</span><span class="o">=</span><span class="s">&#34;/images/avatar.jpg&#34;</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl">  <span class="p">&lt;</span><span class="nt">div</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl">    <span class="p">&lt;</span><span class="nt">h3</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;font-semibold text-gray-900 dark:text-gray-100&#34;</span><span class="p">&gt;</span>Título<span class="p">&lt;/</span><span class="nt">h3</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl">    <span class="p">&lt;</span><span class="nt">p</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;text-sm text-gray-500 dark:text-gray-400&#34;</span><span class="p">&gt;</span>Descrição<span class="p">&lt;/</span><span class="nt">p</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl">  <span class="p">&lt;/</span><span class="nt">div</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl"><span class="p">&lt;/</span><span class="nt">div</span><span class="p">&gt;</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>&ldquo;But this is just inline style with extra steps!&rdquo; — No, it isn&rsquo;t. It&rsquo;s a design system with constraints. You don&rsquo;t pick &ldquo;margin: 17px&rdquo; — you pick <code>m-4</code> (16px) or <code>m-5</code> (20px). That constraint forces visual consistency without needing a design system documented across 40 pages.</p>
<p>And Tailwind v4, which came to Rails via <code>tailwindcss-rails</code>, has a fundamental change: <strong>CSS-first configuration</strong>. No more <code>tailwind.config.js</code>. Everything is CSS:</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-css" data-lang="css"><span class="line"><span class="cl"><span class="p">@</span><span class="k">import</span> <span class="s2">&#34;tailwindcss&#34;</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="p">@</span><span class="k">theme</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nt">--color-brand</span><span class="o">:</span> <span class="p">#</span><span class="nn">8b5cf6</span><span class="o">;</span>
</span></span><span class="line"><span class="cl">  <span class="nt">--font-sans</span><span class="o">:</span> <span class="s2">&#34;Inter&#34;</span><span class="o">,</span> <span class="nt">system-ui</span><span class="o">,</span> <span class="nt">sans-serif</span><span class="o">;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>One less config file. One less build step. Simpler.</p>
<h2>Dark Mode: Respect Your User<span class="hx:absolute hx:-mt-20" id="dark-mode-respect-your-user"></span>
    <a href="#dark-mode-respect-your-user" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-20_01-05-42.jpg" alt="change theme"  loading="lazy" /></p>
<p>Something that deeply annoys me is a site that ignores the operating system&rsquo;s theme preference. The user set dark mode on the OS — respect that.</p>
<p>With Tailwind, dark mode is trivial:</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-html" data-lang="html"><span class="line"><span class="cl"><span class="p">&lt;</span><span class="nt">body</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100&#34;</span><span class="p">&gt;</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The <code>dark:</code> prefix applies the style when the operating system is in dark mode (via <code>prefers-color-scheme: dark</code>). No JavaScript needed. No toggle needed. No cookie needed. It works.</p>
<p>But &ldquo;it works&rdquo; is the bare minimum. The real challenge is maintaining <strong>consistency</strong> across dozens of components. Every color you use needs a <code>dark:</code> variant. Every background, every text, every border.</p>
<p>The trick: create conventions and follow them. Main background? <code>bg-white dark:bg-gray-900</code>. Primary text? <code>text-gray-900 dark:text-gray-100</code>. Secondary text? <code>text-gray-500 dark:text-gray-400</code>. Borders? <code>border-gray-200 dark:border-gray-700</code>. With 4-5 pairs defined, 90% of components are covered.</p>
<h2>Hugo + Hextra: Blog Without a Database<span class="hx:absolute hx:-mt-20" id="hugo--hextra-blog-without-a-database"></span>
    <a href="#hugo--hextra-blog-without-a-database" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The project&rsquo;s blog runs Hugo with the Hextra theme. Hugo is a static site generator written in Go — absurdly fast. A full rebuild in &lt;500ms even with dozens of pages.</p>
<p>What makes Hextra special is that it&rsquo;s a <strong>documentation</strong> theme adapted for blogs. Navigation sidebar, integrated search, table of contents — everything ready. And customizable via partial layouts:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>layouts/
├── _partials/
│   ├── custom/
│   │   └── head-end.html          # Injects analytics, fonts, custom CSS
│   └── section-box.html           # Shared partial (DRY, see below)
├── blog/
│   └── home.html                  # Custom homepage
├── newsletters/
│   └── single.html                # Individual newsletter template
├── podcast-transcripts/
│   └── list.html                  # Episode listing
└── shortcodes/                    # 17 shortcodes — components without JS
    ├── podcast-turn.html          # Speech bubble in the transcript
    ├── akita-comment.html         # Akita&#39;s comment
    ├── marvin-comment.html        # Marvin&#39;s comment
    ├── history-timeline.html      # Historical events timeline
    ├── market-ticker.html         # Financial tickers table
    ├── market-map.html            # FinViz S&amp;P 500 map
    ├── subscribe-cta.html         # Subscription call-to-action
    ├── score.html                 # Relevance badge (high/medium/low)
    ├── holidays.html              # Holidays and commemorative dates
    ├── ref-stories.html           # Reference links
    ├── book-downloads.html        # Book download links
    ├── hacker-news.html           # ┐
    ├── anime-ranking.html         # │ Section boxes — delegate to
    ├── youtube-digest.html        # │ section-box.html (see below)
    ├── world-events.html          # │
    ├── market-news.html           # │
    └── qa.html                    # ┘</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>Each content type has its own template. Newsletters have a navigation sidebar and visual sections. Podcast transcripts have an embedded player and dialogue formatting. The same Markdown content is rendered differently depending on the <code>type</code> in the frontmatter.</p>
<h2>Shortcodes: Components Without JavaScript<span class="hx:absolute hx:-mt-20" id="shortcodes-components-without-javascript"></span>
    <a href="#shortcodes-components-without-javascript" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Hugo shortcodes are the equivalent of React components, but without a build step:</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-html" data-lang="html"><span class="line"><span class="cl"><span class="c">&lt;!-- layouts/shortcodes/podcast-turn.html --&gt;</span>
</span></span><span class="line"><span class="cl">{{ $speaker := .Get &#34;speaker&#34; }}
</span></span><span class="line"><span class="cl">{{ $isAkita := eq $speaker &#34;akita&#34; }}
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="p">&lt;</span><span class="nt">div</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;podcast-turn&#34;</span> <span class="na">style</span><span class="o">=</span><span class="s">&#34;display: flex; {{ if $isAkita }}flex-direction: row{{ else }}flex-direction: row-reverse{{ end }};&#34;</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl">  <span class="p">&lt;</span><span class="nt">img</span> <span class="na">src</span><span class="o">=</span><span class="s">&#34;/images/{{ $speaker }}.jpg&#34;</span>
</span></span><span class="line"><span class="cl">       <span class="na">style</span><span class="o">=</span><span class="s">&#34;width: 40px; height: 40px; border-radius: 50%;&#34;</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl">  <span class="p">&lt;</span><span class="nt">div</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;bubble {{ if $isAkita }}akita-bubble{{ else }}marvin-bubble{{ end }}&#34;</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl">    {{ .Inner | markdownify }}
</span></span><span class="line"><span class="cl">  <span class="p">&lt;/</span><span class="nt">div</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl"><span class="p">&lt;/</span><span class="nt">div</span><span class="p">&gt;</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>In Markdown, the author writes:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>{{&lt; podcast-turn speaker=&#34;akita&#34; &gt;}}
Então, vamos falar sobre o que aconteceu essa semana no mundo tech...
{{&lt; /podcast-turn &gt;}}</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><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-20_01-06-23.jpg" alt="baloon"  loading="lazy" /></p>
<p>And Hugo renders a styled chat bubble, with avatar, correct alignment, and processed markdown. Zero JavaScript. Zero framework. HTML + CSS + template logic.</p>
<h3>DRY With a Shared Partial<span class="hx:absolute hx:-mt-20" id="dry-with-a-shared-partial"></span>
    <a href="#dry-with-a-shared-partial" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>With 17 shortcodes, duplication becomes a problem fast. Seven of them — the &ldquo;section boxes&rdquo; that wrap newsletter sections (Hacker News, Anime, YouTube, etc.) — have the same visual structure: icon, title, colored border, Markdown content. Only the color and label change.</p>
<p>The solution is a shared partial that takes parameters:</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-html" data-lang="html"><span class="line"><span class="cl"><span class="c">&lt;!-- layouts/partials/section-box.html --&gt;</span>
</span></span><span class="line"><span class="cl"><span class="p">&lt;</span><span class="nt">div</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;section-box {{ .class }}&#34;</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl">  <span class="p">&lt;</span><span class="nt">div</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;section-box-header&#34;</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl">    <span class="p">&lt;</span><span class="nt">span</span><span class="p">&gt;</span>{{ .icon }}<span class="p">&lt;/</span><span class="nt">span</span><span class="p">&gt;</span> {{ .label }}
</span></span><span class="line"><span class="cl">  <span class="p">&lt;/</span><span class="nt">div</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl">  <span class="p">&lt;</span><span class="nt">div</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;section-box-content&#34;</span><span class="p">&gt;</span>{{ .content | markdownify }}<span class="p">&lt;/</span><span class="nt">div</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl"><span class="p">&lt;/</span><span class="nt">div</span><span class="p">&gt;</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 each shortcode becomes a one-liner:</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-html" data-lang="html"><span class="line"><span class="cl"><span class="c">&lt;!-- layouts/shortcodes/hacker-news.html --&gt;</span>
</span></span><span class="line"><span class="cl">{{ partial &#34;section-box&#34; (dict &#34;class&#34; &#34;hacker-news&#34; &#34;icon&#34; &#34;<span class="ni">&amp;#128274;</span>&#34; &#34;label&#34; &#34;The Hacker News&#34; &#34;content&#34; .Inner) }}</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><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-20_01-07-02.jpg" alt="hacker news"  loading="lazy" /></p>
<p>It&rsquo;s the Hugo equivalent of component inheritance: a polymorphic partial parameterized by <code>dict</code>. The CSS in <code>custom.css</code> defines colors per class (<code>.hacker-news</code>, <code>.anime-ranking</code>, etc.) with light/dark variants. Result: 7 shortcodes that used to have duplicated HTML are now 7 lines + 1 partial + CSS.</p>
<p>But there&rsquo;s a crucial catch: <strong>shortcodes are exclusive to Hugo</strong>. The same content that goes to the blog also goes out by email. And email doesn&rsquo;t understand shortcodes.</p>
<h2>The Challenge: Same Content, Two Renders<span class="hx:absolute hx:-mt-20" id="the-challenge-same-content-two-renders"></span>
    <a href="#the-challenge-same-content-two-renders" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Here&rsquo;s the central frontend problem in this kind of project: the same Markdown file needs to work in two completely different contexts.</p>
<p><strong>On the blog (Hugo):</strong></p>
<ul>
<li>Shortcodes work (<code>\{\{&lt; subscribe-cta &gt;\}\}</code>)</li>
<li>CSS with classes (Tailwind, Hextra)</li>
<li>Dark mode via <code>prefers-color-scheme</code></li>
<li>Responsive images</li>
<li>Flexible layout</li>
</ul>
<p><strong>In email (Rails → Amazon SES):</strong></p>
<ul>
<li>Shortcodes are dead text — they have to be removed</li>
<li>CSS must be <strong>inline</strong> (many email clients ignore <code>&lt;style&gt;</code>)</li>
<li>Dark mode? Forget it for 80% of clients</li>
<li>Images with absolute URLs and fixed size</li>
<li>Layout with <code>&lt;table&gt;</code> (yes, in 2026)</li>
</ul>
<p>The solution: two independent rendering pipelines. The Markdown content is the source of truth. Hugo transforms it for the web. Rails transforms it for email. Each one with its own rules.</p>
<p>On the Rails side, the <code>MarkdownToHtml</code> service needs to:</p>
<ol>
<li>Process standard Markdown</li>
<li>Convert <code>\{\{&lt; shortcode &gt;\}\}</code> into equivalent HTML or strip them — via a <code>SHORTCODE_PATTERNS</code> hash that maps each shortcode to a regex + inline template with CSS</li>
<li>Apply the theme (light/dark based on the subscriber&rsquo;s preference)</li>
<li>Inject inline CSS for email client compatibility</li>
<li>Rewrite image URLs to absolute</li>
</ol>
<p>It&rsquo;s more work than it sounds. Each shortcode needs an email-safe equivalent. Comment avatars need absolute URLs. Sections with colored backgrounds need <code>&lt;td style=&quot;background-color:...&quot;&gt;</code> instead of CSS classes.</p>
<h2>Email HTML: The Worst Frontend in the World<span class="hx:absolute hx:-mt-20" id="email-html-the-worst-frontend-in-the-world"></span>
    <a href="#email-html-the-worst-frontend-in-the-world" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>If you think cross-browser compatibility is hard, try cross-email-client compatibility. Outlook 2016 renders HTML using the <strong>Microsoft Word engine</strong>. Yes, Word. That&rsquo;s not a joke.</p>
<p>The survival rules:</p>
<ol>
<li><strong>Layout with <code>&lt;table&gt;</code></strong>. <code>display: flex</code>? <code>grid</code>? They don&rsquo;t exist in Outlook.</li>
<li><strong>Inline CSS on every element</strong>. <code>&lt;style&gt;</code> tags are ignored by several clients.</li>
<li><strong>No <code>margin</code> on images</strong>. Use <code>padding</code> on the parent <code>&lt;td&gt;</code>.</li>
<li><strong>Web fonts don&rsquo;t work</strong>. Use a font stack: <code>font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;</code></li>
<li><strong>Themes are a subscriber preference</strong>, not automatic detection — many email clients don&rsquo;t reliably support <code>prefers-color-scheme</code>.</li>
</ol>
<p>The project&rsquo;s email template is an ERB that takes theme variables:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>&lt;td style=&#34;padding: 8px 28px; color: &lt;%= @theme[:text] %&gt;;
           background: &lt;%= @theme[:card_bg] %&gt;;&#34;&gt;
  &lt;%= raw @content %&gt;
&lt;/td&gt;</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 theme defines all the colors:</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">THEMES</span> <span class="o">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="ss">light</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="ss">text</span><span class="p">:</span> <span class="s2">&#34;#1a1a1a&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="ss">card_bg</span><span class="p">:</span> <span class="s2">&#34;#ffffff&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="ss">link_color</span><span class="p">:</span> <span class="s2">&#34;#7c3aed&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># ...</span>
</span></span><span class="line"><span class="cl">  <span class="p">},</span>
</span></span><span class="line"><span class="cl">  <span class="ss">dark</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="ss">text</span><span class="p">:</span> <span class="s2">&#34;#e6edf3&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="ss">card_bg</span><span class="p">:</span> <span class="s2">&#34;#161b22&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="ss">link_color</span><span class="p">:</span> <span class="s2">&#34;#a78bfa&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># ...</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Each subscriber picks their theme at signup time. The email is rendered with the correct colors for that person. No media queries, no automatic detection — <strong>because email clients don&rsquo;t support it reliably</strong>. It&rsquo;s simpler to give the user the choice.</p>
<h2>Color Palette: HSL and the Science Behind the Choices<span class="hx:absolute hx:-mt-20" id="color-palette-hsl-and-the-science-behind-the-choices"></span>
    <a href="#color-palette-hsl-and-the-science-behind-the-choices" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>&ldquo;But how did you pick these colors?&rdquo; — Not by eyeballing it. There&rsquo;s a science behind it, and it&rsquo;s called <strong>HSL</strong> (Hue, Saturation, Lightness).</p>
<p>Most developers think about color as hex (<code>#e8eef8</code>) or RGB (<code>rgb(232, 238, 248)</code>). Those are machine formats — useful for rendering, useless for <strong>designing</strong>. When you look at <code>#3b50a2</code> and <code>#c93030</code> side by side, there&rsquo;s no way to intuitively know if they&rsquo;ll work together. They&rsquo;re just numbers.</p>
<p>HSL solves this. Instead of mixing light channels (Red, Green, Blue), you describe color in human terms:</p>
<ul>
<li><strong>Hue</strong>: the position on the color wheel, from 0° to 360°. Red = 0°, green = 120°, blue = 240°.</li>
<li><strong>Saturation</strong>: how &ldquo;vivid&rdquo; the color is. 0% = gray, 100% = pure color.</li>
<li><strong>Lightness</strong>: how light or dark. 0% = black, 100% = white.</li>
</ul>
<p>With HSL, you control each dimension independently. And that&rsquo;s where building the palette comes in.</p>
<h3>Step 1: Distribute Hues Around the Color Wheel<span class="hx:absolute hx:-mt-20" id="step-1-distribute-hues-around-the-color-wheel"></span>
    <a href="#step-1-distribute-hues-around-the-color-wheel" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-20_01-09-47.jpg" alt="color wheel"  loading="lazy" /></p>
<p>The newsletter has 8 section types, each with its own color. For all of them to &ldquo;belong&rdquo; visually to the same project, the hues are distributed evenly around the 360° wheel:</p>
<table>
  <thead>
      <tr>
          <th>Section</th>
          <th>Hue</th>
          <th>Degrees</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>YouTube</td>
          <td>Red</td>
          <td>0°</td>
      </tr>
      <tr>
          <td>History</td>
          <td>Sepia</td>
          <td>35°</td>
      </tr>
      <tr>
          <td>World</td>
          <td>Amber</td>
          <td>45°</td>
      </tr>
      <tr>
          <td>Book/Downloads</td>
          <td>Green</td>
          <td>140°</td>
      </tr>
      <tr>
          <td>Holidays</td>
          <td>Teal</td>
          <td>180°</td>
      </tr>
      <tr>
          <td>Anime/Q&amp;A</td>
          <td>Blue</td>
          <td>210°</td>
      </tr>
      <tr>
          <td>Market News</td>
          <td>Royal</td>
          <td>225°</td>
      </tr>
      <tr>
          <td>Hacker News</td>
          <td>Purple</td>
          <td>270°</td>
      </tr>
  </tbody>
</table>
<p>Eight hues spaced around the wheel. That&rsquo;s not random — it&rsquo;s the same logic as a <strong>polychromatic palette</strong> in color theory. When hues are equidistant, the human eye perceives harmony even with completely different colors. It&rsquo;s the same principle artists have been using with a 12-color wheel since the 18th century.</p>
<h3>Step 2: Lock Saturation and Lightness Per Layer<span class="hx:absolute hx:-mt-20" id="step-2-lock-saturation-and-lightness-per-layer"></span>
    <a href="#step-2-lock-saturation-and-lightness-per-layer" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/HSV_color_solid_cylinder_saturation_gray.jpg" alt="saturation luminosity"  loading="lazy" /></p>
<p>Here&rsquo;s the trick that makes it all work together. Each visual &ldquo;layer&rdquo; (background, text, border) has <strong>fixed</strong> saturation and lightness — only the hue changes:</p>
<p><strong>Light theme:</strong></p>
<ul>
<li><strong>Backgrounds</strong>: ~92-95% lightness, moderate saturation. With lightness that high, even different saturations result in pastel colors with similar visual weight — no section &ldquo;screams&rdquo; louder than another.</li>
<li><strong>Text</strong>: ~15-20% lightness. Dark enough to guarantee <strong>&gt;7:1 contrast ratio</strong> against the light background (above the WCAG AAA minimum of 7:1).</li>
<li><strong>Borders</strong>: ~45-55% lightness. The chromatic accent — visible but not dominant.</li>
</ul>
<p><strong>Dark theme:</strong></p>
<ul>
<li><strong>Backgrounds</strong>: ~10-12% lightness. Uniform depth — every section has the same &ldquo;darkness&rdquo;.</li>
<li><strong>Text</strong>: ~80-85% lightness. Light enough for <strong>&gt;6:1 contrast ratio</strong> on the dark background.</li>
<li><strong>Borders</strong>: same lightness range as in the light theme — they work as accents in both contexts.</li>
</ul>
<p>In practice, this means generating the dark variant of any color is mechanical: keep the hue, invert the lightness. A background that was 94% becomes 11%. Text that was 18% becomes 83%. That&rsquo;s not design — it&rsquo;s arithmetic.</p>
<h3>Step 3: Check Contrast (WCAG)<span class="hx:absolute hx:-mt-20" id="step-3-check-contrast-wcag"></span>
    <a href="#step-3-check-contrast-wcag" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p><a href="https://accessibilityassistant.com/blog/accessibility-insights/wcag-2-colour-contrast-accessibility-guidelines/"target="_blank" rel="noopener"><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/Why-Colour-Contrast-Accessibility-Matters-More-Than-Ever.jpg" alt="Why Colour Contrast Accessibility Matters More Than Ever"  loading="lazy" /></a></p>
<p>Pretty colors nobody can read are worthless. The WCAG standard (Web Content Accessibility Guidelines) defines minimum ratios:</p>
<ul>
<li><strong>AA</strong>: 4.5:1 for normal text, 3:1 for large text</li>
<li><strong>AAA</strong>: 7:1 for normal text, 4.5:1 for large text</li>
</ul>
<p>The light backgrounds at ~95% lightness against text at ~20% lightness give a ratio &gt;7:1 — WCAG AAA. The dark backgrounds (~11%) against light text (~82%) give &gt;6:1 — comfortably WCAG AA, near AAA.</p>
<p>This isn&rsquo;t altruism. Low-contrast email is harder to read, generates less engagement, and in extreme cases is flagged by email clients&rsquo; accessibility filters.</p>
<h3>The Result<span class="hx:absolute hx:-mt-20" id="the-result"></span>
    <a href="#the-result" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-20_01-07-50.jpg" alt="example colors"  loading="lazy" /></p>
<p>When you open the newsletter and see the Hacker News section (purple), followed by YouTube (red), followed by Anime (blue) — they look like &ldquo;the same project&rdquo; despite being completely different colors. That&rsquo;s HSL at work: different hues, same saturation, same lightness. The eye perceives coherence without knowing how to explain why.</p>
<p>And the best part: adding a new section is trivial. Pick a hue that isn&rsquo;t taken on the wheel, apply the same saturation/lightness rules per layer, and the new section automatically &ldquo;belongs&rdquo; to the existing design.</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="c1"># The comment in the code sums up the rules:</span>
</span></span><span class="line"><span class="cl"><span class="c1"># - Hues evenly spaced: blue 210, green 140, sepia 35, amber 45, royal 225, purple 270, red 0, teal 180</span>
</span></span><span class="line"><span class="cl"><span class="c1"># - Light backgrounds: ~95% lightness, ~25% saturation</span>
</span></span><span class="line"><span class="cl"><span class="c1"># - Light text: ~20% lightness (&gt;7:1 contrast ratio on light bg)</span>
</span></span><span class="line"><span class="cl"><span class="c1"># - Dark backgrounds: ~10-12% lightness</span>
</span></span><span class="line"><span class="cl"><span class="c1"># - Dark text: ~80-85% lightness (&gt;6:1 contrast ratio on dark bg)</span>
</span></span><span class="line"><span class="cl"><span class="c1"># - Borders: ~45-55% lightness</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>Six rules. Eight hues. Two themes. 100% deterministic. Zero subjective &ldquo;I think this blue looks pretty&rdquo; decisions — it&rsquo;s a design system derived from geometric properties of the HSL color space.</p>
<h2>Well-Formed HTML: The Spam Filter Nobody Tells You About<span class="hx:absolute hx:-mt-20" id="well-formed-html-the-spam-filter-nobody-tells-you-about"></span>
    <a href="#well-formed-html-the-spam-filter-nobody-tells-you-about" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Everything I described above — tables, inline CSS, font stacks — solves the <strong>rendering</strong> problem. But there&rsquo;s a second reason to follow these rules that most developers ignore: <strong>email providers analyze the quality of your HTML to decide if it&rsquo;s spam</strong>.</p>
<p>Gmail, Outlook, Yahoo — all of them run your email&rsquo;s HTML through heuristics before showing it to the user. Malformed HTML is one of the classic spam signals. It makes sense: spammers don&rsquo;t test templates. They spit out broken HTML generated by cheap mass-mailing tools. If your email looks like spammer output, it gets treated as spam.</p>
<p>The signals that trip filters:</p>
<ol>
<li><strong>Unclosed or improperly nested tags</strong>. A <code>&lt;div&gt;</code> that opens and never closes, or a <code>&lt;/td&gt;</code> before the <code>&lt;/tr&gt;</code>, is an immediate flag. Providers expect HTML that parses without errors — not HTML &ldquo;that the browser fixes up&rdquo;.</li>
<li><strong>Unbalanced image-to-text ratio</strong>. An email that&rsquo;s basically a giant image with little text? Classic spam. Providers want to see real text — not text inside images. The rule of thumb is to keep at least 60% text, 40% images.</li>
<li><strong>Missing <code>alt</code> attribute on images</strong>. Beyond accessibility, an empty or missing <code>alt</code> is a sign of carelessness — and carelessness correlates with spam. Every <code>&lt;img&gt;</code> needs a descriptive <code>alt</code>.</li>
<li><strong>CSS that hides content</strong>. <code>display: none</code>, <code>visibility: hidden</code>, <code>font-size: 0</code>, text the same color as the background — those are classic keyword stuffing techniques. Even if you use them legitimately for responsive email, the filter doesn&rsquo;t know the difference.</li>
<li><strong>Excessively heavy HTML</strong>. An email with 500KB of HTML raises suspicion. Keep it light — the actual content rarely needs more than 100KB of HTML.</li>
<li><strong>Links with text that doesn&rsquo;t match the href</strong>. <code>&lt;a href=&quot;https://site-real.com&quot;&gt;https://site-falso.com&lt;/a&gt;</code> is textbook phishing. Even subtle discrepancies (domain in the text different from the domain in the link) are penalized.</li>
</ol>
<p>The point that ties it all together: <strong>the same practices that make email render correctly in Outlook are the ones that keep your emails out of spam</strong>. Tables with clean structure? Well-formed HTML. Inline CSS on every element instead of hacks with <code>&lt;style&gt;</code>? No hidden CSS. Images with explicit dimensions and <code>alt</code> text? Legitimate content.</p>
<p>It&rsquo;s no coincidence. Email providers calibrated their filters by watching what spammers do — and spammers do exactly the opposite of well-built HTML email. When you follow best practices for compatibility, <strong>for free</strong> you&rsquo;re also signaling legitimacy to spam filters.</p>
<p>The practical lesson: treat your email&rsquo;s HTML as if it were an XML document. Validate, test, and make sure every tag opens and closes in the right order. Use tools like <a href="https://www.emailonacid.com/"target="_blank" rel="noopener">Email on Acid</a> or <a href="https://www.litmus.com/"target="_blank" rel="noopener">Litmus</a> to test in multiple clients — they also report spam scoring issues. And resist the temptation to copy ready-made templates from the internet without validating them — many are a disaster of malformed HTML that slips past Gmail but gives Outlook a heart attack and gets Yahoo&rsquo;s spam filter to flag you as suspicious.</p>
<h2>RSS Feeds: The Forgotten Frontend<span class="hx:absolute hx:-mt-20" id="rss-feeds-the-forgotten-frontend"></span>
    <a href="#rss-feeds-the-forgotten-frontend" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-20_01-12-48.jpg" alt="RSS"  loading="lazy" /></p>
<p>A detail web developers ignore: RSS is a frontend. Podcasts on Spotify and Apple Podcasts are consumed via RSS. The feed needs:</p>
<ul>
<li>Valid XML (podcast parsers are strict)</li>
<li>Complete metadata (title, description, duration, file size)</li>
<li>Image URLs that <strong>actually work</strong> (Spotify validates them)</li>
<li>Cover art with minimum dimensions (1400×1400 for Spotify)</li>
<li>Unique GUIDs per episode</li>
</ul>
<p>Hugo generates RSS automatically, but the default template doesn&rsquo;t have the fields podcast platforms require. A custom template with iTunes tags is necessary:</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-xml" data-lang="xml"><span class="line"><span class="cl"><span class="nt">&lt;itunes:duration&gt;</span>{{ .Params.duration_seconds }}<span class="nt">&lt;/itunes:duration&gt;</span>
</span></span><span class="line"><span class="cl"><span class="nt">&lt;itunes:image</span> <span class="na">href=</span><span class="s">&#34;{{ .Params.cover_image | absURL }}&#34;</span><span class="nt">/&gt;</span>
</span></span><span class="line"><span class="cl"><span class="nt">&lt;enclosure</span> <span class="na">url=</span><span class="s">&#34;{{ .Params.audio_url }}&#34;</span>
</span></span><span class="line"><span class="cl">           <span class="na">length=</span><span class="s">&#34;{{ .Params.file_size }}&#34;</span>
</span></span><span class="line"><span class="cl">           <span class="na">type=</span><span class="s">&#34;audio/mpeg&#34;</span><span class="nt">/&gt;</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<h2>The Philosophy: Content First, Framework Never<span class="hx:absolute hx:-mt-20" id="the-philosophy-content-first-framework-never"></span>
    <a href="#the-philosophy-content-first-framework-never" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The pattern that emerges from this whole setup is: <strong>content is sovereign</strong>. Markdown with YAML frontmatter is the universal format. Each frontend (blog, email, RSS, podcast player) consumes the same content and renders it its own way.</p>
<p>There&rsquo;s no &ldquo;React component that fetches data from an API&rdquo;. There&rsquo;s a Markdown file read by whoever needs it. This is radically simple — and radically powerful. Swap the blog engine from Hugo to something else? Change the templates, the content stays the same. Swap the email provider from SES to Sendgrid? Change the mailer, the templates stay the same.</p>
<p>It&rsquo;s the Unix philosophy applied to frontend: each tool does one thing well, connected by a universal format (Markdown + YAML).</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>You don&rsquo;t need React to have a modern frontend. Tailwind v4 gives you a design system with constraints, native dark mode, and zero JavaScript. Hugo gives you an absurdly fast blog with flexible templates. ERB with inline CSS gives you emails that work in any client.</p>
<p>The real investment is understanding the constraints of each platform and building rendering pipelines for each one. The payoff comes from that, not from learning the trendy framework that&rsquo;ll die in 18 months.</p>
<p>At the end of the day, the best frontend abstraction is none at all. HTML, CSS, and Markdown. Simple, debuggable, and going to work 10 years from now.</p>
]]></content:encoded><category>the-m-akita-chronicles</category><category>frontend</category><category>web-development</category></item><item><title>Serving AI in the Cloud: My Personal TTS | Behind the Scenes of The M.Akita Chronicles</title><link>https://akitaonrails.github.io/en/2026/02/18/serving-ai-in-the-cloud-my-personal-tts-behind-the-m-akita-chronicles/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2026/02/18/serving-ai-in-the-cloud-my-personal-tts-behind-the-m-akita-chronicles/</guid><pubDate>Wed, 18 Feb 2026 15:28:46 +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;/themakitachronicles&lt;/a&gt;. This is part 3.&lt;/p&gt;
&lt;p&gt;And make sure to 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;I&amp;rsquo;ll talk about the podcast today; remember that the &lt;a href="https://blog.themakitachronicles.com/podcast-transcripts/"target="_blank" rel="noopener"&gt;pilot episode is already live&lt;/a&gt; (transcripts and RSS to subscribe) and on the channel on Spotify.&lt;/p&gt;
&lt;p&gt;&amp;ndash;&lt;/p&gt;
&lt;p&gt;Every AI tutorial starts with &lt;em&gt;&amp;ldquo;install the model, run inference, done!&amp;rdquo;&lt;/em&gt;. None of them tell you what happens when you need to run this in production with real uptime, controlled cost, and consistent quality.&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/">/themakitachronicles</a>. This is part 3.</p>
<p>And make sure to subscribe to my new newsletter <a href="https://themakitachronicles.com/"target="_blank" rel="noopener">The M.Akita Chronicles</a>!</p>
<p>I&rsquo;ll talk about the podcast today; remember that the <a href="https://blog.themakitachronicles.com/podcast-transcripts/"target="_blank" rel="noopener">pilot episode is already live</a> (transcripts and RSS to subscribe) and on the channel on Spotify.</p>
<p>&ndash;</p>
<p>Every AI tutorial starts with <em>&ldquo;install the model, run inference, done!&rdquo;</em>. None of them tell you what happens when you need to run this in production with real uptime, controlled cost, and consistent quality.</p>
<p>I&rsquo;ll tell you how it was to put a TTS (Text-to-Speech) model into production on a GPU in the cloud. Because if you&rsquo;re thinking about serving AI models, you need to know what you&rsquo;re getting yourself into.</p>
<h2>The Scenario<span class="hx:absolute hx:-mt-20" id="the-scenario"></span>
    <a href="#the-scenario" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><blockquote>
  <p>The goal: generate a ~30-minute podcast from text, with two distinct voices, broadcast quality, ready for Spotify. Every week, automatically, with no human intervention.</p>

</blockquote>
<p>The chosen model: <a href="https://qwen.ai/blog?id=qwen3tts-0115"target="_blank" rel="noopener"><strong>Qwen3-TTS</strong></a>, a text-to-speech model that supports voice cloning and fine-tuning. It runs on GPU — needs at least 6GB of VRAM for inference.</p>
<p>The platform: <a href="https://runpod.io/"target="_blank" rel="noopener"><strong>RunPod</strong></a>, which offers serverless GPUs with billing by the second of usage.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-18_12-33-45.jpg" alt="runpod"  loading="lazy" /></p>
<p>Sounds simple, right? Well, hold on.</p>
<h2>Problem 1: Cold Start — The Silent Killer<span class="hx:absolute hx:-mt-20" id="problem-1-cold-start--the-silent-killer"></span>
    <a href="#problem-1-cold-start--the-silent-killer" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Serverless GPU works like this: when there&rsquo;s no request, the worker shuts down. When a request comes in, it boots up, loads the model into VRAM, and processes.</p>
<p>The time to &ldquo;load the model into VRAM&rdquo; is the <em>cold start</em>. For a ~4GB model, we&rsquo;re talking <strong>30 to 90 seconds</strong>. Your HTTP request sits there hanging, waiting. If your timeout is 30 seconds, you don&rsquo;t even get to see the model work — you just see a timeout.</p>
<p>The naive solution: <em>&ldquo;bump the timeout!&rdquo;</em>. The real solution: <strong>health check with polling</strong>.</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="nd">@app.get</span><span class="p">(</span><span class="s2">&#34;/health&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">async</span> <span class="k">def</span> <span class="nf">health</span><span class="p">():</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="ow">not</span> <span class="n">model_loaded</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="n">JSONResponse</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">            <span class="n">status_code</span><span class="o">=</span><span class="mi">503</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">            <span class="n">content</span><span class="o">=</span><span class="p">{</span><span class="s2">&#34;status&#34;</span><span class="p">:</span> <span class="s2">&#34;loading&#34;</span><span class="p">,</span> <span class="s2">&#34;detail&#34;</span><span class="p">:</span> <span class="s2">&#34;Model loading...&#34;</span><span class="p">}</span>
</span></span><span class="line"><span class="cl">        <span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="p">{</span><span class="s2">&#34;status&#34;</span><span class="p">:</span> <span class="s2">&#34;ready&#34;</span><span class="p">}</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The client (in this case, a Rails job) polls <code>/health</code> before sending the actual request:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">wait_for_server</span><span class="p">(</span><span class="ss">max_wait</span><span class="p">:</span> <span class="mi">180</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">deadline</span> <span class="o">=</span> <span class="no">Time</span><span class="o">.</span><span class="n">current</span> <span class="o">+</span> <span class="n">max_wait</span>
</span></span><span class="line"><span class="cl">  <span class="kp">loop</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">    <span class="n">response</span> <span class="o">=</span> <span class="n">http_get</span><span class="p">(</span><span class="s2">&#34;/health&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="k">if</span> <span class="n">response</span><span class="o">.</span><span class="n">code</span> <span class="o">==</span> <span class="mi">200</span>
</span></span><span class="line"><span class="cl">    <span class="k">raise</span> <span class="s2">&#34;Server não iniciou a tempo&#34;</span> <span class="k">if</span> <span class="no">Time</span><span class="o">.</span><span class="n">current</span> <span class="o">&gt;</span> <span class="n">deadline</span>
</span></span><span class="line"><span class="cl">    <span class="nb">sleep</span> <span class="mi">5</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>Sounds primitive? It is. But it works better than any sophisticated &ldquo;warm pool&rdquo; solution that costs 10x more.</p>
<h2>Problem 2: Where Do the Model Weights Live?<span class="hx:absolute hx:-mt-20" id="problem-2-where-do-the-model-weights-live"></span>
    <a href="#problem-2-where-do-the-model-weights-live" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>A 4GB model needs to be available when the worker starts. Three options:</p>
<ol>
<li><strong>Bake into Docker</strong>: 4GB+ image. Slow build. Slow push. Slow pull.</li>
<li><strong>Download from HuggingFace on boot</strong>: Network-dependent. Can fail. Adds minutes to the cold start.</li>
<li><strong>Network Volume</strong>: Persistent disk mounted on the worker.</li>
</ol>
<p>Option 3 is the correct one. RunPod offers Network Volumes that persist between runs. The model sits there, always ready. The cold start is just the time to load from disk to VRAM, no download.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-18_12-35-43.jpg" alt="network storage"  loading="lazy" /></p>
<p>But there&rsquo;s a catch: the volume may or may not be mounted, depending on how the worker is configured. So the setup code needs to be resilient:</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">NETWORK_VOLUME</span> <span class="o">=</span> <span class="s2">&#34;/runpod-volume&#34;</span>
</span></span><span class="line"><span class="cl"><span class="n">LOCAL_FALLBACK</span> <span class="o">=</span> <span class="s2">&#34;/workspace/models&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">resolve_model_path</span><span class="p">():</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">os</span><span class="o">.</span><span class="n">path</span><span class="o">.</span><span class="n">isdir</span><span class="p">(</span><span class="n">NETWORK_VOLUME</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="n">base</span> <span class="o">=</span> <span class="n">os</span><span class="o">.</span><span class="n">path</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="n">NETWORK_VOLUME</span><span class="p">,</span> <span class="s2">&#34;huggingface&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="n">os</span><span class="o">.</span><span class="n">environ</span><span class="p">[</span><span class="s2">&#34;HF_HOME&#34;</span><span class="p">]</span> <span class="o">=</span> <span class="n">base</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="n">os</span><span class="o">.</span><span class="n">path</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="n">base</span><span class="p">,</span> <span class="s2">&#34;hub&#34;</span><span class="p">,</span> <span class="s2">&#34;models--Qwen--Qwen3-TTS&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># Fallback: local download</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">LOCAL_FALLBACK</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>Auto-detection. If the volume exists, use it. If not, it works the same way (slower on the first boot). <strong>Never assume your infrastructure is perfect.</strong></p>
<h2>Problem 3: Two Voice Modes, Two Sets of Parameters<span class="hx:absolute hx:-mt-20" id="problem-3-two-voice-modes-two-sets-of-parameters"></span>
    <a href="#problem-3-two-voice-modes-two-sets-of-parameters" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Here&rsquo;s something no documentation will tell you: TTS models with voice cloning and fine-tuning need <strong>completely different sampling parameters</strong> depending on the mode.</p>
<p>With a fine-tuned voice (trained specifically on the model), you want conservative parameters:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>temperature: 0.5
top_p: 0.8
repetition_penalty: 1.1</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>With voice cloning (base voice + reference audio), the same parameters produce <strong>white noise</strong>. Literally. White noise. Because the clone model needs more &ldquo;creativity&rdquo; to reconstruct the vocal characteristics:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>temperature: 0.9
top_p: 0.95
repetition_penalty: 1.05</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>I discovered this after hours generating unusable audio. The model&rsquo;s documentation doesn&rsquo;t mention it. The academic papers don&rsquo;t mention it. It&rsquo;s the kind of knowledge that only comes from <strong>experimentation in production</strong>.</p>
<h2>Problem 4: The Reference Audio Matters More Than the Model<span class="hx:absolute hx:-mt-20" id="problem-4-the-reference-audio-matters-more-than-the-model"></span>
    <a href="#problem-4-the-reference-audio-matters-more-than-the-model" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Voice cloning uses an audio clip as a reference to replicate the voice. Sounds simple: record 10 seconds, feed it to the model, done.</p>
<p>Except if the reference audio starts with speech immediately — without even 200 milliseconds of silence at the beginning — the model learns that &ldquo;the audio starts with no onset&rdquo;. And <strong>every subsequent generation</strong> will clip the first syllable.</p>
<p>This is not a model bug. It&rsquo;s the model doing exactly what you asked for: <em>&ldquo;generate audio that starts just like this reference&rdquo;</em>. If the reference starts clipped, the generation starts clipped.</p>
<p>The fix is not post-processing (the audio has already been generated without the syllable). It&rsquo;s <strong>fixing the reference</strong>: ensuring at least 300ms of silence before the speech.</p>
<p>For those coming from the software world, this is counterintuitive. We&rsquo;re used to treating input data as immutable and fixing things on the output. With generative models, input quality is <strong>everything</strong>.</p>
<h2>Problem 5: Timeout and Generation Limits<span class="hx:absolute hx:-mt-20" id="problem-5-timeout-and-generation-limits"></span>
    <a href="#problem-5-timeout-and-generation-limits" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>A TTS model generates audio token by token. If you don&rsquo;t cap it, it can generate indefinitely — and on RunPod, you pay by the second of GPU. A 30-word turn of dialogue shouldn&rsquo;t generate 3 minutes of audio, but without a limit, it can.</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">MAX_NEW_TOKENS</span> <span class="o">=</span> <span class="mi">720</span>  <span class="c1"># ~60 seconds of audio at 12Hz</span>
</span></span><span class="line"><span class="cl"><span class="n">GENERATION_TIMEOUT</span> <span class="o">=</span> <span class="mi">180</span>  <span class="c1"># 3 absolute minutes</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">ThreadPoolExecutor</span><span class="p">(</span><span class="n">max_workers</span><span class="o">=</span><span class="mi">1</span><span class="p">)</span> <span class="k">as</span> <span class="n">executor</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="n">future</span> <span class="o">=</span> <span class="n">executor</span><span class="o">.</span><span class="n">submit</span><span class="p">(</span><span class="n">model</span><span class="o">.</span><span class="n">generate</span><span class="p">,</span> <span class="o">**</span><span class="n">params</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">result</span> <span class="o">=</span> <span class="n">future</span><span class="o">.</span><span class="n">result</span><span class="p">(</span><span class="n">timeout</span><span class="o">=</span><span class="n">GENERATION_TIMEOUT</span><span class="p">)</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Double protection: token limit on generation and absolute timeout on the process. If either one trips, the turn fails and can be retried with adjusted parameters.</p>
<h2>Problem 6: Post-Processing Is Half the Work<span class="hx:absolute hx:-mt-20" id="problem-6-post-processing-is-half-the-work"></span>
    <a href="#problem-6-post-processing-is-half-the-work" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The model generates raw WAV. To get to podcast-quality audio, the path is long:</p>
<ol>
<li><strong>Loudness normalization</strong>: Spotify requires -14 LUFS. Without normalization, every turn has a different volume.</li>
<li><strong>Re-encoding</strong>: The model&rsquo;s WAVs can have subtly different formats (sample rate, bit depth). Concatenating with <code>-c copy</code> causes <strong>silent segments</strong>. You need to re-encode everything to the same format.</li>
<li><strong>Conversion to MP3</strong>: CBR 192kbps, 44.1kHz, with ID3 metadata.</li>
</ol>
<p>And loudness normalization needs to be <strong>two-pass</strong>: first measure, then apply. Single-pass is a guess — and guessing in production generates audio with inconsistent volume across episodes.</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"># Pass 1: analysis</span>
</span></span><span class="line"><span class="cl">ffmpeg -i input.wav -af <span class="nv">loudnorm</span><span class="o">=</span><span class="nv">I</span><span class="o">=</span>-14:print_format<span class="o">=</span>json -f null -
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Pass 2: applying the measured values</span>
</span></span><span class="line"><span class="cl">ffmpeg -i input.wav -af <span class="nv">loudnorm</span><span class="o">=</span><span class="nv">I</span><span class="o">=</span>-14:measured_I<span class="o">=</span>-18.5:measured_TP<span class="o">=</span>-2.1:... output.wav</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>All of this runs on the server, after TTS generation. And it needs <code>ffmpeg</code> installed in the container — one more dependency to manage.</p>
<h2>Problem 7: The Script Matters More Than the Voice Model<span class="hx:absolute hx:-mt-20" id="problem-7-the-script-matters-more-than-the-voice-model"></span>
    <a href="#problem-7-the-script-matters-more-than-the-voice-model" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>After solving GPU, cold start, voice cloning, sampling, normalization — the podcast comes out. And it sounds&hellip; robotic. Not because of the TTS. The TTS is excellent. It sounds robotic because the <strong>text</strong> is robotic.</p>
<p>This is the problem nobody mentions in TTS pipelines: audio quality starts in the script, not in the voice model. A perfect TTS reading a dry, formal text produces dry, formal audio. Garbage in, garbage out — but at a level you wouldn&rsquo;t expect.</p>
<h3>Two Passes: Draft and Refine<span class="hx:absolute hx:-mt-20" id="two-passes-draft-and-refine"></span>
    <a href="#two-passes-draft-and-refine" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>The solution is to generate the script in two LLM passes:</p>
<p><strong>Pass 1 (Draft)</strong>: Turns the newsletter into dialogue. Here the LLM gets the full content and creates the structure — who says what, in what order, with what transitions. <strong>Without changing the content, only the form!</strong></p>
<p><strong>Pass 2 (Refine)</strong>: Takes the draft and makes it natural. Adds hesitations, reactions, interruptions, self-corrections. Turns &ldquo;read script&rdquo; into &ldquo;real conversation&rdquo;.</p>
<p>Why not do it all in a single pass? Because these are cognitively different tasks. Structuring content and adding naturalness at the same time results in a text that does neither properly. Two specialized passes consistently produce a better result.</p>
<h3>Personality Needs Brutally Specific Instructions<span class="hx:absolute hx:-mt-20" id="personality-needs-brutally-specific-instructions"></span>
    <a href="#personality-needs-brutally-specific-instructions" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>&ldquo;Make Akita sound confident&rdquo; doesn&rsquo;t work. The LLM will produce generic confidence — motivational phrases, positive tone. That&rsquo;s not it. Akita is confident because he&rsquo;s <strong>assertive and impatient</strong>. The difference is in the prompt details:</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"><span class="gu">## Akita Delivery Style
</span></span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">-</span> **Declarative, not tentative**: &#34;Isso é óbvio&#34; not &#34;Eu acho que talvez isso seja...&#34;
</span></span><span class="line"><span class="cl"><span class="k">-</span> **Dismissive of nonsense**: &#34;Isso é bobagem&#34;, &#34;Não tem nenhum mistério aqui&#34;
</span></span><span class="line"><span class="cl"><span class="k">-</span> **Owns his track record**: &#34;Eu avisei&#34;, &#34;Quem assistiu meu canal já sabe&#34;
</span></span><span class="line"><span class="cl"><span class="k">-</span> **Never hedges**: No &#34;talvez&#34;, &#34;quem sabe&#34;, &#34;pode ser que&#34;
</span></span><span class="line"><span class="cl">- <span class="gs">**Impatient with the obvious**</span>: &#34;Vocês já sabem como funciona&#34;</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>And for Marvin (the robot co-host):</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"><span class="k">-</span> **Filler words** (Marvin only): &#34;bom...&#34;, &#34;olha...&#34;, &#34;enfim&#34;, &#34;pois é&#34;
</span></span><span class="line"><span class="cl"><span class="k">-</span> **Self-corrections** (Marvin only): &#34;quer dizer...&#34;, &#34;ou melhor...&#34;
</span></span><span class="line"><span class="cl"><span class="k">-</span> Akita doesn&#39;t second-guess himself — if he corrects, it&#39;s
</span></span><span class="line"><span class="cl">  &#34;Não, peraí&#34; and he restates with even MORE conviction</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Without this level of specificity, both characters sound the same. The LLM needs concrete examples of <strong>how</strong> each one talks, not abstract personality descriptions.</p>
<h3>Normalization for Speech: What Looks Right in Text Sounds Wrong in Audio<span class="hx:absolute hx:-mt-20" id="normalization-for-speech-what-looks-right-in-text-sounds-wrong-in-audio"></span>
    <a href="#normalization-for-speech-what-looks-right-in-text-sounds-wrong-in-audio" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Acronyms. The invisible enemy of TTS. &ldquo;EUA&rdquo; in text is perfectly readable. Spoken by TTS, it comes out &ldquo;éu-á&rdquo; — incomprehensible. The prompt needs explicit normalization rules:</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"><span class="gu">## Spoken Text Normalization (MANDATORY)
</span></span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">-</span> <span class="sb">`EUA`</span> → <span class="sb">`Estados Unidos`</span>
</span></span><span class="line"><span class="cl"><span class="k">-</span> <span class="sb">`UE`</span> → <span class="sb">`União Europeia`</span>
</span></span><span class="line"><span class="cl"><span class="k">-</span> <span class="sb">`IA`</span> → <span class="sb">`inteligência artificial`</span>
</span></span><span class="line"><span class="cl"><span class="k">-</span> <span class="sb">`LLM`</span> → <span class="sb">`modelo de linguagem`</span>
</span></span><span class="line"><span class="cl">- <span class="sb">`API`</span> → <span class="sb">`interface de programaçã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>This needs to be in the prompt for <strong>both</strong> passes (draft and refine), because the LLM tends to reintroduce acronyms it removed in the previous pass.</p>
<h3>The LLM Will Rewrite Your Opinions (If You Let It)<span class="hx:absolute hx:-mt-20" id="the-llm-will-rewrite-your-opinions-if-you-let-it"></span>
    <a href="#the-llm-will-rewrite-your-opinions-if-you-let-it" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>This was the most subtle lesson. The newsletter has opinion commentary from Akita and Marvin in each section. When the LLM turns it into dialogue, it <strong>substitutes</strong> the opinions with more &ldquo;balanced&rdquo; and &ldquo;nuanced&rdquo; versions. Akita, who said &ldquo;isso é bobagem&rdquo;, becomes Akita saying &ldquo;isso tem seus méritos, mas&hellip;&rdquo;. Pessimistic Marvin becomes &ldquo;mildly skeptical&rdquo; Marvin.</p>
<p>The LLM does this because it was trained to be helpful and balanced. Strong opinions seem &ldquo;unhelpful&rdquo; to the model, so it softens them. The original instruction — &ldquo;use the newsletter comments as seeds for the conversation&rdquo; — was too vague. It gave the model freedom to reinterpret.</p>
<p>The fix is to be explicit and inflexible:</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">**PRESERVE THE ORIGINAL OPINIONS**: The newsletter contains Akita and
</span></span><span class="line"><span class="cl">Marvin commentary on each topic. These opinions are the author&#39;s real
</span></span><span class="line"><span class="cl">voice — you MUST preserve their substance, stance, and conclusions.
</span></span><span class="line"><span class="cl">Make the delivery conversational, but do NOT change what they actually
</span></span><span class="line"><span class="cl">think. If Akita&#39;s comment says &#34;isso é bobagem&#34;, the podcast version
</span></span><span class="line"><span class="cl">must convey the same dismissal. Rephrase for spoken delivery, but
</span></span><span class="line"><span class="cl">never substitute a different opinion.</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>It&rsquo;s the difference between &ldquo;adapt it for conversation&rdquo; (LLM understands: &ldquo;rewrite however you want&rdquo;) and &ldquo;change the HOW, never the WHAT&rdquo; (LLM understands: &ldquo;keep the opinion, swap the packaging&rdquo;).</p>
<p>And this applies to any prompt engineering with authored content: <strong>the LLM will homogenize your voice if you&rsquo;re not explicit about preserving it</strong>. And in a podcast where the hosts&rsquo; personality IS the product, losing the voice means losing everything.</p>
<h2>The Real Cost of AI in Production<span class="hx:absolute hx:-mt-20" id="the-real-cost-of-ai-in-production"></span>
    <a href="#the-real-cost-of-ai-in-production" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Let&rsquo;s get practical about costs on RunPod:</p>
<table>
  <thead>
      <tr>
          <th>Resource</th>
          <th>Cost</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>GPU L40 (serverless)</td>
          <td>~$0.39/hour</td>
      </tr>
      <tr>
          <td>Network Volume 20GB</td>
          <td>$1.40/month</td>
      </tr>
      <tr>
          <td>Generation of 1 episode (~30 min audio)</td>
          <td>~$0.50–1.50</td>
      </tr>
  </tbody>
</table>
<p>The cost per episode is low. The cost of <strong>figuring out how to make it work</strong> is high. It took dozens of hours of experimentation, incomprehensible CUDA logs, failed generations, and unusable-quality audio before landing on a stable pipeline.</p>
<p>And the cognitive cost: keeping two ecosystems (Python for ML, Ruby for the application) in sync, with HTTP interfaces between them, and debugging that crosses language boundaries.</p>
<p>Anyway, for those who thought <em>&ldquo;Why not Elevenlabs?&rdquo;</em>, well, I got similar quality at a fraction of the cost, and I learned a lot in the process (and so did you, reading this).</p>
<h2>Lessons<span class="hx:absolute hx:-mt-20" id="lessons"></span>
    <a href="#lessons" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><ol>
<li>
<p><strong>Cold start is a requirement, not a bug.</strong> Design your system expecting that the model will take time to load.</p>
</li>
<li>
<p><strong>Network volumes are mandatory</strong> for large models. Don&rsquo;t bake weights into Docker, don&rsquo;t download on boot.</p>
</li>
<li>
<p><strong>Sampling parameters are not universal.</strong> Each operation mode of the same model may need completely different configuration.</p>
</li>
<li>
<p><strong>Input quality dominates output quality.</strong> Both for the TTS reference audio and for the script. If the reference clips the first syllable, every generation clips. If the script is generic, the audio is generic.</p>
</li>
<li>
<p><strong>Timeouts at every layer.</strong> Token limit, process timeout, HTTP timeout. If you don&rsquo;t cap it, cost scales and generation hangs.</p>
</li>
<li>
<p><strong>Post-processing is half the pipeline.</strong> The model generates raw material. Normalization, encoding, metadata — all of that is real work.</p>
</li>
<li>
<p><strong>Separate the ML server from the application.</strong> Python for inference, Ruby for orchestration. Trying to do everything in one language is a recipe for frustration.</p>
</li>
<li>
<p><strong>Prompts need two passes for complex content.</strong> Structuring and naturalizing are different tasks. Forcing both into one pass produces mediocre results in both.</p>
</li>
<li>
<p><strong>The LLM will homogenize your voice.</strong> Strong opinions turn into &ldquo;nuanced takes&rdquo; if you&rsquo;re not explicit about preserving them. Instruct the model to change the packaging, never the content.</p>
</li>
</ol>
<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>AI in production is not a 10-line HuggingFace tutorial. It&rsquo;s infrastructure, it&rsquo;s experimentation, it&rsquo;s debugging problems that don&rsquo;t exist in traditional software. But when it works — when your system generates an entire podcast automatically every week, with broadcast quality, no human intervention — the feeling is that it was worth every hour of headache.</p>
<p>The trick is not to underestimate the complexity. And to have a robust system (like the Rails jobs I mentioned in the first post) orchestrating it all — because AI without reliable orchestration is just an expensive script that hangs.</p>
]]></content:encoded><category>the-m-akita-chronicles</category><category>generative-media</category><category>cloud</category></item><item><title>Web Scraping in 2026 | Behind the Scenes of The M.Akita Chronicles</title><link>https://akitaonrails.github.io/en/2026/02/18/web-scraping-in-2026-behind-the-m-akita-chronicles/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2026/02/18/web-scraping-in-2026-behind-the-m-akita-chronicles/</guid><pubDate>Wed, 18 Feb 2026 14:57:22 +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;/themakitachronicles&lt;/a&gt;. This is part 2.&lt;/p&gt;
&lt;p&gt;And don&amp;rsquo;t forget to 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;Ten years ago, fetching data from a website was trivial. Four lines of Ruby: &lt;code&gt;Net::HTTP.get&lt;/code&gt;, Nokogiri parse, CSS selector, done. The whole world was open to any script with a decent User-Agent.&lt;/p&gt;
&lt;p&gt;Today? Half the sites I need to read to put together my newsletter return &amp;ldquo;Are you a robot?&amp;rdquo; for any conventional HTTP request. The other half renders the content entirely in JavaScript — there&amp;rsquo;s no HTML at all in the server response. And the most paranoid ones combine both.&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/">/themakitachronicles</a>. This is part 2.</p>
<p>And don&rsquo;t forget to subscribe to my new newsletter <a href="https://themakitachronicles.com/"target="_blank" rel="noopener">The M.Akita Chronicles</a>!</p>
<p>&ndash;</p>
<p>Ten years ago, fetching data from a website was trivial. Four lines of Ruby: <code>Net::HTTP.get</code>, Nokogiri parse, CSS selector, done. The whole world was open to any script with a decent User-Agent.</p>
<p>Today? Half the sites I need to read to put together my newsletter return &ldquo;Are you a robot?&rdquo; for any conventional HTTP request. The other half renders the content entirely in JavaScript — there&rsquo;s no HTML at all in the server response. And the most paranoid ones combine both.</p>
<p>My goal was to create a daily routine so that M.arvin would deliver the top news to me in the morning on my Discord. That way I wouldn&rsquo;t have to go hunting for the news of the day. Sounded simple&hellip; at first.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-18_11-59-42.jpg" alt="daily routine"  loading="lazy" /></p>
<p>This post is about how I went from &ldquo;one HTTP call&rdquo; to &ldquo;packing an entire Chromium into Docker&rdquo; — and why this is the reality of any project that needs to read the web programmatically in 2026.</p>
<h2>The Golden Age: Simple HTTP<span class="hx:absolute hx:-mt-20" id="the-golden-age-simple-http"></span>
    <a href="#the-golden-age-simple-http" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Let&rsquo;s start with what still works. Some services offer structured data with zero resistance:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="c1"># RSS feeds: the most resilient format on the web</span>
</span></span><span class="line"><span class="cl"><span class="n">uri</span> <span class="o">=</span> <span class="no">URI</span><span class="p">(</span><span class="s2">&#34;https://news.google.com/rss/search?q=when:24h+allinurl:reuters.com/world&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">xml</span> <span class="o">=</span> <span class="no">Net</span><span class="o">::</span><span class="no">HTTP</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="n">uri</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">doc</span> <span class="o">=</span> <span class="no">REXML</span><span class="o">::</span><span class="no">Document</span><span class="o">.</span><span class="n">new</span><span class="p">(</span><span class="n">xml</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">doc</span><span class="o">.</span><span class="n">get_elements</span><span class="p">(</span><span class="s2">&#34;//item&#34;</span><span class="p">)</span><span class="o">.</span><span class="n">each</span> <span class="k">do</span> <span class="o">|</span><span class="n">item</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">  <span class="n">title</span> <span class="o">=</span> <span class="n">item</span><span class="o">.</span><span class="n">get_text</span><span class="p">(</span><span class="s2">&#34;title&#34;</span><span class="p">)</span><span class="o">.</span><span class="n">to_s</span>
</span></span><span class="line"><span class="cl">  <span class="n">url</span>   <span class="o">=</span> <span class="n">item</span><span class="o">.</span><span class="n">get_text</span><span class="p">(</span><span class="s2">&#34;link&#34;</span><span class="p">)</span><span class="o">.</span><span class="n">to_s</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Public APIs: clean JSON, documented, stable</span>
</span></span><span class="line"><span class="cl"><span class="n">response</span> <span class="o">=</span> <span class="no">HTTParty</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;https://api.jikan.moe/v4/top/anime?filter=airing&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">animes</span> <span class="o">=</span> <span class="n">response</span><span class="o">.</span><span class="n">parsed_response</span><span class="o">[</span><span class="s2">&#34;data&#34;</span><span class="o">]</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># GitHub trending: server-rendered HTML with no protection</span>
</span></span><span class="line"><span class="cl"><span class="n">html</span> <span class="o">=</span> <span class="no">HTTParty</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;https://github.com/trending&#34;</span><span class="p">)</span><span class="o">.</span><span class="n">body</span>
</span></span><span class="line"><span class="cl"><span class="n">doc</span> <span class="o">=</span> <span class="no">Nokogiri</span><span class="o">::</span><span class="no">HTML</span><span class="p">(</span><span class="n">html</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">repos</span> <span class="o">=</span> <span class="n">doc</span><span class="o">.</span><span class="n">css</span><span class="p">(</span><span class="s2">&#34;article.Box-row&#34;</span><span class="p">)</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Hacker News, GitHub Trending, anime APIs, RSS feeds, Project Gutenberg — they all work with a <code>GET</code> and a parser. No JavaScript, no implicit authentication, no fingerprinting. Just like the web was supposed to be.</p>
<p>The problem is that these are the exception. Sites with financial data, exclusive news, or premium content — exactly the data that matters most — are increasingly armored.</p>
<h2>The First Obstacle: SPAs and JavaScript Rendering<span class="hx:absolute hx:-mt-20" id="the-first-obstacle-spas-and-javascript-rendering"></span>
    <a href="#the-first-obstacle-spas-and-javascript-rendering" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>When the server returns something like:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="cl"><span class="p">&lt;</span><span class="nt">html</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl"><span class="p">&lt;</span><span class="nt">body</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl">  <span class="p">&lt;</span><span class="nt">div</span> <span class="na">id</span><span class="o">=</span><span class="s">&#34;root&#34;</span><span class="p">&gt;&lt;/</span><span class="nt">div</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl">  <span class="p">&lt;</span><span class="nt">script</span> <span class="na">src</span><span class="o">=</span><span class="s">&#34;/app-3fa8c2.js&#34;</span><span class="p">&gt;&lt;/</span><span class="nt">script</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl"><span class="p">&lt;/</span><span class="nt">body</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl"><span class="p">&lt;/</span><span class="nt">html</span><span class="p">&gt;</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>Your <code>Net::HTTP.get</code> returns exactly that: an empty div and a link to a 2 MB bundle. The real content — articles, prices, lists — is assembled in the browser by React, Vue, Angular, or the framework of the week.</p>
<p>For a script, this page is useless. To extract the content, you need to execute the JavaScript. That means running a real browser.</p>
<p>In the Ruby world, the answer is the <code>ferrum</code> gem — a CDP (Chrome DevTools Protocol) client that controls Chromium programmatically:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="n">browser</span> <span class="o">=</span> <span class="no">Ferrum</span><span class="o">::</span><span class="no">Browser</span><span class="o">.</span><span class="n">new</span><span class="p">(</span><span class="ss">headless</span><span class="p">:</span> <span class="s2">&#34;new&#34;</span><span class="p">,</span> <span class="ss">timeout</span><span class="p">:</span> <span class="mi">30</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">browser</span><span class="o">.</span><span class="n">go_to</span><span class="p">(</span><span class="s2">&#34;https://www.morningstar.com/markets&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">browser</span><span class="o">.</span><span class="n">network</span><span class="o">.</span><span class="n">wait_for_idle</span>  <span class="c1"># wait for JS to finish loading</span>
</span></span><span class="line"><span class="cl"><span class="n">html</span> <span class="o">=</span> <span class="n">browser</span><span class="o">.</span><span class="n">body</span>  <span class="c1"># now the DOM is populated</span>
</span></span><span class="line"><span class="cl"><span class="n">browser</span><span class="o">.</span><span class="n">quit</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>That <code>browser.body</code> returns the HTML <strong>after</strong> the JavaScript has executed. The same data you see in the browser, now accessible to your parser.</p>
<p>But here&rsquo;s where the headache begins: you need a Chromium binary available on the system. In Docker, that means adding ~300MB to the image. On the server, that&rsquo;s one more dependency to manage. And the latency of opening a browser, navigating, waiting for JS to render, and extracting the HTML — we&rsquo;re talking 3 to 10 seconds per page, instead of the 200ms of an HTTP GET.</p>
<p>The project uses this as the main strategy in <code>ArticleFetcher</code>: first it tries with a browser (to catch SPAs), then falls back to simple HTTP (to grab OpenGraph metadata when the browser fails). Most of the links that readers send in Discord are from sites that render with JavaScript, so browser-first pays off.</p>
<h2>The Second Obstacle: Bot Detection<span class="hx:absolute hx:-mt-20" id="the-second-obstacle-bot-detection"></span>
    <a href="#the-second-obstacle-bot-detection" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Opening a page with headless Chromium solves the JavaScript problem. But it solves 50% of the sites — not 100%. Because there&rsquo;s an entire industry dedicated to distinguishing real browsers from automated browsers.</p>
<h3>What Headless Chrome Reveals<span class="hx:absolute hx:-mt-20" id="what-headless-chrome-reveals"></span>
    <a href="#what-headless-chrome-reveals" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Chromium in headless mode is almost identical to regular Chrome. Almost. The differences are subtle, but enough for detection:</p>
<p><strong>1. <code>navigator.webdriver</code></strong> — In any Chrome controlled by automation, this property returns <code>true</code>. In normal browsers, <code>undefined</code>. It&rsquo;s the most obvious flag.</p>
<p><strong>2. <code>navigator.plugins</code></strong> — Normal Chrome has 3-5 plugins (PDF viewer, etc.). Headless returns an empty array. Bot detected.</p>
<p><strong>3. <code>navigator.userAgentData</code></strong> — The Client Hints API. Real Chrome returns brands, platform, mobile flag. Headless may not have that.</p>
<p><strong>4. WebGL fingerprint</strong> — The WebGL renderer in a headless Chrome returns &ldquo;SwiftShader&rdquo; (software renderer). Real Chrome returns &ldquo;NVIDIA GeForce&rdquo; or &ldquo;Intel Iris&rdquo; or whatever. Bot detected.</p>
<p><strong>5. Timing and behavior</strong> — Real browsers take time to load. They scroll. They move the mouse. They have focus events. A script that does <code>go_to</code> → <code>body</code> in 2 seconds doesn&rsquo;t look like a human.</p>
<p>The solution is what I call StealthBrowser — a wrapper that injects JavaScript patches before any script on the page runs:</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">StealthBrowser</span>
</span></span><span class="line"><span class="cl">  <span class="no">STEALTH_JS</span> <span class="o">=</span> <span class="o">&lt;&lt;~</span><span class="no">JS</span>
</span></span><span class="line"><span class="cl">    <span class="sr">//</span> <span class="no">Remove</span> <span class="n">the</span> <span class="n">automation</span> <span class="n">flag</span>
</span></span><span class="line"><span class="cl">    <span class="no">Object</span><span class="o">.</span><span class="n">defineProperty</span><span class="p">(</span><span class="n">navigator</span><span class="p">,</span> <span class="s1">&#39;webdriver&#39;</span><span class="p">,</span> <span class="p">{</span> <span class="ss">get</span><span class="p">:</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="kp">false</span> <span class="p">});</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="sr">//</span> <span class="no">Simulate</span> <span class="n">real</span> <span class="n">plugins</span>
</span></span><span class="line"><span class="cl">    <span class="no">Object</span><span class="o">.</span><span class="n">defineProperty</span><span class="p">(</span><span class="n">navigator</span><span class="p">,</span> <span class="s1">&#39;plugins&#39;</span><span class="p">,</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="ss">get</span><span class="p">:</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="o">[</span>
</span></span><span class="line"><span class="cl">        <span class="p">{</span> <span class="nb">name</span><span class="p">:</span> <span class="s1">&#39;Chrome PDF Plugin&#39;</span><span class="p">,</span> <span class="ss">filename</span><span class="p">:</span> <span class="s1">&#39;internal-pdf-viewer&#39;</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">        <span class="p">{</span> <span class="nb">name</span><span class="p">:</span> <span class="s1">&#39;Chrome PDF Viewer&#39;</span><span class="p">,</span> <span class="ss">filename</span><span class="p">:</span> <span class="s1">&#39;mhjfbmdgcfjbbpaeojofohoefgiehjai&#39;</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">        <span class="p">{</span> <span class="nb">name</span><span class="p">:</span> <span class="s1">&#39;Native Client&#39;</span><span class="p">,</span> <span class="ss">filename</span><span class="p">:</span> <span class="s1">&#39;internal-nacl-plugin&#39;</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">      <span class="o">]</span>
</span></span><span class="line"><span class="cl">    <span class="p">});</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="sr">//</span> <span class="no">Simulate</span> <span class="n">a</span> <span class="n">real</span> <span class="no">WebGL</span> <span class="n">renderer</span>
</span></span><span class="line"><span class="cl">    <span class="n">const</span> <span class="n">getParameter</span> <span class="o">=</span> <span class="no">WebGLRenderingContext</span><span class="o">.</span><span class="n">prototype</span><span class="o">.</span><span class="n">getParameter</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="no">WebGLRenderingContext</span><span class="o">.</span><span class="n">prototype</span><span class="o">.</span><span class="n">getParameter</span> <span class="o">=</span> <span class="n">function</span><span class="p">(</span><span class="nb">p</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="k">if</span> <span class="p">(</span><span class="nb">p</span> <span class="o">===</span> <span class="mi">37445</span><span class="p">)</span> <span class="k">return</span> <span class="s1">&#39;Google Inc. (NVIDIA)&#39;</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">      <span class="k">if</span> <span class="p">(</span><span class="nb">p</span> <span class="o">===</span> <span class="mi">37446</span><span class="p">)</span> <span class="k">return</span> <span class="s1">&#39;ANGLE (NVIDIA, GeForce GTX 1080 ...)&#39;</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">      <span class="k">return</span> <span class="n">getParameter</span><span class="o">.</span><span class="n">call</span><span class="p">(</span><span class="n">this</span><span class="p">,</span> <span class="nb">p</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">    <span class="p">};</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="sr">//</span> <span class="no">Client</span> <span class="no">Hints</span> <span class="no">API</span> <span class="p">(</span><span class="n">absent</span> <span class="k">in</span> <span class="n">headless</span> <span class="o">=</span> <span class="n">bot</span> <span class="n">signal</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="n">navigator</span><span class="o">.</span><span class="n">userAgentData</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="no">Object</span><span class="o">.</span><span class="n">defineProperty</span><span class="p">(</span><span class="n">navigator</span><span class="p">,</span> <span class="s1">&#39;userAgentData&#39;</span><span class="p">,</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="ss">get</span><span class="p">:</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="p">({</span>
</span></span><span class="line"><span class="cl">          <span class="ss">brands</span><span class="p">:</span> <span class="o">[</span>
</span></span><span class="line"><span class="cl">            <span class="p">{</span> <span class="ss">brand</span><span class="p">:</span> <span class="s1">&#39;Chromium&#39;</span><span class="p">,</span> <span class="ss">version</span><span class="p">:</span> <span class="s1">&#39;131&#39;</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">            <span class="p">{</span> <span class="ss">brand</span><span class="p">:</span> <span class="s1">&#39;Google Chrome&#39;</span><span class="p">,</span> <span class="ss">version</span><span class="p">:</span> <span class="s1">&#39;131&#39;</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">          <span class="o">]</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">          <span class="ss">mobile</span><span class="p">:</span> <span class="kp">false</span><span class="p">,</span> <span class="ss">platform</span><span class="p">:</span> <span class="s1">&#39;Linux&#39;</span>
</span></span><span class="line"><span class="cl">        <span class="p">})</span>
</span></span><span class="line"><span class="cl">      <span class="p">});</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="no">JS</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">fetch</span><span class="p">(</span><span class="n">url</span><span class="p">,</span> <span class="nb">name</span><span class="p">:,</span> <span class="ss">wait_for</span><span class="p">:</span> <span class="kp">nil</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">browser</span> <span class="o">=</span> <span class="no">Ferrum</span><span class="o">::</span><span class="no">Browser</span><span class="o">.</span><span class="n">new</span><span class="p">(</span><span class="ss">headless</span><span class="p">:</span> <span class="s2">&#34;new&#34;</span><span class="p">,</span> <span class="ss">timeout</span><span class="p">:</span> <span class="mi">30</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="ss">browser_options</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;disable-blink-features&#34;</span> <span class="o">=&gt;</span> <span class="s2">&#34;AutomationControlled&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;user-agent&#34;</span> <span class="o">=&gt;</span> <span class="no">REAL_USER_AGENT</span>
</span></span><span class="line"><span class="cl">      <span class="p">})</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c1"># Inject BEFORE any script on the page runs</span>
</span></span><span class="line"><span class="cl">    <span class="n">browser</span><span class="o">.</span><span class="n">page</span><span class="o">.</span><span class="n">command</span><span class="p">(</span><span class="s2">&#34;Page.addScriptToEvaluateOnNewDocument&#34;</span><span class="p">,</span> <span class="ss">source</span><span class="p">:</span> <span class="no">STEALTH_JS</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">browser</span><span class="o">.</span><span class="n">go_to</span><span class="p">(</span><span class="n">url</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">yield</span> <span class="n">browser</span>
</span></span><span class="line"><span class="cl">  <span class="k">ensure</span>
</span></span><span class="line"><span class="cl">    <span class="n">browser</span><span class="o">&amp;.</span><span class="n">quit</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The key trick is <code>Page.addScriptToEvaluateOnNewDocument</code> — a CDP command that registers JavaScript to be executed <strong>before</strong> any script on the page. That means when the bot detector runs, it sees the fake values we injected, not the real ones. And <code>disable-blink-features=AutomationControlled</code> removes the <code>navigator.webdriver</code> flag at the browser level, even before JavaScript.</p>
<p>This works for most sites. Morningstar, Hindenburg Research, generic news sites — they pass with the stealth. Yahoo Finance needs more — TLS fingerprinting that blocks even Chromium without crumb authentication. But the stealth browser handles the bulk.</p>
<h2>The Third Obstacle: DataDome and Anti-Bot CDPs<span class="hx:absolute hx:-mt-20" id="the-third-obstacle-datadome-and-anti-bot-cdps"></span>
    <a href="#the-third-obstacle-datadome-and-anti-bot-cdps" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>And now we get to the pros. <strong>DataDome</strong>, Cloudflare Turnstile, PerimeterX, Akamai Bot Manager — services specialized in detecting automation. And they play at a completely different level.</p>
<p>Reuters, for example, uses DataDome. When I tried to hit <code>reuters.com/world/</code> with StealthBrowser, I got back a 1,500-byte page with a JavaScript captcha. All the <code>navigator.webdriver</code>, WebGL, plugin patches — useless.</p>
<p>Why? Because DataDome doesn&rsquo;t just check JavaScript properties. It detects the <strong>automation protocol itself</strong>.</p>
<h3>The Fundamental Problem with CDP<span class="hx:absolute hx:-mt-20" id="the-fundamental-problem-with-cdp"></span>
    <a href="#the-fundamental-problem-with-cdp" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>When <strong>Ferrum</strong> (or Puppeteer, or Playwright) controls a Chrome, it does so via the Chrome DevTools Protocol — CDP. And the first thing Ferrum does when connecting is send <code>Runtime.enable</code> — a command that enables runtime evaluation in the browser.</p>
<p>The problem is that <code>Runtime.enable</code> changes the observable behavior of the browser. Specifically, it activates internal interceptors that can be detected via <code>console.debug</code>. DataDome uses this: it does a <code>console.debug</code> with a getter that watches whether the runtime is intercepting. If it is — and with CDP it always is — bot detected.</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-javascript" data-lang="javascript"><span class="line"><span class="cl"><span class="c1">// What DataDome does (simplified):
</span></span></span><span class="line"><span class="cl"><span class="kd">let</span> <span class="nx">detected</span> <span class="o">=</span> <span class="kc">false</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">el</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nx">createElement</span><span class="p">(</span><span class="s1">&#39;div&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="nb">Object</span><span class="p">.</span><span class="nx">defineProperty</span><span class="p">(</span><span class="nx">el</span><span class="p">,</span> <span class="s1">&#39;id&#39;</span><span class="p">,</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nx">get</span><span class="o">:</span> <span class="p">()</span> <span class="p">=&gt;</span> <span class="p">{</span> <span class="nx">detected</span> <span class="o">=</span> <span class="kc">true</span><span class="p">;</span> <span class="k">return</span> <span class="s1">&#39;test&#39;</span><span class="p">;</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">});</span>
</span></span><span class="line"><span class="cl"><span class="nx">console</span><span class="p">.</span><span class="nx">debug</span><span class="p">(</span><span class="nx">el</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="c1">// If Runtime.enable is active, the getter is triggered
</span></span></span><span class="line"><span class="cl"><span class="c1">// In a normal browser, it isn&#39;t
</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>This is not something you can patch with JavaScript. <code>Runtime.enable</code> is sent by Ferrum <strong>before</strong> any script runs — it&rsquo;s part of the automation protocol. To fix this, you&rsquo;d have to modify Ferrum to not send that command, which would break most of the gem&rsquo;s functionality.</p>
<h3>The Alternatives (and Why None Are Ideal)<span class="hx:absolute hx:-mt-20" id="the-alternatives-and-why-none-are-ideal"></span>
    <a href="#the-alternatives-and-why-none-are-ideal" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>I researched extensively. The options:</p>
<ol>
<li>
<p><strong>nodriver (Python)</strong>: Uses Chrome via CDP without <code>Runtime.enable</code>. No equivalent in Ruby. And even so, it&rsquo;s in a cat-and-mouse race with DataDome.</p>
</li>
<li>
<p><strong>curl-impersonate</strong>: Does TLS fingerprinting identical to Chrome without opening a browser. Good for sites with TLS detection, useless for SPAs.</p>
</li>
<li>
<p><strong>Scraping APIs</strong> (ScraperAPI, ZenRows): Outsource the problem. They work, but they cost money and add latency and dependency.</p>
</li>
<li>
<p><strong>Fork Ferrum</strong>: Modify the gem to optionally not send <code>Runtime.enable</code>. Technically viable, but endless maintenance.</p>
</li>
<li>
<p><strong>Google News RSS</strong>: Google has already read Reuters. The result is in a public RSS feed. No JavaScript, no DataDome, no browser.</p>
</li>
</ol>
<p>Option 5 won. For Reuters, I traded all the headless browser complexity for:</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">ReutersClient</span>
</span></span><span class="line"><span class="cl">  <span class="no">RSS_URL</span> <span class="o">=</span> <span class="s2">&#34;https://news.google.com/rss/search?q=when:24h+allinurl:reuters.com/world&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nc">self</span><span class="o">.</span><span class="nf">fetch_headlines</span><span class="p">(</span><span class="ss">limit</span><span class="p">:</span> <span class="mi">10</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">xml</span> <span class="o">=</span> <span class="no">Net</span><span class="o">::</span><span class="no">HTTP</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="no">URI</span><span class="p">(</span><span class="no">RSS_URL</span><span class="p">))</span>
</span></span><span class="line"><span class="cl">    <span class="n">doc</span> <span class="o">=</span> <span class="no">REXML</span><span class="o">::</span><span class="no">Document</span><span class="o">.</span><span class="n">new</span><span class="p">(</span><span class="n">xml</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">doc</span><span class="o">.</span><span class="n">get_elements</span><span class="p">(</span><span class="s2">&#34;//item&#34;</span><span class="p">)</span><span class="o">.</span><span class="n">first</span><span class="p">(</span><span class="n">limit</span><span class="p">)</span><span class="o">.</span><span class="n">filter_map</span> <span class="k">do</span> <span class="o">|</span><span class="n">item</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">      <span class="n">title</span> <span class="o">=</span> <span class="n">item</span><span class="o">.</span><span class="n">get_text</span><span class="p">(</span><span class="s2">&#34;title&#34;</span><span class="p">)</span><span class="o">&amp;.</span><span class="n">to_s</span><span class="o">&amp;.</span><span class="n">strip</span>
</span></span><span class="line"><span class="cl">      <span class="n">url</span>   <span class="o">=</span> <span class="n">item</span><span class="o">.</span><span class="n">get_text</span><span class="p">(</span><span class="s2">&#34;link&#34;</span><span class="p">)</span><span class="o">&amp;.</span><span class="n">to_s</span><span class="o">&amp;.</span><span class="n">strip</span>
</span></span><span class="line"><span class="cl">      <span class="k">next</span> <span class="k">if</span> <span class="n">title</span><span class="o">.</span><span class="n">blank?</span> <span class="o">||</span> <span class="n">url</span><span class="o">.</span><span class="n">blank?</span>
</span></span><span class="line"><span class="cl">      <span class="p">{</span> <span class="ss">title</span><span class="p">:</span> <span class="n">title</span><span class="o">.</span><span class="n">sub</span><span class="p">(</span><span class="sr">/\s*-\s*Reuters\s*\z/</span><span class="p">,</span> <span class="s2">&#34;&#34;</span><span class="p">),</span> <span class="ss">url</span><span class="p">:</span> <span class="n">url</span><span class="p">,</span> <span class="ss">category</span><span class="p">:</span> <span class="kp">nil</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="k">end</span>
</span></span><span class="line"><span class="cl">  <span class="k">rescue</span> <span class="no">StandardError</span> <span class="o">=&gt;</span> <span class="n">e</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;ReutersClient failed: </span><span class="si">#{</span><span class="n">e</span><span class="o">.</span><span class="n">message</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="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>Simple. Reliable. Zero browser dependencies. The Google News link redirects to the original article when the reader clicks — works perfectly for a newsletter.</p>
<p>Not every scraping problem needs a scraping solution. Sometimes the data is already available in an accessible format — you just have to find it.</p>
<h2>The Fourth Obstacle: Sites That Change Structure (Daily)<span class="hx:absolute hx:-mt-20" id="the-fourth-obstacle-sites-that-change-structure-daily"></span>
    <a href="#the-fourth-obstacle-sites-that-change-structure-daily" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Even when scraping works, maintenance never stops. Morningstar is a textbook case — because it made me get it wrong <strong>twice</strong> before reaching a resilient solution.</p>
<h3>Attempt 1: Select by DOM Hierarchy<span class="hx:absolute hx:-mt-20" id="attempt-1-select-by-dom-hierarchy"></span>
    <a href="#attempt-1-select-by-dom-hierarchy" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Originally, the article links were inside <code>&lt;h2&gt;</code> and <code>&lt;h3&gt;</code>:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="cl"><span class="p">&lt;</span><span class="nt">h2</span><span class="p">&gt;&lt;</span><span class="nt">a</span> <span class="na">href</span><span class="o">=</span><span class="s">&#34;/markets/article-slug&#34;</span><span class="p">&gt;</span>Article Title<span class="p">&lt;/</span><span class="nt">a</span><span class="p">&gt;&lt;/</span><span class="nt">h2</span><span class="p">&gt;</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>One day later: zero results. Morningstar changed the structure to flat links with specific CSS classes — no heading elements:</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-html" data-lang="html"><span class="line"><span class="cl"><span class="p">&lt;</span><span class="nt">a</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;mdc-basic-feed-item__mdc&#34;</span> <span class="na">href</span><span class="o">=</span><span class="s">&#34;/markets/article-slug&#34;</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl">  <span class="p">&lt;</span><span class="nt">header</span><span class="p">&gt;&lt;</span><span class="nt">h3</span><span class="p">&gt;</span>Article Title<span class="p">&lt;/</span><span class="nt">h3</span><span class="p">&gt;&lt;/</span><span class="nt">header</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl">  <span class="p">&lt;</span><span class="nt">div</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;mdc-metadata__mdc&#34;</span><span class="p">&gt;</span>Author · Date<span class="p">&lt;/</span><span class="nt">div</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl"><span class="p">&lt;/</span><span class="nt">a</span><span class="p">&gt;</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>Attempt 2: Select by CSS Class<span class="hx:absolute hx:-mt-20" id="attempt-2-select-by-css-class"></span>
    <a href="#attempt-2-select-by-css-class" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>The obvious fix: swap DOM hierarchy for CSS selectors based on the component class:</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">ARTICLE_CSS</span> <span class="o">=</span> <span class="s2">&#34;a.mdc-basic-feed-item__mdc, a.mdc-card--link__mdc&#34;</span>
</span></span><span class="line"><span class="cl"><span class="n">doc</span><span class="o">.</span><span class="n">css</span><span class="p">(</span><span class="no">ARTICLE_CSS</span><span class="p">)</span><span class="o">.</span><span class="n">each</span> <span class="p">{</span> <span class="o">|</span><span class="n">link</span><span class="o">|</span> <span class="o">...</span> <span class="p">}</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>It worked. For exactly one day. Then: zero results again. Morningstar — like many modern sites with CSS-in-JS frameworks — generates class names dynamically. What was <code>mdc-basic-feed-item__mdc</code> became something else. And it may become something else tomorrow.</p>
<p>This is increasingly common. Sites using CSS Modules, styled-components, or Tailwind with JIT generate random hashes in class names as part of the build. Every time the site deploys, the classes change. Your hardcoded selector is a time bomb.</p>
<h3>Attempt 3: Don&rsquo;t Rely on Any Class Name<span class="hx:absolute hx:-mt-20" id="attempt-3-dont-rely-on-any-class-name"></span>
    <a href="#attempt-3-dont-rely-on-any-class-name" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>The final solution is to flip the logic: instead of telling the parser <strong>which</strong> classes to look for, let the parser <strong>discover</strong> which links are articles based on structural properties that don&rsquo;t change:</p>
<ol>
<li><strong>Article links have long URLs</strong>: <code>/news/why-stock-market-is-up-today-2026-02-17</code> vs. navigation links like <code>/markets</code> or <code>/tools</code></li>
<li><strong>Article links are grouped</strong>: they appear in lists, feeds, grids — multiple links with the same CSS class</li>
<li><strong>Article links have descriptive titles</strong>: long text, not short labels like &ldquo;Markets&rdquo; or &ldquo;Tools&rdquo;</li>
</ol>
<p>The algorithm:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">def</span> <span class="nc">self</span><span class="o">.</span><span class="nf">parse_html</span><span class="p">(</span><span class="n">html</span><span class="p">,</span> <span class="n">limit</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">doc</span> <span class="o">=</span> <span class="no">Nokogiri</span><span class="o">::</span><span class="no">HTML</span><span class="p">(</span><span class="n">html</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="c1"># 1. Collect all internal &lt;a&gt; with href, class, and enough text</span>
</span></span><span class="line"><span class="cl">  <span class="n">candidates</span> <span class="o">=</span> <span class="n">doc</span><span class="o">.</span><span class="n">css</span><span class="p">(</span><span class="s2">&#34;a[href]&#34;</span><span class="p">)</span><span class="o">.</span><span class="n">filter_map</span> <span class="k">do</span> <span class="o">|</span><span class="n">link</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">    <span class="n">href</span> <span class="o">=</span> <span class="n">link</span><span class="o">[</span><span class="s2">&#34;href&#34;</span><span class="o">].</span><span class="n">to_s</span><span class="o">.</span><span class="n">strip</span>
</span></span><span class="line"><span class="cl">    <span class="n">css_class</span> <span class="o">=</span> <span class="n">link</span><span class="o">[</span><span class="s2">&#34;class&#34;</span><span class="o">].</span><span class="n">to_s</span><span class="o">.</span><span class="n">strip</span>
</span></span><span class="line"><span class="cl">    <span class="k">next</span> <span class="k">if</span> <span class="n">href</span><span class="o">.</span><span class="n">blank?</span> <span class="o">||</span> <span class="n">css_class</span><span class="o">.</span><span class="n">blank?</span>
</span></span><span class="line"><span class="cl">    <span class="k">next</span> <span class="k">unless</span> <span class="n">internal_href?</span><span class="p">(</span><span class="n">href</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">path</span> <span class="o">=</span> <span class="n">extract_path</span><span class="p">(</span><span class="n">href</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">segments</span> <span class="o">=</span> <span class="n">path</span><span class="o">.</span><span class="n">split</span><span class="p">(</span><span class="s2">&#34;/&#34;</span><span class="p">)</span><span class="o">.</span><span class="n">reject</span><span class="p">(</span><span class="o">&amp;</span><span class="ss">:empty?</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">next</span> <span class="k">if</span> <span class="n">segments</span><span class="o">.</span><span class="n">length</span> <span class="o">&lt;</span> <span class="mi">2</span>  <span class="c1"># nav links have 1 segment: /markets</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">title</span> <span class="o">=</span> <span class="n">extract_title</span><span class="p">(</span><span class="n">link</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">next</span> <span class="k">if</span> <span class="n">title</span><span class="o">.</span><span class="n">blank?</span> <span class="o">||</span> <span class="n">title</span><span class="o">.</span><span class="n">length</span> <span class="o">&lt;</span> <span class="mi">10</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="p">{</span> <span class="ss">title</span><span class="p">:</span> <span class="n">title</span><span class="p">,</span> <span class="ss">url</span><span class="p">:</span> <span class="n">absolutize</span><span class="p">(</span><span class="n">href</span><span class="p">),</span> <span class="ss">css_class</span><span class="p">:</span> <span class="n">css_class</span><span class="p">,</span> <span class="ss">slug_length</span><span class="p">:</span> <span class="n">path</span><span class="o">.</span><span class="n">length</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="c1"># 2. Group by CSS class</span>
</span></span><span class="line"><span class="cl">  <span class="n">groups</span> <span class="o">=</span> <span class="n">candidates</span><span class="o">.</span><span class="n">group_by</span> <span class="p">{</span> <span class="o">|</span><span class="n">c</span><span class="o">|</span> <span class="n">c</span><span class="o">[</span><span class="ss">:css_class</span><span class="o">]</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="c1"># 3. Filter: minimum 3 links in the group (article clusters, not stray links)</span>
</span></span><span class="line"><span class="cl">  <span class="c1"># 4. Pick the group with the highest average slug length</span>
</span></span><span class="line"><span class="cl">  <span class="n">best</span> <span class="o">=</span> <span class="n">groups</span>
</span></span><span class="line"><span class="cl">    <span class="o">.</span><span class="n">select</span> <span class="p">{</span> <span class="o">|</span><span class="n">_cls</span><span class="p">,</span> <span class="n">items</span><span class="o">|</span> <span class="n">items</span><span class="o">.</span><span class="n">length</span> <span class="o">&gt;=</span> <span class="mi">3</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="o">.</span><span class="n">max_by</span> <span class="p">{</span> <span class="o">|</span><span class="n">_cls</span><span class="p">,</span> <span class="n">items</span><span class="o">|</span> <span class="n">items</span><span class="o">.</span><span class="n">sum</span> <span class="p">{</span> <span class="o">|</span><span class="n">i</span><span class="o">|</span> <span class="n">i</span><span class="o">[</span><span class="ss">:slug_length</span><span class="o">]</span> <span class="p">}</span><span class="o">.</span><span class="n">to_f</span> <span class="o">/</span> <span class="n">items</span><span class="o">.</span><span class="n">length</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">return</span> <span class="o">[]</span> <span class="k">unless</span> <span class="n">best</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="n">best</span><span class="o">[</span><span class="mi">1</span><span class="o">].</span><span class="n">map</span> <span class="p">{</span> <span class="o">|</span><span class="n">c</span><span class="o">|</span> <span class="n">c</span><span class="o">.</span><span class="n">slice</span><span class="p">(</span><span class="ss">:title</span><span class="p">,</span> <span class="ss">:url</span><span class="p">)</span> <span class="p">}</span><span class="o">.</span><span class="n">uniq</span> <span class="p">{</span> <span class="o">|</span><span class="n">a</span><span class="o">|</span> <span class="n">a</span><span class="o">[</span><span class="ss">:url</span><span class="o">]</span> <span class="p">}</span><span class="o">.</span><span class="n">first</span><span class="p">(</span><span class="n">limit</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>What saves this approach: <strong>no class name is hardcoded</strong>. Morningstar can rename every class tomorrow and the parser will keep working — because it discovers the article pattern through structure, not through names.</p>
<p>What&rsquo;s stable on a site:</p>
<ul>
<li><strong>Article URLs are always longer</strong> than navigation URLs (that&rsquo;s architectural, not cosmetic)</li>
<li><strong>Articles are always grouped</strong> under the same component (that&rsquo;s UX, not implementation)</li>
<li><strong>Article titles are descriptive</strong> (that&rsquo;s editorial, not technical)</li>
</ul>
<p>What&rsquo;s <strong>not</strong> stable:</p>
<ul>
<li>CSS class names (change on every build)</li>
<li>DOM hierarchy (changes on every redesign)</li>
<li><code>data-*</code> attributes (change as the frontend team decides)</li>
</ul>
<p>Bottom line: don&rsquo;t try to identify elements by name — identify them by behavior. Article links behave differently from navigation links in measurable ways (URL length, clustering, title), and those properties are inherent to what the element IS, not to how it was implemented.</p>
<h2>The Reliability Hierarchy<span class="hx:absolute hx:-mt-20" id="the-reliability-hierarchy"></span>
    <a href="#the-reliability-hierarchy" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>After hours of banging my head, a hierarchy of data sources became obvious:</p>
<table>
  <thead>
      <tr>
          <th>Source</th>
          <th>Reliability</th>
          <th>Latency</th>
          <th>Maintenance</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Official API (JSON)</td>
          <td>High</td>
          <td>~200ms</td>
          <td>Low</td>
      </tr>
      <tr>
          <td>RSS/Atom feed</td>
          <td>High</td>
          <td>~300ms</td>
          <td>Low</td>
      </tr>
      <tr>
          <td>Server-rendered HTML</td>
          <td>Medium</td>
          <td>~500ms</td>
          <td>Medium</td>
      </tr>
      <tr>
          <td>SPA with headless browser</td>
          <td>Low-Medium</td>
          <td>~5s</td>
          <td>High</td>
      </tr>
      <tr>
          <td>Site with active anti-bot</td>
          <td>Low</td>
          <td>~8s+</td>
          <td>Very high</td>
      </tr>
  </tbody>
</table>
<p>The rule: <strong>always prefer the simplest source that works</strong>. If the site has RSS, use RSS. If it has an API, use the API. Only go to the headless browser when there&rsquo;s no alternative.</p>
<p>In the project, the distribution ended up like this:</p>
<p><strong>Simple HTTP</strong> (Net::HTTP or HTTParty):</p>
<ul>
<li>Google News RSS (Reuters world headlines)</li>
<li>Hacker News RSS (top stories)</li>
<li>HackerNoon RSS (top stories)</li>
<li>dev.to API (trending articles)</li>
<li>Jikan API (anime rankings)</li>
<li>YouTube Data API (videos)</li>
<li>Yahoo Finance news RSS</li>
</ul>
<p><strong>Headless browser</strong> (StealthBrowser + Ferrum):</p>
<ul>
<li>Morningstar (SPA + dynamic components)</li>
<li>Yahoo Finance charts (TLS fingerprinting + crumb auth)</li>
<li>FinViz market map (canvas rendering)</li>
<li>Generic reader articles (any URL could be an SPA)</li>
<li>Hindenburg Research (JavaScript-rendered)</li>
</ul>
<p>In other words: <strong>most data sources work without a browser</strong>. The browser is needed for a handful of problem sites — but those sites are the ones that cause the most trouble.</p>
<h2>The Real Cost<span class="hx:absolute hx:-mt-20" id="the-real-cost"></span>
    <a href="#the-real-cost" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Let&rsquo;s be practical about what &ldquo;packing a Chrome&rdquo; means:</p>
<p><strong>Docker image</strong>: +300MB. Chromium and its dependencies (fonts, graphics libs, sandbox) are huge. In an optimized Dockerfile with multi-stage build:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dockerfile" data-lang="dockerfile"><span class="line"><span class="cl"><span class="c"># Install Chromium and minimal dependencies</span><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="k">RUN</span> apt-get install -y chromium fonts-liberation libnss3 libatk-bridge2.0-0 <span class="se">\
</span></span></span><span class="line"><span class="cl">    libdrm2 libxkbcommon0 libgbm1</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p><strong>Memory</strong>: Each Chromium instance consumes 200-400MB of RAM. If you have 3 parallel workers, that&rsquo;s 1.2GB just for browsers. On a 2GB VPS, that&rsquo;s 60% of the memory.</p>
<p><strong>Latency</strong>: A page that takes 200ms via HTTP takes 3-8 seconds via headless browser (process boot, navigation, JS execution, network idle). For 10 data sources, that&rsquo;s 30-80 seconds instead of 2.</p>
<p><strong>Stability</strong>: Chromium can crash, hang, or leak memory. A 30-second timeout with cleanup in <code>ensure</code> is mandatory:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">def</span> <span class="nc">self</span><span class="o">.</span><span class="nf">fetch</span><span class="p">(</span><span class="n">url</span><span class="p">,</span> <span class="nb">name</span><span class="p">:)</span>
</span></span><span class="line"><span class="cl">  <span class="n">browser</span> <span class="o">=</span> <span class="n">create</span><span class="p">(</span><span class="nb">name</span><span class="p">:</span> <span class="nb">name</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">browser</span><span class="o">.</span><span class="n">go_to</span><span class="p">(</span><span class="n">url</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">yield</span> <span class="n">browser</span>
</span></span><span class="line"><span class="cl"><span class="k">ensure</span>
</span></span><span class="line"><span class="cl">  <span class="n">browser</span><span class="o">&amp;.</span><span class="n">quit</span>  <span class="c1"># ALWAYS quit, even on exception</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 you don&rsquo;t <code>quit</code>, the Chromium process stays hanging around burning CPU and memory. I&rsquo;ve had a server with 15 zombie Chrome processes eating 3GB of RAM because an <code>ensure</code> was missing.</p>
<h2>The Future (Or: It&rsquo;s Going to Get Worse)<span class="hx:absolute hx:-mt-20" id="the-future-or-its-going-to-get-worse"></span>
    <a href="#the-future-or-its-going-to-get-worse" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The trend is clear: sites are getting harder to access programmatically. Every quarter, the DataDomes and Cloudflares of the world add new detection techniques. Headless Chrome gets more similar to real Chrome — but the detectors keep up.</p>
<p>The web that was born open and machine-readable is becoming a walled garden. Even RSS, which is trivial to implement and useful to everyone, is being abandoned by many sites — because they don&rsquo;t want their data read outside the context (and the ads) of the original site.</p>
<p>For anyone building systems that aggregate information, the playbook is:</p>
<ol>
<li><strong>Use APIs and feeds whenever they exist</strong>. More stable, faster, more respectful</li>
<li><strong>Keep a stealth browser for SPAs</strong> that have no alternative</li>
<li><strong>Never hardcode CSS selectors</strong>. Sites with CSS-in-JS change class names on every deploy. Identify elements by structural properties (URL length, clustering, title size) that survive redesigns</li>
<li><strong>Detect blocks and fail gracefully</strong> — <code>og:description</code> as fallback, title extracted from the URL as a last resort</li>
<li><strong>Never depend on a single access method</strong>. The site that works today may block tomorrow</li>
<li><strong>Accept that some sites are inaccessible</strong>. If DataDome won&rsquo;t allow it, the data will have to come from another source</li>
</ol>
<p>The web in 2026 is a battlefield between those who want to read and those who want to control the reading. But in the long run, the creativity of those who want to access data wins — but it costs more effort every year. And, contrary to what I thought at the beginning: it&rsquo;s never simple.</p>
]]></content:encoded><category>the-m-akita-chronicles</category><category>web-development</category><category>automation</category></item><item><title>Sending Emails Without Getting Flagged as Spam | Behind The M.Akita Chronicles</title><link>https://akitaonrails.github.io/en/2026/02/17/sending-emails-without-getting-flagged-as-spam-behind-the-m-akita-chronicles/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2026/02/17/sending-emails-without-getting-flagged-as-spam-behind-the-m-akita-chronicles/</guid><pubDate>Tue, 17 Feb 2026 20:59:48 +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;/themakitachronicles&lt;/a&gt;. This is part 1.&lt;/p&gt;
&lt;p&gt;And make sure to 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;Sending emails looks trivial. Call an API, pass the HTML, send it. Done. Until the day you discover you sent the same newsletter twice to 300 people. Or that 50 emails bounced back and Amazon suspended your account. Or that your server restarted in the middle of a send and now you have no idea who got it and who didn&amp;rsquo;t.&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/">/themakitachronicles</a>. This is part 1.</p>
<p>And make sure to subscribe to my new newsletter <a href="https://themakitachronicles.com/"target="_blank" rel="noopener">The M.Akita Chronicles</a>!</p>
<p>&ndash;</p>
<p>Sending emails looks trivial. Call an API, pass the HTML, send it. Done. Until the day you discover you sent the same newsletter twice to 300 people. Or that 50 emails bounced back and Amazon suspended your account. Or that your server restarted in the middle of a send and now you have no idea who got it and who didn&rsquo;t.</p>
<p>Amazon SES is efficient — and brutally unforgiving with anyone who doesn&rsquo;t treat every send as an operation that can fail in five different ways. In this post I&rsquo;ll show the patterns that turn &ldquo;sending email&rdquo; into &ldquo;sending email reliably&rdquo;.</p>
<h2>Why SES and Not Sendgrid/Mailgun/Resend?<span class="hx:absolute hx:-mt-20" id="why-ses-and-not-sendgridmailgunresend"></span>
    <a href="#why-ses-and-not-sendgridmailgunresend" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Price. Period. SES charges $0.10 per thousand emails. Sendgrid charges $20/month for 50 thousand. For a small newsletter, both are cheap. But SES scales without changing plans — and since I&rsquo;m already on AWS for S3, I don&rsquo;t need yet another vendor.</p>
<p>The downside? SES is low-level. There&rsquo;s no pretty drag-and-drop interface, no integrated analytics, no dedicated IP on the free plan. You get an API that accepts HTML and returns a <code>message_id</code>. The rest is your problem.</p>
<p>And that&rsquo;s exactly where the danger lives.</p>
<h2>The Fundamental Problem: At-Most-Once vs At-Least-Once<span class="hx:absolute hx:-mt-20" id="the-fundamental-problem-at-most-once-vs-at-least-once"></span>
    <a href="#the-fundamental-problem-at-most-once-vs-at-least-once" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>When an external service accepts your request, but your process dies before recording the success, you have a dilemma:</p>
<ol>
<li><strong>Resend</strong>: the subscriber may receive it twice (at-least-once)</li>
<li><strong>Don&rsquo;t resend</strong>: the subscriber may not receive it at all (at-most-once)</li>
</ol>
<p>For financial transactions, at-least-once with deduplication is the standard. For email, <strong>at-most-once is preferable</strong>. Nobody complains about not receiving a newsletter. Everybody complains about receiving two.</p>
<p>This defines the whole architecture: <strong>when in doubt, don&rsquo;t resend</strong>.</p>
<h2>Pattern 1: One Record Per Recipient<span class="hx:absolute hx:-mt-20" id="pattern-1-one-record-per-recipient"></span>
    <a href="#pattern-1-one-record-per-recipient" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The most common mistake in newsletter systems is iterating over a list of subscribers in a loop:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="c1"># ERRADO — se o job morrer no email 247, re-execução reenvia 1-246</span>
</span></span><span class="line"><span class="cl"><span class="n">subscribers</span><span class="o">.</span><span class="n">each</span> <span class="k">do</span> <span class="o">|</span><span class="nb">sub</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">  <span class="no">SesMailer</span><span class="o">.</span><span class="n">send</span><span class="p">(</span><span class="ss">to</span><span class="p">:</span> <span class="nb">sub</span><span class="o">.</span><span class="n">email</span><span class="p">,</span> <span class="ss">body</span><span class="p">:</span> <span class="n">html</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The solution is to create a delivery record <strong>before</strong> sending:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="c1"># Cria registros em batch</span>
</span></span><span class="line"><span class="cl"><span class="n">rows</span> <span class="o">=</span> <span class="n">subscriber_ids</span><span class="o">.</span><span class="n">map</span> <span class="k">do</span> <span class="o">|</span><span class="nb">id</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">  <span class="p">{</span> <span class="ss">newsletter_id</span><span class="p">:</span> <span class="n">newsletter</span><span class="o">.</span><span class="n">id</span><span class="p">,</span> <span class="ss">subscriber_id</span><span class="p">:</span> <span class="nb">id</span><span class="p">,</span> <span class="ss">status</span><span class="p">:</span> <span class="s2">&#34;pending&#34;</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="no">EmailDelivery</span><span class="o">.</span><span class="n">insert_all</span><span class="p">(</span><span class="n">rows</span><span class="p">,</span> <span class="ss">unique_by</span><span class="p">:</span> <span class="o">[</span><span class="ss">:newsletter_id</span><span class="p">,</span> <span class="ss">:subscriber_id</span><span class="o">]</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Enfileira um job individual por registro</span>
</span></span><span class="line"><span class="cl"><span class="no">EmailDelivery</span><span class="o">.</span><span class="n">where</span><span class="p">(</span><span class="ss">newsletter</span><span class="p">:</span> <span class="n">newsletter</span><span class="p">,</span> <span class="ss">status</span><span class="p">:</span> <span class="o">[</span><span class="s2">&#34;pending&#34;</span><span class="p">,</span> <span class="s2">&#34;failed&#34;</span><span class="o">]</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">             <span class="o">.</span><span class="n">find_each</span> <span class="k">do</span> <span class="o">|</span><span class="n">delivery</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">  <span class="no">SendSingleEmailJob</span><span class="o">.</span><span class="n">perform_later</span><span class="p">(</span><span class="n">delivery</span><span class="o">.</span><span class="n">id</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Each <code>EmailDelivery</code> is a state machine: <code>pending → sending → sent</code>. If the job dies, the record stays in <code>sending</code> — and is <strong>never</strong> automatically resent.</p>
<p>The <code>unique_by</code> guarantees idempotency: running this code twice doesn&rsquo;t create duplicate records.</p>
<h2>Pattern 2: Atomic Claiming<span class="hx:absolute hx:-mt-20" id="pattern-2-atomic-claiming"></span>
    <a href="#pattern-2-atomic-claiming" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Two workers can&rsquo;t send the same email. The claim needs to be atomic:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">claim_for_sending!</span>
</span></span><span class="line"><span class="cl">  <span class="n">updated</span> <span class="o">=</span> <span class="nb">self</span><span class="o">.</span><span class="n">class</span>
</span></span><span class="line"><span class="cl">    <span class="o">.</span><span class="n">where</span><span class="p">(</span><span class="nb">id</span><span class="p">:</span> <span class="nb">id</span><span class="p">,</span> <span class="ss">status</span><span class="p">:</span> <span class="sx">%w[pending failed]</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="o">.</span><span class="n">update_all</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">      <span class="ss">status</span><span class="p">:</span> <span class="s2">&#34;sending&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="ss">sending_started_at</span><span class="p">:</span> <span class="no">Time</span><span class="o">.</span><span class="n">current</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="ss">send_attempts</span><span class="p">:</span> <span class="no">Arel</span><span class="o">.</span><span class="n">sql</span><span class="p">(</span><span class="s2">&#34;COALESCE(send_attempts, 0) + 1&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">updated</span> <span class="o">==</span> <span class="mi">1</span>  <span class="c1"># true se ninguém pegou antes</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>This is an UPDATE with a WHERE — if two workers try at the same time, only one gets the <code>updated == 1</code>. The other gets <code>0</code> and gives up. No explicit locks, no race conditions.</p>
<p>The <code>send_attempts</code> is incremented atomically in the database, not in Ruby. That guarantees the correct count, even under concurrency.</p>
<h2>Pattern 3: The SendSingleEmailJob Flow<span class="hx:absolute hx:-mt-20" id="pattern-3-the-sendsingleemailjob-flow"></span>
    <a href="#pattern-3-the-sendsingleemailjob-flow" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Here&rsquo;s the heart of the system. The individual send job follows a deliberate sequence:</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">SendSingleEmailJob</span> <span class="o">&lt;</span> <span class="no">ApplicationJob</span>
</span></span><span class="line"><span class="cl">  <span class="n">queue_as</span> <span class="ss">:mailer</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="n">retry_on</span> <span class="no">Aws</span><span class="o">::</span><span class="no">SESV2</span><span class="o">::</span><span class="no">Errors</span><span class="o">::</span><span class="no">TooManyRequestsException</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">           <span class="ss">wait</span><span class="p">:</span> <span class="ss">:polynomially_longer</span><span class="p">,</span> <span class="ss">attempts</span><span class="p">:</span> <span class="mi">8</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">perform</span><span class="p">(</span><span class="n">delivery_id</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">ses_accepted</span> <span class="o">=</span> <span class="kp">false</span>
</span></span><span class="line"><span class="cl">    <span class="n">delivery</span> <span class="o">=</span> <span class="no">EmailDelivery</span><span class="o">.</span><span class="n">find</span><span class="p">(</span><span class="n">delivery_id</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c1"># 1. Validação: assinante ainda ativo?</span>
</span></span><span class="line"><span class="cl">    <span class="k">unless</span> <span class="n">subscriber_active?</span><span class="p">(</span><span class="n">delivery</span><span class="o">.</span><span class="n">subscriber</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="n">delivery</span><span class="o">.</span><span class="n">mark_failed!</span><span class="p">(</span><span class="s2">&#34;Subscriber inactive&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="k">return</span>
</span></span><span class="line"><span class="cl">    <span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c1"># 2. Guardrail: nunca reenviar o que já foi</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="k">if</span> <span class="sx">%w[sent unknown bounced]</span><span class="o">.</span><span class="n">include?</span><span class="p">(</span><span class="n">delivery</span><span class="o">.</span><span class="n">status</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c1"># 3. Claim atômico</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="k">unless</span> <span class="n">delivery</span><span class="o">.</span><span class="n">claim_for_sending!</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c1"># 4. Renderiza e envia</span>
</span></span><span class="line"><span class="cl">    <span class="n">html</span> <span class="o">=</span> <span class="n">render_newsletter</span><span class="p">(</span><span class="n">delivery</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">message_id</span> <span class="o">=</span> <span class="n">ses_client</span><span class="o">.</span><span class="n">send_email</span><span class="p">(</span><span class="ss">to</span><span class="p">:</span> <span class="n">email</span><span class="p">,</span> <span class="ss">body</span><span class="p">:</span> <span class="n">html</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">ses_accepted</span> <span class="o">=</span> <span class="kp">true</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c1"># 5. Marca sucesso</span>
</span></span><span class="line"><span class="cl">    <span class="n">delivery</span><span class="o">.</span><span class="n">mark_sent!</span><span class="p">(</span><span class="n">message_id</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">rescue</span> <span class="no">Aws</span><span class="o">::</span><span class="no">SESV2</span><span class="o">::</span><span class="no">Errors</span><span class="o">::</span><span class="no">TooManyRequestsException</span> <span class="o">=&gt;</span> <span class="n">e</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># SES rejeitou ANTES de aceitar — seguro retentar</span>
</span></span><span class="line"><span class="cl">    <span class="n">delivery</span><span class="o">&amp;.</span><span class="n">mark_failed!</span><span class="p">(</span><span class="s2">&#34;Rate limited: </span><span class="si">#{</span><span class="n">e</span><span class="o">.</span><span class="n">message</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">raise</span>  <span class="c1"># ActiveJob retry_on cuida do reagendamento</span>
</span></span><span class="line"><span class="cl">  <span class="k">rescue</span> <span class="no">StandardError</span> <span class="o">=&gt;</span> <span class="n">e</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">ses_accepted</span>
</span></span><span class="line"><span class="cl">      <span class="c1"># SES ACEITOU mas algo quebrou depois — estado ambíguo</span>
</span></span><span class="line"><span class="cl">      <span class="n">delivery</span><span class="o">&amp;.</span><span class="n">mark_unknown!</span><span class="p">(</span><span class="s2">&#34;Uncertain: </span><span class="si">#{</span><span class="n">e</span><span class="o">.</span><span class="n">message</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">else</span>
</span></span><span class="line"><span class="cl">      <span class="n">delivery</span><span class="o">&amp;.</span><span class="n">mark_failed!</span><span class="p">(</span><span class="n">e</span><span class="o">.</span><span class="n">message</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">end</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></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>Pay attention to the <code>ses_accepted</code> variable. It&rsquo;s the pivot of all the error logic:</p>
<ul>
<li><strong>SES rejected</strong> (<code>ses_accepted = false</code>): safe to retry. The email wasn&rsquo;t accepted; you can try again.</li>
<li><strong>SES accepted</strong> (<code>ses_accepted = true</code>): ambiguous state. The email was <em>probably</em> sent. Mark as <code>unknown</code> and <strong>never</strong> retry automatically.</li>
</ul>
<p>This distinction is what prevents duplication. Most systems treat everything as &ldquo;failed&rdquo; and retry — which can result in duplicated emails when SES accepted but your process crashed before recording it.</p>
<h2>Pattern 4: The Immutable Status Guardrail<span class="hx:absolute hx:-mt-20" id="pattern-4-the-immutable-status-guardrail"></span>
    <a href="#pattern-4-the-immutable-status-guardrail" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Look at this line:</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">return</span> <span class="k">if</span> <span class="sx">%w[sent unknown bounced]</span><span class="o">.</span><span class="n">include?</span><span class="p">(</span><span class="n">delivery</span><span class="o">.</span><span class="n">status</span><span class="p">)</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>This isn&rsquo;t defensive for no reason. It&rsquo;s a business rule: those three states are <strong>terminal</strong>. Once a delivery reaches <code>sent</code>, <code>unknown</code> or <code>bounced</code>, no code in the system can automatically move it back to <code>pending</code>.</p>
<p>The only way to resend an <code>unknown</code> email is through manual intervention in the console. And that&rsquo;s how it should be — if you&rsquo;re not sure the email was delivered, the decision to resend belongs to a human, not to the system.</p>
<h2>Pattern 5: Recovering Stuck Deliveries<span class="hx:absolute hx:-mt-20" id="pattern-5-recovering-stuck-deliveries"></span>
    <a href="#pattern-5-recovering-stuck-deliveries" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>If the worker crashes with a delivery in <code>sending</code>, it stays stuck in that state forever. A periodic job cleans this up:</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">RecoverStaleDeliveriesJob</span> <span class="o">&lt;</span> <span class="no">ApplicationJob</span>
</span></span><span class="line"><span class="cl">  <span class="no">STALE_AFTER</span> <span class="o">=</span> <span class="mi">30</span><span class="o">.</span><span class="n">minutes</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">perform</span>
</span></span><span class="line"><span class="cl">    <span class="n">stale</span> <span class="o">=</span> <span class="no">EmailDelivery</span><span class="o">.</span><span class="n">where</span><span class="p">(</span><span class="ss">status</span><span class="p">:</span> <span class="s2">&#34;sending&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">                         <span class="o">.</span><span class="n">where</span><span class="p">(</span><span class="s2">&#34;sending_started_at &lt; ?&#34;</span><span class="p">,</span> <span class="no">STALE_AFTER</span><span class="o">.</span><span class="n">ago</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">count</span> <span class="o">=</span> <span class="n">stale</span><span class="o">.</span><span class="n">update_all</span><span class="p">(</span><span class="ss">status</span><span class="p">:</span> <span class="s2">&#34;unknown&#34;</span><span class="p">,</span> <span class="ss">sending_started_at</span><span class="p">:</span> <span class="kp">nil</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">notify_team</span><span class="p">(</span><span class="s2">&#34;</span><span class="si">#{</span><span class="n">count</span><span class="si">}</span><span class="s2"> stale deliveries moved to unknown&#34;</span><span class="p">)</span> <span class="k">if</span> <span class="n">count</span> <span class="o">&gt;</span> <span class="mi">0</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>Why <code>unknown</code> and not <code>failed</code>? Because a delivery that&rsquo;s been in <code>sending</code> for 30 minutes may have been accepted by SES but not recorded. Moving it to <code>failed</code> would mean retrying — and potentially duplicating. <code>unknown</code> is the safe limbo: &ldquo;I don&rsquo;t know what happened; a human needs to decide&rdquo;.</p>
<h2>Pattern 6: Smart Retry on SES<span class="hx:absolute hx:-mt-20" id="pattern-6-smart-retry-on-ses"></span>
    <a href="#pattern-6-smart-retry-on-ses" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>SES has three categories of errors:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="c1"># Transitório — SES está ocupado, tente depois</span>
</span></span><span class="line"><span class="cl"><span class="n">retry_on</span> <span class="no">Aws</span><span class="o">::</span><span class="no">SESV2</span><span class="o">::</span><span class="no">Errors</span><span class="o">::</span><span class="no">TooManyRequestsException</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">         <span class="ss">wait</span><span class="p">:</span> <span class="ss">:polynomially_longer</span><span class="p">,</span> <span class="ss">attempts</span><span class="p">:</span> <span class="mi">8</span>
</span></span><span class="line"><span class="cl"><span class="n">retry_on</span> <span class="no">Aws</span><span class="o">::</span><span class="no">SESV2</span><span class="o">::</span><span class="no">Errors</span><span class="o">::</span><span class="no">LimitExceededException</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">         <span class="ss">wait</span><span class="p">:</span> <span class="ss">:polynomially_longer</span><span class="p">,</span> <span class="ss">attempts</span><span class="p">:</span> <span class="mi">5</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Permanente — sua conta ou email tem problema</span>
</span></span><span class="line"><span class="cl"><span class="k">rescue</span> <span class="no">Aws</span><span class="o">::</span><span class="no">SESV2</span><span class="o">::</span><span class="no">Errors</span><span class="o">::</span><span class="no">MessageRejected</span> <span class="o">=&gt;</span> <span class="n">e</span>
</span></span><span class="line"><span class="cl">  <span class="n">delivery</span><span class="o">.</span><span class="n">mark_failed!</span><span class="p">(</span><span class="n">e</span><span class="o">.</span><span class="n">message</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="c1"># NÃO re-tenta. O SES rejeitou esse email especificamente.</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">rescue</span> <span class="no">Aws</span><span class="o">::</span><span class="no">SESV2</span><span class="o">::</span><span class="no">Errors</span><span class="o">::</span><span class="no">AccountSuspendedException</span> <span class="o">=&gt;</span> <span class="n">e</span>
</span></span><span class="line"><span class="cl">  <span class="n">delivery</span><span class="o">.</span><span class="n">mark_failed!</span><span class="p">(</span><span class="n">e</span><span class="o">.</span><span class="n">message</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="c1"># Sua conta foi suspensa. Não adianta retentar nada.</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The <code>polynomially_longer</code> is key: 3s, 18s, 83s, 293s&hellip; Polynomial backoff is more aggressive than exponential, which is good for SES — it wants you to slow down fast.</p>
<p><strong>Never</strong> use <code>retry_on StandardError</code>. That retries programming errors (nil pointer, nonexistent method) that will fail forever.</p>
<h2>Pattern 7: SES Tags for Traceability<span class="hx:absolute hx:-mt-20" id="pattern-7-ses-tags-for-traceability"></span>
    <a href="#pattern-7-ses-tags-for-traceability" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Every email sent carries tags that let you correlate events later:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="n">mailer</span><span class="o">.</span><span class="n">send_email</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">  <span class="ss">to</span><span class="p">:</span> <span class="n">subscriber</span><span class="o">.</span><span class="n">email</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="ss">subject</span><span class="p">:</span> <span class="n">title</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="ss">html_body</span><span class="p">:</span> <span class="n">html</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="ss">tags</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;NewsletterID&#34;</span> <span class="o">=&gt;</span> <span class="n">newsletter</span><span class="o">.</span><span class="n">id</span><span class="o">.</span><span class="n">to_s</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;DeliveryID&#34;</span>   <span class="o">=&gt;</span> <span class="n">delivery</span><span class="o">.</span><span class="n">id</span><span class="o">.</span><span class="n">to_s</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">)</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>When SES notifies a bounce or complaint via SNS, the payload includes these tags. The reconciler finds the matching <code>EmailDelivery</code> and updates the status:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">reconcile_event</span><span class="p">(</span><span class="n">event</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">delivery</span> <span class="o">=</span> <span class="n">find_by_tag</span><span class="p">(</span><span class="n">event</span><span class="p">,</span> <span class="s2">&#34;DeliveryID&#34;</span><span class="p">)</span> <span class="o">||</span>
</span></span><span class="line"><span class="cl">             <span class="n">find_by_message_id</span><span class="p">(</span><span class="n">event</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">return</span> <span class="k">unless</span> <span class="n">delivery</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">case</span> <span class="n">event_type</span><span class="p">(</span><span class="n">event</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">when</span> <span class="s2">&#34;delivery&#34;</span>  <span class="k">then</span> <span class="n">delivery</span><span class="o">.</span><span class="n">mark_sent!</span><span class="p">(</span><span class="n">message_id</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">when</span> <span class="s2">&#34;bounce&#34;</span>    <span class="k">then</span> <span class="n">delivery</span><span class="o">.</span><span class="n">mark_bounced!</span>
</span></span><span class="line"><span class="cl">  <span class="k">when</span> <span class="s2">&#34;complaint&#34;</span> <span class="k">then</span> <span class="n">delivery</span><span class="o">.</span><span class="n">mark_bounced!</span>
</span></span><span class="line"><span class="cl">  <span class="k">when</span> <span class="s2">&#34;reject&#34;</span>    <span class="k">then</span> <span class="n">delivery</span><span class="o">.</span><span class="n">mark_failed!</span><span class="p">(</span><span class="s2">&#34;SES rejected&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span 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>This closes the loop: even if the delivery stayed as <code>unknown</code> because the worker crashed, SES eventually notifies whether it delivered or not. The reconciler fixes the state.</p>
<h2>Pattern 8: Configuration Sets for Monitoring<span class="hx:absolute hx:-mt-20" id="pattern-8-configuration-sets-for-monitoring"></span>
    <a href="#pattern-8-configuration-sets-for-monitoring" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>SES has a feature called Configuration Sets that&rsquo;s absurdly underused:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="n">params</span><span class="o">[</span><span class="ss">:configuration_set_name</span><span class="o">]</span> <span class="o">=</span> <span class="no">ENV</span><span class="o">[</span><span class="s2">&#34;SES_CONFIGURATION_SET&#34;</span><span class="o">]</span> <span class="k">if</span> <span class="n">configured?</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>Configuration Sets give you:</p>
<ul>
<li><strong>Delivery events</strong>: delivery, bounce, complaint notifications via SNS</li>
<li><strong>Reputation dashboard</strong>: real-time bounce and complaint rate</li>
<li><strong>Sending statistics</strong>: volume per hour/day</li>
</ul>
<p>Without a Configuration Set, you&rsquo;re flying blind. With it, you know exactly the state of your sender reputation.</p>
<p>Amazon suspends your account if the bounce rate goes over 5% or complaints pass 0.1%. With the dashboard you can see this happening before it&rsquo;s too late.</p>
<h2>Pattern 9: Deferred Sending Report<span class="hx:absolute hx:-mt-20" id="pattern-9-deferred-sending-report"></span>
    <a href="#pattern-9-deferred-sending-report" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>It&rsquo;s no use reporting results right after enqueuing. The jobs are still running. The report needs to be <strong>deferred</strong>:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="c1"># Enfileira relatório 30 minutos depois do envio</span>
</span></span><span class="line"><span class="cl"><span class="no">SendingReportJob</span><span class="o">.</span><span class="n">set</span><span class="p">(</span><span class="ss">wait</span><span class="p">:</span> <span class="mi">30</span><span class="o">.</span><span class="n">minutes</span><span class="p">)</span><span class="o">.</span><span class="n">perform_later</span><span class="p">(</span><span class="n">newsletter</span><span class="o">.</span><span class="n">id</span><span class="p">)</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>And the report reschedules itself if many deliveries are still pending:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">perform</span><span class="p">(</span><span class="n">newsletter_id</span><span class="p">,</span> <span class="n">reschedule_count</span> <span class="o">=</span> <span class="mi">0</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">counts</span> <span class="o">=</span> <span class="n">newsletter</span><span class="o">.</span><span class="n">email_deliveries</span><span class="o">.</span><span class="n">group</span><span class="p">(</span><span class="ss">:status</span><span class="p">)</span><span class="o">.</span><span class="n">count</span>
</span></span><span class="line"><span class="cl">  <span class="n">pending_ratio</span> <span class="o">=</span> <span class="n">pending_plus_sending</span> <span class="o">/</span> <span class="n">total</span><span class="o">.</span><span class="n">to_f</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">pending_ratio</span> <span class="o">&gt;</span> <span class="mi">0</span><span class="o">.</span><span class="mi">10</span> <span class="o">&amp;&amp;</span> <span class="n">reschedule_count</span> <span class="o">&lt;</span> <span class="mi">3</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># Mais de 10% ainda em andamento — checa de novo em 15min</span>
</span></span><span class="line"><span class="cl">    <span class="nb">self</span><span class="o">.</span><span class="n">class</span><span class="o">.</span><span class="n">set</span><span class="p">(</span><span class="ss">wait</span><span class="p">:</span> <span class="mi">15</span><span class="o">.</span><span class="n">minutes</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="o">.</span><span class="n">perform_later</span><span class="p">(</span><span class="n">newsletter_id</span><span class="p">,</span> <span class="n">reschedule_count</span> <span class="o">+</span> <span class="mi">1</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="n">send_report</span><span class="p">(</span><span class="n">counts</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The final report shows sent/failed/pending/unknown/bounced. If <code>unknown</code> &gt; 0, the color is red — it needs human attention.</p>
<h2>The Sandbox Trap<span class="hx:absolute hx:-mt-20" id="the-sandbox-trap"></span>
    <a href="#the-sandbox-trap" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>A pain everyone goes through and nobody documents: SES starts in sandbox mode. You can only send to verified emails. To leave the sandbox you need a manual request explaining your use case, estimated volume, and how you handle bounces/complaints.</p>
<p>Tips for approval:</p>
<ol>
<li><strong>Have a verified domain with DKIM</strong> — don&rsquo;t send from <code>@gmail.com</code></li>
<li><strong>Explain double opt-in</strong> — &ldquo;subscriber confirms email before receiving&rdquo;</li>
<li><strong>Show bounce handling</strong> — &ldquo;bounces are automatically processed and removed&rdquo;</li>
<li><strong>Start small</strong> — ask for 200/day, scale later</li>
</ol>
<p>Amazon is paranoid about reputation. Show them you respect that.</p>
<h2>DKIM, SPF, and DMARC: The Trilogy That Keeps Your Emails in the Inbox<span class="hx:absolute hx:-mt-20" id="dkim-spf-and-dmarc-the-trilogy-that-keeps-your-emails-in-the-inbox"></span>
    <a href="#dkim-spf-and-dmarc-the-trilogy-that-keeps-your-emails-in-the-inbox" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Without correct DNS authentication, your emails go to spam. Three records are mandatory:</p>
<ul>
<li><strong>SPF</strong>: declares which servers are allowed to send on behalf of your domain</li>
<li><strong>DKIM</strong>: cryptographic signature proving the email wasn&rsquo;t altered</li>
<li><strong>DMARC</strong>: policy saying what to do with emails that fail SPF/DKIM</li>
</ul>
<p>SES generates the DKIM records automatically (three CNAMEs). SPF and DMARC you configure manually in DNS.</p>
<p>If you don&rsquo;t do this, even Gmail will mark your emails as suspicious. And worse: providers like Microsoft and Yahoo are getting more and more aggressive with emails without DMARC.</p>
<h2>Pattern 10: List-Unsubscribe — The Header Gmail Demands<span class="hx:absolute hx:-mt-20" id="pattern-10-list-unsubscribe--the-header-gmail-demands"></span>
    <a href="#pattern-10-list-unsubscribe--the-header-gmail-demands" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Since February 2024, Gmail has a hard rule for bulk senders (anyone sending more than 5,000 emails/day to Gmail addresses): every email needs the <code>List-Unsubscribe</code> and <code>List-Unsubscribe-Post</code> headers. Without them, Gmail <strong>penalizes the reputation of your entire domain</strong> — not just the bulk emails, but also transactional ones (like signup confirmation, password reset, etc.).</p>
<p>I found this out the hard way: Gmail users weren&rsquo;t receiving the subscription confirmation email. DKIM passing, SPF passing, DMARC passing, SES accepting — all green. But Gmail was silently routing to spam because the newsletter didn&rsquo;t have <code>List-Unsubscribe</code>.</p>
<p>The implementation has two parts: the header in the email and the endpoint that processes the unsubscribe.</p>
<h3>The Header<span class="hx:absolute hx:-mt-20" id="the-header"></span>
    <a href="#the-header" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Each email needs two headers — with a URL personalized per subscriber:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="n">mailer</span><span class="o">.</span><span class="n">send_email</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">  <span class="ss">to</span><span class="p">:</span> <span class="n">subscriber</span><span class="o">.</span><span class="n">email</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="ss">subject</span><span class="p">:</span> <span class="n">title</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="ss">html_body</span><span class="p">:</span> <span class="n">html</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="ss">headers</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;List-Unsubscribe&#34;</span> <span class="o">=&gt;</span> <span class="s2">&#34;&lt;https://seudominio.com/unsubscribe?token=</span><span class="si">#{</span><span class="n">subscriber</span><span class="o">.</span><span class="n">unsubscribe_token</span><span class="si">}</span><span class="s2">&gt;&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;List-Unsubscribe-Post&#34;</span> <span class="o">=&gt;</span> <span class="s2">&#34;List-Unsubscribe=One-Click&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">)</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>In SES v2, custom headers go in the <code>headers</code> field of the <code>simple</code> content:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="n">simple</span> <span class="o">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="ss">subject</span><span class="p">:</span> <span class="p">{</span> <span class="ss">data</span><span class="p">:</span> <span class="n">subject</span><span class="p">,</span> <span class="ss">charset</span><span class="p">:</span> <span class="s2">&#34;UTF-8&#34;</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">  <span class="ss">body</span><span class="p">:</span> <span class="p">{</span> <span class="ss">html</span><span class="p">:</span> <span class="p">{</span> <span class="o">...</span> <span class="p">},</span> <span class="ss">text</span><span class="p">:</span> <span class="p">{</span> <span class="o">...</span> <span class="p">}</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">  <span class="ss">headers</span><span class="p">:</span> <span class="n">headers</span><span class="o">.</span><span class="n">map</span> <span class="p">{</span> <span class="o">|</span><span class="nb">name</span><span class="p">,</span> <span class="n">value</span><span class="o">|</span> <span class="p">{</span> <span class="nb">name</span><span class="p">:</span> <span class="nb">name</span><span class="o">.</span><span class="n">to_s</span><span class="p">,</span> <span class="ss">value</span><span class="p">:</span> <span class="n">value</span><span class="o">.</span><span class="n">to_s</span> <span class="p">}</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p><strong>Watch out</strong>: the unsubscribe link in the <strong>body</strong> of the email (the one in the footer) doesn&rsquo;t count. Gmail specifically requires the <strong>HTTP header</strong>. These are different things. You need both.</p>
<h3>The RFC 8058 Endpoint<span class="hx:absolute hx:-mt-20" id="the-rfc-8058-endpoint"></span>
    <a href="#the-rfc-8058-endpoint" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>When the user clicks &ldquo;Unsubscribe&rdquo; in Gmail, what happens isn&rsquo;t a GET in the browser. Gmail makes a POST directly to your server with the body <code>List-Unsubscribe=One-Click</code>. No cookies, no CSRF token, no redirect.</p>
<p>That means your controller needs to:</p>
<ol>
<li><strong>Skip CSRF verification</strong> for that kind of request</li>
<li><strong>Return 200 OK</strong> instead of a redirect (email clients don&rsquo;t follow redirects)</li>
<li><strong>Keep working normally</strong> for unsubscribes via browser</li>
</ol>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">UnsubscriptionsController</span> <span class="o">&lt;</span> <span class="no">ApplicationController</span>
</span></span><span class="line"><span class="cl">  <span class="c1"># RFC 8058: email clients POST sem CSRF token</span>
</span></span><span class="line"><span class="cl">  <span class="n">skip_forgery_protection</span> <span class="ss">only</span><span class="p">:</span> <span class="ss">:destroy</span><span class="p">,</span> <span class="k">if</span><span class="p">:</span> <span class="ss">:one_click_unsubscribe?</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">destroy</span>
</span></span><span class="line"><span class="cl">    <span class="n">subscriber</span> <span class="o">=</span> <span class="no">Subscriber</span><span class="o">.</span><span class="n">find_by</span><span class="p">(</span><span class="ss">unsubscribe_token</span><span class="p">:</span> <span class="n">params</span><span class="o">[</span><span class="ss">:token</span><span class="o">]</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">subscriber</span><span class="o">&amp;.</span><span class="n">unsubscribe!</span> <span class="k">unless</span> <span class="n">subscriber</span><span class="o">&amp;.</span><span class="n">unsubscribed?</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">one_click_unsubscribe?</span>
</span></span><span class="line"><span class="cl">      <span class="n">head</span> <span class="ss">:ok</span>  <span class="c1"># Gmail espera 200, não redirect</span>
</span></span><span class="line"><span class="cl">    <span class="k">else</span>
</span></span><span class="line"><span class="cl">      <span class="n">redirect_to</span> <span class="n">unsubscribe_path</span><span class="p">(</span><span class="ss">token</span><span class="p">:</span> <span class="n">params</span><span class="o">[</span><span class="ss">:token</span><span class="o">]</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">end</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="kp">private</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">one_click_unsubscribe?</span>
</span></span><span class="line"><span class="cl">    <span class="n">params</span><span class="o">[</span><span class="s2">&#34;List-Unsubscribe&#34;</span><span class="o">]</span> <span class="o">==</span> <span class="s2">&#34;One-Click&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The <code>skip_forgery_protection</code> is conditional — it only triggers when the request contains the <code>List-Unsubscribe=One-Click</code> parameter. Regular browser requests still have full CSRF protection.</p>
<p>After implementing this, the domain reputation at Gmail takes 1-2 days to recover. The confirmation emails started arriving in the inbox again.</p>
<h2>Pattern 11: The Silent SES Suppression List<span class="hx:absolute hx:-mt-20" id="pattern-11-the-silent-ses-suppression-list"></span>
    <a href="#pattern-11-the-silent-ses-suppression-list" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>SES has a trap that catches a lot of people: the <strong>account-level suppression list</strong>. When an email hard bounces (nonexistent address) or generates a complaint (marked as spam), SES automatically adds that address to an internal list. From that point on, every time you send to that address, SES <strong>accepts the request, returns a message_id, and silently doesn&rsquo;t deliver</strong>.</p>
<p>Read it again: <strong>SES returns success, but doesn&rsquo;t send</strong>. Your system thinks it was sent. The subscriber never receives it. No error, no bounce, no log.</p>
<p>To see who&rsquo;s on the list:</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">aws sesv2 list-suppressed-destinations --output table</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>To remove an address (for example, after fixing a deliverability issue):</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">aws sesv2 delete-suppressed-destination --email-address <span class="s2">&#34;usuario@gmail.com&#34;</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p><strong>Careful</strong>: don&rsquo;t go removing everything. If the address bounced because of a typo (<code>usuario@gnail.com</code> instead of <code>@gmail.com</code>), removing it from the suppression list will just generate another bounce — and worsen your bounce rate, exactly what SES monitors to decide whether to suspend your account.</p>
<p>The rule: only remove addresses you have concrete reason to believe are valid. Typos and abandoned addresses stay on the list — SES is protecting you from yourself.</p>
<h2>Encrypting Emails at Rest<span class="hx:absolute hx:-mt-20" id="encrypting-emails-at-rest"></span>
    <a href="#encrypting-emails-at-rest" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>A detail many ignore: your subscribers&rsquo; emails are in the database. If the database leaks, all the emails leak. Rails 8 has native Active Record Encryption:</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">Subscriber</span> <span class="o">&lt;</span> <span class="no">ApplicationRecord</span>
</span></span><span class="line"><span class="cl">  <span class="n">encrypts</span> <span class="ss">:email</span><span class="p">,</span> <span class="ss">deterministic</span><span class="p">:</span> <span class="kp">true</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The <code>deterministic: true</code> is crucial: it lets <code>find_by(email: &quot;x@y.com&quot;)</code> work normally, because the same input always produces the same ciphertext. The unique index keeps working. From the application&rsquo;s point of view, nothing changes — but in the database, the emails are encrypted.</p>
<p>The keys live in environment variables, not in the database. The database leaked? The emails are unreadable without the keys.</p>
<h2>The Total Cost<span class="hx:absolute hx:-mt-20" id="the-total-cost"></span>
    <a href="#the-total-cost" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>For a newsletter with 1,000 subscribers sending 4 editions per month:</p>
<table>
  <thead>
      <tr>
          <th>Item</th>
          <th>Cost</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>SES (4,000 emails)</td>
          <td>$0.40/month</td>
      </tr>
      <tr>
          <td>Verified domain</td>
          <td>$0</td>
      </tr>
      <tr>
          <td>Configuration Set</td>
          <td>$0</td>
      </tr>
      <tr>
          <td>SNS notifications</td>
          <td>~$0 (free tier)</td>
      </tr>
      <tr>
          <td><strong>Total</strong></td>
          <td><strong>$0.40/month</strong></td>
      </tr>
  </tbody>
</table>
<p>Compare that to Mailchimp ($13/month), ConvertKit ($15/month) or Buttondown ($9/month). The difference is that, with SES, you <strong>built</strong> the infrastructure instead of renting it. It&rsquo;s more upfront work, but with zero lock-in and total control.</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>Sending reliable email isn&rsquo;t about calling an API. It&rsquo;s about:</p>
<ul>
<li><strong>Per-recipient records</strong> with atomic claiming</li>
<li><strong>Terminal states</strong> that are never automatically reverted</li>
<li><strong>Explicit distinction</strong> between &ldquo;SES rejected&rdquo; and &ldquo;SES accepted but something broke&rdquo;</li>
<li><strong>Recovery of ambiguous states</strong> always to <code>unknown</code>, never to <code>failed</code></li>
<li><strong>Deferred reports</strong> that reschedule themselves until they have complete data</li>
<li><strong>Webhook reconciliation</strong> that closes the delivery loop</li>
<li><strong>Correct DNS</strong> (DKIM/SPF/DMARC) so you don&rsquo;t go to spam</li>
<li><strong><code>List-Unsubscribe</code> on every email</strong> — without it, Gmail penalizes your entire domain</li>
<li><strong>Awareness of the suppression list</strong> — SES may accept and silently not deliver</li>
</ul>
<p>The golden rule: <strong>when in doubt, don&rsquo;t resend</strong>. Better for a subscriber to miss one edition than to receive two. Your domain reputation — and your subscribers&rsquo; patience — depend on it.</p>
]]></content:encoded><category>the-m-akita-chronicles</category><category>security</category><category>backend</category></item><item><title>Vibe Code: From Zero to Production in 6 DAYS | The M.Akita Chronicles</title><link>https://akitaonrails.github.io/en/2026/02/16/vibe-code-zero-to-production-in-6-days-the-m-akita-chronicles/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2026/02/16/vibe-code-zero-to-production-in-6-days-the-m-akita-chronicles/</guid><pubDate>Mon, 16 Feb 2026 14:19:56 +0000</pubDate><description>&lt;p&gt;&lt;strong&gt;SUBSCRIBE TO THE NEW NEWSLETTER:&lt;/strong&gt; &lt;a href="https://themakitachronicles.com/"target="_blank" rel="noopener"&gt;The M.Akita Chronicles&lt;/a&gt;
&lt;strong&gt;READ THE BLOG VERSION:&lt;/strong&gt; &lt;a href="https://blog.themakitachronicles.com/"target="_blank" rel="noopener"&gt;Blog&lt;/a&gt;
&lt;strong&gt;LISTEN ON SPOTIFY!!&lt;/strong&gt; &lt;a href="https://open.spotify.com/show/7MzG2UB7IAkC3GAwEXEIVD"target="_blank" rel="noopener"&gt;Podcast&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&amp;ndash;&lt;/p&gt;
&lt;p&gt;&lt;img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-14_18-59-53.png" alt="a ideia" loading="lazy" /&gt;&lt;/p&gt;
&lt;p&gt;On February 10, 2026, I posted the tweet above, just throwing out there something that had been on my to-do list for years, ever since I was publishing videos on YouTube. I never did it because I hate HTML/CSS, and having to build an email template was enough to keep me from starting. On top of that, doing yet another newsletter, which is just a boring list of links, didn&amp;rsquo;t motivate me.&lt;/p&gt;</description><content:encoded><![CDATA[<p><strong>SUBSCRIBE TO THE NEW NEWSLETTER:</strong> <a href="https://themakitachronicles.com/"target="_blank" rel="noopener">The M.Akita Chronicles</a>
<strong>READ THE BLOG VERSION:</strong> <a href="https://blog.themakitachronicles.com/"target="_blank" rel="noopener">Blog</a>
<strong>LISTEN ON SPOTIFY!!</strong> <a href="https://open.spotify.com/show/7MzG2UB7IAkC3GAwEXEIVD"target="_blank" rel="noopener">Podcast</a></p>
<p>&ndash;</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-14_18-59-53.png" alt="a ideia"  loading="lazy" /></p>
<p>On February 10, 2026, I posted the tweet above, just throwing out there something that had been on my to-do list for years, ever since I was publishing videos on YouTube. I never did it because I hate HTML/CSS, and having to build an email template was enough to keep me from starting. On top of that, doing yet another newsletter, which is just a boring list of links, didn&rsquo;t motivate me.</p>
<p>But that day I was already wrapping up my second vibe coding project, <a href="https://github.com/akitaonrails/FrankMD"target="_blank" rel="noopener">FrankMD</a>, and I think I figured out the main tricks and the methodology to build a complete product the right way. So a bunch of ideas started popping up. I dumped them all into this <a href="https://gist.github.com/akitaonrails/d2a7983fc4c839b8071f5d0babaadf94"target="_blank" rel="noopener">little document</a>.</p>
<p><a href="https://gist.github.com/akitaonrails/d2a7983fc4c839b8071f5d0babaadf94"target="_blank" rel="noopener"><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-16_12-40-04.jpg" alt="conceito original"  loading="lazy" /></a></p>
<p>I thought: what if I vibe coded not only the newsletter system&rsquo;s code, but also had part of the actual content generated by AI in a quirky way, in the form of commentators: me and a robot? And, of course, with Hitchhiker&rsquo;s Guide in mind, it had to be MARVIN, or rather, &ldquo;M.Arvin&rdquo; (so nobody hassles me about copyright).</p>
<p>Obviously, there would be a weekly email version and an archive blog. But since we were going all in, why not rewrite the whole newsletter as a podcast script? And since <a href="https://qwen.ai/blog?id=qwen3tts-0115"target="_blank" rel="noopener">Qwen3 TTS</a> recently came out, open source, why not train a LoRa with my voice (obviously, I have hundreds of hours of my voice on YouTube) and put together a fully automated podcast?</p>
<p>Finally, one of the things that would demotivate me is building a traditional Web Admin and having to log in manually to register news links and my comments. I knew I&rsquo;d get lazy and, at some point, burn out. There was only one solution: build a smarter bot I could talk to via <strong>DISCORD</strong>. That way I can quickly jump in, whether from my PC or my smartphone, without having to log into a separate system or anything. I already chat with people on Discord, so the friction would be zero.</p>
<p>So:</p>
<ul>
<li>Marvin Bot to manage all the content (the stuff I send in and the automated stuff, with comments and everything else)</li>
<li>An X.com monitor to check what I posted and grab it for the newsletter. Including the replies I give, which will become Q&amp;A!</li>
<li>Newsletter website to subscribe, unsubscribe, etc</li>
<li>Blog for newsletter archives and podcast transcripts (also with terms of service and privacy — yes, I&rsquo;m LGPD compliant!)</li>
<li>Automatic voice generator and podcast assembler that uploads automatically to make it available on Spotify</li>
<li>And also blog and email templates, with light and dark theme versions.</li>
</ul>
<p>You, an experienced programmer, without AI, how long would it take you to build all of that?</p>
<p>Well, I started on February 10. I finished everything on Sunday, February 15. And today, the 16th, the production jobs ran for the first time, which generated the newsletter, blog posts, podcast, Spotify upload, email delivery.</p>
<p>And it wasn&rsquo;t done the crappy way every amateur vibe coder does it:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-16_11-37-20.jpg" alt="newsletter stats"  loading="lazy" /></p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-16_11-37-27.jpg" alt="bot stats"  loading="lazy" /></p>
<p>Rails folks must be surprised: a project with a 1 to 1 test ratio! 1 line of tests for every 1 line of code: 100% coverage. And that&rsquo;s without counting a separate full end-to-end integration project (which I used to make sure production would be flawless).</p>
<p>All the strategies I&rsquo;ve learned over the years putting projects into production: security, scalability, durability, delivery guarantees without spamming duplicate emails, etc. I&rsquo;ll see later if I write more specific posts about some of the technical tricks.</p>
<p>I&rsquo;m not going to open source this project. I built it 100% custom for me, my workflow, and my style. It&rsquo;s not hard for another competent programmer to do the same thing with vibe code: if I pulled it off, anyone can. So just don&rsquo;t be lazy about thinking and doing. Honestly, I don&rsquo;t get how tech YouTubers still aren&rsquo;t launching 1 complete project in production, per video, for everyone to test and watch.</p>
<p>The screenshot also doesn&rsquo;t show the Qwen3-TTS server, which produces audio. That one is much simpler, a PyTorch server. Here I actually used Python. The most important part is the fine tuning of the model to generate voices exactly the way I wanted:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-16_11-45-20.jpg" alt="qwen3 server"  loading="lazy" /></p>
<p>The blog is all <strong>Hugo/Hextra</strong>, same as this one. And my bot publishes there automatically.</p>
<h2>M.Arvin Bot<span class="hx:absolute hx:-mt-20" id="marvin-bot"></span>
    <a href="#marvin-bot" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>M.Arvin has a personality I spelled out exactly the way I wanted. And it&rsquo;s a Discord bot, so my workflow looks like this:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-16_12-45-48.jpg" alt="bot url register"  loading="lazy" /></p>
<p>I just need to send a link with my comment (the same way I&rsquo;d do on X.com), or I can post directly on X and it&rsquo;ll pick it up the same way. Then it fetches the content, writes a little summary, does a small fact-check, and M.Arvin generates its own comment on top of mine.</p>
<p>I programmed it to respond to a bunch of commands to make my day-to-day easier. This is just a small part of it:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-16_11-56-13.jpg" alt="bot help commands"  loading="lazy" /></p>
<p>Here&rsquo;s an example: I have control over how much AI it&rsquo;s using (it&rsquo;s pretty cheap):</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-16_11-57-07.jpg" alt="AI cost"  loading="lazy" /></p>
<h2>Deployment<span class="hx:absolute hx:-mt-20" id="deployment"></span>
    <a href="#deployment" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>In this post I&rsquo;m not going to go into too much detail, but one thing I spent a lot of time on, going through several discussions and versions with Claude Code, was the infrastructure architecture. And in the end it managed to give me a Deployment Guide that I was able to follow all the way through, knowing at a minimum it&rsquo;s set up the way I would have done it myself if I&rsquo;d built everything alone:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-16_11-58-45.jpg" alt="topology"  loading="lazy" /></p>
<p>No software is guaranteed to be 100% perfect; this one shouldn&rsquo;t be either. Even with me reviewing for days, going through every step multiple times, there&rsquo;s always some bug I didn&rsquo;t anticipate. I &ldquo;think&rdquo; that, at least, everything I know usually causes problems in production, I &ldquo;think&rdquo; I managed to cover satisfactorily. If it were a human team, I would have pushed it to production in a worse state than what I reached with Claude.</p>
<p>Everything I could protect with Cloudflare, I configured. Every good practice for email/mailing, I followed. Every precaution against accidents I took (every deploy automated with Kamal) and every subscriber&rsquo;s email was encrypted with AES-256 in the database (even if they leaked by accident, the user data doesn&rsquo;t leak). I didn&rsquo;t implement profiling, cookies, or tracking pixels, nothing. Privacy was the focus.</p>
<p>Everything I could reduce costs on, I reduced; after all, I&rsquo;m paying out of pocket. So things like the RunPod GPU for generating the audio are serverless. I only spin it up at the moment of generation; it runs the whole process and then the machine shuts down. The server is all organized into a lightweight Docker image. All the AI models live on a separate network volume.</p>
<h2>Process Spoiler<span class="hx:absolute hx:-mt-20" id="process-spoiler"></span>
    <a href="#process-spoiler" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Later I plan to do a bigger post about my methodology, but it&rsquo;s very simple: treat Claude Code/Codex like a hard-working human programmer — one who&rsquo;s going to make lots of mistakes, but knows how to obey super explicit, direct, clear, unambiguous orders. My role is product manager, Tech Lead, or QA. It has to be all THREE roles.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-16_12-06-47.jpg" alt="commits"  loading="lazy" /></p>
<p>In total, Claude Code made 201 commits. But these aren&rsquo;t normal commits; I figure that in a normal project with humans, each one would have been roughly <strong>150 Pull Requests</strong>.</p>
<p>Question for you folks doing sprints on real projects: how long does it take to get 150 user stories/bug fixes approved and in production on your actual project? How many user stories can you get done and approved (with QA and everything) in ONE week?</p>
<p>Here&rsquo;s an example of a commit/&ldquo;user story&rdquo;:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-16_12-08-55.jpg" alt="commit example"  loading="lazy" /></p>
<p>When I was around commit 50, I was already thinking: &ldquo;It works already!&rdquo;* That&rsquo;s the stage where every content creator (who has never actually put real projects into production) would already be posting, saying: <em>&ldquo;I vibe coded it in 2 days&rdquo;</em>. In fact, in 2 days I already had the bulk of the functionality ready.</p>
<p>The other 4 days were:</p>
<ul>
<li>1 day for production deploy (when everything is planned ahead of time, all the contingencies already thought through, it&rsquo;s easy).</li>
<li>3 days just testing, writing automated/integration tests, generating and regenerating, exercising every step of every part, multiple times, and making adjustments, refactors, or even throwing away entire pieces to change them, because the tests showed it was worth it.</li>
</ul>
<p>That took from commit 50 to 200! And that&rsquo;s the part every vibe coder <strong>skips</strong>. To be fair, it&rsquo;s the part anyone, with or without AI, would skip. And that&rsquo;s why projects always go wrong and end up in production full of errors and obvious security holes.</p>
<blockquote>
  <p>Even after asking Claude Code to review security best practices and test coverage, multiple times. Then I opened Codex and asked it to do the same thing, and it still found more security holes and more missing tests, multiple times. Even after both of them did these checks several times, I still found more holes on my own that I had them fix. At no point was either of them able to find &ldquo;EVERYTHING&rdquo;. Nobody can, not even AI.</p>

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

</blockquote>
<p>I&rsquo;ll ask again: how long, without AI, would it take you to produce 150 user stories/bug fixes, in the scope I outlined at the beginning? All the way to production with everything tested? I doubt it would be in 6 days like I did it.</p>
]]></content:encoded><category>the-m-akita-chronicles</category><category>vibe-coding</category><category>agile</category></item></channel></rss>