<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Digital Craft Workshop</title>
    <description>The latest articles on DEV Community by Digital Craft Workshop (@danielrusnok).</description>
    <link>https://dev.to/danielrusnok</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F277448%2Fdf127f3b-f0f6-45fe-b25e-1608deb963fe.png</url>
      <title>DEV Community: Digital Craft Workshop</title>
      <link>https://dev.to/danielrusnok</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/danielrusnok"/>
    <language>en</language>
    <item>
      <title>Meter Your AI Features Before the Call, Not After It</title>
      <dc:creator>Digital Craft Workshop</dc:creator>
      <pubDate>Fri, 18 Sep 2026 15:02:05 +0000</pubDate>
      <link>https://dev.to/danielrusnok/meter-your-ai-features-before-the-call-not-after-it-52lo</link>
      <guid>https://dev.to/danielrusnok/meter-your-ai-features-before-the-call-not-after-it-52lo</guid>
      <description>&lt;p&gt;Ten to thirty seconds. That is how long a streamed AI call leaves a quota unenforced if the counter goes up when the stream closes, which is the obvious place to put it and the place I nearly put it.&lt;/p&gt;

&lt;p&gt;I have wired a usage quota in front of four things now: Claude API calls, video renders, outbound email batches, and one cached lookup that barely deserved one. Every one came down to one small decision. Does the counter go up before the work, or after it comes back?&lt;/p&gt;

&lt;p&gt;&lt;a href="https://levelup.gitconnected.com/the-race-condition-hiding-in-every-saas-ai-feature-00d7b9db7016" rel="noopener noreferrer"&gt;I wrote the underlying bug up in March&lt;/a&gt;, back when it was still a surprise to me. The postmortem is published, so this is what came after it.&lt;/p&gt;

&lt;p&gt;The short version: &lt;strong&gt;for metered work, increment before the work happens.&lt;/strong&gt; What decides it is duration. A ten-second call needs the counter in front of it; so does a ten-second render.&lt;/p&gt;




&lt;h2&gt;
  
  
  Only the write can move
&lt;/h2&gt;

&lt;p&gt;Every metered feature has the same skeleton. Read the counter, compare it against the plan limit, do the work, write the counter back. The write is the only one of the four I can slide.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;checkPlanLimits()&lt;/code&gt; is the boring part. It looks up the tenant and reads the usage row for the current calendar month, keyed by a plain string like "2026-03". A new month creates a new row on the first call and the old one stays behind as history, so nothing has to be reset on a schedule.&lt;/p&gt;

&lt;p&gt;Put the write last and you get the version that reads as fair, because only successful work gets charged.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;limits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;checkPlanLimits&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tenant&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;limits&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aiUsage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;used&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;limits&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aiUsage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;tooManyRequests&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;anthropic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;incrementAiUsage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tenant&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// count after&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Put the write before the work and you get the version I ship.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;limits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;checkPlanLimits&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tenant&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;limits&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aiUsage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;used&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;limits&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aiUsage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;tooManyRequests&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;incrementAiUsage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tenant&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// count first&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;anthropic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two lines swapped. In &lt;a href="https://drippery.app" rel="noopener noreferrer"&gt;Drippery&lt;/a&gt;, the drip email tool I build, those two lines sit in the route handler behind AI email generation, capped at ten prompts a month on the Starter plan.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;tooManyRequests()&lt;/code&gt; returns a 429 with a short JSON body, and the client turns that into a counter in the UI reading five of ten used this month. Both versions return the same 429 and render an identical usage counter. The only difference is which side of the model call the write lands on.&lt;/p&gt;

&lt;p&gt;Counting last opens a gap between the read and the write. Anything arriving inside that gap reads a stale number and starts work it should not have been allowed to start. How wide the gap gets depends entirely on how long the work runs.&lt;/p&gt;

&lt;p&gt;For the non-streaming call that builds a whole email sequence, the work runs five to fifteen seconds. For a streamed generation, the response object stays open until the last token lands, and the natural place to put the increment is the point where the stream closes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;await &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;content_block_delta&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;text_delta&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;enqueue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;encoder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`data: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;\n\n`&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="c1"&gt;// counting here puts the write 10-30 seconds after the read&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ten to thirty seconds is a long time to leave a paid endpoint unguarded. It is also long enough that nobody has to be malicious to walk through it. A user who clicks generate and then, seeing nothing happen, clicks again has already done it.&lt;/p&gt;

&lt;p&gt;Most rate-limiting advice was written for CRUD endpoints. A form submission finishes in forty milliseconds, so hitting the gap takes a deliberately timed second request rather than an impatient second click. A model call finishes in twenty seconds. That leaves enough time for a second click to land inside the gap without anyone trying.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvikb0ckmmijjxkfky4tt.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvikb0ckmmijjxkfky4tt.jpg" alt="Two request timelines: counting after leaves a race window, counting first leaves none" width="799" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The race window is the whole span between the check and the increment. | Generated with Claude&lt;/p&gt;




&lt;h2&gt;
  
  
  The cheaper mistake is the one with a ceiling
&lt;/h2&gt;

&lt;p&gt;Counting first has an obvious cost. When the model call fails, the user has spent a prompt and received nothing back. I sat on that one for a while before shipping it.&lt;/p&gt;

&lt;p&gt;Then I put numbers on both sides of it. Anthropic API errors show up in my logs at well under one percent of calls. When one lands, a Starter user has nine prompts left for the month instead of ten, and if they write to me about it I add one back by hand.&lt;/p&gt;

&lt;p&gt;I have never automated that refund. It happens rarely enough that a hand-written reply is cheaper than the code to avoid it. People also seem to like getting an actual reply.&lt;/p&gt;

&lt;p&gt;The error on the other side has no ceiling. Someone firing requests faster than the work completes gets as many paid generations as they can queue inside the window, and I pay Anthropic for every one of them.&lt;/p&gt;

&lt;p&gt;A generated email runs a few thousand tokens, so one call costs me somewhere around four cents at the tier I use. Ten of those a month come to forty cents against a nine-dollar subscription, which is why I could price the quota generously in the first place. Without a working quota, there is no ceiling except how fast a user can click.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I keep running into this shape of decision, where the only honest question is which mistake I would rather absorb. I wrote up the way I think about it as a free email series, &lt;a href="https://drippery.app/subscribe/ea9bfdda-233b-470f-9145-b62fd464c582" rel="noopener noreferrer"&gt;Good-Enough Engineering&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Card processors settled this argument decades ago. &lt;a href="https://docs.stripe.com/payments/place-a-hold-on-a-payment-method" rel="noopener noreferrer"&gt;The authorization hold goes on the card before the warehouse picks the item&lt;/a&gt;, and it is captured or released once the outcome is known. Same bet here. Stripe releases the hold automatically; I do it by editing a row by hand.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fo05fwlpbbj78v89g0xbn.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fo05fwlpbbj78v89g0xbn.jpg" alt="Comparison of counting first versus counting after across four rows" width="799" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Both orders make a mistake; only one of them has a ceiling. | Generated with Claude&lt;/p&gt;




&lt;h2&gt;
  
  
  The second race is still in there
&lt;/h2&gt;

&lt;p&gt;Moving the increment forward closes the wide race. A smaller one is still in there, inside the increment function.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;incrementAiUsage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;month&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getCurrentMonth&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;existing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;aiUsage&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nf"&gt;and&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;aiUsage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;aiUsage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;month&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;month&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;existing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;aiUsage&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;promptCount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;existing&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;promptCount&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;updatedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;and&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;aiUsage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;aiUsage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;month&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;month&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;aiUsage&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;month&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;promptCount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two concurrent calls can both select a count of four. Both then write five, and the tenant gets a free prompt out of the arithmetic.&lt;/p&gt;

&lt;p&gt;Postgres will collapse the read and the write into one statement whenever I decide it matters. The unique index on tenant and month is already in the schema, which is the only thing &lt;code&gt;ON CONFLICT&lt;/code&gt; needs to key on.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;drippery_ai_usage&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;month&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prompt_count&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;CONFLICT&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;month&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;DO&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;prompt_count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;drippery_ai_usage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;prompt_count&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That statement buys less than it sounds like. It removes the lost update between the SELECT and the UPDATE. It does not cap the counter, because the quota comparison is a separate read further up the route, so &lt;code&gt;prompt_count&lt;/code&gt; will still go from ten to eleven if the handler asks it to. The March piece said the rewrite left "no race window at all", which was too strong.&lt;/p&gt;

&lt;p&gt;The other option was to wrap the select and the update in a transaction with &lt;code&gt;SELECT ... FOR UPDATE&lt;/code&gt;, which holds the row until the transaction commits. That closes the lost update, but it still needs a retry for the first call of a new month, when there is no row to lock yet and both inserts race the unique index. It is also more code than the single statement above, so if I am going to touch this at all I would rather touch it once.&lt;/p&gt;

&lt;p&gt;I have not shipped it either way. The window here is one database round trip rather than thirty seconds of streaming, and Drippery serves a few dozen AI requests a day, so two of them colliding inside a couple of milliseconds has never appeared in the logs. I ran those numbers here too and got the opposite answer.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnpwhgvbw2l5fstqefcmb.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnpwhgvbw2l5fstqefcmb.jpg" alt="Sequence diagram contrasting select-then-update with insert on conflict" width="799" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Two reads of four both write five, and one prompt goes unbilled. | Generated with Claude&lt;/p&gt;




&lt;h2&gt;
  
  
  Three of my four quotas count first
&lt;/h2&gt;

&lt;p&gt;The rule is not universal, and the four quotas I run do not all land the same way.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Claude API calls.&lt;/strong&gt; Count first. The work takes seconds, and it costs money on someone else's meter. Once the request is out I cannot un-spend it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Video renders.&lt;/strong&gt; Count first. The job is queued long before the GPU picks it up, so the counter goes up at enqueue. A render sits in a queue for minutes before it starts and then occupies a single graphics card for several more. Counting at completion would leave a window measured in minutes, and the queue is allowed to get long.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Outbound email batches.&lt;/strong&gt; Count first, at enqueue. Sent email is the least reversible thing on this list. A batch that goes out twice is a deliverability problem I get to explain to a mail provider, and that one I cannot absorb quietly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A cached lookup.&lt;/strong&gt; Count after, and honestly it barely matters. The work is a local read that finishes in single-digit milliseconds. Charging a user for a lookup that errored would be the more annoying of the two mistakes, so this is the one case where counting after is the correct call.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A four-cent API call and a render on hardware I already own leave the same window open if they both take thirty seconds.&lt;/strong&gt; Which is why the cache and the render queue land on opposite sides despite costing me nothing but electricity.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzrgwvovqnwkvgtammetw.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzrgwvovqnwkvgtammetw.jpg" alt="Four quotas as bars ordered by how long the work stays in flight" width="799" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Only the cache finishes fast enough that counting after stays safe. | Generated with Claude&lt;/p&gt;

&lt;p&gt;The render queue is the one that surprised me. I had it counting on completion for a while, on the reasoning that a render that crashes halfway through a scene should not cost anybody anything. Then I watched the queue back up and realised I was measuring the wrong span. The window is queue wait plus render time, which on a busy night is most of an hour.&lt;/p&gt;

&lt;p&gt;The Drippery code has not changed since I wrote that first piece. The decision takes me about thirty seconds now instead of an evening. Any time I put a quota in front of something that runs longer than a database write, the counter goes up before the slow thing starts, and I move on to the interesting half of the feature.&lt;/p&gt;




&lt;h2&gt;
  
  
  External Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;a href="https://platform.claude.com/docs/en/build-with-claude/streaming" rel="noopener noreferrer"&gt;Anthropic streaming docs&lt;/a&gt; — the delta types the streaming loop has to guard on&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://www.postgresql.org/docs/current/sql-insert.html" rel="noopener noreferrer"&gt;PostgreSQL INSERT reference&lt;/a&gt; — ON CONFLICT DO UPDATE semantics&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://docs.stripe.com/payments/place-a-hold-on-a-payment-method" rel="noopener noreferrer"&gt;Stripe on authorization holds&lt;/a&gt; — the hold-then-capture pattern&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events" rel="noopener noreferrer"&gt;MDN on Server-Sent Events&lt;/a&gt; — the streaming transport itself&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;Nearby: &lt;a href="https://levelup.gitconnected.com/getting-claude-to-return-strict-json-without-drift-a-system-prompt-template-9ee1b4eed5c4" rel="noopener noreferrer"&gt;getting Claude to return strict JSON without drift&lt;/a&gt;, from the tool that publishes these posts, and &lt;a href="https://levelup.gitconnected.com/5-functions-1-route-0-month-my-entire-saas-background-job-architecture-24363b6f45aa" rel="noopener noreferrer"&gt;the $0/month background job architecture&lt;/a&gt; behind the email side of Drippery.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I build Drippery in public and write up the decisions as they happen. If the trade-off in this piece is the part you want more of, &lt;a href="https://drippery.app/subscribe/ea9bfdda-233b-470f-9145-b62fd464c582" rel="noopener noreferrer"&gt;Good-Enough Engineering&lt;/a&gt; is a free five-email series on making that call.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I build small tools and kits for solo creators. You can find them here: &lt;a href="https://danielrusnok.gumroad.com?utm_source=devto&amp;amp;utm_medium=article_footer&amp;amp;utm_campaign=catalog" rel="noopener noreferrer"&gt;https://danielrusnok.gumroad.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
    </item>
    <item>
      <title>I Taught My Articles To Make Their Own Reels</title>
      <dc:creator>Digital Craft Workshop</dc:creator>
      <pubDate>Wed, 16 Sep 2026 15:02:34 +0000</pubDate>
      <link>https://dev.to/danielrusnok/i-taught-my-articles-to-make-their-own-reels-2apn</link>
      <guid>https://dev.to/danielrusnok/i-taught-my-articles-to-make-their-own-reels-2apn</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;I spent one evening building a pipeline that turns any published article into a 9:16 Reel. It is narrated in my cloned voice, captioned with karaoke-style word highlights, branded with Digital Craft Workshop colors, and uploaded to Cloudflare R2 ready to post on Instagram or YouTube Shorts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The whole thing lives behind a single button in Article Forge.&lt;/strong&gt; Regenerating a reel costs zero API credits, because the scenario and audio are cached per article.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why I Bothered
&lt;/h2&gt;

&lt;p&gt;I write one article a week. Sometimes more. Each one already takes hours.&lt;/p&gt;

&lt;p&gt;Cutting a vertical video on top of that with the usual tools means opening Premiere, writing a script, recording a voice over, and syncing captions by hand. That is enough friction that I never do it.&lt;/p&gt;

&lt;p&gt;But Reels and Shorts feed the algorithm. A 30-second hook with my voice out-performs a static link share by a wide margin, and I was leaving all of that on the table for one dumb reason: the editing tax.&lt;/p&gt;

&lt;p&gt;So I built the friction out.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Pipeline, End to End
&lt;/h2&gt;

&lt;p&gt;A single POST request to &lt;code&gt;/api/articles/[id]/video&lt;/code&gt; kicks off the whole thing. The render runs in the background; the editor UI polls and shows a preview when the MP4 is ready.&lt;/p&gt;

&lt;p&gt;Under the hood it runs ten coordinated steps:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt; &lt;strong&gt;Postgres and Drizzle&lt;/strong&gt; &lt;a href="https://levelup.gitconnected.com/5-functions-1-route-0-month-my-entire-saas-background-job-architecture-24363b6f45aa" rel="noopener noreferrer"&gt;track the render job&lt;/a&gt; through its states — queued, then processing, then done.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Cloudflare R2&lt;/strong&gt; stores the final MP4 and the audio assets.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Claude Haiku 4.5&lt;/strong&gt; turns the article into a 3-to-5 scene script with a hook, body beats, and a closing call to action.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;node-html-parser&lt;/strong&gt; scrapes the cover and diagram images from the published Substack post, so the reel reuses the brand-styled diagrams I already shipped instead of generic stock art.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;ElevenLabs&lt;/strong&gt; narrates each scene in my cloned voice on the $5/month Starter plan. The &lt;code&gt;/with-timestamps&lt;/code&gt; endpoint returns per-word alignment, which the captions need later.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;sharp and SVG&lt;/strong&gt; render each scene as three PNG layers: the background image, the static chrome (header plus progress dots), and the foreground content overlay.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;FFmpeg&lt;/strong&gt; assembles the per-scene MP4 with a Ken Burns zoom on the background, a fade-in on the content, and a 0.4-second silent pause between scenes so the ear can land before the next narration starts.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;libass&lt;/strong&gt; burns in the ASS karaoke captions, turning each word red as it is spoken and locking the line to a fixed bottom anchor so it never jumps as scene lengths change.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Loudnorm at -14 LUFS&lt;/strong&gt; matches the reel to Instagram and Shorts feed loudness. Skip this and the reel plays quieter than everything around it in the feed.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;An R2 upload&lt;/strong&gt; publishes the final MP4, and the UI panel renders it inline with a download link.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fs2lj6dtbhlqgm14ejxm3.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fs2lj6dtbhlqgm14ejxm3.jpg" alt="Ten-step pipeline from POST request to final Reel MP4" width="800" height="1040"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The ten stages that turn a published article into a captioned 9:16 Reel. | Generated with Claude&lt;/p&gt;

&lt;p&gt;The whole thing takes 45 to 60 seconds per reel.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I broke this build down one stage at a time in a free email series, &lt;a href="https://drippery.app/subscribe/6add7e0d-e96d-4d2e-bd26-2f3cd32c6590" rel="noopener noreferrer"&gt;Reel Pipeline Series&lt;/a&gt;, if you want the version with the actual prompts and config.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The Hardest Part Was Not What I Expected
&lt;/h2&gt;

&lt;p&gt;The hardest part wasn't the AI. It was the captions.&lt;/p&gt;

&lt;p&gt;ASS subtitles need libass, and Homebrew's stock FFmpeg formula ships without it. Generic FFmpeg overlay filters handle static images fine, but they blow up exponentially once you try to layer 30 word-level frame overlays for karaoke timing.&lt;/p&gt;

&lt;p&gt;I spent a frustrating hour on that before the fix clicked: uninstall the stock formula, install &lt;code&gt;homebrew-ffmpeg/ffmpeg/ffmpeg&lt;/code&gt; from the dedicated tap, and libass comes along for free.&lt;/p&gt;

&lt;p&gt;The second caption problem was the line jumping. ASS bottom-anchor alignment plus a variable number of lines means a one-line caption sits at a different height than a two-line caption. I fixed it by &lt;a href="https://levelup.gitconnected.com/captions-that-read-along-one-word-at-a-time-3751f51d3c85" rel="noopener noreferrer"&gt;pinning every dialogue event&lt;/a&gt; with an explicit &lt;code&gt;\pos(540,1800)&lt;/code&gt; override — same anchor, every scene, no jumping.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Surprise Win Was Caching
&lt;/h2&gt;

&lt;p&gt;I added per-article caching on the second iteration, after noticing that every "regenerate" was re-calling Haiku and re-running six ElevenLabs synthesis requests. Each regeneration was burning roughly $0.10 of credits, which does not sound like much until you are iterating on a visual tweak for the tenth time.&lt;/p&gt;

&lt;p&gt;Now the scenario JSON and the per-scene audio buffers live in Postgres and R2. The next time I click "Generate Reel" for the same article, the render reuses both and only re-runs FFmpeg.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Zero API calls. Zero credits spent.&lt;/strong&gt; I can iterate on visual tweaks — a different brand color, a different motion variant — without paying for any of it.&lt;/p&gt;

&lt;p&gt;If I want a genuinely fresh take, I hit &lt;code&gt;POST /api/articles/[id]/video?fresh=1&lt;/code&gt; and the pipeline goes all the way back to Haiku.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I'd Build Next
&lt;/h2&gt;

&lt;p&gt;Three things sit on the wishlist.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  Swap Haiku for &lt;code&gt;gpt-4o-mini&lt;/code&gt; for the script step — same JSON output, cheaper per call. It is on the list, just not done.&lt;/li&gt;
&lt;li&gt;  A "Post to Instagram" button on the panel that pushes the MP4 straight through the Graph API, so I stop downloading and re-uploading by hand.&lt;/li&gt;
&lt;li&gt;  AI-generated background images per scene for the beats that don't have a Substack diagram to reuse, using a cheap fast model like Flux Schnell.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these are blocking. The pipeline already does the job I built it for, and I would rather ship reels than polish the tool that ships reels.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Real Lesson
&lt;/h2&gt;

&lt;p&gt;The pipeline is not impressive for any single component. What matters is that the gap between "I published an article" and "I have a Reel" is now thirty seconds of clicking.&lt;/p&gt;

&lt;p&gt;That changes my behavior. I post the reel. The algorithm rewards the post. The next reel gets a little more reach, and the flywheel keeps turning.&lt;/p&gt;

&lt;p&gt;This is the part of &lt;a href="https://medium.com/@danielrusnok/i-built-a-0-month-email-drip-tool-because-kit-was-too-expensive-its-now-live-a64bfb308ed9" rel="noopener noreferrer"&gt;building in public&lt;/a&gt; that took me longest to accept. The build is the easy half. Being public consistently is the hard half, and the only way I stay consistent is to delete every excuse not to post before it can talk me out of it.&lt;/p&gt;

&lt;p&gt;This pipeline deletes one of them.&lt;/p&gt;




&lt;h2&gt;
  
  
  External Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;a href="https://elevenlabs.io/docs" rel="noopener noreferrer"&gt;ElevenLabs API&lt;/a&gt; — voice cloning and per-word timestamps&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://ffmpeg.org/" rel="noopener noreferrer"&gt;FFmpeg&lt;/a&gt; — per-scene assembly and Ken Burns motion&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://github.com/libass/libass" rel="noopener noreferrer"&gt;libass&lt;/a&gt; — ASS subtitle rendering for karaoke captions&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://sharp.pixelplumbing.com/" rel="noopener noreferrer"&gt;sharp&lt;/a&gt; — SVG-to-PNG scene layer compositing&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://developers.cloudflare.com/r2/" rel="noopener noreferrer"&gt;Cloudflare R2&lt;/a&gt; — object storage for the finished MP4&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  One More Thing
&lt;/h2&gt;

&lt;p&gt;— Daniel&lt;/p&gt;

&lt;p&gt;&lt;em&gt;P.S. If you want the whole build as a step-by-step log, I packaged it as a free email series, &lt;a href="https://drippery.app/subscribe/6add7e0d-e96d-4d2e-bd26-2f3cd32c6590" rel="noopener noreferrer"&gt;Reel Pipeline Series&lt;/a&gt;, one email per stage.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I turn articles like this one into short vertical videos with my own pipeline. The free playbook is here: &lt;a href="https://danielrusnok.gumroad.com/l/article-to-reel-playbook?utm_source=devto&amp;amp;utm_medium=article_footer&amp;amp;utm_campaign=playbook" rel="noopener noreferrer"&gt;https://danielrusnok.gumroad.com/l/article-to-reel-playbook&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
    </item>
    <item>
      <title>My app records its own tutorials now</title>
      <dc:creator>Digital Craft Workshop</dc:creator>
      <pubDate>Mon, 14 Sep 2026 15:06:21 +0000</pubDate>
      <link>https://dev.to/danielrusnok/my-app-records-its-own-tutorials-now-2le3</link>
      <guid>https://dev.to/danielrusnok/my-app-records-its-own-tutorials-now-2le3</guid>
      <description>&lt;p&gt;A couple of evenings ago I sat down to make tutorial videos for the Reel Pipeline Kit. Eight of them, one per part of the app. And I hit the same wall I always do: I don't want to be on camera, and I don't want to hand-narrate eight screen recordings at nine in the evening.&lt;/p&gt;

&lt;p&gt;So I didn't. I built a thing that records the app for me.&lt;/p&gt;




&lt;h2&gt;
  
  
  The wall
&lt;/h2&gt;

&lt;p&gt;The kit has about eight areas a buyer needs to understand: install and keys, fetching an article, the scenario editor, voice, music, animated clips, branding, and the final render. Each one deserves its own short, detailed walkthrough. Faceless, the same look as the demo videos already on the product page.&lt;/p&gt;

&lt;p&gt;Doing that by hand is death by a thousand takes. Open the app, click the thing, fumble the mouse, re-record, line up the voiceover, cut the captions. Then do it seven more times. On top of that, my hand shakes on the mouse and my narration drifts out of sync, so the eight videos never quite match each other. &lt;a href="https://medium.com/generative-ai/i-automated-away-an-hour-of-project-setup-with-a-claude-code-skill-1ff025e43814" rel="noopener noreferrer"&gt;I'd rather write the pipeline once&lt;/a&gt; and let it run while I make tea.&lt;/p&gt;




&lt;h2&gt;
  
  
  The shape of it
&lt;/h2&gt;

&lt;p&gt;Here is how it works. A headless Chrome drives the real app on localhost. It clicks through the actual UI, real buttons, not a mock. While it drives, it captures the screen frame by frame. My cloned voice reads a script I wrote. Captions light up word by word underneath. A small Digital Craft Workshop card opens each video with a whoosh.&lt;/p&gt;

&lt;p&gt;The obvious route is a desktop screen grabber, OBS or something like it. I tried that first and got a black rectangle. A screen recorder launched over SSH has no desktop to point a camera at: no monitor, no compositor, nothing painted to a screen for it to see. So instead I let Chrome film itself. It streams its own frames over the debugging protocol, one image per repaint, lossless, and ffmpeg stitches them into a 1080p video afterward. The browser is both the thing being recorded and the camera holding still in front of it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7ar4ksdi52r675xhwrve.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7ar4ksdi52r675xhwrve.jpg" alt="Flow diagram: headless Chrome to CDP screencast to ffmpeg to 1080p MP4, with OBS crossed out" width="800" height="347"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Chrome films its own frames over the debugging protocol, no desktop required. | Generated with Claude&lt;/p&gt;

&lt;p&gt;The cursor is fake too. Headless Chrome has no pointer to record, so I draw one on the page myself. It is a small element that slides between the buttons the script is about to press, and it flashes red for the fraction of a second it clicks. Without it the videos felt haunted, buttons depressing on their own with nothing moving toward them. The fake cursor is the cheapest trick in the whole pipeline and it does more for the "a person did this" feeling than anything else.&lt;/p&gt;

&lt;p&gt;The voice is ElevenLabs with a clone of mine. When it returns the audio it also hands back the timing of every word, where each one starts and ends in the clip. That timing is what makes the captions land on the beat instead of drifting a syllable behind the whole way down the video.&lt;/p&gt;




&lt;h2&gt;
  
  
  The fifteen-line map
&lt;/h2&gt;

&lt;p&gt;The first pass looked fine and read wrong. The captions said exactly what I said out loud.&lt;/p&gt;

&lt;p&gt;When the voiceover says "localhost three thousand" so my cloned voice pronounces it properly, I don't want the caption to read "localhost three thousand." I want &lt;code&gt;localhost:3000&lt;/code&gt;. Same with &lt;code&gt;data/settings.json&lt;/code&gt;, which I say as "data settings json," and &lt;code&gt;Node 20&lt;/code&gt;. A developer watching a developer tool notices that kind of thing right away, and once they notice it the whole video reads as amateur.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3dt6tsutvspfsaplehxz.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3dt6tsutvspfsaplehxz.jpg" alt="Before and after term map: spoken phrases mapped to written tokens like localhost:3000" width="800" height="374"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The spoken form maps back to how you would actually write it. | Generated with Claude&lt;/p&gt;

&lt;p&gt;So there is a small map. I spell each term out the long way for the voice, and the caption swaps it back to how you would actually write it. That is the whole trick. It is maybe fifteen lines of code, and it is also the difference between something that looks like a tool and something that looks like a toy. The same word-level caption timing I &lt;a href="https://levelup.gitconnected.com/captions-that-read-along-one-word-at-a-time-3751f51d3c85?source=rss-7467f0f889eb------2" rel="noopener noreferrer"&gt;wrote about for the reels&lt;/a&gt; earns its keep here too.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;If you want this whole machine broken down stage by stage, I wrote it up as a free email series, one part a week: &lt;a href="https://drippery.app/subscribe/6add7e0d-e96d-4d2e-bd26-2f3cd32c6590" rel="noopener noreferrer"&gt;the Reel Pipeline Series&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Don't touch my setup
&lt;/h2&gt;

&lt;p&gt;One more thing I care about. The recorder pokes my live config while it films. It clicks the voice provider, types a key into a field, toggles animation on a scene, opens the branding panel. Those are my real settings on my real machine, and the whole point of filming is to show them being used.&lt;/p&gt;

&lt;p&gt;So it puts everything back. It types a fake key, films the moment, clears the field before anything saves. It flips a radio to show the option, then flips it back to what it was. Nothing the recording does survives the recording. A tutorial recording has no business quietly changing my own configuration.&lt;/p&gt;




&lt;h2&gt;
  
  
  What came out
&lt;/h2&gt;

&lt;p&gt;Eight episodes. One per feature, one to three minutes each, 1080p, my voice, red captions, the DCW mark on the front. They are all up on the Digital Craft Workshop channel:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt; &lt;a href="https://www.youtube.com/watch?v=dDwNLgIxM-U" rel="noopener noreferrer"&gt;Install &amp;amp; API keys&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt; &lt;a href="https://www.youtube.com/watch?v=mTICwM1O3gs" rel="noopener noreferrer"&gt;Fetch the source article&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt; &lt;a href="https://www.youtube.com/watch?v=e49_U5wqjLU" rel="noopener noreferrer"&gt;The scenario editor&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt; &lt;a href="https://www.youtube.com/watch?v=6ZGsFEZNW1E" rel="noopener noreferrer"&gt;Voice and narration&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt; &lt;a href="https://www.youtube.com/watch?v=4J5z81s9yC8" rel="noopener noreferrer"&gt;Music bed&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt; &lt;a href="https://www.youtube.com/watch?v=EVvDMkgZJE8" rel="noopener noreferrer"&gt;Animated clips (Wan i2v)&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt; &lt;a href="https://www.youtube.com/watch?v=NB4-Qa9xAro" rel="noopener noreferrer"&gt;Branding&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt; &lt;a href="https://www.youtube.com/watch?v=BTbKiy0EgHI" rel="noopener noreferrer"&gt;Render and history&lt;/a&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7hucujkenj2jp954b0jj.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7hucujkenj2jp954b0jj.jpg" alt="Summary card: eight tutorial episodes across one pipeline" width="800" height="314"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Eight episodes ride the same pipeline; the ninth is barely any work. | Generated with Claude&lt;/p&gt;

&lt;p&gt;My favorite part is the ninth episode, the one I haven't made yet. It would barely be work: a script and a short recipe of which buttons to press, run through the same pipeline. The expensive part was building the first one. Everything after that rides rails that are already there.&lt;/p&gt;




&lt;h2&gt;
  
  
  The honest bit
&lt;/h2&gt;

&lt;p&gt;It isn't spotless. The render episode shows the app producing a real reel, and the same machine is grinding through my production render queue while it films. So the render list is noisy with other jobs, and a thumbnail or two comes up black in headless Chrome. In that episode I play the finished reel from history instead of watching a progress bar crawl for ten minutes. Good enough for a walkthrough. I would rather ship eight honest videos than polish one forever, and that is a trade I &lt;a href="https://medium.com/p/db51a8d7cc74" rel="noopener noreferrer"&gt;keep making on purpose&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The kit is on the bench, the tutorials record themselves now, and I got my evening back.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The reel pipeline behind these tutorials is its own free email series, one part a week: &lt;a href="https://drippery.app/subscribe/6add7e0d-e96d-4d2e-bd26-2f3cd32c6590" rel="noopener noreferrer"&gt;the Reel Pipeline Series&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  External Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;a href="https://elevenlabs.io" rel="noopener noreferrer"&gt;ElevenLabs&lt;/a&gt; — cloned voice with per-word timings&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://chromedevtools.github.io/devtools-protocol/" rel="noopener noreferrer"&gt;Chrome DevTools Protocol&lt;/a&gt; — headless frame capture&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://ffmpeg.org" rel="noopener noreferrer"&gt;FFmpeg&lt;/a&gt; — stitches frames into video&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://github.com/Wan-Video/Wan2.2" rel="noopener noreferrer"&gt;Wan 2.2&lt;/a&gt; — image-to-video for animated clips&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;The kit itself: &lt;a href="https://danielrusnok.gumroad.com/l/reel-pipeline" rel="noopener noreferrer"&gt;Reel Pipeline Kit&lt;/a&gt; — turn any article into a narrated, captioned 9:16 reel, locally, on your own keys.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>automation</category>
      <category>video</category>
      <category>buildinginpublic</category>
      <category>developertools</category>
    </item>
    <item>
      <title>I skipped RAG for my AI app's voice. Static markdown won.</title>
      <dc:creator>Digital Craft Workshop</dc:creator>
      <pubDate>Sat, 12 Sep 2026 15:03:35 +0000</pubDate>
      <link>https://dev.to/danielrusnok/i-skipped-rag-for-my-ai-apps-voice-static-markdown-won-1k31</link>
      <guid>https://dev.to/danielrusnok/i-skipped-rag-for-my-ai-apps-voice-static-markdown-won-1k31</guid>
      <description>&lt;p&gt;I was one commit away from putting a vector database behind my AI's voice. I deleted that plan and used a single markdown file instead. The drafts got better, and the context now costs me $0 a month.&lt;/p&gt;

&lt;p&gt;The feature is a Note drafter I built into my Substack growth dashboard. You give it a prompt like "build-in-public milestone, 3 variants" and it returns three Substack notes that sound like me. What made it worth writing up was where the complexity actually belonged, which turned out to be somewhere much simpler than I first assumed.&lt;/p&gt;

&lt;p&gt;The first design I sketched stored every published note and every voice decision in a &lt;a href="https://generativeai.pub/how-i-added-persistent-semantic-memory-to-claude-code-in-15-minutes-9b91f9399a76?source=rss-7467f0f889eb------2" rel="noopener noreferrer"&gt;vector database&lt;/a&gt;, and let the model retrieve the relevant pieces per draft. Embeddings and a similarity search in front of the model, the standard RAG pipeline. That setup is built for fetching knowledge on demand, which sounded right until I looked at what voice actually needs.&lt;/p&gt;




&lt;h2&gt;
  
  
  What voice actually is
&lt;/h2&gt;

&lt;p&gt;Voice for an AI assistant is a &lt;strong&gt;stable identity&lt;/strong&gt;: who you are and what tone you use. It is the part that should come out the same every single time you draft.&lt;/p&gt;

&lt;p&gt;Knowledge changes by query. What happened recently in this project, or what a term means: that kind of context shifts per question and benefits from retrieval.&lt;/p&gt;

&lt;p&gt;For a Note drafter specifically, consistency is most of the value. The whole point of the feature is that three drafts in a row read like the same person wrote them, and that the person is me. If the voice drifts between drafts, I have to rewrite them by hand, which defeats the reason I built the tool. So the register is the one thing I cannot let move.&lt;/p&gt;

&lt;p&gt;RAG is a knowledge tool. When you bolt it onto a voice problem, a few things break, and they all push the output in the same wrong direction.&lt;/p&gt;

&lt;p&gt;The context stops being consistent. Two adjacent prompts pull different chunks, the model sees different priors, and the voice wanders between drafts. One note sounds like me, the next sounds like a press release.&lt;/p&gt;

&lt;p&gt;The retrieval also pulls in irrelevant hits. A query about a "build-in-public note" matches old project facts and architecture decisions. None of that is voice. It is noise, and the model folds it in, dragging the register toward generic blog post.&lt;/p&gt;

&lt;p&gt;And you pay for all of it. Each generation turns into a vector search call plus an LLM call. For voice, the search half adds latency and cost and returns nothing you needed.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzg92vlnn7suodzw20t3q.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzg92vlnn7suodzw20t3q.jpg" alt="RAG retrieves different chunks per query while a static voice file stays constant" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;RAG pulls different chunks each query; a voice baseline stays constant. | Generated with Claude&lt;/p&gt;




&lt;h2&gt;
  
  
  What I did instead
&lt;/h2&gt;

&lt;p&gt;The voice lives in a 500 to 1000 token markdown file in the database, edited from a settings page. Nothing clever. It looks roughly like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gh"&gt;# Voice profile&lt;/span&gt;

&lt;span class="gu"&gt;## Tone&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Specific, no fluff. Short sentences.
&lt;span class="p"&gt;-&lt;/span&gt; Czech directness in English. Admit uncertainty when present.

&lt;span class="gu"&gt;## No-go phrases&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; "In today's fast-paced world"
&lt;span class="p"&gt;-&lt;/span&gt; "Game-changer", "Unlock", "Mind-blown"
&lt;span class="p"&gt;-&lt;/span&gt; Em-dashes used as a drama device

&lt;span class="gu"&gt;## Good note examples&lt;/span&gt;
[3 actual high-performing notes pasted in]

&lt;span class="gu"&gt;## Bad note examples (and why)&lt;/span&gt;
[1-2 generic AI-sounding notes pasted in]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On top of that file I inject the &lt;strong&gt;last 10 published notes from the app's own database&lt;/strong&gt; as a register reference. They are not retrieved by similarity. They are the trailing window: the last 10 published notes, always that same set, pulled without any search step.&lt;/p&gt;

&lt;p&gt;Assembling that window is a plain SQL query. The dashboard already stores every note I publish, so the drafter just selects the ten most recent rows ordered by publish date and pastes their text into the prompt. There is no embedding step and no index to keep warm. The query runs in a few milliseconds and the result is the same for every prompt in a given moment, so it stays cheap no matter how many drafts I ask for.&lt;/p&gt;

&lt;p&gt;That trailing window matters more than it looks. It keeps the model anchored to how I have actually been writing lately, not how I wrote six months ago, and it updates on its own as I publish. The file holds the rules, and the window keeps showing the model how I write right now.&lt;/p&gt;

&lt;p&gt;Editing it is just as boring, which is the point. When a draft comes out wrong, I open the settings page, add the offending phrase to the no-go list or paste in a better example, and save. No re-indexing, no pipeline to babysit. The next draft picks up the change immediately.&lt;/p&gt;

&lt;p&gt;The whole prompt lands at about 2k tokens in and 300 out, on Gemini 2.5 Flash Lite. That works out to roughly $0.0003 per draft, or about five cents for a month of five drafts a day.&lt;/p&gt;




&lt;h2&gt;
  
  
  The 80% comes from the examples
&lt;/h2&gt;

&lt;p&gt;The strongest thing I took from this build: &lt;strong&gt;three good note examples plus one or two bad ones did more for output quality than a model upgrade would have.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The model learns the register from actual sentences, not from a description of the register. A rule like "no fluff phrases" tells it what to avoid. An example shows it the target to hit, and a target is far easier to copy than a prohibition.&lt;/p&gt;

&lt;p&gt;I tested it both ways. With rules only, a "shipped a new feature" draft came back as something like "Excited to share a game-changing update that will supercharge your workflow." Correct topic, generic voice, exactly the register I do not use. With three of my real notes in the file, the same prompt produced a draft that opened with the concrete thing I had shipped and why it annoyed me enough to build it, in short flat sentences. Same rules, same model, same prompt. The only difference was that the model now had sentences to imitate instead of a list of things to avoid.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjumyubvg15diy1hmt8ij.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjumyubvg15diy1hmt8ij.jpg" alt="A note draft with rules only versus the same rules plus three concrete examples" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Rules alone drift generic; three real examples pull the draft into voice. | Generated with Claude&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I collect more of these "where does the complexity actually belong" calls in a free email series, &lt;a href="https://drippery.app/subscribe/6e302973-cf5f-47ff-8803-22122ac3bd9d" rel="noopener noreferrer"&gt;The Claude Code Memory Starter&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  When RAG is right
&lt;/h2&gt;

&lt;p&gt;None of this is anti-RAG. Retrieval earns its place when the context actually changes by query, so that feature X and feature Y pull different docs, or when there is more context than the prompt window can hold.&lt;/p&gt;

&lt;p&gt;There is a spot for it in this same dashboard, in fact. The day I add a "what have I already written about pricing" lookup, that is retrieval work: the answer is a specific past post, and there is far more of it than I want sitting in a prompt. The voice file has nothing to do with it.&lt;/p&gt;

&lt;p&gt;For voice, none of that is true. The identity is the same every time, and it fits in the prompt with room to spare. Getting the register right is a matter of showing the model the right examples, not searching for them.&lt;/p&gt;




&lt;h2&gt;
  
  
  The rule I keep coming back to
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Voice is a baseline. Knowledge is retrieval.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Bake the constant part into the &lt;a href="https://medium.com/gitconnected/getting-claude-to-return-strict-json-without-drift-a-system-prompt-template-9ee1b4eed5c4" rel="noopener noreferrer"&gt;system prompt&lt;/a&gt; as a static blob, and retrieve only what actually changes per query.&lt;/p&gt;

&lt;p&gt;I built this for my own dashboard, but the same split holds for any &lt;a href="https://medium.com/@danielrusnok/my-ai-coding-assistant-kept-making-the-same-mistakes-so-i-made-it-take-notes-66fe0db1416f" rel="noopener noreferrer"&gt;AI feature you bolt onto your own tool&lt;/a&gt;, whether that is a chat wrapper or a content drafter like mine. Before reaching for a vector DB, ask whether you actually need retrieval, or whether you just need a baseline.&lt;/p&gt;

&lt;p&gt;Most of the time, you do not.&lt;/p&gt;




&lt;h2&gt;
  
  
  External Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;a href="https://en.wikipedia.org/wiki/Retrieval-augmented_generation" rel="noopener noreferrer"&gt;Retrieval-augmented generation&lt;/a&gt; — what RAG is, in one page&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://www.pinecone.io" rel="noopener noreferrer"&gt;Pinecone&lt;/a&gt; — managed vector database for retrieval&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://ai.google.dev" rel="noopener noreferrer"&gt;Google AI for Developers&lt;/a&gt; — Gemini Flash Lite models and pricing&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;I write up more of these build decisions as a free email series, &lt;a href="https://drippery.app/subscribe/6e302973-cf5f-47ff-8803-22122ac3bd9d" rel="noopener noreferrer"&gt;The Claude Code Memory Starter&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I'm Daniel. I build content tools and small games solo, and write about what breaks on the way.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I build small tools and kits for solo creators. You can find them here: &lt;a href="https://danielrusnok.gumroad.com?utm_source=devto&amp;amp;utm_medium=article_footer&amp;amp;utm_campaign=catalog" rel="noopener noreferrer"&gt;https://danielrusnok.gumroad.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>rag</category>
      <category>promptengineering</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Three things I learned shipping ONNX to Fly.io</title>
      <dc:creator>Digital Craft Workshop</dc:creator>
      <pubDate>Thu, 10 Sep 2026 15:04:03 +0000</pubDate>
      <link>https://dev.to/danielrusnok/three-things-i-learned-shipping-onnx-to-flyio-32m9</link>
      <guid>https://dev.to/danielrusnok/three-things-i-learned-shipping-onnx-to-flyio-32m9</guid>
      <description>&lt;p&gt;&lt;code&gt;ld-linux-x86-64.so.2: No such file or directory&lt;/code&gt;. That error cost me 20 minutes. Then OOM killed my Fly machine within 30 seconds of first inference. Then Pinecone's SDK threw "Must pass in at least 1 recordID" on a call where I clearly passed an array of IDs. Three errors in one afternoon, all misleading.&lt;/p&gt;

&lt;p&gt;I was shipping a small Node feature for grownote, my Substack growth dashboard. The feature: a button that pulls memories from &lt;a href="https://generativeai.pub/how-i-added-persistent-semantic-memory-to-claude-code-in-15-minutes-9b91f9399a76" rel="noopener noreferrer"&gt;my mem0 (Pinecone-backed)&lt;/a&gt; and bakes them into AI-generated engagement drafts. To query Pinecone semantically I needed the same embedding model that wrote the index: &lt;code&gt;sentence-transformers/all-MiniLM-L6-v2&lt;/code&gt;. The Node-friendly route is &lt;code&gt;@xenova/transformers&lt;/code&gt;, an ONNX runtime port of the same model.&lt;/p&gt;

&lt;p&gt;Three things had to work: getting the ONNX native binary running on Linux, fitting the model in machine memory, and making the Pinecone client behave like its docs say. None of them did on the first try.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. Alpine vs glibc: the silent native binary trap
&lt;/h2&gt;

&lt;p&gt;My Dockerfile was the standard &lt;code&gt;node:22-alpine&lt;/code&gt; recipe. Multi-stage build, deps + build + runtime, ~180MB image. It's been good for grownote since launch.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;@xenova/transformers&lt;/code&gt; installed cleanly and the build passed. The container came up, but the first call to the embedding pipeline returned this from Fly logs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error loading shared library ld-linux-x86-64.so.2: 
No such file or directory 
(needed by /app/node_modules/onnxruntime-node/bin/napi-v3/linux/x64/libonnxruntime.so.1.14.0)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvmqn756rq870n971u1yi.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvmqn756rq870n971u1yi.jpg" alt="Fly logs showing the ld-linux-x86-64.so.2 shared library error" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The ld-linux error that really means Alpine has no glibc | Generated with Claude&lt;/p&gt;

&lt;p&gt;The lie in the error message: &lt;code&gt;ld-linux-x86-64.so.2&lt;/code&gt; is the &lt;strong&gt;glibc dynamic linker&lt;/strong&gt;. The file is missing because &lt;strong&gt;Alpine doesn't have glibc&lt;/strong&gt;. Alpine uses musl libc, which uses a different linker (&lt;code&gt;ld-musl-x86_64.so.1&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;&lt;code&gt;@xenova/transformers&lt;/code&gt; ships prebuilt binaries via &lt;code&gt;onnxruntime-node&lt;/code&gt;. Those binaries are linked against glibc. They don't run on musl, and there's no &lt;code&gt;onnxruntime-node&lt;/code&gt; build for musl in the official package.&lt;/p&gt;

&lt;p&gt;You can fix this two ways:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option A: switch to a glibc base image.&lt;/strong&gt; Change &lt;code&gt;ARG NODE_VERSION=22-alpine&lt;/code&gt; to &lt;code&gt;node:22-bookworm-slim&lt;/code&gt;. Image grows ~85MB (Debian is heavier than Alpine). Otherwise drop-in compatible. ONNX prebuilt binaries load without complaint.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option B: keep Alpine and build ONNX from source against musl.&lt;/strong&gt; Theoretically possible. Practically not worth the time unless you have hard reasons to keep Alpine (image size for very large fleets, security policy). For one app, this is hours of yak-shaving.&lt;/p&gt;

&lt;p&gt;I went with Option A. The Dockerfile change was one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;ARG&lt;/span&gt;&lt;span class="s"&gt; NODE_VERSION=22-bookworm-slim&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rebuilt, pushed, the container came up clean.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; Native Node modules that ship prebuilt binaries are usually glibc-only. Alpine works for pure-JS packages. Once your dep tree includes a compiled module like ONNX or sqlite3, Debian slim is the safer base. If a package fails at runtime instead of install time, you want to catch it in 30 seconds via &lt;code&gt;docker run&lt;/code&gt; locally, not after deploy.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. 512 MB Fly machine: the OOM with misleading symptoms
&lt;/h2&gt;

&lt;p&gt;The next deploy started fine. Health check passed. I clicked "Sync from mem0" in the dashboard. Frontend showed a spinner. After 30 seconds: 502 from Fly's edge proxy.&lt;/p&gt;

&lt;p&gt;Browser DevTools showed an empty response body. Frontend logged a generic "Uncaught Error: An unexpected response was received from the server."&lt;/p&gt;

&lt;p&gt;I checked Fly logs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[125.024082] Out of memory: Killed process 634 (node) 
total-vm:23813904kB, anon-rss:391536kB, file-rss:184kB
INFO Process appears to have been OOM killed!
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9rydmx5d8zperh0x5tkc.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9rydmx5d8zperh0x5tkc.jpg" alt="Fly OOM kill log with virtual and resident memory numbers" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;23.8 GB virtual is noise; 391 MB resident is what got it killed | Generated with Claude&lt;/p&gt;

&lt;p&gt;The numbers: 23.8 GB virtual memory, 391 MB resident. The kernel killed the process because it was using ~76% of the 512 MB machine RAM, and its heuristic for "this process is about to thrash and bring down everything else" fired.&lt;/p&gt;

&lt;p&gt;The 23 GB virtual is misleading. ONNX Runtime mmaps the model file. mmap reserves virtual address space without committing physical memory upfront, and pages get pulled in only as you read them. So the &lt;em&gt;virtual&lt;/em&gt; number is meaningless. The &lt;em&gt;resident&lt;/em&gt; number (391 MB) is what mattered. On a 512 MB machine, with Node + Next.js + Drizzle + Pinecone client all running, 391 MB just for the embedding pipeline pushed total RSS past the limit.&lt;/p&gt;

&lt;p&gt;Two options:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option A: bump machine memory.&lt;/strong&gt; Edit &lt;code&gt;fly.toml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="nn"&gt;[[vm]]&lt;/span&gt;
  &lt;span class="py"&gt;cpu_kind&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"shared"&lt;/span&gt;
  &lt;span class="py"&gt;cpus&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
  &lt;span class="py"&gt;memory&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"1024mb"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cost delta: ~$2.50/month at full uptime, less if you have &lt;code&gt;auto_stop_machines = "stop"&lt;/code&gt; and &lt;code&gt;min_machines_running = 0&lt;/code&gt; (idle machines don't bill). For grownote, which I use evenings, real cost delta is ~$0.40/month.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option B: externalize the embedding.&lt;/strong&gt; Use HuggingFace Inference API for the model. POST text, receive vector. No local model. It adds an HF API token as a new secret and 200-500ms of HTTP roundtrip per query, but no native deps and no memory overhead.&lt;/p&gt;

&lt;p&gt;I tried Option B first because it felt cleaner. Then I realized HF Inference API for a personal app is yet another vendor dependency. If HF goes down, my engagement queue can't sync mem0. The whole point of self-hosted Pinecone with a local-friendly model was to own the stack.&lt;/p&gt;

&lt;p&gt;I went with Option A. One-line config change, 1 GB machine, problem disappears. Fly auto-stop means I'm paying ~$0.40/mo extra for the few hours per day the app actually serves traffic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; When local model loading &lt;a href="https://levelup.gitconnected.com/our-next-js-app-crashed-every-24-hours-here-are-the-six-memory-fixes-35245f6548e4" rel="noopener noreferrer"&gt;OOMs on a small VM&lt;/a&gt;, don't reflexively reach for an API. Check the cost of bumping memory first. Auto-stopped machines are nearly free at idle. Bumping memory is usually cheaper than adding an external API, which means one more token to manage and one more service that can go down.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Wiring memory into your own Claude Code setup is its own rabbit hole; I packed the hooks, the config, and the mistakes that bite first into a free email series, &lt;a href="https://drippery.app/subscribe/6e302973-cf5f-47ff-8803-22122ac3bd9d" rel="noopener noreferrer"&gt;The Claude Code Memory Starter&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  3. Pinecone v7 SDK: the misleading validator
&lt;/h2&gt;

&lt;p&gt;With Alpine swapped and machine bumped, the embedding worked. Now the actual feature: list all my mem0 records, translate Czech ones to English, re-embed, upsert. Pinecone has explicit APIs for all four steps.&lt;/p&gt;

&lt;p&gt;The list call worked:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;index&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listPaginated&lt;/span&gt;&lt;span class="p"&gt;({});&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ids&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;vectors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fetch call did not:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fetched&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;index&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// PineconeArgumentError: Must pass in at least 1 recordID.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I logged &lt;code&gt;ids&lt;/code&gt; to confirm: an array of 10 valid UUIDs. The validator was clearly checking &lt;em&gt;something&lt;/em&gt;, but not what the error message said.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsw93b97in9s0a5w55s2v.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsw93b97in9s0a5w55s2v.jpg" alt="index.fetch with a bare array failing versus the object form working" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;index.fetch(ids) throws; the validator wants { ids }, not a bare array | Generated with Claude&lt;/p&gt;

&lt;p&gt;I dug into the fetch validator in &lt;code&gt;node_modules/@pinecone-database/pinecone&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;validator&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ids&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PineconeArgumentError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Must pass in at least 1 recordID.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It checks &lt;code&gt;options.ids&lt;/code&gt;, not the argument I handed in. My bare array arrived as &lt;code&gt;options&lt;/code&gt; itself, so &lt;code&gt;options.ids&lt;/code&gt; was &lt;code&gt;undefined&lt;/code&gt; and the guard fired, even though the array held 10 entries.&lt;/p&gt;

&lt;p&gt;The signature that works:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fetched&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;index&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;ids&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Object-form, with &lt;code&gt;ids&lt;/code&gt; as a property. That's the v7 &lt;code&gt;FetchOptions&lt;/code&gt; shape (&lt;code&gt;{ ids: string[] }&lt;/code&gt;); the error message just never names the property it actually wants.&lt;/p&gt;

&lt;p&gt;The clearer case is &lt;code&gt;upsert&lt;/code&gt;, which Pinecone v7 explicitly moved to object-form. Pass the old array and you get the same kind of validator complaint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// v6 / examples in older docs:&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;index&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;upsert&lt;/span&gt;&lt;span class="p"&gt;([{&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;values&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;metadata&lt;/span&gt; &lt;span class="p"&gt;}]);&lt;/span&gt;

&lt;span class="c1"&gt;// v7:&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;index&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;upsert&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;records&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;values&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;metadata&lt;/span&gt; &lt;span class="p"&gt;}]&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same unhelpful message ("Must pass in at least 1 record to upsert") on a non-empty array.&lt;/p&gt;

&lt;p&gt;This cost me 30 minutes between the two endpoints. The fix was trivial. The error message was actively unhelpful.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; When an SDK error says "must pass in at least 1 X" on a call where you clearly passed multiple X's, the validator is probably reading a property off an options object you didn't pass. Try wrapping the array in an object with a sensibly named property (&lt;code&gt;ids&lt;/code&gt;, &lt;code&gt;records&lt;/code&gt;, &lt;code&gt;vectors&lt;/code&gt;). It's a 5-second test that works disturbingly often.&lt;/p&gt;

&lt;p&gt;The Pinecone v7 release notes do mention the object-form move. I found them after I'd already debugged it. Read the migration guide first, even when the major version bump feels minor.&lt;/p&gt;




&lt;h2&gt;
  
  
  The payoff: same embedding model, two languages
&lt;/h2&gt;

&lt;p&gt;The mem0 server that wrote the Pinecone index is a &lt;a href="https://generativeai.pub/how-i-wired-persistent-memory-into-every-claude-code-session-001970318cd5" rel="noopener noreferrer"&gt;Python MCP server using the mem0 library&lt;/a&gt;, configured to use &lt;code&gt;huggingface/all-MiniLM-L6-v2&lt;/code&gt; for embeddings. The Node app reading the index uses &lt;code&gt;@xenova/transformers&lt;/code&gt; with &lt;code&gt;Xenova/all-MiniLM-L6-v2&lt;/code&gt;, an ONNX export of the same model.&lt;/p&gt;

&lt;p&gt;I'd half-expected a vector dim mismatch or subtle drift between the Python and Node implementations. There was none. The ONNX export is deterministic. Vectors written by the Python pipeline and queried by the Node pipeline match within float precision.&lt;/p&gt;

&lt;p&gt;The only thing I had to ensure: the same &lt;code&gt;pooling: 'mean'&lt;/code&gt; and &lt;code&gt;normalize: true&lt;/code&gt; settings on both sides. Otherwise the model is the model, regardless of which runtime you call it from.&lt;/p&gt;

&lt;p&gt;This is genuinely useful: you can write data in one language ecosystem (Python's mature ML tooling) and read it in another (Node's web ecosystem) using the same embedding space. As long as both runtimes load the same model weights and apply the same post-processing, the vectors are interchangeable.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1ap89uad1rketzkvldzz.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1ap89uad1rketzkvldzz.jpg" alt="Python writes and Node reads the same Pinecone index using one embedding model" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One embedding model, written from Python and read from Node | Generated with Claude&lt;/p&gt;




&lt;h2&gt;
  
  
  The rules I kept
&lt;/h2&gt;

&lt;p&gt;Here's what I'm carrying into the next build:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Default to Debian for any Node app that might add native deps later.&lt;/strong&gt; The Alpine image-size win is real for pure-JS apps. The moment you add a native dep, Alpine becomes a tax. Debian slim just works.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Probe small VM memory limits with the actual workload before assuming "it'll fit."&lt;/strong&gt; A 512 MB machine on Fly comfortably runs Next.js + Drizzle + a few API integrations. It does not run Next.js + ONNX + a model in RAM at the same time. The OOM kill happens fast and the error is generic. Test on the target hardware, not your 32 GB Mac.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When SDK errors don't make sense, check argument shape before checking your data.&lt;/strong&gt; "Must pass at least 1 X" on a non-empty array is almost always an arguments signature mismatch, not a data problem. Try the object-wrapped form first.&lt;/p&gt;

&lt;p&gt;The actual feature now works. Sync runs in 8-12 seconds, costs ~$0.02 per click, and returns a 5-section markdown block of my recent decisions and project state that feeds into AI-drafted Substack engagement comments. The build was supposed to take an evening. It took two evenings plus a memory bump. Worth it for the next year of automated drafts that actually reference what I'm working on.&lt;/p&gt;

&lt;p&gt;If you're shipping ML-adjacent Node code to Fly: bookworm-slim base and at least 1 GB of memory. Use Pinecone v7 in object-form. You'll skip three afternoons of confused debugging.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;If you want the memory setup behind grownote's mem0 sync, it is a free email series, &lt;a href="https://drippery.app/subscribe/6e302973-cf5f-47ff-8803-22122ac3bd9d" rel="noopener noreferrer"&gt;The Claude Code Memory Starter&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  External Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;a href="https://github.com/huggingface/transformers.js" rel="noopener noreferrer"&gt;Transformers.js (@xenova/transformers)&lt;/a&gt;: ONNX-powered transformers for Node&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://github.com/microsoft/onnxruntime" rel="noopener noreferrer"&gt;ONNX Runtime&lt;/a&gt;: the native runtime behind the binaries&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://docs.pinecone.io/reference/node-sdk" rel="noopener noreferrer"&gt;Pinecone Node SDK reference&lt;/a&gt;: v7 fetch and upsert signatures&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://fly.io/docs/machines/guides-examples/machine-sizing/" rel="noopener noreferrer"&gt;Fly.io machine sizing&lt;/a&gt;: memory and auto-stop config&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2" rel="noopener noreferrer"&gt;all-MiniLM-L6-v2 model card&lt;/a&gt;: the shared embedding model&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;I build small tools and kits for solo creators. You can find them here: &lt;a href="https://danielrusnok.gumroad.com?utm_source=devto&amp;amp;utm_medium=article_footer&amp;amp;utm_campaign=catalog" rel="noopener noreferrer"&gt;https://danielrusnok.gumroad.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
    </item>
    <item>
      <title>I Built a Content Outreach Bot That Runs Itself. Two MCP Servers and a Cron Did the Work</title>
      <dc:creator>Digital Craft Workshop</dc:creator>
      <pubDate>Tue, 08 Sep 2026 15:02:40 +0000</pubDate>
      <link>https://dev.to/danielrusnok/i-built-a-content-outreach-bot-that-runs-itself-two-mcp-servers-and-a-cron-did-the-work-4ch9</link>
      <guid>https://dev.to/danielrusnok/i-built-a-content-outreach-bot-that-runs-itself-two-mcp-servers-and-a-cron-did-the-work-4ch9</guid>
      <description>&lt;p&gt;I write Notes on Substack to reach other authors. The good move is to pick a strong recent article from someone in my niche, make something for it, and tag them. Done well, it's outreach that doesn't feel like outreach.&lt;/p&gt;

&lt;p&gt;Done by hand, it's a chore. Scroll archives. Judge what's actually landing. Render a video. Post the Note. Tag the right person. Forty minutes, three times a day.&lt;/p&gt;

&lt;p&gt;I have one good evening window. &lt;strong&gt;I'm not spending it scrolling.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;So I built it to run without me. Now three times a day a remote agent picks an article, my home GPU renders a reel for it, and a video lands in my Telegram. I react with a thumbs-up. That ships a Substack Note with the video and a real @mention. If I don't react, nothing happens.&lt;/p&gt;

&lt;p&gt;The interesting part isn't the AI. &lt;strong&gt;It's how dumb the orchestration is once the hard parts live behind MCP.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; Two MCP servers (one on Fly, one on my home PC behind a Cloudflare tunnel) become the "hands", a scheduled claude.ai routine becomes the "schedule", and a Telegram reaction becomes the "approval." Each new capability is one MCP tool. Orchestration is a prompt on a cron.&lt;/p&gt;




&lt;h2&gt;
  
  
  The mental model
&lt;/h2&gt;

&lt;p&gt;Three roles, cleanly split:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;MCP servers are my hands.&lt;/strong&gt; Each thing I can't do yet becomes a tool. Deploy it once, it exists forever.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;A cloud routine is my schedule.&lt;/strong&gt; A remote Claude agent on a cron. It calls the tools in order. It never asks me anything.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;A chat reaction is my approval.&lt;/strong&gt; The one human gate. I react to a Telegram message and that ships.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When I framed it that way, the build got boring in the best way. Every "how do I automate this?" turned into "which of those three buckets is this?"&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Farl79fx916z70bz0vuvx.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Farl79fx916z70bz0vuvx.jpg" alt="Three roles: MCP servers as hands, a cloud routine as schedule, a chat reaction as approval" width="800" height="347"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The three roles behind a self-running bot: tools do the work, a routine picks the moment, one tap approves | Generated with Claude&lt;/p&gt;




&lt;h2&gt;
  
  
  The flow
&lt;/h2&gt;

&lt;p&gt;End to end, one run looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  ┌─────────────────────────┐
  │  cloud routine (cron 3×) │   the schedule
  └────────────┬────────────┘
               │ 1. find_reel_candidate
               ▼
  ┌─────────────────────────┐
  │      substack-mcp        │   (Fly.io)
  │  ranks archives, dedup   │
  └────────────┬────────────┘
               │ 2. render_reel_from_url  (jobId, then poll)
               ▼
  ┌─────────────────────────┐
  │         pc-mcp           │   (home PC, CF tunnel)
  │   RPK + ComfyUI → R2     │
  └────────────┬────────────┘
               │ 3. record_reel_outreach
               │ 4. send_reel_draft
               ▼
  ┌─────────────────────────┐
  │        Telegram          │   video lands in my chat
  └────────────┬────────────┘
               │  👍 (message_reaction)
               ▼
  ┌─────────────────────────┐
  │  webhook on substack-mcp │
  │      → publish_note      │
  └────────────┬────────────┘
               ▼
           Substack  (Note + video + @mention)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I touch exactly one of those arrows. The rest runs whether I'm at my desk or asleep.&lt;/p&gt;




&lt;h2&gt;
  
  
  Server 1: substack-mcp, the brain on Fly
&lt;/h2&gt;

&lt;p&gt;This one wraps the Substack API plus my own "grownote" engine, the thing that drafts and schedules my Substack Notes. The tools:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;code&gt;find_reel_candidate&lt;/code&gt;: scans the archives of publications I subscribe to, ranks them by reactions, comments, and restacks, and returns the best one I haven't touched.&lt;/li&gt;
&lt;li&gt;  &lt;code&gt;record_reel_outreach&lt;/code&gt;: logs that I'm about to tap an author, so I don't hit them again next run.&lt;/li&gt;
&lt;li&gt;  &lt;code&gt;send_reel_draft&lt;/code&gt;: posts the finished reel to Telegram and stashes the publish payload.&lt;/li&gt;
&lt;li&gt;  &lt;code&gt;publish_note&lt;/code&gt;: posts the Note. It now supports attaching a video and a real @mention, which is the whole point of the outreach.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The dedup is the part that matters. &lt;strong&gt;An outreach bot that taps the same author twice a day is a spam bot.&lt;/strong&gt; So &lt;code&gt;find_reel_candidate&lt;/code&gt; excludes, server-side, every article I've ever made a reel for and every author I've tapped in the last 7 days.&lt;/p&gt;

&lt;p&gt;I didn't want a migration for this. So state is just small JSON blobs in a Postgres &lt;code&gt;app_config&lt;/code&gt; table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- conceptually:&lt;/span&gt;
&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;app_config&lt;/span&gt; &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'reel_done_article_ids'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;app_config&lt;/span&gt; &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'reel_author_taps'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;find_reel_candidate&lt;/code&gt; reads both, filters the candidate set before it ever returns, and the caller can't accidentally skip the check. The dedup lives where the decision lives.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;That's the rule I keep relearning: put the guard inside the tool, not in the prompt that calls it.&lt;/strong&gt; It's the same reason I &lt;a href="https://medium.com/generative-ai/how-i-put-a-code-reviewer-in-front-of-every-git-push-to-main-with-a-claude-code-subagent-17408aa3236e?sk=b141e0baa56cea334ad5887116c4f771" rel="noopener noreferrer"&gt;put a code reviewer in front of every git push to main&lt;/a&gt; instead of trusting myself to remember.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Wiring your own capabilities into Claude Code like this? I keep the whole setup, the hooks, the config, and the mistakes that bite first, in a free email series, &lt;a href="https://drippery.app/subscribe/6e302973-cf5f-47ff-8803-22122ac3bd9d" rel="noopener noreferrer"&gt;The Claude Code Memory Starter&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Server 2: pc-mcp, the muscle on my home PC
&lt;/h2&gt;

&lt;p&gt;The reel itself is rendered locally. My home PC runs the Reel Pipeline Kit (a Next.js app) driving ComfyUI on an RTX 3060. &lt;strong&gt;The GPU is free; cloud GPU minutes are not.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;So I exposed exactly one tool from that machine: &lt;code&gt;render_reel_from_url&lt;/code&gt;. It takes an article URL, produces the &lt;a href="https://levelup.gitconnected.com/captions-that-read-along-one-word-at-a-time-3751f51d3c85" rel="noopener noreferrer"&gt;branded reel with read-along captions&lt;/a&gt;, and uploads it to R2.&lt;/p&gt;

&lt;p&gt;The PC sits behind a Cloudflare tunnel so the cloud routine can reach it without me opening a port. Which leads to the one design detail that actually bit me.&lt;/p&gt;




&lt;h2&gt;
  
  
  The async-job pattern that bites everyone
&lt;/h2&gt;

&lt;p&gt;A reel render takes one to eight minutes. &lt;strong&gt;Cloudflare's edge kills a request at roughly 100 seconds on the free tier.&lt;/strong&gt; So a synchronous &lt;code&gt;render_reel_from_url&lt;/code&gt; that blocks until the video is done will always time out for the slow ones. The tunnel hangs up, the routine sees an error, and you've burned a render for nothing.&lt;/p&gt;

&lt;p&gt;The fix is to never do long work synchronously behind MCP. Return a handle, poll for the result:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// render_reel_from_url returns immediately&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;jobId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;reel_a3f9&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;queued&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// render_reel_status(jobId) is called until done&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;rendering&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;rendering&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;done&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;videoUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://...r2.dev/reel_a3f9.mp4&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The caller fires the job, then polls &lt;code&gt;render_reel_status&lt;/code&gt; every so often until &lt;code&gt;status&lt;/code&gt; is &lt;code&gt;done&lt;/code&gt;. Each call is well under the timeout. The render keeps running on the PC regardless of who's connected.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F51d9ymbo73i1sdpkooy1.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F51d9ymbo73i1sdpkooy1.jpg" alt="The job plus poll pattern keeping every call under the Cloudflare edge timeout" width="800" height="347"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Fire the job, then poll for status, so no single call hits the 100-second edge limit | Generated with Claude&lt;/p&gt;

&lt;p&gt;I'd already learned this with my local image-gen tool, which works the exact same way. So when I added the reel renderer, job + poll was the default, not a fix after the first timeout.&lt;/p&gt;

&lt;p&gt;If you take one thing from this post: &lt;strong&gt;long work behind MCP-over-Cloudflare has to be job + poll, never a sync call.&lt;/strong&gt; The edge timeout isn't negotiable, and you don't want it to be. The alternative is a held-open socket for eight minutes, which is its own kind of fragile.&lt;/p&gt;




&lt;h2&gt;
  
  
  The orchestrator is just a prompt on a cron
&lt;/h2&gt;

&lt;p&gt;Here's the part that still feels like cheating. The thing tying all this together is a &lt;strong&gt;claude.ai cloud routine&lt;/strong&gt;, a scheduled remote Claude agent that runs three times a day, with the MCP servers connected and a prompt that says, in effect:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Call &lt;code&gt;find_reel_candidate&lt;/code&gt;. Take the article URL. Call &lt;code&gt;render_reel_from_url&lt;/code&gt;, then poll &lt;code&gt;render_reel_status&lt;/code&gt; until done. Call &lt;code&gt;record_reel_outreach&lt;/code&gt;. Call &lt;code&gt;send_reel_draft&lt;/code&gt; with the video URL. Stop.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That's it. No local machine has to be awake, and no session I have to babysit. It never asks me a question, because there's nothing to ask. The candidate is chosen, the dedup is enforced server-side, and the only human decision happens later, in Telegram.&lt;/p&gt;

&lt;p&gt;The orchestration logic is just a paragraph, not code I maintain. When I want to change the behavior, say render two candidates or skip weekends, &lt;strong&gt;I edit the prompt, not a deploy.&lt;/strong&gt; It's the same instinct behind &lt;a href="https://generativeai.pub/how-i-wired-persistent-memory-into-every-claude-code-session-001970318cd5" rel="noopener noreferrer"&gt;wiring persistent memory into every Claude Code session&lt;/a&gt;: let the agent read the state, don't hard-code it.&lt;/p&gt;




&lt;h2&gt;
  
  
  The approval loop: react-to-publish
&lt;/h2&gt;

&lt;p&gt;This is my favorite trick. &lt;code&gt;send_reel_draft&lt;/code&gt; posts the reel to Telegram as a video, and stores the full &lt;code&gt;publish_note&lt;/code&gt; payload keyed by the Telegram &lt;code&gt;message_id&lt;/code&gt;. The draft just sits there.&lt;/p&gt;

&lt;p&gt;Then I set a Telegram &lt;strong&gt;bot webhook&lt;/strong&gt; on substack-mcp, scoped to exactly one update type:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// one-time setup&lt;/span&gt;
&lt;span class="nf"&gt;setWebhook&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://substack-mcp.fly.dev/telegram/webhook&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;allowed_updates&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;message_reaction&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now when I react to a draft message with any emoji, Telegram fires a &lt;code&gt;message_reaction&lt;/code&gt; update. The webhook reads the &lt;code&gt;message_id&lt;/code&gt;, looks up the stored payload, and calls &lt;code&gt;publish_note&lt;/code&gt;. &lt;strong&gt;Reacting is shipping.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;There are no buttons and no separate app to open. Nothing to remember. I'm in Telegram anyway, so a thumbs-up from my phone publishes a Substack Note with the video attached and the author @mentioned.&lt;/p&gt;

&lt;p&gt;One detail worth stealing: the handler &lt;strong&gt;removes the pending entry before it publishes.&lt;/strong&gt; If it published first and deleted after, a second reaction (or Telegram retrying the webhook) could double-post the same Note.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pending&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;take&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;message_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// atomic read-and-remove&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;                &lt;span class="c1"&gt;// already shipped, or never existed&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;publishNote&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Delete-then-act makes the operation idempotent. A second reaction finds nothing and quietly does nothing.&lt;/p&gt;




&lt;h2&gt;
  
  
  What this pattern is, generalized
&lt;/h2&gt;

&lt;p&gt;Strip away my specifics and the shape is reusable for any solo builder:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  Every capability you don't have yet becomes &lt;strong&gt;one MCP tool you deploy.&lt;/strong&gt; Now it exists for every agent, forever.&lt;/li&gt;
&lt;li&gt;  Keep your &lt;strong&gt;guards&lt;/strong&gt; (dedup, rate limits, idempotency) inside the tools, where a drifting prompt can't skip them.&lt;/li&gt;
&lt;li&gt;  Use &lt;strong&gt;job + poll&lt;/strong&gt; for anything slow behind a tunnel.&lt;/li&gt;
&lt;li&gt;  Let a &lt;strong&gt;cloud routine&lt;/strong&gt; be the clock. Orchestration as a prompt is editable in seconds and needs no machine of yours awake.&lt;/li&gt;
&lt;li&gt;  Make &lt;strong&gt;one human gate&lt;/strong&gt;, and put it where you already are. A chat reaction beats a dashboard you have to remember to open.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The split is the whole trick: the tools do the work, a cloud routine picks the moment, and I approve with a tap. Wire those together and "build it once, it runs itself" stops being a slogan.&lt;/p&gt;

&lt;p&gt;I haven't manually picked a reel candidate in two weeks. The bot taps three authors a day. I tap an emoji when I like the result. &lt;strong&gt;That's the whole job now.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;If you're building your own Claude Code setup, the hooks and guardrails I wish I'd had on day one are in a free email series, &lt;a href="https://drippery.app/subscribe/6e302973-cf5f-47ff-8803-22122ac3bd9d" rel="noopener noreferrer"&gt;The Claude Code Memory Starter&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  External Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;a href="https://modelcontextprotocol.io" rel="noopener noreferrer"&gt;Model Context Protocol&lt;/a&gt; (the open spec behind MCP tools)&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/" rel="noopener noreferrer"&gt;Cloudflare Tunnel&lt;/a&gt; (expose a local service without opening a port)&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://github.com/comfyanonymous/ComfyUI" rel="noopener noreferrer"&gt;ComfyUI&lt;/a&gt; (node-based local image and video pipeline)&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://core.telegram.org/bots/api" rel="noopener noreferrer"&gt;Telegram Bot API&lt;/a&gt; (webhooks and message_reaction updates)&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://fly.io" rel="noopener noreferrer"&gt;Fly.io&lt;/a&gt; (where the substack-mcp server runs)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;P.S. The funniest part is how little of this is AI. The model picks an article and writes a Note, sure. But the architecture (job + poll, server-side dedup, react-to-publish, a prompt on a cron) is plain systems plumbing. What made it work was drawing the boundaries right. The model just colors inside them, as usual.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I turn articles like this one into short vertical videos with my own pipeline. The free playbook is here: &lt;a href="https://danielrusnok.gumroad.com/l/article-to-reel-playbook?utm_source=devto&amp;amp;utm_medium=article_footer&amp;amp;utm_campaign=playbook" rel="noopener noreferrer"&gt;https://danielrusnok.gumroad.com/l/article-to-reel-playbook&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Replaying 10,000 Production Fingerprints Through JSDOM in 30 Seconds</title>
      <dc:creator>Digital Craft Workshop</dc:creator>
      <pubDate>Sun, 06 Sep 2026 15:02:51 +0000</pubDate>
      <link>https://dev.to/danielrusnok/replaying-10000-production-fingerprints-through-jsdom-in-30-seconds-12mh</link>
      <guid>https://dev.to/danielrusnok/replaying-10000-production-fingerprints-through-jsdom-in-30-seconds-12mh</guid>
      <description>&lt;h1&gt;
  
  
  Replaying 10,000 Production Fingerprints Through JSDOM in 30 Seconds
&lt;/h1&gt;

&lt;p&gt;&lt;em&gt;Not a member? &lt;a href="https://danielrusnok.medium.com/membership" rel="noopener noreferrer"&gt;Use this link.&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I had ten thousand selectors sitting in a Postgres table and I was not sure the code that wrote them was still compatible with the code that read them. Parser strictness, schema renames, scoring algorithm changes. Any of them could have drifted between writer and reader.&lt;/p&gt;

&lt;p&gt;Unit tests cannot cover this; fixtures only contain what I remembered to write. The honest move is to replay production data through current code and count what survives.&lt;/p&gt;

&lt;p&gt;It took one CSV export and fifty lines of Node, in thirty seconds, to get a clear answer about compatibility over 10,000 records.&lt;/p&gt;




&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;Export the stored fingerprints to a CSV:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;COPY&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fp&lt;/span&gt;
    &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;element_fingerprints&lt;/span&gt;
    &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;
    &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;10000&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="k"&gt;STDOUT&lt;/span&gt; &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="n"&gt;CSV&lt;/span&gt; &lt;span class="n"&gt;HEADER&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each &lt;code&gt;fp&lt;/code&gt; is a JSON blob like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"Ver"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"v3"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"Txt"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Submit"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"Sels"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Sel"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"button[type=&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;submit&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;]"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Prio"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Count"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Offset"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"IsStateful"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Sel"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;".form button"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;            &lt;/span&gt;&lt;span class="nl"&gt;"Prio"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Count"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Offset"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"IsStateful"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The question I want to answer: &lt;strong&gt;can every stored selector still be parsed by the current code path?&lt;/strong&gt; Every call ends in &lt;code&gt;document.querySelector(sel)&lt;/code&gt;, so what I actually need to check is whether the browser's CSS parser accepts the string.&lt;/p&gt;

&lt;p&gt;You don't need a browser to answer that. JSDOM ships with a CSS selector engine that implements the same W3C Selectors grammar Chrome uses. That is enough to validate selector strings without booting a browser.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdypja82247dnk3rgdvyx.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdypja82247dnk3rgdvyx.jpg" alt="Replay pipeline diagram: Postgres COPY, CSV file, Node script, JSDOM parser, output tally" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Postgres COPY to CSV to Node and JSDOM, ending in a compatibility tally | Generated with Claude&lt;/p&gt;




&lt;h2&gt;
  
  
  The script
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fs&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;JSDOM&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;jsdom&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;doc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;JSDOM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;lt;!DOCTYPE html&amp;gt;&amp;lt;body&amp;gt;&amp;lt;/body&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;parseCsv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt; &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;field&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="nx"&gt;inQuotes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;inQuotes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;'&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;'&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;field&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;'&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;'&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;inQuotes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="nx"&gt;field&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;'&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;inQuotes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;field&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="nx"&gt;field&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;field&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt; &lt;span class="nx"&gt;field&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\r&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="nx"&gt;field&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;field&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;field&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parseCsv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./fingerprints.csv&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;shift&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;totalSelectors&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;invalidSelectors&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;fullyCompatible&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;partially&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;allInvalid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;emptySels&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;invalidExamples&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;fpRaw&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;fpRaw&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;fpRaw&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;null&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;emptySels&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fpRaw&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Sels&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;fp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Sels&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;emptySels&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;bad&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;fp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Sels&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;totalSelectors&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Sel&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;bad&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;invalidSelectors&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;invalidExamples&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;invalidExamples&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Sel&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;allInvalid&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bad&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;partially&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="nx"&gt;fullyCompatible&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;totalRows&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;fullyCompatible&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;partially&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;allInvalid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;emptySels&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;totalSelectors&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;invalidSelectors&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;invalidRatio&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;invalidSelectors&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;totalSelectors&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toFixed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;%&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;invalidExamples&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;invalidExamples&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;node validate.js
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="err"&gt;totalRows:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="err"&gt;fullyCompatible:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;9956&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="err"&gt;partially:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="err"&gt;allInvalid:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="err"&gt;emptySels:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;44&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="err"&gt;totalSelectors:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;221693&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="err"&gt;invalidSelectors:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="err"&gt;invalidRatio:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;'&lt;/span&gt;&lt;span class="mf"&gt;0.000&lt;/span&gt;&lt;span class="err"&gt;%'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="err"&gt;invalidExamples:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fof6z00hf29htoz4cx8sc.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fof6z00hf29htoz4cx8sc.jpg" alt="Terminal output: 10,000 rows, 9,956 fully compatible, zero parse failures across 221,693 selectors" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The replay verdict from node validate.js | Generated with Claude&lt;/p&gt;




&lt;h2&gt;
  
  
  Why this matters more than unit tests
&lt;/h2&gt;

&lt;p&gt;Your unit test suite probably has a dozen selector fixtures. Maybe a hundred. Production has patterns you didn't anticipate:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;code&gt;div[tracker="[object Object]"]&lt;/code&gt; — yes, someone's app stringified an object into an attribute&lt;/li&gt;
&lt;li&gt;  &lt;code&gt;.dark\:border-gray-800 .w-full&lt;/code&gt; — escaped Tailwind variant colons&lt;/li&gt;
&lt;li&gt;  &lt;code&gt;.group-hover\:bg-background-50&lt;/code&gt; — 12 such classes chained together&lt;/li&gt;
&lt;li&gt;  &lt;code&gt;label[for="mat-mdc-checkbox-10-input"]&lt;/code&gt; — Angular Material&lt;/li&gt;
&lt;li&gt;  &lt;code&gt;label[for="radix-:r7:"]&lt;/code&gt; — Radix UI's colon-slug ids (these we filter out at capture, but old data still has them)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Most fixtures I've written are five or six selectors I remembered to type. The replay covered 221,693 stored ones at once.&lt;/p&gt;




&lt;h2&gt;
  
  
  What JSDOM catches that a type check won't
&lt;/h2&gt;

&lt;p&gt;TypeScript checks the shape of &lt;code&gt;fp.Sels[i]&lt;/code&gt; but not whether the string inside is a valid CSS selector. The CSS grammar has its own error modes: unclosed brackets, unknown pseudo-classes, malformed escapes. The only way to catch them cheaply is to run them through a parser.&lt;/p&gt;

&lt;p&gt;The parser does a lot of small things you do not notice when you write a fixture. It tokenises the input, checks bracket balance, validates pseudo-class names, and either returns a node or throws. The throw is the only signal I care about here; the node lookup is a side effect.&lt;/p&gt;

&lt;p&gt;That is why this loop scales linearly with selector count and not with DOM size. The DOM has one body element. The parser runs the full string-to-AST pipeline on every selector I feed it. On 221,693 selectors that pipeline took about thirty seconds on a laptop, single-threaded, no warm-up.&lt;/p&gt;

&lt;p&gt;You might worry about using JSDOM's parser to stand in for the browser's. They're not literally the same implementation, but for CSS selector syntax both implement the W3C grammar. The invalid selectors JSDOM rejects are the ones Chrome will also reject. (If you want certainty, run the same script under &lt;code&gt;puppeteer&lt;/code&gt; and call &lt;code&gt;page.evaluate(sel =&amp;gt; document.querySelector(sel))&lt;/code&gt; — same result, minutes instead of seconds.)&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Sidebar: this kind of "use a cheap tool to validate against reality" move is one of the patterns I keep coming back to as a solo builder. I have a free email series, &lt;a href="https://drippery.app/subscribe/a8ff09ab-6665-409f-a8e4-acb87e2f1a12" rel="noopener noreferrer"&gt;AI as a Solo Founder's Tool&lt;/a&gt;, that walks through more of them.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Making it a CI job
&lt;/h2&gt;

&lt;p&gt;The thirty-second runtime is the interesting part. This isn't a "once a quarter" audit — it's a &lt;a href="https://medium.com/p/db51a8d7cc74" rel="noopener noreferrer"&gt;cheap pre-deploy check&lt;/a&gt; you can run on every PR that touches the fingerprint code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Fingerprint compatibility check&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;psql -c "COPY (SELECT id, fp FROM fingerprints ORDER BY random() LIMIT 5000) TO STDOUT WITH CSV HEADER" &amp;gt; sample.csv&lt;/span&gt;
    &lt;span class="s"&gt;node scripts/validate-fingerprints.js sample.csv --fail-on-regression&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I take a random sample of 5,000 from the production table and compare it against the baseline (&lt;code&gt;fullyCompatible / totalRows&lt;/code&gt;). If the ratio drops by more than 1%, the PR fails. The threshold is tunable; I started at 1% and have not had to change it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Generalizing the pattern
&lt;/h2&gt;

&lt;p&gt;I use the same loop on more than fingerprints. Any time three things line up, the trick works:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt; &lt;strong&gt;You have stored structured data&lt;/strong&gt; (selectors, queries, configs, serialized state).&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Code consumes that data&lt;/strong&gt; with a parser somewhere in the path.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;The parser can reject&lt;/strong&gt; inputs the writer thought were valid.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fi5829j06mqm0mqklh9ik.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fi5829j06mqm0mqklh9ik.jpg" alt="Replay-validation triangle: stored data, code consumer, parser that can reject" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The three conditions that make replay-based validation worth running | Generated with Claude&lt;/p&gt;

&lt;p&gt;Whenever that triangle exists, replay-based validation is worth running. A handful of examples from our own codebase:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  SQL queries stored for saved reports → run each through &lt;code&gt;EXPLAIN&lt;/code&gt; and check for errors&lt;/li&gt;
&lt;li&gt;  Zod schemas versioned over time → re-validate last month's records against this month's schemas&lt;/li&gt;
&lt;li&gt;  Mongo aggregation pipelines saved in documents → dry-run them against &lt;code&gt;db.runCommand({aggregate, explain:true})&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;  Regex patterns stored in a rules engine → compile each; anything that throws is a stored record you can't read anymore&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It's the cheapest form of backward-compatibility testing you'll ever write, and it's quantitative: instead of "I think it still works," you get "99.56% are fully compatible; the other 44 had empty selector lists I can investigate one at a time." It pairs naturally with a &lt;a href="https://medium.com/generative-ai/how-i-put-a-code-reviewer-in-front-of-every-git-push-to-main-with-a-claude-code-subagent-17408aa3236e?sk=b141e0baa56cea334ad5887116c4f771" rel="noopener noreferrer"&gt;pre-push validation&lt;/a&gt; step that watches for the kind of drift this script measures.&lt;/p&gt;




&lt;h2&gt;
  
  
  External Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;a href="https://github.com/jsdom/jsdom" rel="noopener noreferrer"&gt;JSDOM&lt;/a&gt; — Node.js DOM implementation used as the parser&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://www.w3.org/TR/selectors/" rel="noopener noreferrer"&gt;W3C Selectors specification&lt;/a&gt; — CSS selector grammar&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://pptr.dev/" rel="noopener noreferrer"&gt;Puppeteer documentation&lt;/a&gt; — headless Chrome alternative for browser-grade verification&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://github.com/capricorn86/happy-dom" rel="noopener noreferrer"&gt;happy-dom&lt;/a&gt; — faster DOM alternative referenced in the post&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://www.postgresql.org/docs/current/sql-copy.html" rel="noopener noreferrer"&gt;PostgreSQL COPY command&lt;/a&gt; — used to export the fingerprint CSV&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Production is the test suite I didn't write
&lt;/h2&gt;

&lt;p&gt;I had a year of production data I had never used as a test. I left mine sitting in Postgres because some half-formed worry told me I could not run real data locally. I could. Thirty seconds and fifty lines of JavaScript. The replay took thirty seconds. I should have done it the first week.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;If this kind of cheap reality-check is useful, I write up more of them in a free email series, &lt;a href="https://drippery.app/subscribe/a8ff09ab-6665-409f-a8e4-acb87e2f1a12" rel="noopener noreferrer"&gt;AI as a Solo Founder's Tool&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I build small tools and kits for solo creators. You can find them here: &lt;a href="https://danielrusnok.gumroad.com" rel="noopener noreferrer"&gt;https://danielrusnok.gumroad.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
    </item>
    <item>
      <title>My App Returned 403 But My Code Never Did</title>
      <dc:creator>Digital Craft Workshop</dc:creator>
      <pubDate>Fri, 04 Sep 2026 15:04:09 +0000</pubDate>
      <link>https://dev.to/danielrusnok/my-app-returned-403-but-my-code-never-did-1ok0</link>
      <guid>https://dev.to/danielrusnok/my-app-returned-403-but-my-code-never-did-1ok0</guid>
      <description>&lt;p&gt;&lt;em&gt;Not a member? Use &lt;a href="https://danielrusnok.medium.com/membership" rel="noopener noreferrer"&gt;this friend link&lt;/a&gt; to read this story for free.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;My Drippery autosaves started returning 403 on one specific email series. I spent the next two hours searching the codebase for a status code my code does not emit.&lt;/p&gt;

&lt;p&gt;The hosting platform was rejecting the request before it ever reached my Node process, because the JSON body contained raw HTML.&lt;/p&gt;

&lt;p&gt;Here is the debugging trail, the root cause, and the 40-line fix.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fapvwx0wxquj7ope96gz4.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fapvwx0wxquj7ope96gz4.jpg" alt="Edge WAF blocks the POST before it reaches the Node app" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The edge WAF blocks the POST before the Node app ever sees it | Generated with Claude&lt;/p&gt;




&lt;h2&gt;
  
  
  The app and the endpoint that broke
&lt;/h2&gt;

&lt;p&gt;I am building &lt;a href="https://drippery.app" rel="noopener noreferrer"&gt;Drippery&lt;/a&gt;, a drip email tool for content creators. Users write emails in a rich text editor and the app saves the HTML to PostgreSQL.&lt;/p&gt;

&lt;p&gt;The stack is Next.js 16, Clerk auth, Drizzle ORM, PostgreSQL, hosted on Render.com.&lt;/p&gt;

&lt;p&gt;The bulk-save endpoint receives every change to an email series in a single POST. A typical payload looks like this.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"sequence"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"My Newsletter Series"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"updatedEmails"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"abc-123"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"subject"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Welcome!"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"html"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;h1&amp;gt;Welcome to the series&amp;lt;/h1&amp;gt;&amp;lt;p&amp;gt;Here is what you will learn...&amp;lt;/p&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"textContent"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Welcome to the series..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"dayOffset"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"enabled"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"emailOrder"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"abc-123"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing suspicious. Clean HTML, no script tags, no event handlers. Just formatted email content wrapped in a JSON string.&lt;/p&gt;

&lt;p&gt;Saving this payload should have been the most boring operation in the app. It wasn't.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Sidebar: debugging trails like this one live in my Claude Code sessions, and keeping that context between sessions is its own small problem. &lt;a href="https://drippery.app/subscribe/6e302973-cf5f-47ff-8803-22122ac3bd9d" rel="noopener noreferrer"&gt;The Claude Code Memory Starter&lt;/a&gt; is a free email series on how I solved it.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  A 403 that reproduced every time
&lt;/h2&gt;

&lt;p&gt;One specific series with five emails, about 11 KB of total HTML, failed on every save attempt. The browser console showed the same line three times in a row.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;POST /api/series/c91e0a38-.../bulk-save 403 (Forbidden)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three autosave attempts in a row, every one a 403. The response body was the same every time. I had not yet bothered to open it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Searching for a 403 that does not exist
&lt;/h2&gt;

&lt;p&gt;My first instinct was to grep the codebase for the status code.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s2"&gt;"403"&lt;/span&gt; src/app/api/
&lt;span class="c"&gt;# (no matches)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The bulk-save endpoint only returns 401 for unauthorized requests and 500 for unexpected errors. The 403 I was chasing was nowhere in my own code.&lt;/p&gt;

&lt;p&gt;I checked the auth middleware next. The app runs Clerk with &lt;code&gt;auth.protect()&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/middleware.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nf"&gt;clerkMiddleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;isPublicRoute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;protect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I dug into Clerk's source. On API routes &lt;code&gt;auth.protect()&lt;/code&gt; hides protected endpoints by returning a 404, not a 403. Clerk was not the source either.&lt;/p&gt;

&lt;p&gt;Next.js 16 ships CSRF protection for Server Actions, not for API route handlers, and none of the experimental error-handling flags that could short-circuit a request were enabled in my config. So I added debug logs everywhere.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// bulk-save/route.ts&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`[bulk-save] Request received for sequence &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tenant&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getCurrentTenant&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`[bulk-save] Tenant &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;tenant&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; saving sequence &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The logs never appeared.&lt;/strong&gt; The request was never reaching my application code in the first place.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the response body actually said
&lt;/h2&gt;

&lt;p&gt;I finally opened the full response body of the 403 in DevTools. It was not JSON.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Your request was blocked by this site's
web application firewall (WAF).
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The edge WAF in front of Render was intercepting the POST before it reached my app. The JSON body contained raw HTML — &lt;code&gt;&amp;lt;h1&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;p&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;a href="..."&amp;gt;&lt;/code&gt; — and the firewall's pattern matcher flagged it as a potential XSS injection.&lt;/p&gt;

&lt;p&gt;Legitimate email content wrapped in a JSON string field had the same shape as an attack payload, as far as the firewall was concerned.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkg812yt6f4x0oob36l56.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkg812yt6f4x0oob36l56.jpg" alt="DevTools Network tab showing a WAF block page where JSON was expected" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The 403 response body was the WAF block page, not JSON | Generated with Claude&lt;/p&gt;




&lt;h2&gt;
  
  
  Why this took two hours
&lt;/h2&gt;

&lt;p&gt;The 403 status code sent me the wrong way from the start. My reflex was to grep my own code first. When the code does not produce that status, I start doubting the auth library, then the framework, then anything in between. Infrastructure is the last layer I suspect.&lt;/p&gt;

&lt;p&gt;The missing logs reinforced the wrong theory. Because the WAF blocked the request before Node ran, there was nothing in the application log. No exception, no failed request entry. From the app's perspective the call simply did not exist.&lt;/p&gt;

&lt;p&gt;I spent an embarrassing amount of time inside Clerk's TypeScript definitions, looking for a branch that could throw a 403 instead of a 401. Then another half hour reading the Next.js 16 changelog for any new CSRF or rate-limit middleware I might have inherited from an upgrade. Both were dead ends, but they felt closer to the code, so they got my attention first.&lt;/p&gt;

&lt;p&gt;The minimal repro that finally pinned the blame on the edge was a pair of curl calls. The endpoint accepted a small body with no HTML. Strip the HTML, status 200. Drop a single raw &lt;code&gt;&amp;lt;h1&amp;gt;&lt;/code&gt; tag into the same body, status 403. That was the moment the WAF hypothesis stopped feeling speculative.&lt;/p&gt;

&lt;p&gt;And it was payload-dependent. Small emails saved fine. The WAF only blocked the request once the HTML content was large enough to match its rule set, so the failure looked like a content-specific bug in my own code, not a piece of infrastructure I had never thought about.&lt;/p&gt;




&lt;h2&gt;
  
  
  The fix: base64 in transit
&lt;/h2&gt;

&lt;p&gt;The shape of the fix is small. Base64-encode the HTML fields on the client, decode them on the server before writing to Postgres.&lt;/p&gt;

&lt;p&gt;The shared encoding helper has to be Unicode-safe, because plain &lt;code&gt;btoa()&lt;/code&gt; throws &lt;code&gt;InvalidCharacterError&lt;/code&gt; the moment a user pastes an accented character or an emoji.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/lib/html-encoding.ts&lt;/span&gt;

&lt;span class="cm"&gt;/** Encode a string to base64 (Unicode-safe) */&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;encodeHtml&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TextEncoder&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;binary&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;binary&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromCharCode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;btoa&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;binary&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="cm"&gt;/** Decode a base64 string back to HTML (Unicode-safe) */&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;decodeHtml&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;encoded&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;encoded&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;encoded&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;binary&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;atob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;encoded&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;binary&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;binary&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;binary&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;charCodeAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TextDecoder&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The TextEncoder dance is the only non-obvious part. It serializes the string into UTF-8 bytes that &lt;code&gt;btoa()&lt;/code&gt; can safely process, then reverses the same trip on the way back.&lt;/p&gt;

&lt;p&gt;I considered cheaper transformations before reaching for base64. HTML-escaping the angle brackets keeps the body human-readable but still leaves the dangerous substrings the WAF is matching on. URL-encoding shifts the bytes around without changing what the pattern matcher sees. Base64 is the smallest transformation that mangles the patterns enough for the rule set to ignore the body entirely.&lt;/p&gt;

&lt;p&gt;On the client, encode every HTML field before sending.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/api/series/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/bulk-save`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;newEmails&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;newEmails&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;html&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;encodeHtml&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="na"&gt;textContent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;encodeHtml&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;})),&lt;/span&gt;
    &lt;span class="na"&gt;updatedEmails&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;updatedEmails&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;html&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;encodeHtml&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="na"&gt;textContent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;encodeHtml&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;})),&lt;/span&gt;
  &lt;span class="p"&gt;}),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the server, decode before writing to the database.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;decodeHtml&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/lib/html-encoding&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;created&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;emails&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;html&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;decodeHtml&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;newEmail&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;textContent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;decodeHtml&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;newEmail&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;emails&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;html&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;decodeHtml&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;updated&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;textContent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;decodeHtml&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;updated&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The WAF now sees &lt;code&gt;PGgxPkhlbGxvIFfDtnJsZCE8L2gxPg==&lt;/code&gt; instead of &lt;code&gt;&amp;lt;h1&amp;gt;Hello World!&amp;lt;/h1&amp;gt;&lt;/code&gt;. &lt;strong&gt;No HTML patterns in the body, no firewall trigger.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa6oydl7g5yonyn337pr8.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa6oydl7g5yonyn337pr8.jpg" alt="Same request before and after base64 encoding the HTML field" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Same request before and after base64 encoding the HTML field | Generated with Claude&lt;/p&gt;




&lt;h2&gt;
  
  
  Every other endpoint I had to patch
&lt;/h2&gt;

&lt;p&gt;Once I had the root cause I audited every endpoint that accepts HTML in a POST or PATCH body.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;code&gt;/api/series/[id]/bulk-save&lt;/code&gt; — POST, html + textContent&lt;/li&gt;
&lt;li&gt;  &lt;code&gt;/api/series/[id]/emails&lt;/code&gt; — POST, html + textContent&lt;/li&gt;
&lt;li&gt;  &lt;code&gt;/api/series/[id]/emails/[emailId]&lt;/code&gt; — PUT, html + textContent&lt;/li&gt;
&lt;li&gt;  &lt;code&gt;/api/emails/send-test&lt;/code&gt; — POST, html + textContent&lt;/li&gt;
&lt;li&gt;  &lt;code&gt;/api/tenant&lt;/code&gt; — PATCH, emailHeader + emailFooter&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All of them needed the same encode-on-client, decode-on-server treatment. Bulk-save just happened to trip first because it sends the most HTML in a single request.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxtppokv63gk2qkrznc8v.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxtppokv63gk2qkrznc8v.jpg" alt="Five Drippery endpoints routed through one base64 codec helper" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Five HTML-accepting endpoints, all routed through one base64 helper | Generated with Claude&lt;/p&gt;

&lt;p&gt;This is the same fan-out audit I did when chasing memory leaks in &lt;a href="https://levelup.gitconnected.com/our-next-js-app-crashed-every-24-hours-here-are-the-six-memory-fixes-35245f6548e4?source=rss-7467f0f889eb------2" rel="noopener noreferrer"&gt;the Next.js crash story&lt;/a&gt;. Find the root cause, then assume the rest of the surface area has the same shape.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I would do differently
&lt;/h2&gt;

&lt;p&gt;The two-line lesson is: &lt;strong&gt;read the full response body before searching your codebase&lt;/strong&gt;. If I had opened the 403 body in DevTools first, I would have seen the WAF page and solved this in minutes.&lt;/p&gt;

&lt;p&gt;The deeper lesson is to think in layers. When your application log is empty for a failed request, the request was intercepted upstream by a WAF or a reverse proxy. The absence of a log entry is itself a signal.&lt;/p&gt;

&lt;p&gt;A WAF does not understand context. It sees &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; in a POST body and blocks it. It does not know the HTML is a value inside a JSON string field that will be stored in a database and rendered in a completely different context. Base64 is a pragmatic workaround for that context mismatch.&lt;/p&gt;

&lt;p&gt;There is a real cost to the workaround. Base64 inflates the payload by about thirty-three percent, and the request body in any proxy log is now opaque, so a support ticket where a user reports broken HTML is harder to reproduce without first running the body through the decoder. I accept that. The alternative was a daily false-positive 403 in production, which was worse.&lt;/p&gt;

&lt;p&gt;Render does let you configure rules and disable categories, but turning the WAF off entirely removes protection against actual attacks. Base64 keeps the protection in place for everything else while routing legitimate HTML around the false positive.&lt;/p&gt;

&lt;p&gt;Custom WAF rules that allowlist specific paths are another option where the host supports them. Encoding is portable: it works regardless of who runs the firewall in front of your app.&lt;/p&gt;

&lt;p&gt;I push every change in Drippery straight to production, so I care about exactly which safety nets are in front of my code. The same logic I use in &lt;a href="https://medium.com/p/db51a8d7cc74" rel="noopener noreferrer"&gt;my pre-deploy script&lt;/a&gt; applies here. I want the safety nets. I just want to know which payloads they eat.&lt;/p&gt;




&lt;h2&gt;
  
  
  External Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;a href="https://render.com/docs/ddos-protection" rel="noopener noreferrer"&gt;Render network protection&lt;/a&gt; — what sits in front of every Render service by default&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://owasp.org/www-community/attacks/xss/" rel="noopener noreferrer"&gt;OWASP XSS overview&lt;/a&gt; — why WAFs aggressively reject raw HTML in request bodies&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/TextEncoder" rel="noopener noreferrer"&gt;MDN TextEncoder&lt;/a&gt; — the Unicode-safe primitive used in the encode helper&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://developer.mozilla.org/en-US/docs/Glossary/Base64" rel="noopener noreferrer"&gt;MDN base64&lt;/a&gt; — why &lt;code&gt;btoa&lt;/code&gt; alone breaks on non-ASCII input&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;  My SaaS endpoint returned 403 but my code never emits that status.&lt;/li&gt;
&lt;li&gt;  The full response body was an HTML page from the edge WAF, not JSON.&lt;/li&gt;
&lt;li&gt;  The WAF flagged raw HTML inside the JSON POST body as potential XSS.&lt;/li&gt;
&lt;li&gt;  The fix is base64-encoding HTML fields on the client and decoding them on the server.&lt;/li&gt;
&lt;li&gt;  Audit every endpoint with the same payload shape; they will all eventually trip.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;If you want more of this in your inbox, &lt;a href="https://drippery.app/subscribe/6e302973-cf5f-47ff-8803-22122ac3bd9d" rel="noopener noreferrer"&gt;The Claude Code Memory Starter&lt;/a&gt; is a free email series on keeping your tooling's context — and your own — between sessions.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I build small tools and kits for solo creators. You can find them here: &lt;a href="https://danielrusnok.gumroad.com" rel="noopener noreferrer"&gt;https://danielrusnok.gumroad.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Don't Trust AI-Generated Code Without Testing It First</title>
      <dc:creator>Digital Craft Workshop</dc:creator>
      <pubDate>Wed, 02 Sep 2026 15:04:07 +0000</pubDate>
      <link>https://dev.to/danielrusnok/dont-trust-ai-generated-code-without-testing-it-first-49d3</link>
      <guid>https://dev.to/danielrusnok/dont-trust-ai-generated-code-without-testing-it-first-49d3</guid>
      <description>&lt;p&gt;I let an AI refactor a database query in my .NET project. The code looked clean. I glanced at it, nodded, committed, and pushed. Then QA pinged me with a stack trace.&lt;/p&gt;

&lt;p&gt;The error: &lt;code&gt;InvalidOperationException: Lambda expression used inside Include is not valid.&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;The AI had used a filtered &lt;code&gt;.Include()&lt;/code&gt; with a &lt;code&gt;.Where()&lt;/code&gt; clause inside. That syntax shipped in Entity Framework Core 5.0. Our project runs on EF Core 3.1. The AI did not check the version, and on a busy afternoon I did not either.&lt;/p&gt;

&lt;p&gt;That moment, staring at the error in the QA environment and knowing I had rubber-stamped AI output without ever running the code, was one of the most embarrassing moments in my recent career. The bug was not complex. I had every tool to catch it and I let it through anyway.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Task Was Simple
&lt;/h2&gt;

&lt;p&gt;I was fixing an export feature for our Knowledge Base. The original query worked but had a suboptimal structure. It loaded data through &lt;code&gt;ArticleFeedbacks&lt;/code&gt; and joined articles on the side. I wanted to flip the approach: start from &lt;code&gt;ArticlesContents&lt;/code&gt;, include the related data, and filter feedbacks inline.&lt;/p&gt;

&lt;p&gt;The AI rewrote the query into a single LINQ expression with filtered includes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;database&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ArticlesContents&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Include&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Article&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;ThenInclude&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Category&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Include&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Article&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;ThenInclude&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ArticlesViewsSimple&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Include&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ArticleFeedbacks&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
            &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;IsNullOrEmpty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Source&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
            &lt;span class="p"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;dateFrom&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HasValue&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CreatedAt&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;dateFrom&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
            &lt;span class="p"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;dateTo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HasValue&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CreatedAt&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;dateToEndOfDay&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Value&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ProjectId&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;projectId&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;!&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsDeleted&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;!&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsDeleted&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;OrderByDescending&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CreatedAt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToListAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The filtered &lt;code&gt;Include&lt;/code&gt; syntax, where you pass a &lt;code&gt;.Where()&lt;/code&gt; lambda inside &lt;code&gt;.Include()&lt;/code&gt;, was one of the headline features of EF Core 5.0. It shows up everywhere in modern .NET tutorials and Stack Overflow answers from the last few years.&lt;/p&gt;

&lt;p&gt;We are on EF Core 3.1. In 3.1, the only valid argument to &lt;code&gt;.Include()&lt;/code&gt; is a simple navigation property expression. No filtering. No logic. Just &lt;code&gt;a =&amp;gt; a.ArticleFeedbacks&lt;/code&gt;, and the query plan flows from there.&lt;/p&gt;




&lt;h2&gt;
  
  
  The AI Hallucinated a Newer Framework
&lt;/h2&gt;

&lt;p&gt;AI coding assistants are trained on a lot of code. They have seen millions of EF Core queries, most of them from newer versions. The filtered Include pattern is everywhere in modern .NET content, from official samples to weekend blog posts.&lt;/p&gt;

&lt;p&gt;The AI does not open my &lt;code&gt;.csproj&lt;/code&gt; file to read the package version. It does not know I have a project pinned to a legacy framework version. It generates what looks correct based on the most common patterns it has learned across the corpus, and most of that corpus assumes a newer EF Core than the one I actually ship against.&lt;/p&gt;

&lt;p&gt;The query would compile against a project a few framework versions ahead of mine. In mine, it crashed at runtime as soon as the export endpoint was hit.&lt;/p&gt;

&lt;p&gt;I have seen this pattern repeatedly with AI-generated code:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  It uses APIs that exist in newer versions of your framework&lt;/li&gt;
&lt;li&gt;  It imports packages you do not have installed&lt;/li&gt;
&lt;li&gt;  It uses syntax from a language version your compiler does not support&lt;/li&gt;
&lt;li&gt;  It assumes configuration or infrastructure that does not exist in your environment&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The AI optimizes for code that matches the bulk of its training data. The bulk of that training data sits on newer framework versions than the legacy projects most working developers actually maintain. That mismatch shows up at runtime rather than at compile time, often inside the very request that hits the real database for the first time after the change.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcm3xgi39b3lqoffmk3vj.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcm3xgi39b3lqoffmk3vj.jpg" alt="AI training corpus skewed to EF Core 5-8 sending a version-blind suggestion into a project still on EF Core 3.1." width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;How a version-blind suggestion lands in a legacy project. | Generated with Claude&lt;/p&gt;

&lt;p&gt;If you'd rather have the setup that keeps my AI assistant from repeating these mistakes, I put it into &lt;a href="https://drippery.app/subscribe/6e302973-cf5f-47ff-8803-22122ac3bd9d" rel="noopener noreferrer"&gt;The Claude Code Memory Starter&lt;/a&gt; — a free email series.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Real Problem: My Review Process
&lt;/h2&gt;

&lt;p&gt;The AI making a mistake is not where I want to focus. The story is that I did not catch it.&lt;/p&gt;

&lt;p&gt;I looked at the code. It made logical sense. The query structure was clean. The filtering was correct. I thought "yeah, that is better than what we had" and moved on.&lt;/p&gt;

&lt;p&gt;I did not:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  Run the application locally&lt;/li&gt;
&lt;li&gt;  Hit the export endpoint even once&lt;/li&gt;
&lt;li&gt;  Write or run a single test against it&lt;/li&gt;
&lt;li&gt;  Check if filtered Include was supported in our EF Core version&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I treated the AI output like code from a senior colleague I trust completely. The AI is no senior colleague. It generates plausible code without knowing my project constraints, and on a quiet afternoon it will hand me a snippet that compiles in a different version of the world.&lt;/p&gt;

&lt;p&gt;When QA pinged me, my first reaction was confusion. The query looked fine in the diff. I pulled the branch, ran the export endpoint locally, and got the same stack trace on the first request. The fix took less time than the conversation about the bug.&lt;/p&gt;

&lt;p&gt;Load all feedbacks via a plain &lt;code&gt;.Include(a =&amp;gt; a.ArticleFeedbacks)&lt;/code&gt; and filter them in memory after materialization:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;database&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ArticlesContents&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Include&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Article&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;ThenInclude&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Category&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Include&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Article&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;ThenInclude&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ArticlesViewsSimple&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Include&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ArticleFeedbacks&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ProjectId&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;projectId&lt;/span&gt;
        &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;!&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsDeleted&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;!&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsDeleted&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;OrderByDescending&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CreatedAt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToListAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ArticleFeedbacks&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ArticleFeedbacks&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
            &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;IsNullOrEmpty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Source&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
            &lt;span class="p"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;dateFrom&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HasValue&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CreatedAt&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;dateFrom&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
            &lt;span class="p"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;dateTo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HasValue&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CreatedAt&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;dateToEndOfDay&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Value&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToList&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A 30-second fix for a bug that should never have shipped. Running the code once would have caught it, and one local hit on the export endpoint was the difference between a silent commit and a QA ticket.&lt;/p&gt;

&lt;p&gt;This is the same instinct I now put behind every git push. I wrote about wiring &lt;a href="https://medium.com/generative-ai/how-i-put-a-code-reviewer-in-front-of-every-git-push-to-main-with-a-claude-code-subagent-17408aa3236e" rel="noopener noreferrer"&gt;a code-reviewer subagent in front of every push to main&lt;/a&gt; for exactly this reason. The hook reads the diff before the push actually goes out and flags the kinds of things I would normally only notice during a slow PR review, which gives me a second pair of eyes on every commit even when I am working alone.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Embarrassment Factor
&lt;/h2&gt;

&lt;p&gt;Let me be honest about why this stung. I have been writing C# and working with Entity Framework for years. If a junior developer on my team had submitted this code in a pull request, I would have caught the filtered Include issue immediately and left a comment on the line.&lt;/p&gt;

&lt;p&gt;Because the AI generated it, I treated it differently. There is something about AI-generated code that short-circuits the normal review instinct. The structure is professional. The variable names are sane and the indentation is consistent, so the eye glides over it without ever landing on the lambda inside the Include.&lt;/p&gt;

&lt;p&gt;So I let my guard down.&lt;/p&gt;

&lt;p&gt;Then QA finds a crash, and I am sitting there explaining that I pushed code I never ran because "the AI wrote it and it looked fine."&lt;/p&gt;

&lt;p&gt;That is not a good look for anyone.&lt;/p&gt;

&lt;p&gt;It is also not the first time I have had to rethink how I work with AI. I went from &lt;a href="https://medium.com/@danielrusnok/stop-using-ai-as-a-search-engine-use-it-as-a-co-founder-0d0eeeae8857" rel="noopener noreferrer"&gt;treating AI like a faster Google&lt;/a&gt; to actually pairing with it on real changes. As my trust grew, the verification gap got bigger instead of smaller. The well-structured looking diffs are the ones I now have to remind myself to actually read, because the polish is what makes them feel safe to skim.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmtn9b47i51lpu19k7dem.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmtn9b47i51lpu19k7dem.jpg" alt="The same diff looking clean in my editor versus blowing up as an InvalidOperationException stack trace in QA." width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The same diff in my editor and in QA. | Generated with Claude&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The AI-as-a-tool workflow behind posts like this is a short email series I run — &lt;a href="https://drippery.app/subscribe/a8ff09ab-6665-409f-a8e4-acb87e2f1a12" rel="noopener noreferrer"&gt;AI as a Solo Founder’s Tool&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Five Rules I Follow Now
&lt;/h2&gt;

&lt;p&gt;After this incident I changed how I work with AI coding assistants. These are not theoretical guidelines. They are rules I follow because I got burned in QA in front of the whole team and I would prefer not to repeat the experience.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rule 1: Always run it.&lt;/strong&gt; No exceptions. If the AI changes a query, I hit that endpoint locally before committing. If it modifies a component, I open the browser and click through the happy path at least once. The minimum bar is that the code runs end-to-end on real data without throwing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rule 2: Check version-sensitive APIs.&lt;/strong&gt; When the AI uses a method or pattern I am not certain about for my current framework version, I verify. A quick search for "EF Core 3.1 filtered include" would have saved me in 30 seconds. The same is true for anything tied to a package version, an SDK version, or a runtime that the AI cannot see from the prompt.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rule 3: Treat AI output like a PR from a new hire.&lt;/strong&gt; Smart, probably correct, but worth a second look. I read every line and question the assumptions baked into the diff. If I would push back on a teammate's PR for missing a test on a behavior change, I push back on the AI's output the same way.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rule 4: Don't let clean code fool you.&lt;/strong&gt; The most dangerous AI-generated bugs are in code that looks perfect. Syntax errors are easy to catch in review. Version incompatibilities in well-structured code slip through because the reviewer brain says "this looks like what a senior would write" and quietly stops working.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rule 5: Own the code.&lt;/strong&gt; The moment I commit it, it is mine. "The AI wrote it" is not an excuse anyone wants to hear in a postmortem. I review the diff like I wrote it, because once it is in the repo, I did.&lt;/p&gt;

&lt;p&gt;I treat these like a seatbelt: boring, repeatable, applied even when I am tired and the laptop fan is loud. &lt;a href="https://medium.com/@danielrusnok/i-thought-code-comments-were-anti-patterns-then-ai-changed-my-mind-c1e1e1e6e4ff" rel="noopener noreferrer"&gt;My AI workflow also changed how I write comments&lt;/a&gt;, but these five rules are the ones that actually live in muscle memory now. I do not always remember them at the start of a task. I remember them at the moment my finger hovers over &lt;code&gt;git push&lt;/code&gt;, which turns out to be enough.&lt;/p&gt;




&lt;h2&gt;
  
  
  Faster Is Only Faster If It Ships Clean
&lt;/h2&gt;

&lt;p&gt;AI coding assistants are genuinely useful. I use them every day. They save time on the boilerplate I would otherwise type by hand, and they let me move through a sprint at a pace I could not sustain alongside a side project and a small daughter at home.&lt;/p&gt;

&lt;p&gt;Moving faster only counts if I am moving in the right direction. Shipping broken code faster is a liability dressed up as a productivity gain.&lt;/p&gt;

&lt;p&gt;The developer community is going through an awkward phase with AI tools. We are past the "wow, it can write code" excitement and not yet at the point where we have built reliable habits for verifying AI output. The tools are good enough to be dangerous, and the workflows around them have not fully caught up to that reality.&lt;/p&gt;

&lt;p&gt;Every minute I save by skipping a test of AI-generated code, I pay back with interest when something breaks at runtime, because the AI does not know my project the way I do.&lt;/p&gt;

&lt;p&gt;When I weigh the cost of running the code locally against the cost of a stack trace landing in QA the next morning, the local run is always cheaper. So I run it. Then I push.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;\→ Get it free: &lt;a href="https://drippery.app/subscribe/a8ff09ab-6665-409f-a8e4-acb87e2f1a12" rel="noopener noreferrer"&gt;AI as a Solo Founder’s Tool&lt;/a&gt;, a 5-part email series.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  External Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;a href="https://learn.microsoft.com/en-us/ef/core/querying/related-data/eager" rel="noopener noreferrer"&gt;EF Core eager loading docs&lt;/a&gt; — official Microsoft docs on what is valid inside &lt;code&gt;.Include()&lt;/code&gt; per version.&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://devblogs.microsoft.com/dotnet/announcing-entity-framework-core-efcore-5-0/" rel="noopener noreferrer"&gt;EF Core 5.0 announcement&lt;/a&gt; — Microsoft blog post listing filtered &lt;code&gt;Include&lt;/code&gt; as a headline feature.&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://github.com/dotnet/efcore/issues/1833" rel="noopener noreferrer"&gt;dotnet/efcore #1833&lt;/a&gt; — the long-running GitHub issue that became filtered &lt;code&gt;Include&lt;/code&gt; in EF Core 5.&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://learn.microsoft.com/en-us/aspnet/core/test/integration-tests" rel="noopener noreferrer"&gt;ASP.NET Core integration tests&lt;/a&gt; — official guide to the kind of test that would have caught the bug in one run.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Daniel Rusnok is a solo builder shipping content, SaaS, and games.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;If you want that habit as a few short emails instead of a hard afternoon, &lt;a href="https://drippery.app/subscribe/6e302973-cf5f-47ff-8803-22122ac3bd9d" rel="noopener noreferrer"&gt;The Claude Code Memory Starter&lt;/a&gt; walks you through it — free.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I build small tools and kits for solo creators. You can find them here: &lt;a href="https://danielrusnok.gumroad.com" rel="noopener noreferrer"&gt;https://danielrusnok.gumroad.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
    </item>
    <item>
      <title>What Upgrading to Next.js 16 Taught Me About Trusting Framework Defaults</title>
      <dc:creator>Digital Craft Workshop</dc:creator>
      <pubDate>Mon, 31 Aug 2026 15:05:54 +0000</pubDate>
      <link>https://dev.to/danielrusnok/what-upgrading-to-nextjs-16-taught-me-about-trusting-framework-defaults-n8</link>
      <guid>https://dev.to/danielrusnok/what-upgrading-to-nextjs-16-taught-me-about-trusting-framework-defaults-n8</guid>
      <description>&lt;p&gt;I used to think the cost of a framework upgrade was the stuff in the migration guide. The renamed APIs. The deprecated config. The list of things the maintainers tell you to change. You read it, you do the work, you move on.&lt;/p&gt;

&lt;p&gt;That is the visible tax. It is not the one that hurts.&lt;/p&gt;

&lt;p&gt;The tax that hurts is the convention that still compiles, still looks right, still matches the docs, and quietly does something different than it did in the last major version. No error at upgrade time. No warning in the console. Just a behavior that drifted out from under you while you were not looking. You find it weeks later, in production, when something that always worked stops working and nothing tells you why.&lt;/p&gt;

&lt;p&gt;I learned this the slow way when I moved to Next.js 16. One file convention I had trusted for years took down a page, returned a clean 200 status, and gave me almost nothing to debug from. &lt;strong&gt;The fix was five lines. Finding it cost an hour.&lt;/strong&gt; And the hour was the lesson, not the fix.&lt;/p&gt;




&lt;h2&gt;
  
  
  The convention I trusted
&lt;/h2&gt;

&lt;p&gt;Back in April I shipped a public blog at &lt;code&gt;drippery.app/blog&lt;/code&gt;. Brand-new Next.js 16 app, App Router, Turbopack (the bundler that replaced webpack as the Next 16 default), the works. Routes rendered fine in dev. &lt;code&gt;/blog&lt;/code&gt; listed posts. &lt;code&gt;/blog/[slug]&lt;/code&gt; rendered the article body, the breadcrumb JSON-LD, the FAQ schema, everything I expected.&lt;/p&gt;

&lt;p&gt;Then I added a file-based &lt;code&gt;opengraph-image.tsx&lt;/code&gt; next to the detail page, the way the docs say to, and the detail page stopped rendering.&lt;/p&gt;

&lt;p&gt;A quick recap for anyone who has not used the convention. In the App Router, &lt;code&gt;opengraph-image&lt;/code&gt; is one of the special file names that live inside a route segment. You drop a dynamic &lt;code&gt;opengraph-image.tsx&lt;/code&gt; next to a &lt;code&gt;page.tsx&lt;/code&gt;, export a default function that returns an &lt;code&gt;ImageResponse&lt;/code&gt; from &lt;code&gt;next/og&lt;/code&gt;, and Next.js evaluates it and injects the matching &lt;code&gt;og:image&lt;/code&gt; meta tags into the &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; of every page in that segment. For a blog with dynamic slugs, each post gets its own social card, generated from the post title, with zero manual wiring.&lt;/p&gt;

&lt;p&gt;The payoff is link previews. A page without a valid &lt;code&gt;og:image&lt;/code&gt; shows up as a bare text link when shared, and I have written before about &lt;a href="https://levelup.gitconnected.com/one-missing-html-tag-is-killing-your-social-media-traffic-dcdf25e2b012" rel="noopener noreferrer"&gt;how much traffic one missing HTML tag can cost you&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;So the plan was simple. Add the file, get per-post social cards. That is the workflow the convention promises, and on Next.js 14 it had worked for me exactly that way. &lt;strong&gt;I trusted it because it had earned the trust.&lt;/strong&gt; That is the trap. The conventions you trust most are the ones that bite hardest, because you stop suspecting them.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7ju9ykduyj4jz76iluoq.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7ju9ykduyj4jz76iluoq.jpg" alt="Next.js file convention: what the docs promise versus what Next 16 delivered" width="800" height="347"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  What "broke" actually looked like
&lt;/h2&gt;

&lt;p&gt;The page did not 500. It returned a 200, but the React tree was missing. The HTML shell came back, the body content did not. In the server logs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;TypeError: Cannot read properties of undefined (reading 'default')
at /app/(marketing)/blog/[slug]/page
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The error fired during metadata resolution, not during the page render itself. Turbopack was trying to import the &lt;code&gt;opengraph-image&lt;/code&gt; module to compute the &lt;code&gt;&amp;lt;meta&amp;gt;&lt;/code&gt; tag, and the dynamic import returned &lt;code&gt;undefined&lt;/code&gt;. That crashed the whole metadata pipeline, which then silently bailed out of rendering the page.&lt;/p&gt;

&lt;p&gt;Metadata resolution runs as part of serving the page, which is why the symptom is so confusing. When it throws, the page body never renders, even though your page component is perfectly healthy. The file I added for link previews was the thing breaking the page.&lt;/p&gt;

&lt;p&gt;The stack trace makes it worse. It points at the page segment, because the import happens inside the framework's metadata machinery for that segment. Nothing in it names &lt;code&gt;opengraph-image.tsx&lt;/code&gt;, which is a big part of why I spent the next hour blaming everything except the new file.&lt;/p&gt;

&lt;p&gt;Removing the file made everything work again. Adding it back, even with a hello-world export, broke it again. My image code never ran far enough to matter.&lt;/p&gt;

&lt;p&gt;This is what a silent breaking change feels like from the inside. &lt;strong&gt;The framework did not tell me the convention changed. The page just lied to me with a 200.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Why the upgrade guide will never catch this
&lt;/h2&gt;

&lt;p&gt;Here is the uncomfortable part. There was nothing to read.&lt;/p&gt;

&lt;p&gt;A migration guide can document what the maintainers changed on purpose. It cannot document the interaction between a new default bundler, a file convention, and &lt;code&gt;generateMetadata&lt;/code&gt; that nobody set out to change and nobody noticed shifted. The Next 16 release notes told me Turbopack was now the default. They did not tell me that a convention I had used since Next 14 would behave differently underneath it, because as far as anyone knew, it would not.&lt;/p&gt;

&lt;p&gt;That is the shape of the real upgrade tax. It is not in the changelog. It lives in the gap between what the framework says it does and what it actually does on your specific combination of features. The bigger the framework, the more conventions it ships, the more of those gaps exist. Every convention is a promise the framework makes on your behalf, and a major version is the moment those promises quietly get renegotiated without your signature.&lt;/p&gt;

&lt;p&gt;I am not anti-convention. File-based routing, colocated metadata, auto-discovered images — these save real time and I use them every day. But I had been treating them as guarantees, and they are not guarantees. They are conveniences with an implicit asterisk: &lt;em&gt;this works until a default underneath it changes.&lt;/em&gt; The upgrade is exactly when that asterisk comes due.&lt;/p&gt;




&lt;h2&gt;
  
  
  How I narrowed it down
&lt;/h2&gt;

&lt;p&gt;I want to spell out the debugging sequence, because the 200 status code sent me in the wrong direction for a while, and the sequence is reusable on any "silent breakage" problem, not just this one.&lt;/p&gt;

&lt;p&gt;My first suspicion was the data layer. The detail page pulls the post from the database by slug, and a missing post would explain an empty page. But the post was there, and the listing page that uses the same query rendered fine. Second suspicion was the structured data. I had added breadcrumb JSON-LD and an FAQ schema to the same page recently, and a malformed script tag can do strange things to hydration. Stripping those out changed nothing.&lt;/p&gt;

&lt;p&gt;The browser console was clean the entire time. That is the trap: every signal on the client side says the page is fine, and only the server log contains the actual error. I have been burned by this exact shape before. When &lt;a href="https://levelup.gitconnected.com/our-next-js-app-crashed-every-24-hours-here-are-the-six-memory-fixes-35245f6548e4" rel="noopener noreferrer"&gt;our Next.js app crashed every 24 hours&lt;/a&gt;, the visible symptom also pointed nowhere near the cause, and the server-side evidence was the only trail worth following. Same when my &lt;a href="https://levelup.gitconnected.com/how-to-make-chrome-extension-iframes-transparent-on-dark-mode-sites-css-color-scheme-fix-09e6e8162ba3" rel="noopener noreferrer"&gt;extension iframes painted solid white over dark-mode sites&lt;/a&gt; — the culprit was a CSS color-scheme default that nothing in the symptom hinted at.&lt;/p&gt;

&lt;p&gt;Once I read the server log carefully, the stack trace mentioned the page segment, and the only recent change in that segment besides the JSON-LD was the new image file. From there it was a binary search with one variable. Delete &lt;code&gt;opengraph-image.tsx&lt;/code&gt;, restart dev, page renders. Recreate it with the simplest possible content — a div with static text inside an &lt;code&gt;ImageResponse&lt;/code&gt; — page breaks. At that point there was nothing of mine left in the file to blame.&lt;/p&gt;

&lt;p&gt;That test is the one I want you to take away. &lt;strong&gt;If a hello-world version of the file reproduces the failure, the failure happens before your code runs.&lt;/strong&gt; Stop auditing your own logic and start suspecting the convention. That single move is what turns an hour of flailing into a five-minute diagnosis the next time a default betrays you.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3ccuawzztry2131vujem.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3ccuawzztry2131vujem.jpg" alt="Binary-search debugging timeline ruling out the data layer, JSON-LD and the browser console" width="800" height="454"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;If build notes like this are your thing, &lt;a href="https://drippery.app/subscribe/6e302973-cf5f-47ff-8803-22122ac3bd9d" rel="noopener noreferrer"&gt;The Claude Code Memory Starter&lt;/a&gt; is a short free email series where I unpack the setup behind them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: stop trusting the convention, wire it yourself
&lt;/h2&gt;

&lt;p&gt;The lesson translated into a concrete decision. I gave up on the file-based convention for this segment and built a regular route handler instead — the kind of explicit code the framework cannot quietly change underneath me:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/api/og/blog/[slug]/route.tsx&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;ImageResponse&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;next/og&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;getPostBySlug&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/lib/blog&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;runtime&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;edge&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;GET&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;_req&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;params&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;slug&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getPostBySlug&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Not found&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;404&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ImageResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        drippery.app
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;630&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the same &lt;code&gt;ImageResponse&lt;/code&gt; API the convention uses under the hood, the same JSX, the same edge runtime. The file just lives under &lt;code&gt;app/api&lt;/code&gt; as a normal route handler, with nothing implicit about it.&lt;/p&gt;

&lt;p&gt;Two details trip people up. &lt;code&gt;params&lt;/code&gt; has been a Promise since Next 15 (the sync fallback is gone in 16), so await it before reading the slug. And the file needs the &lt;code&gt;.tsx&lt;/code&gt; extension because of the JSX. Both are easy to miss when you copy a route handler template from an older project.&lt;/p&gt;

&lt;p&gt;The route by itself does nothing for your meta tags. You wire it up in the page's &lt;code&gt;generateMetadata&lt;/code&gt;, in the &lt;code&gt;openGraph.images&lt;/code&gt; field:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;seoTitle&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;metaDescription&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;openGraph&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;images&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`/api/og/blog/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;630&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The relative URL gets resolved against your &lt;code&gt;metadataBase&lt;/code&gt;, the base URL you set once in the root layout metadata, so the rendered tag carries the full absolute address social crawlers need. The only conceptual difference from the convention is that nothing is auto-discovered. I am wiring the URL myself.&lt;/p&gt;

&lt;p&gt;There is a real trade-off in that sentence, so let me be fair to the convention. With the file-based approach, the framework keeps the image URL, the &lt;code&gt;alt&lt;/code&gt; text, the dimensions, and the content type in sync for you. With the explicit route, those live in two places: the route handler defines the image, and &lt;code&gt;generateMetadata&lt;/code&gt; declares it. Rename the route and forget the metadata object, and your &lt;code&gt;og:image&lt;/code&gt; points at a 404 with no error to warn you.&lt;/p&gt;

&lt;p&gt;I accept that maintenance cost on purpose. &lt;strong&gt;The explicit version fails as a broken preview card. The convention failed as a broken page.&lt;/strong&gt; Those are not the same severity, and I will trade a little duplication for a failure mode I can actually see.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq6uu0lxkhvd7o1bqqoww.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq6uu0lxkhvd7o1bqqoww.jpg" alt="Request flow forking into the broken auto-discovered path and the explicit API route" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Why I think it happens (and why the cause is not the point)
&lt;/h2&gt;

&lt;p&gt;This is a working theory. I do not have a confirmed root cause, and I want to be honest about that.&lt;/p&gt;

&lt;p&gt;My theory is that Turbopack's dynamic import for the file-based metadata convention is not resolved correctly in the module graph when the route segment also exports &lt;code&gt;generateMetadata&lt;/code&gt;. The docs say you can use both together, and in Next.js 14 with webpack I did. In this Next.js 16 project, the auto-imported &lt;code&gt;opengraph-image&lt;/code&gt; module ends up as &lt;code&gt;{ default: undefined }&lt;/code&gt; at the moment metadata is resolved, and the framework calls into the missing default export and throws.&lt;/p&gt;

&lt;p&gt;The error string has history. The Next.js issue tracker has &lt;a href="https://github.com/vercel/next.js/issues/42402" rel="noopener noreferrer"&gt;a closed Next 13 issue&lt;/a&gt; with the same TypeError, thrown from React's module-resolution layer, &lt;code&gt;resolveModuleMetaData&lt;/code&gt;. The trigger back then was client component navigation, not an image file, but the shape matches: the metadata system imports a module, the module comes back undefined, the page fails to render. That pattern made me more comfortable blaming the resolution machinery than my own code.&lt;/p&gt;

&lt;p&gt;I also did not isolate everything. I did not retest the segment with webpack instead of Turbopack, and I did not test the convention in a segment without &lt;code&gt;generateMetadata&lt;/code&gt;. If the combination matters the way I think it does, one of those two changes would make the file convention work again. For my project, the API route was cheaper than the experiment.&lt;/p&gt;

&lt;p&gt;And honestly, the precise cause is not the point. &lt;strong&gt;A convention I trusted broke in a way no upgrade guide warned me about, and the only durable defense was to stop trusting it for this segment.&lt;/strong&gt; Whether the bug is in Turbopack's module graph or in some interaction three layers down, the engineering response is the same: make the implicit thing explicit and move on.&lt;/p&gt;




&lt;h2&gt;
  
  
  How to not get burned on the next upgrade
&lt;/h2&gt;

&lt;p&gt;I cannot give you a checklist that catches every silent convention change, because by definition you do not know which convention drifted until something breaks. But the upgrade taught me a handful of habits that turn a lost hour into a quick diagnosis, and they generalize past Next.js.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Suspect the new default first, not last.&lt;/strong&gt; When a major version ships a new bundler, runtime, or compiler default, treat every "magic" convention layered on top of it as unverified until you have seen it work in your project. The thing the release notes brag about is exactly the thing most likely to have shifted behavior underneath your conventions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reproduce with a hello-world before you debug your own code.&lt;/strong&gt; If replacing your logic with the simplest possible version still fails, the failure is upstream of you. This one move would have saved me most of the hour. It is the fastest way to tell "my bug" from "the framework's bug."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Read the server log, not the browser.&lt;/strong&gt; Anything that fails during metadata resolution, server rendering, or the build will leave its real evidence server-side while the client reports a clean 200. If the symptom is on the client but the console is empty, your trail is in the server output.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Prefer explicit wiring at the boundaries that matter.&lt;/strong&gt; I treat file conventions as optional sugar now, not guarantees. For anything load-bearing — SEO tags, social cards, anything a silent failure would cost me real traffic on — I write the explicit route and wire it by hand. Five extra lines buys a failure mode I can see, and a thing the next major version cannot quietly renegotiate.&lt;/p&gt;

&lt;p&gt;The migration guide tells you what changed on purpose. These habits are how you survive what changed by accident. The first kind of upgrade tax you pay once, at the top of the changelog. The second kind you pay every time you trust a default a little more than it deserves.&lt;/p&gt;




&lt;p&gt;If you want a few more of these in your inbox, &lt;a href="https://drippery.app/subscribe/6e302973-cf5f-47ff-8803-22122ac3bd9d" rel="noopener noreferrer"&gt;The Claude Code Memory Starter&lt;/a&gt; walks through one of my setups over a handful of short emails — free.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I build small tools and kits for solo creators. You can find them here: &lt;a href="https://danielrusnok.gumroad.com" rel="noopener noreferrer"&gt;https://danielrusnok.gumroad.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
    </item>
    <item>
      <title>How I Wired Persistent Memory Into Every Claude Code Session</title>
      <dc:creator>Digital Craft Workshop</dc:creator>
      <pubDate>Fri, 24 Jul 2026 15:06:27 +0000</pubDate>
      <link>https://dev.to/danielrusnok/how-i-wired-persistent-memory-into-every-claude-code-session-4b1h</link>
      <guid>https://dev.to/danielrusnok/how-i-wired-persistent-memory-into-every-claude-code-session-4b1h</guid>
      <description>&lt;p&gt;&lt;em&gt;Not&amp;nbsp;a&amp;nbsp;member?&amp;nbsp;&lt;a href="https://medium.com/@danielrusnok/membership" rel="noopener noreferrer"&gt;Use&amp;nbsp;this&amp;nbsp;link.&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A&amp;nbsp;memory&amp;nbsp;you&amp;nbsp;have&amp;nbsp;to&amp;nbsp;remember&amp;nbsp;to&amp;nbsp;use&amp;nbsp;is&amp;nbsp;not&amp;nbsp;memory.&amp;nbsp;It&amp;nbsp;is&amp;nbsp;a&amp;nbsp;database&amp;nbsp;with&amp;nbsp;a&amp;nbsp;search&amp;nbsp;bar.&lt;/p&gt;

&lt;p&gt;That&amp;nbsp;was&amp;nbsp;the&amp;nbsp;gap.&amp;nbsp;After&amp;nbsp;eight&amp;nbsp;hours&amp;nbsp;of&amp;nbsp;a&amp;nbsp;day&amp;nbsp;job,&amp;nbsp;on&amp;nbsp;the&amp;nbsp;evening&amp;nbsp;shift&amp;nbsp;where&amp;nbsp;I&amp;nbsp;do&amp;nbsp;my&amp;nbsp;own&amp;nbsp;building,&amp;nbsp;I&amp;nbsp;never&amp;nbsp;remembered&amp;nbsp;the&amp;nbsp;search&amp;nbsp;bar&amp;nbsp;was&amp;nbsp;there.&amp;nbsp;I&amp;nbsp;gave&amp;nbsp;Claude&amp;nbsp;Code&amp;nbsp;that&amp;nbsp;memory&amp;nbsp;last&amp;nbsp;month:&amp;nbsp;a&amp;nbsp;&lt;code&gt;mem0_search&lt;/code&gt;&amp;nbsp;tool.&amp;nbsp;Then&amp;nbsp;I&amp;nbsp;spent&amp;nbsp;a&amp;nbsp;month&amp;nbsp;barely&amp;nbsp;using&amp;nbsp;it,&amp;nbsp;because&amp;nbsp;using&amp;nbsp;it&amp;nbsp;meant&amp;nbsp;remembering&amp;nbsp;it&amp;nbsp;existed.&lt;/p&gt;

&lt;p&gt;So&amp;nbsp;I&amp;nbsp;spent&amp;nbsp;one&amp;nbsp;evening&amp;nbsp;wiring&amp;nbsp;it&amp;nbsp;into&amp;nbsp;the&amp;nbsp;session&amp;nbsp;lifecycle&amp;nbsp;itself.&amp;nbsp;Now&amp;nbsp;recall&amp;nbsp;happens&amp;nbsp;when&amp;nbsp;a&amp;nbsp;session&amp;nbsp;opens&amp;nbsp;and&amp;nbsp;capture&amp;nbsp;happens&amp;nbsp;when&amp;nbsp;it&amp;nbsp;closes&amp;nbsp;—&amp;nbsp;both&amp;nbsp;without&amp;nbsp;me.&amp;nbsp;Claude&amp;nbsp;walks&amp;nbsp;into&amp;nbsp;every&amp;nbsp;session&amp;nbsp;already&amp;nbsp;knowing&amp;nbsp;what&amp;nbsp;we&amp;nbsp;decided&amp;nbsp;last&amp;nbsp;week,&amp;nbsp;and&amp;nbsp;closes&amp;nbsp;it&amp;nbsp;having&amp;nbsp;written&amp;nbsp;down&amp;nbsp;what&amp;nbsp;we&amp;nbsp;decided&amp;nbsp;today.&lt;/p&gt;

&lt;p&gt;I&amp;nbsp;have&amp;nbsp;not&amp;nbsp;typed&amp;nbsp;"search&amp;nbsp;your&amp;nbsp;memory"&amp;nbsp;in&amp;nbsp;a&amp;nbsp;month.&lt;/p&gt;

&lt;p&gt;Three&amp;nbsp;hooks&amp;nbsp;do&amp;nbsp;it.&amp;nbsp;Here&amp;nbsp;is&amp;nbsp;the&amp;nbsp;wiring.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TLDR:&lt;/strong&gt;&amp;nbsp;A&amp;nbsp;Claude&amp;nbsp;Code&amp;nbsp;shell-command&amp;nbsp;hook&amp;nbsp;cannot&amp;nbsp;call&amp;nbsp;an&amp;nbsp;MCP&amp;nbsp;tool.&amp;nbsp;It&amp;nbsp;can&amp;nbsp;only&amp;nbsp;inject&amp;nbsp;text.&amp;nbsp;So&amp;nbsp;my&amp;nbsp;SessionStart&amp;nbsp;hook&amp;nbsp;does&amp;nbsp;not&amp;nbsp;&lt;em&gt;do&lt;/em&gt;&amp;nbsp;the&amp;nbsp;recall&amp;nbsp;—&amp;nbsp;it&amp;nbsp;injects&amp;nbsp;an&amp;nbsp;instruction&amp;nbsp;telling&amp;nbsp;Claude&amp;nbsp;to&amp;nbsp;do&amp;nbsp;it,&amp;nbsp;with&amp;nbsp;the&amp;nbsp;tools&amp;nbsp;Claude&amp;nbsp;already&amp;nbsp;has.&amp;nbsp;SessionEnd&amp;nbsp;spawns&amp;nbsp;a&amp;nbsp;detached&amp;nbsp;Python&amp;nbsp;process&amp;nbsp;that&amp;nbsp;extracts&amp;nbsp;the&amp;nbsp;session's&amp;nbsp;decisions&amp;nbsp;with&amp;nbsp;Gemini&amp;nbsp;and&amp;nbsp;writes&amp;nbsp;them&amp;nbsp;to&amp;nbsp;Pinecone,&amp;nbsp;after&amp;nbsp;my&amp;nbsp;terminal&amp;nbsp;is&amp;nbsp;already&amp;nbsp;closed.&amp;nbsp;Three&amp;nbsp;scripts,&amp;nbsp;around&amp;nbsp;250&amp;nbsp;lines,&amp;nbsp;zero&amp;nbsp;ongoing&amp;nbsp;effort.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The&amp;nbsp;realization:&amp;nbsp;hooks&amp;nbsp;inject,&amp;nbsp;they&amp;nbsp;don't&amp;nbsp;execute
&lt;/h2&gt;

&lt;p&gt;The&amp;nbsp;first&amp;nbsp;thing&amp;nbsp;I&amp;nbsp;got&amp;nbsp;wrong&amp;nbsp;was&amp;nbsp;assuming&amp;nbsp;a&amp;nbsp;hook&amp;nbsp;could&amp;nbsp;run&amp;nbsp;&lt;code&gt;mem0_search&lt;/code&gt;&amp;nbsp;itself&amp;nbsp;and&amp;nbsp;paste&amp;nbsp;the&amp;nbsp;result&amp;nbsp;into&amp;nbsp;context.&lt;/p&gt;

&lt;p&gt;It&amp;nbsp;can't&amp;nbsp;—&amp;nbsp;not&amp;nbsp;the&amp;nbsp;kind&amp;nbsp;of&amp;nbsp;hook&amp;nbsp;I&amp;nbsp;am&amp;nbsp;using.&amp;nbsp;A&amp;nbsp;hook&amp;nbsp;that&amp;nbsp;is&amp;nbsp;a&amp;nbsp;shell&amp;nbsp;command,&amp;nbsp;which&amp;nbsp;is&amp;nbsp;what&amp;nbsp;all&amp;nbsp;three&amp;nbsp;of&amp;nbsp;mine&amp;nbsp;are,&amp;nbsp;cannot&amp;nbsp;call&amp;nbsp;an&amp;nbsp;MCP&amp;nbsp;tool.&amp;nbsp;It&amp;nbsp;has&amp;nbsp;no&amp;nbsp;MCP&amp;nbsp;client.&amp;nbsp;The&amp;nbsp;only&amp;nbsp;thing&amp;nbsp;it&amp;nbsp;can&amp;nbsp;hand&amp;nbsp;back&amp;nbsp;is&amp;nbsp;text:&amp;nbsp;a&amp;nbsp;small&amp;nbsp;JSON&amp;nbsp;object&amp;nbsp;with&amp;nbsp;an&amp;nbsp;&lt;code&gt;additionalContext&lt;/code&gt;&amp;nbsp;field.&lt;/p&gt;

&lt;p&gt;(Newer&amp;nbsp;Claude&amp;nbsp;Code&amp;nbsp;does&amp;nbsp;have&amp;nbsp;an&amp;nbsp;&lt;code&gt;mcp_tool&lt;/code&gt;&amp;nbsp;hook&amp;nbsp;type&amp;nbsp;that&amp;nbsp;calls&amp;nbsp;a&amp;nbsp;tool&amp;nbsp;directly.&amp;nbsp;But&amp;nbsp;a&amp;nbsp;blind,&amp;nbsp;unconditional&amp;nbsp;tool&amp;nbsp;call&amp;nbsp;is&amp;nbsp;not&amp;nbsp;what&amp;nbsp;I&amp;nbsp;want&amp;nbsp;here&amp;nbsp;—&amp;nbsp;I&amp;nbsp;want&amp;nbsp;Claude&amp;nbsp;to&amp;nbsp;decide&amp;nbsp;whether&amp;nbsp;and&amp;nbsp;how&amp;nbsp;to&amp;nbsp;recall,&amp;nbsp;with&amp;nbsp;judgment.&amp;nbsp;A&amp;nbsp;command&amp;nbsp;hook&amp;nbsp;that&amp;nbsp;injects&amp;nbsp;an&amp;nbsp;instruction&amp;nbsp;gives&amp;nbsp;me&amp;nbsp;exactly&amp;nbsp;that.)&lt;/p&gt;

&lt;p&gt;That&amp;nbsp;sounds&amp;nbsp;like&amp;nbsp;a&amp;nbsp;wall.&amp;nbsp;It&amp;nbsp;is&amp;nbsp;actually&amp;nbsp;the&amp;nbsp;design.&lt;/p&gt;

&lt;p&gt;A&amp;nbsp;shell-command&amp;nbsp;hook&amp;nbsp;does&amp;nbsp;not&amp;nbsp;perform&amp;nbsp;the&amp;nbsp;recall.&amp;nbsp;It&amp;nbsp;writes&amp;nbsp;a&amp;nbsp;prompt.&amp;nbsp;It&amp;nbsp;injects&amp;nbsp;an&amp;nbsp;instruction&amp;nbsp;—&amp;nbsp;"before&amp;nbsp;you&amp;nbsp;answer,&amp;nbsp;search&amp;nbsp;your&amp;nbsp;memory&amp;nbsp;for&amp;nbsp;X"&amp;nbsp;—&amp;nbsp;and&amp;nbsp;Claude,&amp;nbsp;seeing&amp;nbsp;that&amp;nbsp;instruction&amp;nbsp;sitting&amp;nbsp;in&amp;nbsp;context,&amp;nbsp;runs&amp;nbsp;the&amp;nbsp;search&amp;nbsp;with&amp;nbsp;the&amp;nbsp;&lt;code&gt;mem0_search&lt;/code&gt;&amp;nbsp;tool&amp;nbsp;it&amp;nbsp;already&amp;nbsp;has.&amp;nbsp;&lt;strong&gt;The&amp;nbsp;hook&amp;nbsp;is&amp;nbsp;a&amp;nbsp;prompt-writer.&amp;nbsp;Claude&amp;nbsp;is&amp;nbsp;still&amp;nbsp;the&amp;nbsp;one&amp;nbsp;doing&amp;nbsp;the&amp;nbsp;work,&amp;nbsp;with&amp;nbsp;full&amp;nbsp;judgment&amp;nbsp;about&amp;nbsp;how&amp;nbsp;to&amp;nbsp;do&amp;nbsp;it.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Once&amp;nbsp;that&amp;nbsp;clicked,&amp;nbsp;all&amp;nbsp;three&amp;nbsp;hooks&amp;nbsp;got&amp;nbsp;simple.&amp;nbsp;Two&amp;nbsp;of&amp;nbsp;them&amp;nbsp;write&amp;nbsp;recall&amp;nbsp;instructions.&amp;nbsp;One&amp;nbsp;of&amp;nbsp;them&amp;nbsp;captures.&amp;nbsp;None&amp;nbsp;of&amp;nbsp;them&amp;nbsp;touch&amp;nbsp;mem0&amp;nbsp;directly&amp;nbsp;except&amp;nbsp;the&amp;nbsp;last&amp;nbsp;—&amp;nbsp;and&amp;nbsp;even&amp;nbsp;that&amp;nbsp;one&amp;nbsp;runs&amp;nbsp;outside&amp;nbsp;the&amp;nbsp;session&amp;nbsp;entirely.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fizf4izaiipuyiubio5j0.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fizf4izaiipuyiubio5j0.jpg" alt="A hook that runs a command hands Claude a static fact; a hook that injects an instruction hands Claude a task" width="800" height="347"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Hook&amp;nbsp;1:&amp;nbsp;recall&amp;nbsp;the&amp;nbsp;moment&amp;nbsp;a&amp;nbsp;session&amp;nbsp;opens
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;SessionStart&lt;/code&gt;&amp;nbsp;fires&amp;nbsp;when&amp;nbsp;I&amp;nbsp;open&amp;nbsp;Claude&amp;nbsp;Code.&amp;nbsp;My&amp;nbsp;hook&amp;nbsp;here&amp;nbsp;is&amp;nbsp;a&amp;nbsp;bash&amp;nbsp;script&amp;nbsp;that&amp;nbsp;does&amp;nbsp;one&amp;nbsp;cheap&amp;nbsp;thing:&amp;nbsp;look&amp;nbsp;at&amp;nbsp;the&amp;nbsp;current&amp;nbsp;directory,&amp;nbsp;and&amp;nbsp;if&amp;nbsp;it&amp;nbsp;is&amp;nbsp;one&amp;nbsp;of&amp;nbsp;my&amp;nbsp;projects,&amp;nbsp;inject&amp;nbsp;a&amp;nbsp;recall&amp;nbsp;instruction&amp;nbsp;scoped&amp;nbsp;to&amp;nbsp;that&amp;nbsp;project.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;BASENAME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;basename&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CWD&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="c"&gt;# ...match BASENAME against my known project slugs, set $SLUG...&lt;/span&gt;

&lt;span class="nv"&gt;MSG&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"[session-context] cwd is project '&lt;/span&gt;&lt;span class="nv"&gt;$SLUG&lt;/span&gt;&lt;span class="s2"&gt;'. Before answering the
first prompt, call mem0_search with query=&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="nv"&gt;$SLUG&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt; for past
decisions, preferences, and in-flight work."&lt;/span&gt;

jq &lt;span class="nt"&gt;-nc&lt;/span&gt; &lt;span class="nt"&gt;--arg&lt;/span&gt; msg &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$MSG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s1"&gt;'{hookSpecificOutput: {hookEventName: "SessionStart", additionalContext: $msg}}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That&amp;nbsp;is&amp;nbsp;the&amp;nbsp;whole&amp;nbsp;mechanism.&amp;nbsp;The&amp;nbsp;JSON&amp;nbsp;goes&amp;nbsp;back&amp;nbsp;to&amp;nbsp;Claude&amp;nbsp;Code,&amp;nbsp;the&amp;nbsp;&lt;code&gt;additionalContext&lt;/code&gt;&amp;nbsp;string&amp;nbsp;lands&amp;nbsp;in&amp;nbsp;the&amp;nbsp;session&amp;nbsp;as&amp;nbsp;if&amp;nbsp;it&amp;nbsp;had&amp;nbsp;always&amp;nbsp;been&amp;nbsp;there,&amp;nbsp;and&amp;nbsp;because&amp;nbsp;the&amp;nbsp;instruction&amp;nbsp;is&amp;nbsp;explicit&amp;nbsp;and&amp;nbsp;imperative,&amp;nbsp;Claude&amp;nbsp;runs&amp;nbsp;the&amp;nbsp;search&amp;nbsp;before&amp;nbsp;it&amp;nbsp;answers&amp;nbsp;my&amp;nbsp;first&amp;nbsp;message.&amp;nbsp;By&amp;nbsp;the&amp;nbsp;time&amp;nbsp;I&amp;nbsp;have&amp;nbsp;typed&amp;nbsp;anything,&amp;nbsp;the&amp;nbsp;recall&amp;nbsp;results&amp;nbsp;are&amp;nbsp;already&amp;nbsp;in&amp;nbsp;context.&lt;/p&gt;

&lt;p&gt;The&amp;nbsp;scoping&amp;nbsp;is&amp;nbsp;deterministic&amp;nbsp;on&amp;nbsp;purpose.&amp;nbsp;The&amp;nbsp;script&amp;nbsp;matches&amp;nbsp;the&amp;nbsp;folder&amp;nbsp;name&amp;nbsp;against&amp;nbsp;a&amp;nbsp;hard-coded&amp;nbsp;list&amp;nbsp;of&amp;nbsp;my&amp;nbsp;project&amp;nbsp;slugs&amp;nbsp;—&amp;nbsp;no&amp;nbsp;LLM&amp;nbsp;call&amp;nbsp;inside&amp;nbsp;the&amp;nbsp;hook,&amp;nbsp;no&amp;nbsp;latency,&amp;nbsp;no&amp;nbsp;cost.&amp;nbsp;In&amp;nbsp;a&amp;nbsp;project&amp;nbsp;folder&amp;nbsp;it&amp;nbsp;recalls&amp;nbsp;that&amp;nbsp;project.&amp;nbsp;In&amp;nbsp;a&amp;nbsp;random&amp;nbsp;folder&amp;nbsp;it&amp;nbsp;falls&amp;nbsp;back&amp;nbsp;to&amp;nbsp;a&amp;nbsp;generic&amp;nbsp;recall&amp;nbsp;keyed&amp;nbsp;on&amp;nbsp;the&amp;nbsp;folder&amp;nbsp;name.&amp;nbsp;I&amp;nbsp;gave&amp;nbsp;the&amp;nbsp;hook&amp;nbsp;a&amp;nbsp;five-second&amp;nbsp;timeout,&amp;nbsp;and&amp;nbsp;it&amp;nbsp;never&amp;nbsp;comes&amp;nbsp;close&amp;nbsp;to&amp;nbsp;it.&lt;/p&gt;

&lt;p&gt;The&amp;nbsp;payoff&amp;nbsp;shows&amp;nbsp;up&amp;nbsp;as&amp;nbsp;a&amp;nbsp;kind&amp;nbsp;of&amp;nbsp;friction&amp;nbsp;quietly&amp;nbsp;disappearing.&amp;nbsp;I&amp;nbsp;no&amp;nbsp;longer&amp;nbsp;re-explain&amp;nbsp;decisions&amp;nbsp;I&amp;nbsp;already&amp;nbsp;made&amp;nbsp;—&amp;nbsp;which&amp;nbsp;database&amp;nbsp;a&amp;nbsp;project&amp;nbsp;uses,&amp;nbsp;why&amp;nbsp;an&amp;nbsp;endpoint&amp;nbsp;is&amp;nbsp;shaped&amp;nbsp;the&amp;nbsp;way&amp;nbsp;it&amp;nbsp;is,&amp;nbsp;the&amp;nbsp;API&amp;nbsp;quirk&amp;nbsp;that&amp;nbsp;cost&amp;nbsp;me&amp;nbsp;an&amp;nbsp;evening&amp;nbsp;last&amp;nbsp;month.&amp;nbsp;The&amp;nbsp;recall&amp;nbsp;puts&amp;nbsp;those&amp;nbsp;back&amp;nbsp;in&amp;nbsp;front&amp;nbsp;of&amp;nbsp;Claude&amp;nbsp;before&amp;nbsp;its&amp;nbsp;first&amp;nbsp;answer,&amp;nbsp;so&amp;nbsp;it&amp;nbsp;builds&amp;nbsp;on&amp;nbsp;them&amp;nbsp;instead&amp;nbsp;of&amp;nbsp;slowly&amp;nbsp;reinventing&amp;nbsp;them.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Side note: if you'd rather have this whole setup broken into a few short, copy-paste steps in your inbox, I turned it into &lt;a href="https://drippery.app/subscribe/6e302973-cf5f-47ff-8803-22122ac3bd9d" rel="noopener noreferrer"&gt;The Claude Code Memory Starter&lt;/a&gt; — a free email series. Back to the hooks.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Hook&amp;nbsp;2:&amp;nbsp;catch&amp;nbsp;the&amp;nbsp;project&amp;nbsp;I&amp;nbsp;name&amp;nbsp;mid-conversation
&lt;/h2&gt;

&lt;p&gt;SessionStart&amp;nbsp;covers&amp;nbsp;where&amp;nbsp;I&amp;nbsp;am&amp;nbsp;sitting.&amp;nbsp;But&amp;nbsp;half&amp;nbsp;my&amp;nbsp;prompts&amp;nbsp;mention&amp;nbsp;a&amp;nbsp;&lt;em&gt;different&lt;/em&gt;&amp;nbsp;project&amp;nbsp;than&amp;nbsp;the&amp;nbsp;folder&amp;nbsp;I&amp;nbsp;am&amp;nbsp;in&amp;nbsp;—&amp;nbsp;"wait,&amp;nbsp;how&amp;nbsp;did&amp;nbsp;I&amp;nbsp;handle&amp;nbsp;inbound&amp;nbsp;email&amp;nbsp;in&amp;nbsp;subhook?"&lt;/p&gt;

&lt;p&gt;So&amp;nbsp;the&amp;nbsp;second&amp;nbsp;hook&amp;nbsp;runs&amp;nbsp;on&amp;nbsp;&lt;code&gt;UserPromptSubmit&lt;/code&gt;,&amp;nbsp;before&amp;nbsp;each&amp;nbsp;message&amp;nbsp;reaches&amp;nbsp;Claude.&amp;nbsp;It&amp;nbsp;reads&amp;nbsp;the&amp;nbsp;prompt&amp;nbsp;text,&amp;nbsp;lowercases&amp;nbsp;it,&amp;nbsp;and&amp;nbsp;scans&amp;nbsp;for&amp;nbsp;any&amp;nbsp;known&amp;nbsp;project&amp;nbsp;slug&amp;nbsp;as&amp;nbsp;a&amp;nbsp;whole&amp;nbsp;word.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;s &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nv"&gt;$HUB_SLUGS&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  if &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PROMPT_LC&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-qE&lt;/span&gt; &lt;span class="s2"&gt;"(^|[^a-z0-9])&lt;/span&gt;&lt;span class="nv"&gt;$s&lt;/span&gt;&lt;span class="s2"&gt;([^a-z0-9]|&lt;/span&gt;&lt;span class="se"&gt;\$&lt;/span&gt;&lt;span class="s2"&gt;)"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nv"&gt;MATCHED&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$MATCHED&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$s&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="k"&gt;fi
done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The&amp;nbsp;whole-word&amp;nbsp;boundary&amp;nbsp;in&amp;nbsp;that&amp;nbsp;regex&amp;nbsp;is&amp;nbsp;load-bearing.&amp;nbsp;Without&amp;nbsp;it,&amp;nbsp;a&amp;nbsp;short&amp;nbsp;slug&amp;nbsp;matches&amp;nbsp;inside&amp;nbsp;longer&amp;nbsp;unrelated&amp;nbsp;words&amp;nbsp;and&amp;nbsp;ordinary&amp;nbsp;prompts&amp;nbsp;trigger&amp;nbsp;recalls&amp;nbsp;all&amp;nbsp;day.&amp;nbsp;With&amp;nbsp;it,&amp;nbsp;only&amp;nbsp;a&amp;nbsp;real&amp;nbsp;mention&amp;nbsp;counts.&lt;/p&gt;

&lt;p&gt;If&amp;nbsp;a&amp;nbsp;slug&amp;nbsp;matches,&amp;nbsp;the&amp;nbsp;hook&amp;nbsp;injects&amp;nbsp;the&amp;nbsp;same&amp;nbsp;kind&amp;nbsp;of&amp;nbsp;recall&amp;nbsp;instruction&amp;nbsp;—&amp;nbsp;pull&amp;nbsp;context&amp;nbsp;for&amp;nbsp;&lt;em&gt;that&lt;/em&gt;&amp;nbsp;project&amp;nbsp;before&amp;nbsp;answering.&amp;nbsp;If&amp;nbsp;nothing&amp;nbsp;matches,&amp;nbsp;the&amp;nbsp;hook&amp;nbsp;exits&amp;nbsp;silently&amp;nbsp;and&amp;nbsp;adds&amp;nbsp;nothing&amp;nbsp;at&amp;nbsp;all.&amp;nbsp;Most&amp;nbsp;prompts&amp;nbsp;hit&amp;nbsp;the&amp;nbsp;silent&amp;nbsp;path.&lt;/p&gt;

&lt;p&gt;This&amp;nbsp;is&amp;nbsp;the&amp;nbsp;part&amp;nbsp;I&amp;nbsp;notice&amp;nbsp;most&amp;nbsp;in&amp;nbsp;daily&amp;nbsp;use.&amp;nbsp;I&amp;nbsp;name&amp;nbsp;a&amp;nbsp;project&amp;nbsp;and&amp;nbsp;Claude&amp;nbsp;already&amp;nbsp;has&amp;nbsp;its&amp;nbsp;history&amp;nbsp;before&amp;nbsp;it&amp;nbsp;responds,&amp;nbsp;even&amp;nbsp;when&amp;nbsp;that&amp;nbsp;repo&amp;nbsp;is&amp;nbsp;three&amp;nbsp;directories&amp;nbsp;away&amp;nbsp;from&amp;nbsp;where&amp;nbsp;I&amp;nbsp;am&amp;nbsp;actually&amp;nbsp;working.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hook&amp;nbsp;3:&amp;nbsp;capture&amp;nbsp;without&amp;nbsp;making&amp;nbsp;me&amp;nbsp;wait
&lt;/h2&gt;

&lt;p&gt;The&amp;nbsp;first&amp;nbsp;two&amp;nbsp;hooks&amp;nbsp;are&amp;nbsp;reads.&amp;nbsp;Reads&amp;nbsp;are&amp;nbsp;easy&amp;nbsp;—&amp;nbsp;they&amp;nbsp;are&amp;nbsp;fast,&amp;nbsp;and&amp;nbsp;a&amp;nbsp;few&amp;nbsp;hundred&amp;nbsp;milliseconds&amp;nbsp;at&amp;nbsp;session&amp;nbsp;start&amp;nbsp;is&amp;nbsp;invisible.&lt;/p&gt;

&lt;p&gt;The&amp;nbsp;third&amp;nbsp;hook&amp;nbsp;is&amp;nbsp;the&amp;nbsp;write,&amp;nbsp;and&amp;nbsp;writes&amp;nbsp;are&amp;nbsp;where&amp;nbsp;this&amp;nbsp;got&amp;nbsp;ugly.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;SessionEnd&lt;/code&gt;&amp;nbsp;fires&amp;nbsp;when&amp;nbsp;I&amp;nbsp;close&amp;nbsp;a&amp;nbsp;session.&amp;nbsp;The&amp;nbsp;work&amp;nbsp;it&amp;nbsp;wants&amp;nbsp;to&amp;nbsp;do&amp;nbsp;is&amp;nbsp;not&amp;nbsp;fast:&amp;nbsp;read&amp;nbsp;the&amp;nbsp;whole&amp;nbsp;transcript,&amp;nbsp;send&amp;nbsp;it&amp;nbsp;to&amp;nbsp;an&amp;nbsp;LLM&amp;nbsp;to&amp;nbsp;extract&amp;nbsp;what&amp;nbsp;actually&amp;nbsp;mattered,&amp;nbsp;dedupe&amp;nbsp;each&amp;nbsp;item&amp;nbsp;against&amp;nbsp;the&amp;nbsp;vector&amp;nbsp;store,&amp;nbsp;write&amp;nbsp;the&amp;nbsp;survivors.&amp;nbsp;That&amp;nbsp;is&amp;nbsp;several&amp;nbsp;seconds&amp;nbsp;of&amp;nbsp;work.&amp;nbsp;If&amp;nbsp;the&amp;nbsp;hook&amp;nbsp;does&amp;nbsp;it&amp;nbsp;inline,&amp;nbsp;my&amp;nbsp;terminal&amp;nbsp;hangs&amp;nbsp;for&amp;nbsp;several&amp;nbsp;seconds&amp;nbsp;every&amp;nbsp;single&amp;nbsp;time&amp;nbsp;I&amp;nbsp;close&amp;nbsp;it.&amp;nbsp;Not&amp;nbsp;acceptable.&lt;/p&gt;

&lt;p&gt;So&amp;nbsp;the&amp;nbsp;SessionEnd&amp;nbsp;hook&amp;nbsp;does&amp;nbsp;almost&amp;nbsp;nothing&amp;nbsp;itself.&amp;nbsp;It&amp;nbsp;spawns&amp;nbsp;a&amp;nbsp;detached&amp;nbsp;background&amp;nbsp;process&amp;nbsp;and&amp;nbsp;returns&amp;nbsp;immediately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="o"&gt;(&lt;/span&gt;
  &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PYTHON_BIN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SCRIPT_PATH&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TRANSCRIPT_PATH&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SESSION_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOOK_EVENT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CWD&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &amp;lt;/dev/null &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOG_FILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;&amp;amp;1 &amp;amp;
  &lt;span class="nb"&gt;disown&lt;/span&gt;
&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null 2&amp;gt;&amp;amp;1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The&amp;nbsp;extraction&amp;nbsp;runs&amp;nbsp;after&amp;nbsp;my&amp;nbsp;terminal&amp;nbsp;is&amp;nbsp;already&amp;nbsp;gone.&amp;nbsp;I&amp;nbsp;find&amp;nbsp;out&amp;nbsp;whether&amp;nbsp;it&amp;nbsp;worked&amp;nbsp;by&amp;nbsp;reading&amp;nbsp;a&amp;nbsp;log&amp;nbsp;file&amp;nbsp;later,&amp;nbsp;never&amp;nbsp;by&amp;nbsp;waiting&amp;nbsp;on&amp;nbsp;it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0vmonkav1hr57d9xewea.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0vmonkav1hr57d9xewea.jpg" alt="SessionEnd spawns a detached process that reads the transcript, extracts with Gemini, dedupes, and writes to Pinecone" width="800" height="320"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Three&amp;nbsp;things&amp;nbsp;bit&amp;nbsp;me&amp;nbsp;here.&amp;nbsp;They&amp;nbsp;are&amp;nbsp;the&amp;nbsp;reason&amp;nbsp;this&amp;nbsp;section&amp;nbsp;is&amp;nbsp;the&amp;nbsp;longest&amp;nbsp;one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Parallel&amp;nbsp;runs.&lt;/strong&gt;&amp;nbsp;At&amp;nbsp;the&amp;nbsp;end&amp;nbsp;of&amp;nbsp;an&amp;nbsp;evening&amp;nbsp;I&amp;nbsp;close&amp;nbsp;three&amp;nbsp;or&amp;nbsp;four&amp;nbsp;sessions&amp;nbsp;in&amp;nbsp;a&amp;nbsp;burst.&amp;nbsp;Without&amp;nbsp;a&amp;nbsp;lock,&amp;nbsp;that&amp;nbsp;is&amp;nbsp;three&amp;nbsp;or&amp;nbsp;four&amp;nbsp;extractors&amp;nbsp;hammering&amp;nbsp;the&amp;nbsp;vector&amp;nbsp;store&amp;nbsp;at&amp;nbsp;once.&lt;/p&gt;

&lt;p&gt;(My&amp;nbsp;last&amp;nbsp;memory&amp;nbsp;article&amp;nbsp;had&amp;nbsp;a&amp;nbsp;footnote&amp;nbsp;about&amp;nbsp;a&amp;nbsp;"helpful"&amp;nbsp;hook&amp;nbsp;that&amp;nbsp;once&amp;nbsp;spawned&amp;nbsp;seven&amp;nbsp;parallel&amp;nbsp;Claude&amp;nbsp;instances.&amp;nbsp;I&amp;nbsp;have&amp;nbsp;held&amp;nbsp;a&amp;nbsp;grudge&amp;nbsp;against&amp;nbsp;unguarded&amp;nbsp;hooks&amp;nbsp;ever&amp;nbsp;since.)&amp;nbsp;The&amp;nbsp;fix&amp;nbsp;is&amp;nbsp;two&amp;nbsp;locks:&amp;nbsp;a&amp;nbsp;global&amp;nbsp;one&amp;nbsp;created&amp;nbsp;with&amp;nbsp;&lt;code&gt;mkdir&lt;/code&gt;&amp;nbsp;—&amp;nbsp;atomic&amp;nbsp;because&amp;nbsp;the&amp;nbsp;filesystem&amp;nbsp;either&amp;nbsp;creates&amp;nbsp;the&amp;nbsp;directory&amp;nbsp;or&amp;nbsp;fails,&amp;nbsp;with&amp;nbsp;no&amp;nbsp;gap&amp;nbsp;between&amp;nbsp;the&amp;nbsp;check&amp;nbsp;and&amp;nbsp;the&amp;nbsp;create,&amp;nbsp;unlike&amp;nbsp;testing&amp;nbsp;for&amp;nbsp;a&amp;nbsp;lockfile&amp;nbsp;and&amp;nbsp;then&amp;nbsp;writing&amp;nbsp;it&amp;nbsp;—&amp;nbsp;plus&amp;nbsp;a&amp;nbsp;per-session&amp;nbsp;lock&amp;nbsp;with&amp;nbsp;a&amp;nbsp;120-second&amp;nbsp;cooldown&amp;nbsp;so&amp;nbsp;one&amp;nbsp;session&amp;nbsp;cannot&amp;nbsp;double-fire.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Inherited&amp;nbsp;environment.&lt;/strong&gt;&amp;nbsp;The&amp;nbsp;extractor&amp;nbsp;is&amp;nbsp;a&amp;nbsp;plain&amp;nbsp;Python&amp;nbsp;script,&amp;nbsp;but&amp;nbsp;spawned&amp;nbsp;from&amp;nbsp;inside&amp;nbsp;Claude&amp;nbsp;Code&amp;nbsp;it&amp;nbsp;inherits&amp;nbsp;Claude&amp;nbsp;Code's&amp;nbsp;entire&amp;nbsp;environment&amp;nbsp;—&amp;nbsp;&lt;code&gt;ANTHROPIC_BASE_URL&lt;/code&gt;,&amp;nbsp;an&amp;nbsp;OAuth&amp;nbsp;token,&amp;nbsp;a&amp;nbsp;dozen&amp;nbsp;&lt;code&gt;CLAUDE_CODE_*&lt;/code&gt;&amp;nbsp;variables.&amp;nbsp;That&amp;nbsp;environment&amp;nbsp;makes&amp;nbsp;the&amp;nbsp;subprocess&amp;nbsp;behave&amp;nbsp;as&amp;nbsp;if&amp;nbsp;it&amp;nbsp;is&amp;nbsp;still&amp;nbsp;running&amp;nbsp;inside&amp;nbsp;Claude.&amp;nbsp;The&amp;nbsp;fix&amp;nbsp;is&amp;nbsp;one&amp;nbsp;&lt;code&gt;unset&lt;/code&gt;&amp;nbsp;block&amp;nbsp;before&amp;nbsp;the&amp;nbsp;Python&amp;nbsp;runs,&amp;nbsp;clearing&amp;nbsp;every&amp;nbsp;inherited&amp;nbsp;Claude&amp;nbsp;variable.&amp;nbsp;This&amp;nbsp;one&amp;nbsp;cost&amp;nbsp;me&amp;nbsp;a&amp;nbsp;confused&amp;nbsp;hour.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Key&amp;nbsp;source.&lt;/strong&gt;&amp;nbsp;The&amp;nbsp;extractor&amp;nbsp;needs&amp;nbsp;API&amp;nbsp;keys.&amp;nbsp;I&amp;nbsp;could&amp;nbsp;hard-code&amp;nbsp;them&amp;nbsp;into&amp;nbsp;the&amp;nbsp;script.&amp;nbsp;Instead&amp;nbsp;it&amp;nbsp;reads&amp;nbsp;them&amp;nbsp;straight&amp;nbsp;out&amp;nbsp;of&amp;nbsp;&lt;code&gt;~/.claude.json&lt;/code&gt;&amp;nbsp;—&amp;nbsp;the&amp;nbsp;same&amp;nbsp;file&amp;nbsp;the&amp;nbsp;live&amp;nbsp;mem0&amp;nbsp;MCP&amp;nbsp;server&amp;nbsp;already&amp;nbsp;reads&amp;nbsp;its&amp;nbsp;own&amp;nbsp;keys&amp;nbsp;from.&amp;nbsp;One&amp;nbsp;source&amp;nbsp;of&amp;nbsp;truth.&amp;nbsp;When&amp;nbsp;I&amp;nbsp;rotate&amp;nbsp;a&amp;nbsp;key&amp;nbsp;I&amp;nbsp;rotate&amp;nbsp;it&amp;nbsp;in&amp;nbsp;one&amp;nbsp;place,&amp;nbsp;and&amp;nbsp;both&amp;nbsp;the&amp;nbsp;hook&amp;nbsp;and&amp;nbsp;the&amp;nbsp;server&amp;nbsp;pick&amp;nbsp;it&amp;nbsp;up.&lt;/p&gt;

&lt;h2&gt;
  
  
  What&amp;nbsp;the&amp;nbsp;extractor&amp;nbsp;actually&amp;nbsp;keeps
&lt;/h2&gt;

&lt;p&gt;The&amp;nbsp;background&amp;nbsp;script&amp;nbsp;sends&amp;nbsp;the&amp;nbsp;transcript&amp;nbsp;to&amp;nbsp;Gemini&amp;nbsp;2.5&amp;nbsp;Flash&amp;nbsp;Lite&amp;nbsp;with&amp;nbsp;a&amp;nbsp;tight&amp;nbsp;extraction&amp;nbsp;prompt:&amp;nbsp;keep&amp;nbsp;architectural&amp;nbsp;decisions&amp;nbsp;and&amp;nbsp;their&amp;nbsp;reasoning,&amp;nbsp;keep&amp;nbsp;preferences,&amp;nbsp;keep&amp;nbsp;root&amp;nbsp;causes&amp;nbsp;of&amp;nbsp;bugs,&amp;nbsp;keep&amp;nbsp;project-specific&amp;nbsp;facts.&amp;nbsp;Skip&amp;nbsp;transient&amp;nbsp;state,&amp;nbsp;skip&amp;nbsp;routine&amp;nbsp;procedure.&amp;nbsp;Flash&amp;nbsp;Lite&amp;nbsp;sits&amp;nbsp;on&amp;nbsp;the&amp;nbsp;free&amp;nbsp;tier,&amp;nbsp;so&amp;nbsp;a&amp;nbsp;chatty&amp;nbsp;day&amp;nbsp;of&amp;nbsp;building&amp;nbsp;costs&amp;nbsp;nothing&amp;nbsp;—&amp;nbsp;and&amp;nbsp;transcript&amp;nbsp;extraction&amp;nbsp;is&amp;nbsp;genuinely&amp;nbsp;not&amp;nbsp;a&amp;nbsp;job&amp;nbsp;that&amp;nbsp;needs&amp;nbsp;a&amp;nbsp;frontier&amp;nbsp;model.&amp;nbsp;A&amp;nbsp;small&amp;nbsp;OpenAI&amp;nbsp;model&amp;nbsp;is&amp;nbsp;wired&amp;nbsp;in&amp;nbsp;as&amp;nbsp;a&amp;nbsp;fallback&amp;nbsp;for&amp;nbsp;the&amp;nbsp;days&amp;nbsp;Gemini&amp;nbsp;fails.&lt;/p&gt;

&lt;p&gt;Each&amp;nbsp;surviving&amp;nbsp;item&amp;nbsp;comes&amp;nbsp;back&amp;nbsp;as&amp;nbsp;structured&amp;nbsp;JSON&amp;nbsp;—&amp;nbsp;the&amp;nbsp;fact,&amp;nbsp;a&amp;nbsp;project&amp;nbsp;scope,&amp;nbsp;and&amp;nbsp;a&amp;nbsp;category&amp;nbsp;like&amp;nbsp;&lt;code&gt;decision&lt;/code&gt;&amp;nbsp;or&amp;nbsp;&lt;code&gt;learning&lt;/code&gt;.&amp;nbsp;In&amp;nbsp;practice&amp;nbsp;a&amp;nbsp;memory&amp;nbsp;looks&amp;nbsp;like&amp;nbsp;one&amp;nbsp;short&amp;nbsp;sentence:&amp;nbsp;"subhook&amp;nbsp;uses&amp;nbsp;Postmark&amp;nbsp;inbound&amp;nbsp;for&amp;nbsp;subdomain&amp;nbsp;mail&amp;nbsp;because&amp;nbsp;Cloudflare&amp;nbsp;Email&amp;nbsp;Routing&amp;nbsp;only&amp;nbsp;forwards&amp;nbsp;the&amp;nbsp;apex&amp;nbsp;on&amp;nbsp;the&amp;nbsp;free&amp;nbsp;plan,"&amp;nbsp;tagged&amp;nbsp;&lt;code&gt;{project:&amp;nbsp;subhook,&amp;nbsp;category:&amp;nbsp;decision}&lt;/code&gt;.&amp;nbsp;Before&amp;nbsp;writing&amp;nbsp;anything,&amp;nbsp;the&amp;nbsp;script&amp;nbsp;searches&amp;nbsp;the&amp;nbsp;vector&amp;nbsp;store&amp;nbsp;for&amp;nbsp;that&amp;nbsp;exact&amp;nbsp;text&amp;nbsp;and&amp;nbsp;skips&amp;nbsp;it&amp;nbsp;if&amp;nbsp;something&amp;nbsp;already&amp;nbsp;sits&amp;nbsp;above&amp;nbsp;0.88&amp;nbsp;cosine&amp;nbsp;similarity.&amp;nbsp;&lt;strong&gt;That&amp;nbsp;dedupe&amp;nbsp;is&amp;nbsp;the&amp;nbsp;line&amp;nbsp;between&amp;nbsp;a&amp;nbsp;memory&amp;nbsp;that&amp;nbsp;compounds&amp;nbsp;and&amp;nbsp;a&amp;nbsp;memory&amp;nbsp;that&amp;nbsp;fills&amp;nbsp;up&amp;nbsp;with&amp;nbsp;a&amp;nbsp;hundred&amp;nbsp;near-identical&amp;nbsp;entries.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;What&amp;nbsp;survives&amp;nbsp;gets&amp;nbsp;written&amp;nbsp;to&amp;nbsp;Pinecone&amp;nbsp;with&amp;nbsp;&lt;code&gt;infer=False&lt;/code&gt;&amp;nbsp;—&amp;nbsp;store&amp;nbsp;the&amp;nbsp;text&amp;nbsp;verbatim.&amp;nbsp;The&amp;nbsp;extraction&amp;nbsp;already&amp;nbsp;happened,&amp;nbsp;in&amp;nbsp;the&amp;nbsp;Gemini&amp;nbsp;step.&amp;nbsp;I&amp;nbsp;do&amp;nbsp;not&amp;nbsp;want&amp;nbsp;the&amp;nbsp;vector&amp;nbsp;store&amp;nbsp;re-paraphrasing&amp;nbsp;facts&amp;nbsp;I&amp;nbsp;already&amp;nbsp;shaped&amp;nbsp;into&amp;nbsp;the&amp;nbsp;form&amp;nbsp;I&amp;nbsp;wanted&amp;nbsp;them&amp;nbsp;in.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fd9x4l6kdp5g7v3wzn020.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fd9x4l6kdp5g7v3wzn020.jpg" alt="Three Claude Code lifecycle hooks: SessionStart and UserPromptSubmit recall memory in, SessionEnd captures it out" width="800" height="347"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  If&amp;nbsp;you&amp;nbsp;copy&amp;nbsp;one&amp;nbsp;idea&amp;nbsp;from&amp;nbsp;this
&lt;/h2&gt;

&lt;p&gt;Not&amp;nbsp;the&amp;nbsp;project-slug&amp;nbsp;list.&amp;nbsp;Not&amp;nbsp;Pinecone,&amp;nbsp;not&amp;nbsp;Gemini.&amp;nbsp;Those&amp;nbsp;are&amp;nbsp;my&amp;nbsp;plumbing,&amp;nbsp;and&amp;nbsp;yours&amp;nbsp;will&amp;nbsp;be&amp;nbsp;different.&lt;/p&gt;

&lt;p&gt;Copy&amp;nbsp;this:&amp;nbsp;&lt;strong&gt;a&amp;nbsp;hook&amp;nbsp;that&amp;nbsp;injects&amp;nbsp;an&amp;nbsp;instruction&amp;nbsp;beats&amp;nbsp;a&amp;nbsp;hook&amp;nbsp;that&amp;nbsp;runs&amp;nbsp;a&amp;nbsp;command.&lt;/strong&gt;&amp;nbsp;A&amp;nbsp;hook&amp;nbsp;that&amp;nbsp;runs&amp;nbsp;&lt;code&gt;git&amp;nbsp;status&lt;/code&gt;&amp;nbsp;and&amp;nbsp;returns&amp;nbsp;the&amp;nbsp;output&amp;nbsp;hands&amp;nbsp;Claude&amp;nbsp;a&amp;nbsp;fact.&amp;nbsp;A&amp;nbsp;hook&amp;nbsp;that&amp;nbsp;injects&amp;nbsp;"before&amp;nbsp;answering,&amp;nbsp;check&amp;nbsp;X&amp;nbsp;and&amp;nbsp;recall&amp;nbsp;Y"&amp;nbsp;hands&amp;nbsp;Claude&amp;nbsp;a&amp;nbsp;&lt;em&gt;task&lt;/em&gt;&amp;nbsp;—&amp;nbsp;carried&amp;nbsp;out&amp;nbsp;with&amp;nbsp;the&amp;nbsp;full&amp;nbsp;toolset&amp;nbsp;and&amp;nbsp;judgment&amp;nbsp;Claude&amp;nbsp;already&amp;nbsp;has.&amp;nbsp;The&amp;nbsp;first&amp;nbsp;is&amp;nbsp;static.&amp;nbsp;The&amp;nbsp;second&amp;nbsp;is&amp;nbsp;behavior.&lt;/p&gt;

&lt;p&gt;Once&amp;nbsp;I&amp;nbsp;started&amp;nbsp;seeing&amp;nbsp;shell-command&amp;nbsp;hooks&amp;nbsp;as&amp;nbsp;a&amp;nbsp;place&amp;nbsp;to&amp;nbsp;write&amp;nbsp;prompts&amp;nbsp;instead&amp;nbsp;of&amp;nbsp;a&amp;nbsp;place&amp;nbsp;to&amp;nbsp;run&amp;nbsp;scripts,&amp;nbsp;the&amp;nbsp;memory&amp;nbsp;wiring&amp;nbsp;stopped&amp;nbsp;looking&amp;nbsp;like&amp;nbsp;a&amp;nbsp;hack&amp;nbsp;and&amp;nbsp;started&amp;nbsp;looking&amp;nbsp;like&amp;nbsp;the&amp;nbsp;obvious&amp;nbsp;shape.&amp;nbsp;Recall&amp;nbsp;on&amp;nbsp;the&amp;nbsp;way&amp;nbsp;in.&amp;nbsp;Capture&amp;nbsp;on&amp;nbsp;the&amp;nbsp;way&amp;nbsp;out.&amp;nbsp;And&amp;nbsp;an&amp;nbsp;evening-tired&amp;nbsp;version&amp;nbsp;of&amp;nbsp;me&amp;nbsp;who&amp;nbsp;never&amp;nbsp;has&amp;nbsp;to&amp;nbsp;think&amp;nbsp;about&amp;nbsp;either&amp;nbsp;one.&lt;/p&gt;

&lt;p&gt;If&amp;nbsp;you&amp;nbsp;want&amp;nbsp;the&amp;nbsp;layer&amp;nbsp;this&amp;nbsp;builds&amp;nbsp;on,&amp;nbsp;the&amp;nbsp;same&amp;nbsp;lifecycle&amp;nbsp;thinking&amp;nbsp;shows&amp;nbsp;up&amp;nbsp;in&amp;nbsp;how&amp;nbsp;I&amp;nbsp;&lt;a href="https://medium.com/generative-ai/how-i-put-a-code-reviewer-in-front-of-every-git-push-to-main-with-a-claude-code-subagent-17408aa3236e?sk=b141e0baa56cea334ad5887116c4f771" rel="noopener noreferrer"&gt;put&amp;nbsp;a&amp;nbsp;code&amp;nbsp;reviewer&amp;nbsp;in&amp;nbsp;front&amp;nbsp;of&amp;nbsp;every&amp;nbsp;git&amp;nbsp;push&amp;nbsp;with&amp;nbsp;a&amp;nbsp;Claude&amp;nbsp;Code&amp;nbsp;subagent&lt;/a&gt;&amp;nbsp;—&amp;nbsp;another&amp;nbsp;small&amp;nbsp;piece&amp;nbsp;of&amp;nbsp;glue&amp;nbsp;that&amp;nbsp;runs&amp;nbsp;without&amp;nbsp;me&amp;nbsp;thinking&amp;nbsp;about&amp;nbsp;it.&lt;/p&gt;

&lt;h2&gt;
  
  
  External&amp;nbsp;Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;Claude&amp;nbsp;Code&amp;nbsp;hooks&amp;nbsp;reference&lt;/a&gt;&amp;nbsp;—&amp;nbsp;official&amp;nbsp;lifecycle&amp;nbsp;event&amp;nbsp;and&amp;nbsp;JSON&amp;nbsp;schema&amp;nbsp;docs&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://github.com/mem0ai/mem0" rel="noopener noreferrer"&gt;mem0&amp;nbsp;on&amp;nbsp;GitHub&lt;/a&gt;&amp;nbsp;—&amp;nbsp;the&amp;nbsp;memory&amp;nbsp;layer&amp;nbsp;this&amp;nbsp;builds&amp;nbsp;on&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://docs.pinecone.io/guides/get-started/overview" rel="noopener noreferrer"&gt;Pinecone&amp;nbsp;documentation&lt;/a&gt;&amp;nbsp;—&amp;nbsp;managed&amp;nbsp;vector&amp;nbsp;database&amp;nbsp;getting-started&amp;nbsp;guide&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://ai.google.dev/gemini-api/docs" rel="noopener noreferrer"&gt;Gemini&amp;nbsp;API&amp;nbsp;documentation&lt;/a&gt;&amp;nbsp;—&amp;nbsp;Google's&amp;nbsp;developer&amp;nbsp;docs&amp;nbsp;for&amp;nbsp;Gemini&amp;nbsp;models&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://github.com/google-gemini/cookbook" rel="noopener noreferrer"&gt;Gemini&amp;nbsp;cookbook&lt;/a&gt;&amp;nbsp;—&amp;nbsp;examples&amp;nbsp;and&amp;nbsp;guides&amp;nbsp;for&amp;nbsp;the&amp;nbsp;Gemini&amp;nbsp;API&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;If you want the starter version of this to land in your inbox, &lt;a href="https://drippery.app/subscribe/6e302973-cf5f-47ff-8803-22122ac3bd9d" rel="noopener noreferrer"&gt;The Claude Code Memory Starter&lt;/a&gt; walks you through it over a few short emails — free. The memory layer this one builds on is the earlier article on &lt;a href="https://generativeai.pub/how-i-added-persistent-semantic-memory-to-claude-code-in-15-minutes-9b91f9399a76" rel="noopener noreferrer"&gt;adding persistent semantic memory to Claude Code&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Target&amp;nbsp;publication:&lt;/strong&gt;&amp;nbsp;Generative&amp;nbsp;AI&amp;nbsp;(~50K&amp;nbsp;followers)&amp;nbsp;[PRIMARY&amp;nbsp;for&amp;nbsp;the&amp;nbsp;AI/Claude&amp;nbsp;Code&amp;nbsp;angle]&amp;nbsp;·&amp;nbsp;Level&amp;nbsp;Up&amp;nbsp;Coding&amp;nbsp;(~60K&amp;nbsp;followers)&amp;nbsp;[SECONDARY]&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tags:&lt;/strong&gt;&amp;nbsp;Claude&amp;nbsp;Code&amp;nbsp;·&amp;nbsp;AI&amp;nbsp;·&amp;nbsp;Developer&amp;nbsp;Tools&amp;nbsp;·&amp;nbsp;Programming&amp;nbsp;·&amp;nbsp;Productivity&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I build small tools and kits for solo creators. You can find them here: &lt;a href="https://danielrusnok.gumroad.com" rel="noopener noreferrer"&gt;https://danielrusnok.gumroad.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
    </item>
    <item>
      <title>How to Make Chrome Extension Iframes Transparent on Dark-Mode Sites (CSS color-scheme Fix)</title>
      <dc:creator>Digital Craft Workshop</dc:creator>
      <pubDate>Wed, 22 Jul 2026 15:15:31 +0000</pubDate>
      <link>https://dev.to/danielrusnok/how-to-make-chrome-extension-iframes-transparent-on-dark-mode-sites-css-color-scheme-fix-2b23</link>
      <guid>https://dev.to/danielrusnok/how-to-make-chrome-extension-iframes-transparent-on-dark-mode-sites-css-color-scheme-fix-2b23</guid>
      <description>&lt;p&gt;A customer of ours emailed: "Your Chrome extension shows me a blank white page on our dashboard." I checked. His site uses &lt;code&gt;color-scheme: dark\&lt;/code&gt;. Ours doesn't set it. &lt;strong&gt;Our iframe was painting a solid white rectangle over his entire viewport&lt;/strong&gt; — and I had no idea, because every site I test on is light-themed.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq5wg88typ5vrkfrvdbki.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq5wg88typ5vrkfrvdbki.jpg" alt="Same iframe overlay rendered on a color-scheme light host (invisible) versus a color-scheme dark host (opaque white rectangle)" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;
Same iframe, same transparent body — only the host's color-scheme differs.



&lt;h2&gt;
  
  
  The white rectangle nobody sees
&lt;/h2&gt;

&lt;p&gt;Drop an iframe into a customer's page. Set &lt;code&gt;background: transparent\&lt;/code&gt; on its &lt;code&gt;&amp;lt;body&amp;gt;\&lt;/code&gt;, its &lt;code&gt;&amp;lt;html&amp;gt;\&lt;/code&gt;, even &lt;code&gt;#root\&lt;/code&gt;. Check DevTools — every layer reports &lt;code&gt;background-color: rgba(0, 0, 0, 0)\&lt;/code&gt;. The iframe should be see-through.&lt;/p&gt;

&lt;p&gt;On most sites, it is.&lt;/p&gt;

&lt;p&gt;On any site that sets &lt;code&gt;color-scheme: dark\&lt;/code&gt; on its &lt;code&gt;&amp;lt;html&amp;gt;\&lt;/code&gt; element, &lt;strong&gt;the iframe paints a solid white rectangle over everything underneath&lt;/strong&gt;. Your transparent body is sitting on top of a white backdrop you didn't ask for and can't see in the computed styles panel.&lt;/p&gt;

&lt;p&gt;---&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Chrome does this
&lt;/h2&gt;

&lt;p&gt;The CSS property responsible is &lt;a href="https://developer.mozilla.org/en-US/docs/Web/CSS/color-scheme" rel="noopener noreferrer"&gt;&lt;code&gt;color-scheme\&lt;/code&gt;&lt;/a&gt;. It tells the browser what theme a document expects — &lt;code&gt;light\&lt;/code&gt;, &lt;code&gt;dark\&lt;/code&gt;, or &lt;code&gt;normal\&lt;/code&gt;. The browser uses this to pick the right shade for form controls, scrollbars, and a system color called &lt;strong&gt;Canvas&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Canvas\&lt;/code&gt; is the default backdrop the browser paints behind any element with a transparent background. In light mode it's white. In dark mode it's near-black.&lt;/p&gt;

&lt;p&gt;The spec detail that matters: &lt;strong&gt;&lt;code&gt;color-scheme\&lt;/code&gt; does not propagate across iframe document boundaries&lt;/strong&gt;. Every iframe document defaults to &lt;code&gt;color-scheme: normal\&lt;/code&gt; (which resolves to light) regardless of what the parent page declares.&lt;/p&gt;

&lt;p&gt;The CSS Color Adjustment spec calls this a &lt;strong&gt;color-scheme mismatch&lt;/strong&gt;. The spec requires the user agent to substitute an opaque Canvas color matching the iframe document's resolved color scheme — light, in this case, which means white. Your transparent body is then composited on top of that opaque white backdrop, not on top of the dark parent page.&lt;/p&gt;

&lt;p&gt;In practice, this means the browser inserts an opaque layer between your iframe document and the parent page before any of your own CSS gets a chance to run. There is no DOM node you can inspect for it. The computed background-color on every element in your iframe still reports &lt;code&gt;rgba(0, 0, 0, 0)\&lt;/code&gt;. The Elements panel will not show you a white rectangle, because the white rectangle is not in the DOM — it is the user agent's substitute Canvas, painted by the compositor.&lt;/p&gt;

&lt;p&gt;This is documented in &lt;a href="https://issues.chromium.org/issues/40157837" rel="noopener noreferrer"&gt;Chromium issue 40157837&lt;/a&gt; and the &lt;a href="https://github.com/w3c/csswg-drafts/issues/4772" rel="noopener noreferrer"&gt;CSSWG drafts discussion&lt;/a&gt;. It is intentional, it is spec-compliant, and it has been breaking iframe overlays for years — &lt;a href="https://levelup.gitconnected.com/one-missing-html-tag-is-killing-your-social-media-traffic-dcdf25e2b012" rel="noopener noreferrer"&gt;one missing HTML tag has been killing social media traffic&lt;/a&gt; the same way for just as long. The web is full of these tiny spec gotchas.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnzfqc6y4t2gt4o476tc5.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnzfqc6y4t2gt4o476tc5.jpg" alt="Painting order diagram: parent page color-scheme dark, iframe Canvas backdrop opaque white from mismatch, transparent iframe body, final composite shows a white rectangle over the dark parent" width="800" height="480"&gt;&lt;/a&gt;&lt;/p&gt;
Four layers — only the user agent's substitute Canvas is visible to the eye, and only when the parent is dark.



&lt;p&gt;---&lt;/p&gt;

&lt;h2&gt;
  
  
  The two-line CSS fix
&lt;/h2&gt;

&lt;p&gt;Tell the iframe element explicitly what color scheme to use, and make sure its body is transparent:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nt"&gt;iframe&lt;/span&gt;&lt;span class="nc"&gt;.my-overlay&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="py"&gt;color-scheme&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;light&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;transparent&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. &lt;code&gt;color-scheme: light\&lt;/code&gt; on the iframe &lt;strong&gt;element&lt;/strong&gt; in the parent document tells Chrome "the iframe wants light scheme, same as you would assume by default." No mismatch, no Canvas backdrop. &lt;code&gt;background: transparent\&lt;/code&gt; makes sure the element itself doesn't paint either.&lt;/p&gt;

&lt;p&gt;If you control the iframe document too, also add this in its CSS so it's transparent even before the inline style is parsed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nt"&gt;html&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nt"&gt;body&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;#root&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;transparent&lt;/span&gt; &lt;span class="cp"&gt;!important&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="py"&gt;color-scheme&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;light&lt;/span&gt; &lt;span class="cp"&gt;!important&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;!important\&lt;/code&gt; matters because some CSS resets and base layers (including configurations that add a body background) override your plain rule.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Small fixes like this are the kind of thing I collect in &lt;a href="https://drippery.app/subscribe/6e302973-cf5f-47ff-8803-22122ac3bd9d" rel="noopener noreferrer"&gt;The Claude Code Memory Starter&lt;/a&gt; — a free email series you can join in one click.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;---&lt;/p&gt;

&lt;h2&gt;
  
  
  The reusable React component
&lt;/h2&gt;

&lt;p&gt;If you have one iframe, do the CSS-only fix. If you have ten — which is what I had across our flow editor — you need a wrapper so nobody can add an iframe and forget the rule.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;forwardRef&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;IframeHTMLAttributes&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;SafeIframe&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;forwardRef&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;HTMLIFrameElement&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;IframeHTMLAttributes&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;HTMLIFrameElement&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;SafeIframe&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;rest&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;iframe&lt;/span&gt;
                &lt;span class="na"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;ref&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
                &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;rest&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
                &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="na"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;transparent&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="na"&gt;colorScheme&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;light&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
            &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things to notice. The wrapper uses &lt;code&gt;forwardRef\&lt;/code&gt; so callers can still attach refs (you'll need that for &lt;code&gt;getBoundingClientRect()\&lt;/code&gt; and &lt;code&gt;postMessage\&lt;/code&gt; patterns common in overlay UIs). And the default style spread comes &lt;strong&gt;before&lt;/strong&gt; &lt;code&gt;...style\&lt;/code&gt;, so any caller-provided style overrides the defaults — including setting &lt;code&gt;colorScheme: 'dark'\&lt;/code&gt; for the rare iframe that actually wants the dark backdrop.&lt;/p&gt;

&lt;p&gt;Then a &lt;code&gt;sed\&lt;/code&gt; pass replaces native iframes with the wrapper:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;f &lt;span class="k"&gt;in &lt;/span&gt;src/components/&lt;span class="k"&gt;**&lt;/span&gt;/&lt;span class="k"&gt;*&lt;/span&gt;.tsx&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt; &lt;span class="s1"&gt;'s|&amp;lt;iframe|&amp;lt;SafeIframe|g; s|&amp;lt;/iframe&amp;gt;|&amp;lt;/SafeIframe&amp;gt;|g'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add the import in each touched file. Done. Every future iframe added to the project is transparent-by-default unless somebody actively opts out.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftmkdlfbr6870kjut650q.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftmkdlfbr6870kjut650q.jpg" alt="Code diff comparing before and after migration: 16 native iframes plus 20 inline color-scheme hot-patches replaced by one SafeIframe wrapper with the defaults baked in" width="800" height="507"&gt;&lt;/a&gt;&lt;/p&gt;
One sed pass plus four imports — sixteen iframes become one wrapper.



&lt;p&gt;---&lt;/p&gt;

&lt;h2&gt;
  
  
  Make the iframe document transparent too
&lt;/h2&gt;

&lt;p&gt;The wrapper fixes the iframe element. The CSS above fixes the iframe document. &lt;strong&gt;You want both&lt;/strong&gt;, because each protects against a different failure mode.&lt;/p&gt;

&lt;p&gt;The element-level fix breaks if a caller passes an explicit &lt;code&gt;style={{ colorScheme: 'dark' }}\&lt;/code&gt; override. The document-level fix breaks if the iframe is created somewhere outside your wrapper (a third-party library, a manual &lt;code&gt;document.createElement('iframe')\&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;Add the document-level CSS to your iframe app's root stylesheet:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nt"&gt;html&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nt"&gt;body&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;#root&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;transparent&lt;/span&gt; &lt;span class="cp"&gt;!important&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="py"&gt;color-scheme&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;light&lt;/span&gt; &lt;span class="cp"&gt;!important&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your iframe app is React-based and uses Tailwind, put this &lt;strong&gt;after&lt;/strong&gt; &lt;code&gt;@tailwind base\&lt;/code&gt; so it overrides preflight defaults.&lt;/p&gt;

&lt;p&gt;---&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this bug is invisible on light-themed sites
&lt;/h2&gt;

&lt;p&gt;If you don't test on a dark-themed host, you will never see this bug. &lt;strong&gt;The white Canvas blends into a light parent page&lt;/strong&gt; — you see what looks like a transparent overlay, but it's actually a white rectangle on top of a white page.&lt;/p&gt;

&lt;p&gt;The bug is identical on light and dark hosts. Only the visibility changes. My dev environment is light. The marketing site is light. Most customer playground apps are light. The bug existed for months, and nobody on the team saw it because the entire test surface looked clean. The customer who reported it ran a security product with a dark dashboard — as soon as the white rectangle had something to contrast against, the "blank page" complaints started. (Same shape as &lt;a href="https://levelup.gitconnected.com/our-next-js-app-crashed-every-24-hours-here-are-the-six-memory-fixes-35245f6548e4" rel="noopener noreferrer"&gt;our Next.js app that crashed every 24 hours&lt;/a&gt; — silent production bug only outsiders ever noticed.)&lt;/p&gt;

&lt;p&gt;---&lt;/p&gt;

&lt;h2&gt;
  
  
  How to test for it before customers do
&lt;/h2&gt;

&lt;p&gt;Add this to your &lt;a href="https://medium.com/p/db51a8d7cc74" rel="noopener noreferrer"&gt;release checklist&lt;/a&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt; Open any of your iframe overlays on a host page that sets &lt;code&gt;color-scheme: dark\&lt;/code&gt; on its &lt;code&gt;&amp;lt;html&amp;gt;\&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt; If you don't have such a customer, force it for testing: in DevTools, find the &lt;code&gt;&amp;lt;html&amp;gt;\&lt;/code&gt; of any site you're testing on, add &lt;code&gt;style="color-scheme: dark"\&lt;/code&gt; inline. The bug will appear immediately if it's there.&lt;/li&gt;
&lt;li&gt; Or open Chrome DevTools → Rendering tab → set "Emulate CSS media feature prefers-color-scheme" to &lt;strong&gt;dark&lt;/strong&gt;, then visit any site whose CSS respects the system preference.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If your overlay paints a visible rectangle, you have the bug. The fix is the two-line CSS rule above.&lt;/p&gt;

&lt;p&gt;In DevTools the rectangle is solid white, the exact &lt;code&gt;Canvas\&lt;/code&gt; light-mode color — not off-white, not semi-transparent. It appears the instant the iframe element gets a layout box, before any of your iframe document's own CSS runs, so there is no flash-of-transparent-content moment to catch. To verify the fix is working, apply it, hard-reload the host page, and confirm the iframe area composites cleanly against the dark parent background with no white edge anywhere along its bounding box.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F46t8iq57u1i90o39cng4.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F46t8iq57u1i90o39cng4.jpg" alt="DevTools Rendering panel set to emulate prefers-color-scheme: dark, with the host page showing a white iframe rectangle appearing the instant emulation flips to dark" width="800" height="480"&gt;&lt;/a&gt;&lt;/p&gt;
Force-trigger the bug from DevTools — Rendering panel emulates dark scheme on any site.



&lt;p&gt;---&lt;/p&gt;

&lt;h2&gt;
  
  
  What we shipped
&lt;/h2&gt;

&lt;p&gt;In our codebase, the fix touched four files, replaced sixteen native &lt;code&gt;&amp;lt;iframe&amp;gt;\&lt;/code&gt; elements with one &lt;code&gt;SafeIframe\&lt;/code&gt; wrapper component, added one CSS rule to the global stylesheet, and deleted around twenty inline &lt;code&gt;colorScheme: 'light'\&lt;/code&gt; hot-patches that I had been sprinkling around while hunting the bug.&lt;/p&gt;

&lt;p&gt;The wrapper component is fifteen lines. The CSS rule is three lines. The entire fix is &lt;a href="https://levelup.gitconnected.com/stop-rewriting-the-whole-article-section-scoped-edits-for-llm-content-tools-d704ec05c1e5" rel="noopener noreferrer"&gt;shorter than this paragraph&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The bug had no console error, no DevTools warning, no broken selector. I only found it because a customer's screen didn't match mine.&lt;/p&gt;

&lt;p&gt;If you inject UI into customer pages, set up a dark-themed test host. I found mine had been broken on dark customers for months.&lt;/p&gt;

&lt;p&gt;---&lt;/p&gt;

&lt;p&gt;&lt;em&gt;If this saved you a dark-mode debugging session, &lt;a href="https://drippery.app/subscribe/6e302973-cf5f-47ff-8803-22122ac3bd9d" rel="noopener noreferrer"&gt;The Claude Code Memory Starter&lt;/a&gt; is a free email series where I share more fixes like it — one short email at a time.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>css</category>
      <category>webdev</category>
      <category>javascript</category>
      <category>chrome</category>
    </item>
  </channel>
</rss>
