<?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: Omar Bni</title>
    <description>The latest articles on DEV Community by Omar Bni (@omar_bni_f6856a8bb0e021e9).</description>
    <link>https://dev.to/omar_bni_f6856a8bb0e021e9</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%2F4020398%2F0f11873f-a642-47ce-be74-9a22a6fbf87b.png</url>
      <title>DEV Community: Omar Bni</title>
      <link>https://dev.to/omar_bni_f6856a8bb0e021e9</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/omar_bni_f6856a8bb0e021e9"/>
    <language>en</language>
    <item>
      <title>The Clanker Support WordPress plugin is approved — here's how it was built</title>
      <dc:creator>Omar Bni</dc:creator>
      <pubDate>Wed, 02 Sep 2026 14:18:15 +0000</pubDate>
      <link>https://dev.to/omar_bni_f6856a8bb0e021e9/the-clanker-support-wordpress-plugin-is-approved-heres-how-it-was-built-32k9</link>
      <guid>https://dev.to/omar_bni_f6856a8bb0e021e9/the-clanker-support-wordpress-plugin-is-approved-heres-how-it-was-built-32k9</guid>
      <description>&lt;p&gt;Good news for the roughly 40% of the web: &lt;strong&gt;Clanker Support is now an official WordPress plugin, approved for the WordPress.org plugin directory.&lt;/strong&gt; Install it, paste your project key under Settings → Clanker Support, save — and every page on your site gets a streaming AI support agent that answers from your knowledge base and hands off to a human when it should.&lt;/p&gt;

&lt;p&gt;This post is the whole story: why we built it, what it took to get through the WordPress.org review (spoiler: our zip file was secretly a tar), and how to use it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a plugin at all
&lt;/h2&gt;

&lt;p&gt;Adding a chat widget to WordPress has always meant one of two bad options: edit your theme and paste a script tag into &lt;code&gt;header.php&lt;/code&gt; (which silently disappears the next time you switch or update themes), or install a generic "insert headers and footers" plugin and manage raw HTML in a settings box. Either way, you're maintaining code to use a product that was supposed to save you time.&lt;/p&gt;

&lt;p&gt;The plugin is the third option. It's deliberately thin — under the hood it enqueues the same &lt;code&gt;widget.js&lt;/code&gt; embed our dashboard generates — but moving it into a plugin changes what it's like to live with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It survives your theme.&lt;/strong&gt; The settings live in your database and the widget is injected on every front-end page, whatever theme is active.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No performance tax.&lt;/strong&gt; The script loads asynchronously and renders after your page is interactive. The plugin ships no JavaScript or CSS of its own to your visitors — one script tag is its entire front-end footprint, and nothing loads in the admin area.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Always current.&lt;/strong&gt; The widget ships from the API, not from the plugin, so new widget features appear on your site without a plugin update.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reconfigure without touching HTML.&lt;/strong&gt; Brand color, escalation threshold, turning the bubble off — all settings saves, not snippet edits.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;Everything lives on one screen under &lt;strong&gt;Settings → Clanker Support&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%2Ftfkhqd6ho3ixdlb7rlkm.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%2Ftfkhqd6ho3ixdlb7rlkm.jpg" alt="The Clanker Support settings page in wp-admin: project key, floating widget toggle, brand color, escalation threshold, and API URL" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Project key&lt;/strong&gt; — the public key from your dashboard (Project → Embed). It's the same key the script embed exposes in your HTML, so it isn't a secret.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Floating widget&lt;/strong&gt; — the site-wide launcher bubble, on by default.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Brand color&lt;/strong&gt; — match the launcher and chat bubbles to your site.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Escalation threshold&lt;/strong&gt; — how many visitor messages before "Talk to a human" appears. Leave it blank and the widget uses your project's server-side default.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;API URL&lt;/strong&gt; — for self-hosters (more on that below).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The settings page also does something a pasted script tag never will: it checks itself. On load, the plugin verifies your project key server-side against the API and shows a status pill — connected, invalid key, or unreachable — so a typo'd key is caught on the settings screen, not discovered days later when you wonder why nobody's chatting. The result is cached for five minutes, and saving the settings re-checks immediately.&lt;/p&gt;

&lt;p&gt;And the floating bubble isn't the only placement. Drop the shortcode into any page or post — a contact page, a help center, a pricing FAQ — and the chat renders inline in a sandboxed iframe:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[clanker_support width="400" height="600"]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The shortcode works even when the site-wide bubble is toggled off, so you can offer chat only where it makes sense.&lt;/p&gt;

&lt;p&gt;Your visitors get the same full support loop as every Clanker Support embed: streaming AI answers grounded in your docs, human escalation that notifies your team by email and Slack, operator replies from the dashboard inbox appearing in the widget within seconds, and per-message ratings with an end-of-conversation CSAT prompt.&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%2Fyhhjend3wceevblohw0e.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%2Fyhhjend3wceevblohw0e.jpg" alt="How the WordPress plugin works: install and paste your key, the widget loads async from the Clanker API, visitors chat with AI answers and human handoff" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The build: shipped in a day, rebuilt the next
&lt;/h2&gt;

&lt;p&gt;The first version came together in an afternoon — a thin, pure-PHP injector, a settings page, the shortcode, a packaging script. We merged it, looked at it against what the WordPress.org directory actually expects, and pulled it back the same day.&lt;/p&gt;

&lt;p&gt;Because a plugin that works and a plugin that belongs in the directory are different artifacts. The rebuild that landed the next morning added everything reviewers (and WordPress conventions) expect: a proper class-based structure instead of one long bootstrap file, a translation template so the plugin is translatable, silence-is-golden &lt;code&gt;index.php&lt;/code&gt; files in every directory, an uninstaller that removes the plugin's two stored values (its settings option and the connection-status cache — conversations live in your Clanker Support project, never in your WordPress database), directory listing assets, and a full &lt;code&gt;readme.txt&lt;/code&gt; in the WordPress.org format.&lt;/p&gt;

&lt;p&gt;The readme deserves a special mention. Since the plugin is a connector to a hosted service, WordPress.org requires an explicit &lt;strong&gt;external services disclosure&lt;/strong&gt;: what loads from where, what data is sent, and links to the terms and privacy policy. Ours spells out that the widget script comes from your configured API origin, that nothing about a visitor is sent until they interact with the widget — and that if you self-host, every one of those requests goes to your own deployment instead of ours.&lt;/p&gt;

&lt;h2&gt;
  
  
  The review: three rejections' worth of lessons
&lt;/h2&gt;

&lt;p&gt;Before a human reviewer ever sees your plugin, automated checks run on the upload — and ours found things.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The zip that was secretly a tar.&lt;/strong&gt; Our packaging script built the upload zip with the system archiver, and on Windows that quietly falls apart: there's no &lt;code&gt;zip&lt;/code&gt; CLI, &lt;code&gt;tar.exe&lt;/code&gt; accepts &lt;code&gt;-a -cf plugin.zip&lt;/code&gt; but can't actually write zip format — so it silently emits a TAR with a &lt;code&gt;.zip&lt;/code&gt; name — and the PowerShell alternatives store backslash entry paths that unzip into garbage on Linux. The WordPress.org uploader rejected the artifact with the marvelously unhelpful "the plugin has no name." The fix: we threw out every external archiver and wrote a minimal zip writer on Node's built-in zlib — one deterministic code path on every OS — and the build now verifies the magic bytes of its own output. If your zip doesn't start with &lt;code&gt;PK&lt;/code&gt;, it isn't a zip.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Plugin URI ≠ Author URI.&lt;/strong&gt; The submission checker requires the plugin's homepage and the author's homepage to be different URLs. Both of ours pointed at clankersupport.com. The Plugin URI now points at the plugin's home in our monorepo on GitHub.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Validate the readme where the validator can read it.&lt;/strong&gt; The &lt;a href="https://wordpress.org/plugins/developers/readme-validator/" rel="noopener noreferrer"&gt;readme validator&lt;/a&gt; accepts a URL — but point it at a normal GitHub file link and it gets GitHub's HTML page, not your readme. We keep a byte-for-byte mirror of &lt;code&gt;readme.txt&lt;/code&gt; at the package root and validate against the raw URL.&lt;/p&gt;

&lt;p&gt;With those fixed, the submission went in — and a few days later, the approval came through. Approval on WordPress.org means the plugin gets its own SVN repository and a directory listing, which brings the two things a GitHub zip can't: &lt;strong&gt;one-click installs&lt;/strong&gt; from Plugins → Add New, and &lt;strong&gt;automatic updates&lt;/strong&gt; for everyone who installs it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Using it
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;In your WordPress admin, go to &lt;strong&gt;Plugins → Add New&lt;/strong&gt; and search for &lt;strong&gt;"Clanker Support"&lt;/strong&gt; (or grab the zip from &lt;a href="https://github.com/theopenco/llmchat/tree/main/packages/wordpress-plugin" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; and upload it under Add New → Upload Plugin).&lt;/li&gt;
&lt;li&gt;Install and activate.&lt;/li&gt;
&lt;li&gt;In your &lt;a href="https://app.clankersupport.com" rel="noopener noreferrer"&gt;Clanker Support dashboard&lt;/a&gt;, copy your project's public key (Project → Embed).&lt;/li&gt;
&lt;li&gt;In WordPress, go to &lt;strong&gt;Settings → Clanker Support&lt;/strong&gt;, paste the key, and save. Watch the status pill turn to connected.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That's the whole setup. The bubble is live on every page; add the &lt;code&gt;[clanker_support]&lt;/code&gt; shortcode wherever you want inline chat.&lt;/p&gt;

&lt;p&gt;And because Clanker Support is open source (&lt;a href="https://github.com/theopenco/llmchat" rel="noopener noreferrer"&gt;theopenco/llmchat&lt;/a&gt;), the plugin treats self-hosters as first-class: point the API URL setting at your own deployment and everything — the widget script, the chat API, the inline embed, even the settings page's connection check — talks to your infrastructure instead of ours.&lt;/p&gt;

&lt;p&gt;Next on the roadmap: automatically identifying logged-in WordPress users so escalations arrive with a name and email attached, and WooCommerce context so the agent knows about the visitor's order. If either of those matters to your store, come tell us in &lt;a href="https://discord.gg/RnyjHWuTKP" rel="noopener noreferrer"&gt;Discord&lt;/a&gt; — it directly shapes what we build first.&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>ai</category>
      <category>opensource</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Add AI customer support to your Next.js app with one Server Component</title>
      <dc:creator>Omar Bni</dc:creator>
      <pubDate>Wed, 26 Aug 2026 14:18:59 +0000</pubDate>
      <link>https://dev.to/omar_bni_f6856a8bb0e021e9/add-ai-customer-support-to-your-nextjs-app-with-one-server-component-p7f</link>
      <guid>https://dev.to/omar_bni_f6856a8bb0e021e9/add-ai-customer-support-to-your-nextjs-app-with-one-server-component-p7f</guid>
      <description>&lt;p&gt;Support widgets have shipped the same way since 2015: paste a script tag before &lt;code&gt;&amp;lt;/body&amp;gt;&lt;/code&gt;, hope it doesn't fight your framework. In a Next.js app that means no types, no server rendering, a mystery global mutating your DOM, and a launcher that pops in after hydration.&lt;/p&gt;

&lt;p&gt;We just shipped a better way. &lt;a href="https://www.npmjs.com/package/@clankersupport/widget-rsc" rel="noopener noreferrer"&gt;&lt;code&gt;@clankersupport/widget-rsc&lt;/code&gt;&lt;/a&gt; is Clanker Support as a native React Server Components package: one component in your root layout puts a streaming AI support agent on every page, server-rendered into your HTML. And because the whole widget is built on headless primitives, you can restyle it with plain CSS or replace our UI entirely.&lt;/p&gt;

&lt;p&gt;This tutorial takes you from &lt;code&gt;npm install&lt;/code&gt; to a customized widget in about ten minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you'll need
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A Next.js 15+ app using the App Router (any React 19 RSC framework works; we'll use Next.js here).&lt;/li&gt;
&lt;li&gt;A Clanker Support project and its public widget key — grab it from the dashboard under &lt;strong&gt;Project → Embed&lt;/strong&gt;. Self-hosting the open-source &lt;a href="https://github.com/theopenco/llmchat" rel="noopener noreferrer"&gt;llmchat&lt;/a&gt; stack works too; you'll just pass your own &lt;code&gt;apiUrl&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The key is public by design — it's the same key the script-tag embed exposes — so it's safe in client code and env files.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Install the SDK
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; @clankersupport/widget-rsc
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;React 19 and React DOM are the only peer dependencies. The package itself has zero runtime dependencies — the streaming protocol, API client, and storage layer are self-contained, so your bundle barely notices it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Add the widget to your root layout
&lt;/h2&gt;

&lt;p&gt;Open &lt;code&gt;app/layout.tsx&lt;/code&gt; and render &lt;code&gt;ClankerSupport&lt;/code&gt; just before the closing body tag:&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;ClankerSupport&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;@clankersupport/widget-rsc&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;default&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;RootLayout&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="nx"&gt;children&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="nl"&gt;children&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;React&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ReactNode&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;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;html&lt;/span&gt; &lt;span class="na"&gt;lang&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"en"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
            &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;body&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;children&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
                &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ClankerSupport&lt;/span&gt; &lt;span class="na"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NEXT_PUBLIC_CLANKER_KEY&lt;/span&gt;&lt;span class="o"&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;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;body&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;html&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add the key to &lt;code&gt;.env.local&lt;/code&gt;:&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;NEXT_PUBLIC_CLANKER_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;pk_your_project_key
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run &lt;code&gt;next dev&lt;/code&gt; and open any page. A launcher bubble sits bottom-right; click it and you're chatting with your support agent — streaming answers from your knowledge base, with "Talk to a human" escalation and everything else the hosted widget does.&lt;/p&gt;

&lt;p&gt;Here's what makes this different from a script tag. &lt;code&gt;ClankerSupport&lt;/code&gt; is an async Server Component: it fetches your widget config (branding, privacy URL) on the server, cached and revalidated every five minutes, so the client skips a round-trip and never flashes the wrong branding. The fetch is wrapped in Suspense with a &lt;code&gt;null&lt;/code&gt; fallback and fails soft — if our API is slow or unreachable, your page renders normally and the widget simply appears with safe defaults. A support vendor should never be able to block your page. Now it can't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Make it yours
&lt;/h2&gt;

&lt;p&gt;Everything visual is a typed prop:&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="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ClankerSupport&lt;/span&gt;
    &lt;span class="na"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NEXT_PUBLIC_CLANKER_KEY&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="na"&gt;brandColor&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"#16a34a"&lt;/span&gt;
    &lt;span class="na"&gt;position&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"bottom-left"&lt;/span&gt;
    &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Acme Support"&lt;/span&gt;
    &lt;span class="na"&gt;greeting&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Hi! Ask us anything about Acme."&lt;/span&gt;
    &lt;span class="na"&gt;escalationThreshold&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;brandColor&lt;/code&gt; drives the launcher, header, and user bubbles.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;position&lt;/code&gt; docks the widget to either bottom corner.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;greeting&lt;/code&gt; is the opening bubble (it personalizes automatically once a visitor gives their name; pass &lt;code&gt;null&lt;/code&gt; to hide it).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;escalationThreshold&lt;/code&gt; controls how many visitor messages appear before the "Talk to a human" option shows. Default is 3.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 4: Restyle with CSS
&lt;/h2&gt;

&lt;p&gt;The default UI is plain, namespaced CSS — &lt;code&gt;.clanker-*&lt;/code&gt; classes driven by custom properties, no shadow DOM — so your stylesheet always wins:&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="nc"&gt;.clanker-root&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="py"&gt;--clanker-brand&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#16a34a&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="py"&gt;--clanker-surface&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#0b0f14&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="py"&gt;--clanker-text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#e5e7eb&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="py"&gt;--clanker-bubble&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#1f2937&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="py"&gt;--clanker-border&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#1f2937&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nc"&gt;.clanker-panel&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;border-radius&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8px&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 a dark-mode widget in eleven lines, no configuration UI required.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Go headless when CSS isn't enough
&lt;/h2&gt;

&lt;p&gt;The styled widget is a thin composition over primitives we export from &lt;code&gt;@clankersupport/widget-rsc/headless&lt;/code&gt; — the same pattern as Radix: unstyled semantic elements, &lt;code&gt;data-*&lt;/code&gt; state attributes, &lt;code&gt;asChild&lt;/code&gt; support, and full prop passthrough.&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;use client&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="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;SupportChat&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;@clankersupport/widget-rsc/headless&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;function&lt;/span&gt; &lt;span class="nf"&gt;HelpButton&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="nc"&gt;SupportChat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Root&lt;/span&gt; &lt;span class="na"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NEXT_PUBLIC_CLANKER_KEY&lt;/span&gt;&lt;span class="o"&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;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;SupportChat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Trigger&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"btn"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Need help?&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;SupportChat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Trigger&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
            &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;SupportChat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Panel&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"panel"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
                &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;SupportChat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Messages&lt;/span&gt;&lt;span class="p"&gt;&amp;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;m&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;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Bubble&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;role&lt;/span&gt;&lt;span class="si"&gt;}&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;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Bubble&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
                &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;SupportChat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Messages&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
                &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;SupportChat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;EscalateButton&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Talk to a human&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;SupportChat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;EscalateButton&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
                &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;SupportChat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Composer&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
                    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;SupportChat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Input&lt;/span&gt; &lt;span class="na"&gt;placeholder&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Ask anything…"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
                    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;SupportChat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Submit&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Send&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;SupportChat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Submit&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
                &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;SupportChat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Composer&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
                &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;SupportChat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Branding&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
            &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;SupportChat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Panel&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;SupportChat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Root&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And when even components are too much structure, there's a single hook that exposes the whole state machine — messages, streaming status, sending, escalation, ratings, CSAT:&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;use client&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;useClankerSupport&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;@clankersupport/widget-rsc/headless&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;function&lt;/span&gt; &lt;span class="nf"&gt;SupportShortcut&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;send&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="nx"&gt;canEscalate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;escalate&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useClankerSupport&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;&amp;gt;&lt;/span&gt;
            &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt; &lt;span class="na"&gt;onClick&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Where is my order?&lt;/span&gt;&lt;span class="dl"&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;Track my order&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;button&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;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;streaming&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="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;TypingDots&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
            &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;canEscalate&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt; &lt;span class="na"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;escalate&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Talk to a human&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;/&amp;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;Build a ⌘K support palette, a docked sidebar, a help tab inside your settings page — the SDK handles the protocol (streaming, polling, escalation semantics, optimistic ratings) and you own every pixel.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you get out of the box
&lt;/h2&gt;

&lt;p&gt;Whichever layer you use, the behavior is the full Clanker Support loop:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Streaming AI answers grounded in your knowledge base (docs URLs, text snippets, Q&amp;amp;A pairs).&lt;/li&gt;
&lt;li&gt;Human escalation that emails your team and posts to Slack, with the agent going quiet while a human owns the conversation.&lt;/li&gt;
&lt;li&gt;Operator replies from the dashboard inbox appearing in the widget within seconds — no refresh.&lt;/li&gt;
&lt;li&gt;Per-message thumbs ratings and an end-of-conversation CSAT prompt.&lt;/li&gt;
&lt;li&gt;A privacy notice, identity capture, and conversations that survive reloads.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One detail worth knowing if you're already using our script tag: the SDK uses the same browser storage keys, so switching to the React package keeps every existing visitor conversation and identity. Migration is deleting one script tag and adding one import.&lt;/p&gt;

&lt;h2&gt;
  
  
  Self-hosting
&lt;/h2&gt;

&lt;p&gt;Everything above works against your own deployment of the open-source stack — pass your API origin and you're done:&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="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ClankerSupport&lt;/span&gt; &lt;span class="na"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"pk_…"&lt;/span&gt; &lt;span class="na"&gt;apiUrl&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"https://support-api.your-domain.com"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The full API reference — every prop, primitive, and hook field — lives in the &lt;a href="https://github.com/theopenco/llmchat/tree/main/packages/widget-rsc" rel="noopener noreferrer"&gt;package README&lt;/a&gt;. If you build something with the headless layer, we'd genuinely like to see it — come show us in &lt;a href="https://discord.gg/RnyjHWuTKP" rel="noopener noreferrer"&gt;Discord&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>react</category>
      <category>webdev</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>I spent an hour trying to break my own product with 12 languages. It won.</title>
      <dc:creator>Omar Bni</dc:creator>
      <pubDate>Tue, 11 Aug 2026 00:10:58 +0000</pubDate>
      <link>https://dev.to/omar_bni_f6856a8bb0e021e9/i-spent-an-hour-trying-to-break-my-own-product-with-12-languages-it-won-396o</link>
      <guid>https://dev.to/omar_bni_f6856a8bb0e021e9/i-spent-an-hour-trying-to-break-my-own-product-with-12-languages-it-won-396o</guid>
      <description>&lt;p&gt;Last night I sat down with one goal: make my own product embarrass me.&lt;/p&gt;

&lt;p&gt;The product is &lt;a href="https://clankersupport.com" rel="noopener noreferrer"&gt;Clanker Support&lt;/a&gt; — an open-source support agent you drop on your site with one script tag. The plan: one continuous conversation, same question every time, and I switch languages mid-thread until something cracks.&lt;/p&gt;

&lt;p&gt;Spanish. French. German. Portuguese. Japanese. Mandarin. Korean. Hindi. Russian. Turkish. Arabic.&lt;/p&gt;

&lt;p&gt;It answered every single one in the language it was asked. Not "detected Spanish, please hold" — just… the answer, in Spanish.&lt;/p&gt;

&lt;h2&gt;
  
  
  The frame that sold me on my own product
&lt;/h2&gt;

&lt;p&gt;My favorite moment isn't one answer — it's the seam between two.&lt;/p&gt;

&lt;p&gt;The Spanish reply has fully landed: trial terms, pricing, a source citation. And in the input box below it, the next question is already half-typed. In French. Same thread, three seconds apart, and the agent doesn't blink — it answers the French question in French like that's a completely normal thing for a support conversation to do.&lt;/p&gt;

&lt;p&gt;Then Arabic. The entire exchange flips right-to-left — question, answer, the pricing list — like the widget grew up writing it.&lt;/p&gt;

&lt;p&gt;(I also fed it Moroccan Darija typed in Arabizi, my personal "no software knows this exists" test. It passed that too, code-switching and all.)&lt;/p&gt;

&lt;h2&gt;
  
  
  The kicker
&lt;/h2&gt;

&lt;p&gt;There is no language setting in the product. No dropdown, no locale config, nothing to enable. There's no translation layer either — no detect-then-translate pipeline bolted on the side. The agent just answers in whatever it's spoken to. That's the whole trick, and it cost zero lines of code.&lt;/p&gt;

&lt;p&gt;Your customer decides the language. Every message, fresh.&lt;/p&gt;

&lt;h2&gt;
  
  
  And now it does it out loud
&lt;/h2&gt;

&lt;p&gt;We just added voice: there's a phone icon in the widget, and your customer can talk to the support agent on a live call. Same knowledge, same languages, spoken back in real time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one rule that survives all of this
&lt;/h2&gt;

&lt;p&gt;When the agent can't answer — any language — it doesn't improvise. It hands the conversation to a human on your team with the whole thread attached. That rule is why the product exists. The polyglot part is just it being useful on the way there.&lt;/p&gt;




&lt;p&gt;The chat bubble on &lt;a href="https://clankersupport.com" rel="noopener noreferrer"&gt;clankersupport.com&lt;/a&gt; is the live agent, not a demo. Go ask it something in your language — I'd genuinely love to know if it holds up in yours. It's open source, and hosted plans have a 7-day free trial.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>saas</category>
      <category>buildinpublic</category>
      <category>webdev</category>
    </item>
    <item>
      <title>We Audited Our Marketing Site Against Our Codebase and Found 6 Lies</title>
      <dc:creator>Omar Bni</dc:creator>
      <pubDate>Mon, 27 Jul 2026 14:47:33 +0000</pubDate>
      <link>https://dev.to/omar_bni_f6856a8bb0e021e9/we-audited-our-marketing-site-against-our-codebase-and-found-6-lies-2bal</link>
      <guid>https://dev.to/omar_bni_f6856a8bb0e021e9/we-audited-our-marketing-site-against-our-codebase-and-found-6-lies-2bal</guid>
      <description>&lt;p&gt;Last week we put our marketing site in one tab and our codebase in the other and checked every falsifiable sentence on the site against the code that would have to make it true. Six claims failed the check, and the people most likely to notice were exactly the people we most need to convince.&lt;/p&gt;

&lt;p&gt;We build &lt;a href="https://github.com/theopenco/llmchat" rel="noopener noreferrer"&gt;Clanker Support&lt;/a&gt;, an open-source, MIT-licensed AI support agent you embed with one script tag. We're early: no wall of logos, no review-site score to lean on. The one trust asset available to a company like ours is honesty a stranger can verify, and claim drift burns it invisibly — usually midway through a technical evaluation, when a developer checks.&lt;/p&gt;

&lt;p&gt;To be clear about how the drift accumulated: nobody sat down and decided to fabricate features. Copy got written against a roadmap, the code took a different route, and nobody ever diffs the homepage against the repo. Drift never feels like lying from the inside. The visitor reading the page can't tell the difference, so functionally it is.&lt;/p&gt;

&lt;p&gt;Every fix below shipped as &lt;a href="https://github.com/theopenco/llmchat/pull/156" rel="noopener noreferrer"&gt;one public pull request&lt;/a&gt;. You can read each diff.&lt;/p&gt;

&lt;h2&gt;
  
  
  The six claims that failed the diff
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. "Run any model or provider."&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The model picker is a curated catalog of web-search-capable models, filtered from a generated snapshot of our gateway's catalog. Higher tiers unlock more of the list. "Any model" was aspirational copy for a picker that refuses to even boot with an empty list:&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;// packages/shared/src/models.ts&lt;/span&gt;
&lt;span class="c1"&gt;// Loud failure, never a blank picker: if the generated snapshot is ever empty&lt;/span&gt;
&lt;span class="c1"&gt;// (a botched regen), fail at import rather than silently offer no models.&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;WEB_SEARCH_MODELS&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;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;WEB_SEARCH_MODELS is empty — run `pnpm gen:web-search-models` to regenerate from @llmgateway/models&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;A curated catalog is a defensible design choice. We rewrote the copy to describe it, because it's what you get.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. "How many exchanges before the bot hands off."&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That's how the site described the escalation threshold: as if the agent decides, at some configured point, to hand the conversation over. The setting behind that copy is a per-project message threshold, and what it does is reveal a "Talk to a human" button after N messages. The visitor decides. A counter, not a judgment. The copy now describes the threshold.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. "Answers when it can. Hands off when it can't."&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Tidy copy, and it claims the AI monitors its own confidence and bails out the moment it's unsure. Great feature. We don't have it. What actually exists is visitor-initiated hand-off: the threshold button above, plus pattern detection for a visitor explicitly asking for a person. The detector's own doc comment is more honest than our homepage was:&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;// packages/widget/src/escalation-intent.ts&lt;/span&gt;
&lt;span class="cm"&gt;/**
 * Detects a visitor explicitly asking for a human, so the "Talk to a human"
 * CTA can surface immediately instead of waiting for the message-count
 * threshold.
 *
 * Matching leans toward recall over precision: a match only REVEALS the
 * escalate button (the visitor still has to click it), so a rare false
 * positive costs one extra affordance while a false negative traps a
 * frustrated visitor with the bot.
 * …
 */&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Even the explicit-ask path only reveals a button. The visitor clicks it. Nothing anywhere in the codebase asks the model how confident it feels. This one stung the most, because the fake version sounds smarter and we'd absorbed it into how we described the product out loud.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Infrastructure attributed to the wrong vendor.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Our comparison pages said "Fully self-hostable on Cloudflare infrastructure — D1, KV, and workerd." We don't run on Cloudflare. We run on serverless workerd via a platform called Ploy, and the same copy now reads "serverless workerd via Ploy (D1-compatible SQLite + KV state)." Same runtime family, wrong vendor. Nobody sues over this one, but a reader who catches the infrastructure paragraph being wrong has no reason to trust the security paragraph.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. "Self-host free, full feature set."&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The worst one, because it bent the promise open-source people actually check. Self-hosting is free forever with your own LLM keys; that part was true. What the copy skipped: a fresh self-hosted install resolves to a locked plan unless an environment allowlist is set. The unlock is a few lines of config parsing:&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;// apps/api/src/lib/plan.ts&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;internalEmails&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Env&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="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;vars&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;INTERNAL_ACCOUNT_EMAILS&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;raw&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="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&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="nf"&gt;map&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Boolean&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;Unset, no workspace is exempt, and software running on your own server greets you with a paywall you had no way to anticipate. The immediate fix was documentation: &lt;a href="https://docs.clankersupport.com" rel="noopener noreferrer"&gt;the docs&lt;/a&gt; now tell you the unlock exists and how to set it, instead of letting you discover the lock the hard way. Documenting an awkward mechanism beats hiding it, and it bought us time to decide what the mechanism should become.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6. SSO/SAML and audit logs listed on the enterprise tier.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Display copy with zero code behind it. We don't mean "beta" or "partially built" — grep the repo for SAML and the only hits are the copy itself. Both are now labeled as roadmap. Had an enterprise buyer asked for an audit-log demo, the demo would have been us typing very fast in another room.&lt;/p&gt;

&lt;p&gt;A seventh came from an internal doc rather than the site: a per-plan member cap we claimed to enforce. The entitlement number exists in the billing config, but there's no invite endpoint, so there's nothing to gate. A limit with no enforcement path is a wish with a number on it. Claim dropped.&lt;/p&gt;

&lt;h2&gt;
  
  
  One page undersold us
&lt;/h2&gt;

&lt;p&gt;The audit cut the other way exactly once. Our own Chatwoot comparison listed "fully open-source (MIT license) — read, fork, and contribute" as the competitor's advantage, implying we weren't. We are, and the LICENSE file has been in the repo the whole time. That fix went into the same pull request as the six above.&lt;/p&gt;

&lt;p&gt;Finding it reframed the whole exercise. The target is agreement between the site and the repo, and undersell is the same defect as oversell: the two disagree. Treat both directions as bugs and the audit stops feeling like penance and starts feeling like ordinary QA, which is what got it finished.&lt;/p&gt;

&lt;h2&gt;
  
  
  The re-pass found 18 more, four written during the fix
&lt;/h2&gt;

&lt;p&gt;After the first fix pass we felt pretty good about ourselves. Then we ran an adversarial review: a two-agent panel, LLMs given the repo, whose only job was to attack the corrected copy against the codebase.&lt;/p&gt;

&lt;p&gt;They found 14 missed instances of the same six overclaims, spread across pages, docs, and meta descriptions the first pass never opened.&lt;/p&gt;

&lt;p&gt;Worse, they found 4 brand-new overclaims in the replacement copy itself. Text written during a truth-telling exercise, by people actively trying to be accurate, still drifted optimistic within the same edit session. We'd swap a false claim for a true one and unconsciously round it up while typing.&lt;/p&gt;

&lt;p&gt;Marketing language has gravity: every sentence wants to be slightly more impressive than the facts, and you don't feel the pull while writing. The only countermeasure we've found is a second pass by someone, or something, that doesn't share your incentives.&lt;/p&gt;

&lt;h2&gt;
  
  
  Diff your homepage against your repo
&lt;/h2&gt;

&lt;p&gt;The repeatable version fits in an afternoon, and you don't have to read code to run it. Each step is an ask you can hand to your team as one sentence.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Extract every falsifiable claim.&lt;/strong&gt; Scrape your homepage, pricing page, feature pages, and docs. Pull out every sentence that asserts something checkable: a feature exists, a limit is enforced, a platform is used, a license applies. Ignore vibes ("delightful"), keep facts ("supports SSO").&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Assign each claim a code location.&lt;/strong&gt; For every claim, ask: which file or endpoint makes this true? "Any model" should point at the model list. "Audit logs" should point at an audit-log table. A claim nobody can point anywhere is a finding, and whoever wrote the feature can answer the question in about a minute.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Classify: true / roadmap / false / understated.&lt;/strong&gt; A roadmap item is fine as long as it's labeled as one. Anything shipped-but-unclaimed goes in the understated bucket, and it gets fixed in the same pass.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Fix everything in one public pull request.&lt;/strong&gt; The public part is the point. A private cleanup earns nothing; a public one is evidence you can hand a skeptical visitor for years.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Re-audit the fix adversarially.&lt;/strong&gt; This is the step everyone skips, and we nearly did. It produced 18 of our findings — four of which we authored during the fix itself. A colleague, an advisor, an LLM given the repo and told to attack: anyone whose job is to disagree with your copy.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Then put it on a calendar, because drift regrows and every new landing page restarts the clock. The cost of skipping the whole exercise never appears as a line item. It arrives as an evaluation that quietly churned when a developer caught claim number four, or an enterprise call where someone asks to see the audit log.&lt;/p&gt;

&lt;p&gt;We've run a related exercise on our search presence before, in &lt;a href="https://clankersupport.com/blog/ai-seo-audit-checklist" rel="noopener noreferrer"&gt;our AI SEO audit checklist&lt;/a&gt;. Don't blur the two: that one audits how your pages present claims to search and answer engines; this one audits the product claims themselves against code.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cheapest credibility available
&lt;/h2&gt;

&lt;p&gt;The product carries the same rule at a smaller scale. Where the dashboard has no real value for a metric, it renders an em dash rather than a guessed number, and the inbox stats' doc comment spells the policy out:&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;// apps/dashboard/src/app/inbox/_components/InboxStats.tsx&lt;/span&gt;
&lt;span class="cm"&gt;/**
 * …The avg rating is the mean CSAT across rated conversations only, shown
 * as "—" when none are rated (never NaN). While the aggregate is loading,
 * values render as "—".
 */&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The usage meter in the sidebar goes further and renders nothing at all until the number resolves — its comment reads "(no fabricated zero)". That's the same rule our homepage broke six ways, applied at the level of a single stat card.&lt;/p&gt;

&lt;p&gt;What a company at our stage can offer is verifiability: an MIT repo you can read, a public pull request where we corrected our own marketing, docs that explain the awkward parts like the self-host unlock instead of burying them. A skeptical developer can check every word of this post against the diffs in about ten minutes, and that checkability is worth more to us than any adjective we could have kept.&lt;/p&gt;

&lt;p&gt;If you're early and your homepage promises things your repo can't cash, you're spending trust you haven't minted yet. Diff the site against the repo and fix in both directions, where people can watch. It's an afternoon of mildly humiliating work, and it's the cheapest credibility you'll ever buy.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  How do you audit marketing claims against a codebase?
&lt;/h3&gt;

&lt;p&gt;Extract every falsifiable claim from your homepage, pricing page, and docs; assign each one the file or endpoint that would make it true; classify each as true, roadmap, false, or understated; fix everything in one public pull request; then have someone adversarial re-audit the fix. The last step matters most — our re-pass caught overclaims written during the fix itself.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is claim drift?
&lt;/h3&gt;

&lt;p&gt;Claim drift is the gap that grows between what a marketing site says and what the codebase does. It rarely starts as a lie: copy gets written against a roadmap, the product takes a different route, and nobody re-checks the copy. The visitor can't distinguish drift from fabrication, so it costs the same trust.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should the fixes be public?
&lt;/h3&gt;

&lt;p&gt;If the product is open source, yes. A private cleanup earns nothing, while a public pull request is standing evidence you can hand a skeptical evaluator years later. It also raises the cost of future drift, which is half the point.&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>startup</category>
      <category>marketing</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>Two messages, one sequence number: the concurrency bug that never threw</title>
      <dc:creator>Omar Bni</dc:creator>
      <pubDate>Mon, 27 Jul 2026 14:45:32 +0000</pubDate>
      <link>https://dev.to/omar_bni_f6856a8bb0e021e9/two-messages-one-sequence-number-the-concurrency-bug-that-never-threw-4ep0</link>
      <guid>https://dev.to/omar_bni_f6856a8bb0e021e9/two-messages-one-sequence-number-the-concurrency-bug-that-never-threw-4ep0</guid>
      <description>&lt;p&gt;One day, two messages in the same support conversation both had &lt;code&gt;sequence = 2&lt;/code&gt;. Both inserts succeeded and nothing was logged; everything downstream simply picked an order. The colliding pair says it all: the "Visitor requested a human operator" marker and the AI answer the visitor was escalating away from, and depending on which order a client picked, the request for a human rendered before or after the answer that prompted it. That &lt;code&gt;sequence&lt;/code&gt; integer is load-bearing in Clanker Support: the widget orders the thread by it, the dashboard inbox orders by it, unread tracking uses it as a high-water mark. For a support product, that is close to the worst available failure. The conversation thread is the artifact your customer trusts, and a garbled support thread fails the way a garbled bank statement does: the customer stops believing the record.&lt;/p&gt;

&lt;p&gt;The expensive property of this bug class is who finds it. It never appears in tests, never appears in logs, and the corruption compounds quietly, so the first person positioned to notice is a customer reading a thread that makes no sense — long after the writes that caused it. We caught it in our own testing before it cost anyone anything; we're early, and that's the cheapest possible place to catch it. Nothing about the race changes at scale except how much data it quietly ruins first. The product is open source (&lt;a href="https://github.com/theopenco/llmchat" rel="noopener noreferrer"&gt;theopenco/llmchat&lt;/a&gt;), so everything below links to real diffs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two sins, one write path
&lt;/h2&gt;

&lt;p&gt;Every writer in the system did some version of this: read the conversation, do work, then write a message whose sequence was computed from the earlier read. Simplified:&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;// BEFORE: read early, write late&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;convo&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="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;conversation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findFirst&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;where&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;conversation&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;conversationId&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// ... 5–20 seconds of LLM streaming happens here ...&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;message&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;conversationId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;sequence&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;convo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;messageCount&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="c1"&gt;// computed from a read made earlier&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;conversation&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;messageCount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;convo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;messageCount&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="c1"&gt;// absolute assignment&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sin one: &lt;code&gt;sequence&lt;/code&gt; is computed from &lt;code&gt;messageCount&lt;/code&gt; as it stood when this request read it. If any other writer inserts between the read and the insert, both writers saw &lt;code&gt;messageCount = 3&lt;/code&gt; and both write &lt;code&gt;sequence = 4&lt;/code&gt;. SQLite takes both rows without complaint.&lt;/p&gt;

&lt;p&gt;Sin two: the count update is an absolute assignment. Writer A sets &lt;code&gt;messageCount = 4&lt;/code&gt;; writer B, working from the same stale read, also sets it to 4. Two messages arrived and the count moved by one. The damage compounds from there, because the next writer derives its sequence from a count that's already wrong, so one race seeds the next.&lt;/p&gt;

&lt;p&gt;We had no shortage of concurrent writers, either. The chat handler persists the assistant's reply after the stream finishes, inside &lt;code&gt;waitUntil&lt;/code&gt;, seconds after it read the conversation. An operator can reply from the dashboard inbox at any moment. Internal notes land. An escalation writes a system message. An inbound email reply arrives through a webhook. Five independent writers, all doing read-then-write against the same counter.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why every test passed
&lt;/h2&gt;

&lt;p&gt;Our tests exercised each writer in isolation: insert a user message, assert sequence 1; insert a reply, assert sequence 2. Green across the board.&lt;/p&gt;

&lt;p&gt;The race needs two writers interleaved inside the same window, and the widest window in the whole system is the one no unit test reproduces: the assistant-persist that runs after an LLM stream completes. In tests the "stream" resolves instantly with nothing else running. In production it takes 5 to 20 seconds, and the true trigger is mundane: the visitor hits "Talk to a human" mid-stream (the exact collision we found), or an operator replies from the inbox while the agent is still streaming. Either one lands a write inside the gap every single time it happens.&lt;/p&gt;

&lt;p&gt;So tests pass because they're sequential, and production fails because it isn't. No quantity of extra tests fixes that cleanly. The gap itself has to go.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix shipped in three steps, and the order is the point
&lt;/h2&gt;

&lt;p&gt;You can't just add a unique index: the old writers are still running while you deploy, and the index build fails outright if duplicates exist. You can't just fix the writers either: existing duplicate rows stay corrupted, and any future regression goes back to being silent. So the fix went out as three deploys, strictly ordered.&lt;/p&gt;

&lt;h3&gt;
  
  
  Clean the data first
&lt;/h3&gt;

&lt;p&gt;The backfill (&lt;a href="https://github.com/theopenco/llmchat/pull/161" rel="noopener noreferrer"&gt;the public pull request&lt;/a&gt;) finds every conversation with at least one duplicated &lt;code&gt;(conversation_id, sequence)&lt;/code&gt; pair, renumbers all of that conversation's messages with &lt;code&gt;ROW_NUMBER()&lt;/code&gt;, then trues up the drifted &lt;code&gt;message_count&lt;/code&gt;. Lightly trimmed:&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;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;_seq_backfill&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt;
    &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;mid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;ROW_NUMBER&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="n"&gt;OVER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;PARTITION&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;conversation_id&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;sequence&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;new_seq&lt;/span&gt;
    &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;
    &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;conversation_id&lt;/span&gt; &lt;span class="k"&gt;IN&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;conversation_id&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;
        &lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;conversation_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sequence&lt;/span&gt; &lt;span class="k"&gt;HAVING&lt;/span&gt; &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;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;UPDATE&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;sequence&lt;/span&gt; &lt;span class="o"&gt;=&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;new_seq&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;_seq_backfill&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;mid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;IN&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;mid&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;_seq_backfill&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;DROP&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;_seq_backfill&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;conversation&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;message_count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;conversation_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;conversation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;message_count&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;conversation_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;conversation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&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;Two details worth stealing. The &lt;code&gt;ORDER BY sequence, created_at, id&lt;/code&gt; makes the renumbering deterministic: ties on the duplicated sequence break by creation time, and ties on creation time break by id. That last tiebreaker matters more than it looks — our timestamps are unix seconds, so same-second writes are common, which is the whole bug. Run the backfill twice and you get the same answer. And it stages through a temp table rather than a correlated self-update, because renumbering a partition while you're reading it is how you end up with a backfill you can't reason about.&lt;/p&gt;

&lt;h3&gt;
  
  
  One writer, and the database allocates the key
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/theopenco/llmchat/pull/162" rel="noopener noreferrer"&gt;The writer refactor&lt;/a&gt; replaces every inline insert-and-bump in every route with one function: &lt;code&gt;insertMessage()&lt;/code&gt; in &lt;code&gt;apps/api/src/lib/messages.ts&lt;/code&gt;. Chat persist, operator reply, notes, escalation markers, inbound email replies: all of them now go through it.&lt;/p&gt;

&lt;p&gt;The core move is that the sequence is allocated by a scalar subquery inside the INSERT itself:&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="nf"&gt;db&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;)&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;message&lt;/span&gt;&lt;span class="p"&gt;)&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;conversationId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;conversationId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;role&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="c1"&gt;// ...&lt;/span&gt;
        &lt;span class="na"&gt;sequence&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;sql&lt;/span&gt;&lt;span class="s2"&gt;`(SELECT COALESCE(MAX(&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sequence&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;), 0) + 1 FROM &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; WHERE &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;conversationId&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="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;conversationId&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="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;returning&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 the entire trick. SQLite (and D1, which is SQLite at the edge) serialize writers per statement, so &lt;code&gt;MAX(sequence) + 1&lt;/code&gt; and the insert are atomic. There is no gap between reading the current max and writing the next value, because they're the same statement. The database allocates the ordering key, and the application never holds it in a variable where it can go stale.&lt;/p&gt;

&lt;p&gt;The counter fix rides along:&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;bumped&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;db&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;)&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;conversation&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;messageCount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;sql&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;conversation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;messageCount&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&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;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;conversation&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;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;conversationId&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;returning&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;messageCount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;conversation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;messageCount&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;message_count = message_count + 1&lt;/code&gt; is commutative: two concurrent bumps produce +2 no matter how they interleave, where two absolute assignments produced +1. The &lt;code&gt;RETURNING&lt;/code&gt; clause hands the post-bump count back to callers so nobody is tempted to re-derive it from a sequence number.&lt;/p&gt;

&lt;h3&gt;
  
  
  The unique index that makes any regression loud
&lt;/h3&gt;

&lt;p&gt;The last deploy adds &lt;a href="https://github.com/theopenco/llmchat/pull/163" rel="noopener noreferrer"&gt;the unique index&lt;/a&gt; that makes any regression loud:&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;CREATE&lt;/span&gt; &lt;span class="k"&gt;UNIQUE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;message_conv_seq_uidx&lt;/span&gt;
    &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;conversation_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sequence&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the part we'd get wrong if we did this again without notes: that migration re-runs the exact same dedupe backfill, immediately before creating the index, in the same migration. Between the backfill deploy and the writer deploy, the old racy writers were still live in production. Any duplicate they minted in that window would make the index build fail and take the whole deploy down with it. The re-dedupe costs nothing when the data is already clean and saves the deploy when it isn't. (We're rigid about deploy ordering around risky schema changes in general — it's the same discipline that kept &lt;a href="https://clankersupport.com/blog/additive-migration-almost-broke-every-login" rel="noopener noreferrer"&gt;an "always safe" additive column from breaking every login&lt;/a&gt;.)&lt;/p&gt;

&lt;p&gt;Once the new writer is live, the index should never fire. It exists so that if someone adds an inline insert with a precomputed sequence eight months from now, the result is a constraint error in the logs instead of six more weeks of silently shuffled threads. Downgrading a bug from silent corruption to loud error is most of the value of the whole exercise.&lt;/p&gt;

&lt;h2&gt;
  
  
  Drizzle buries the error your retry path needs on &lt;code&gt;.cause&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;insertMessage()&lt;/code&gt; has a retry path: if an insert ever trips the unique index, it retries once, and the re-run subquery naturally picks the next free slot. To do that it has to detect a unique-constraint violation, and our first attempt was the obvious one:&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;// looks right, never matches in prod&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;err&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nb"&gt;Error&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="sr"&gt;/unique constraint failed/i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&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 never matches. Drizzle 0.45 wraps every driver error in a &lt;code&gt;DrizzleQueryError&lt;/code&gt; whose message reads &lt;code&gt;"Failed query: insert into message ..."&lt;/code&gt;. The actual &lt;code&gt;UNIQUE constraint failed: message.conversation_id, message.sequence&lt;/code&gt; text lives on &lt;code&gt;err.cause&lt;/code&gt;, one level down. A message-only check compiles, passes any test that fakes the error, and silently classifies every real violation as an unknown error to rethrow. The retry path becomes dead code, and you find out the day the tripwire fires and nothing retries.&lt;/p&gt;

&lt;p&gt;The version that works walks the cause chain, with a cycle guard because &lt;code&gt;cause&lt;/code&gt; can technically point anywhere:&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;function&lt;/span&gt; &lt;span class="nf"&gt;isUniqueViolation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&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;seen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Set&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;unknown&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;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;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&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="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;msg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nb"&gt;Error&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;message&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/unique constraint failed/i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;msg&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="kc"&gt;true&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="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;cause&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nx"&gt;cause&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="kc"&gt;false&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 you classify database errors through any ORM, check whether the string you match lives on &lt;code&gt;.message&lt;/code&gt; or on &lt;code&gt;.cause.message&lt;/code&gt;. &lt;code&gt;Error.cause&lt;/code&gt; chains have been standard since ES2022, and most error-classification code we've read predates them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The five-second audit
&lt;/h2&gt;

&lt;p&gt;The rule underneath all of this: never derive an ordering key from state you read earlier in the request. Not a sequence number, and not a version counter either. The moment the value leaves the database and sits in a variable it's a snapshot, and every millisecond between read and write is a window some other writer will eventually hit. Streams, webhooks, and background jobs stretch those windows to seconds.&lt;/p&gt;

&lt;p&gt;You don't need to write code to check your own product for this today. Ask whoever owns the backend to search the codebase for &lt;code&gt;count + 1&lt;/code&gt; or &lt;code&gt;position + 1&lt;/code&gt; computed in application code and written back later. The search takes about five seconds, and each hit is this bug wearing different clothes. Then ask two follow-ups: is there one writer function for that key, and is there a database constraint that would make a violation loud? If either answer is no, you have this bug on a timer.&lt;/p&gt;

&lt;p&gt;The repair, when you need it, is the same recipe in the same order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Backfill first&lt;/strong&gt;, deterministically and idempotently, so the data is clean.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Let the database allocate the key atomically&lt;/strong&gt;, in the same statement as the write, through one writer function with no exceptions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add the constraint last&lt;/strong&gt;, re-cleaning immediately before you build it, so the bug class becomes impossible rather than unlikely, and any regression is a loud error instead of quiet drift.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;All three diffs are small enough to read in one sitting, and every one of them is public on &lt;a href="https://github.com/theopenco/llmchat" rel="noopener noreferrer"&gt;our repo&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  How do you safely generate a per-group sequence number in SQLite or D1?
&lt;/h3&gt;

&lt;p&gt;Allocate it inside the INSERT itself with a scalar subquery: &lt;code&gt;sequence = (SELECT COALESCE(MAX(sequence), 0) + 1 FROM message WHERE conversation_id = ?)&lt;/code&gt;. SQLite serializes writers per statement, so reading the current max and writing the next value happen atomically. Any pattern that reads a counter into application code first has a race window, and streaming or background work stretches that window to seconds.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why isn't a unique index alone enough to fix a sequence race?
&lt;/h3&gt;

&lt;p&gt;Deploys aren't instantaneous. While the index migration runs, older application code with the racy writer is still serving traffic, and any existing duplicates make the index build fail outright. Clean the data first, route every write through one atomic writer, then create the index, re-running the dedupe immediately before it to absorb duplicates minted between deploys.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why doesn't err.message contain "UNIQUE constraint failed" with Drizzle?
&lt;/h3&gt;

&lt;p&gt;Drizzle 0.45 wraps driver errors in a &lt;code&gt;DrizzleQueryError&lt;/code&gt; whose message describes the failed query; the constraint text lives on &lt;code&gt;err.cause&lt;/code&gt;. Walk the cause chain (with a cycle guard) when classifying database errors, or your retry and fallback paths will silently never run.&lt;/p&gt;

</description>
      <category>sql</category>
      <category>database</category>
      <category>webdev</category>
      <category>devbugsmash</category>
    </item>
    <item>
      <title>Every hosted plan now starts with a 7-day free trial</title>
      <dc:creator>Omar Bni</dc:creator>
      <pubDate>Wed, 22 Jul 2026 15:21:23 +0000</pubDate>
      <link>https://dev.to/omar_bni_f6856a8bb0e021e9/every-hosted-plan-now-starts-with-a-7-day-free-trial-38kj</link>
      <guid>https://dev.to/omar_bni_f6856a8bb0e021e9/every-hosted-plan-now-starts-with-a-7-day-free-trial-38kj</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://clankersupport.com/blog/7-day-free-trial" rel="noopener noreferrer"&gt;the Clanker Support blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;As of last week, every new hosted Clanker Support subscription starts with a 7-day free trial. It applies automatically at checkout — no promo code, nothing to hunt for. Pick a plan, and for seven days you use it for free.&lt;/p&gt;

&lt;p&gt;Here's the honest version of why. Our hosted product has never had a free tier — that's by design, because self-hosting is the free version and always will be. But it meant the first thing a new customer met was a paywall, before the product had said a word for itself. We didn't like that first impression, so we replaced it: now the first thing you meet is the full product, free for a week.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trial is the whole plan, not a preview
&lt;/h2&gt;

&lt;p&gt;The seven days are the plan you picked, from day one. Every feature, the full response allowance — 2,000 AI responses on Starter, 12,000 on Growth, 50,000 on Scale. There's no demo mode, no locked features, no "upgrade to unlock" halfway through.&lt;/p&gt;

&lt;p&gt;That matters because of what a week is actually enough for. Install is one script tag, and most teams are live in about five minutes. Point the agent at your docs and knowledge sources, and it starts answering your real customers — escalating to your team instead of guessing when it can't. By day seven you're not evaluating our product anymore. You're deciding whether to switch off something that's already handling your support.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why we ask for a card
&lt;/h2&gt;

&lt;p&gt;We do ask for a card at checkout, and we'd rather explain that than hide it.&lt;/p&gt;

&lt;p&gt;Nothing is charged during the trial. Usage during the trial is never billed — not on day seven, not ever. The first charge happens only when the trial ends and you've chosen to stay.&lt;/p&gt;

&lt;p&gt;The card is there so the week ends cleanly either way. If Clanker Support has earned its place, your subscription continues without you re-entering anything or losing a day of coverage. If it hasn't, cancelling is self-serve from your billing settings — through the Stripe billing portal, before day seven, and you pay nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the seven days end
&lt;/h2&gt;

&lt;p&gt;If you stay, your plan simply continues and your first charge goes through. And you're still covered: every hosted plan comes with a 14-day money-back guarantee, you can cancel anytime, and there are no contracts.&lt;/p&gt;

&lt;p&gt;Stack that up and the arrangement is deliberately lopsided. Seven free days, no charge until the trial ends, self-serve cancellation from billing settings, money-back guarantee after that. We carry the risk of you trying Clanker Support. You don't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Already a customer?
&lt;/h2&gt;

&lt;p&gt;One thing we want to be straightforward about: switching between paid tiers doesn't restart a trial. The trial is for workspaces starting their first subscription — if you're already with us and move from Starter to Growth, the change applies right away, without a second free week.&lt;/p&gt;

&lt;p&gt;We think that's the fair version. The trial exists so new customers can see the product work before paying, not as a loop to be replayed. Existing customers already know what they're paying for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Self-hosting stays free
&lt;/h2&gt;

&lt;p&gt;Nothing about this changes the open-source side. Clanker Support is open source, and self-hosting is free forever — bring your own LLM keys and run the whole thing on your own infrastructure. The hosted plans are for teams who'd rather we operate it; the trial just means trying that now costs nothing either.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to start
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;See it working first, with no signup.&lt;/strong&gt; The &lt;a href="https://showcase.clankersupport.com" rel="noopener noreferrer"&gt;live demo&lt;/a&gt; is the real widget running in your browser — open it and ask it something.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pick a plan on &lt;a href="https://clankersupport.com/pricing" rel="noopener noreferrer"&gt;/pricing&lt;/a&gt;.&lt;/strong&gt; We'd suggest starting on Starter — $19 a month, 2,000 AI responses, no per-seat fees — and switching tiers later if you outgrow it. Annual billing gets you two months free. Your 7-day trial starts at checkout, automatically.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Drop in the script tag.&lt;/strong&gt; One script tag on your site; most teams are live in about five minutes. Then let it take your real conversations for a week.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Questions you might have
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Why do you require a card for a free trial?
&lt;/h3&gt;

&lt;p&gt;So the trial ends cleanly. Nothing is charged during the seven days — if you stay, your plan continues without interruption or re-entering details; if you don't, you cancel yourself from billing settings and pay nothing.&lt;/p&gt;

&lt;h3&gt;
  
  
  What happens when the 7 days end?
&lt;/h3&gt;

&lt;p&gt;Your subscription begins and your first charge goes through. You're still protected by the 14-day money-back guarantee, and you can cancel anytime — there are no contracts.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does usage during the trial cost anything?
&lt;/h3&gt;

&lt;p&gt;No. Trial usage is never billed, no matter how much of your plan's allowance you use.&lt;/p&gt;

&lt;h3&gt;
  
  
  I already subscribe — do I get a trial if I switch plans?
&lt;/h3&gt;

&lt;p&gt;No — tier changes apply immediately without a new trial. The trial is for workspaces starting their first hosted subscription.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is self-hosting still free?
&lt;/h3&gt;

&lt;p&gt;Yes, forever. Open source, your own infrastructure, your own LLM keys.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start wherever you're comfortable
&lt;/h2&gt;

&lt;p&gt;There's a ladder here, and you can stop on any rung. Try the &lt;a href="https://showcase.clankersupport.com" rel="noopener noreferrer"&gt;live demo&lt;/a&gt; with no signup at all. When you're curious what it does with your docs and your customers, take the 7 free days. And if by day seven your support agent already feels like yours — that's the point at which staying is the easy decision.&lt;/p&gt;

&lt;p&gt;Pick your plan at &lt;a href="https://clankersupport.com/pricing" rel="noopener noreferrer"&gt;/pricing&lt;/a&gt;. The trial starts the moment you do.&lt;/p&gt;

</description>
      <category>saas</category>
      <category>ai</category>
      <category>startup</category>
      <category>customersupport</category>
    </item>
    <item>
      <title>Next.js merges metadata shallowly your og:image is probably missing (plus a 9-point AI SEO audit checklist)</title>
      <dc:creator>Omar Bni</dc:creator>
      <pubDate>Mon, 20 Jul 2026 10:54:45 +0000</pubDate>
      <link>https://dev.to/omar_bni_f6856a8bb0e021e9/nextjs-merges-metadata-shallowly-your-ogimage-is-probably-missing-plus-a-9-point-ai-seo-audit-1o7e</link>
      <guid>https://dev.to/omar_bni_f6856a8bb0e021e9/nextjs-merges-metadata-shallowly-your-ogimage-is-probably-missing-plus-a-9-point-ai-seo-audit-1o7e</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://clankersupport.com/blog/ai-seo-audit-checklist" rel="noopener noreferrer"&gt;the Clanker Support blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;For about a month, every page on our marketing site that used our own metadata helper without an image of its own — &lt;code&gt;/pricing&lt;/code&gt;, every &lt;code&gt;/vs/*&lt;/code&gt; comparison, every &lt;code&gt;/features/*&lt;/code&gt; page — shipped a &lt;code&gt;summary_large_image&lt;/code&gt; Twitter card with no image. Not a broken image. No image at all, on pages whose metadata we thought a shared helper had made uniform. The cause is a Next.js behavior that hits any site combining a root Open Graph image with page-level metadata — which is most maturing Next.js sites — and we'll get to it first because it earned its place at the top of the checklist.&lt;/p&gt;

&lt;p&gt;We found it while auditing our five web surfaces (marketing, docs, dashboard, showcase, admin). An AI-SEO audit checks the machine-facing surface of your site twice: once for search crawlers — robots rules, canonicals, sitemaps, Open Graph, structured data, index hygiene — and once for AI answer engines — crawler access for GPTBot, ClaudeBot and PerplexityBot, &lt;code&gt;llms.txt&lt;/code&gt; and &lt;code&gt;llms-full.txt&lt;/code&gt; files, and self-contained extractable answers. The output is a pass/fail list per domain you operate, including the domains your product mints pages on.&lt;/p&gt;

&lt;p&gt;The audit landed in two waves: a first pass of fixes straight to main on July 6–8, then the cross-domain wave that merged as &lt;a href="https://github.com/theopenco/llmchat/pull/148" rel="noopener noreferrer"&gt;PR #148&lt;/a&gt; on July 17 — that one touched marketing, docs, showcase, admin and the api. Everything is public in &lt;a href="https://github.com/theopenco/llmchat" rel="noopener noreferrer"&gt;the repo&lt;/a&gt;, so every finding links to real code. Here is the checklist, then the four findings that were genuinely non-obvious.&lt;/p&gt;

&lt;h2&gt;
  
  
  The checklist
&lt;/h2&gt;

&lt;p&gt;Run each question against every domain you operate — including subdomains you forgot you had.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;#&lt;/th&gt;
&lt;th&gt;Check&lt;/th&gt;
&lt;th&gt;Ask yourself&lt;/th&gt;
&lt;th&gt;Us, before the audit&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Social card images&lt;/td&gt;
&lt;td&gt;Does the &lt;strong&gt;rendered HTML&lt;/strong&gt; of every page contain &lt;code&gt;og:image&lt;/code&gt; — not just the homepage?&lt;/td&gt;
&lt;td&gt;Fail — every helper-built page without its own cover had none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;noindex reachability&lt;/td&gt;
&lt;td&gt;Can crawlers fetch your noindex'd pages? A robots Disallow hides the tag&lt;/td&gt;
&lt;td&gt;Fail — the dashboard got noindex + robots in wave one; admin's robots.txt came in wave two&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Product-minted URLs&lt;/td&gt;
&lt;td&gt;Does every per-customer URL your product serves send noindex?&lt;/td&gt;
&lt;td&gt;Fail — &lt;code&gt;/embed/:key&lt;/code&gt; was indexable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;AI crawler access&lt;/td&gt;
&lt;td&gt;Do any robots rules block GPTBot, ClaudeBot, PerplexityBot, or Google-Extended?&lt;/td&gt;
&lt;td&gt;Pass — nothing blocked (now deliberate)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;llms.txt files&lt;/td&gt;
&lt;td&gt;Do you publish a link map (&lt;code&gt;/llms.txt&lt;/code&gt;) and a full-content file (&lt;code&gt;/llms-full.txt&lt;/code&gt;)?&lt;/td&gt;
&lt;td&gt;Half — link map yes, full-content no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;Secondary domains&lt;/td&gt;
&lt;td&gt;Does every subdomain have robots, a sitemap, canonicals, OG tags, and structured data?&lt;/td&gt;
&lt;td&gt;Fail — the docs subdomain had none of the five&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;Title lengths&lt;/td&gt;
&lt;td&gt;Do your titles survive the ~60-character SERP cutoff?&lt;/td&gt;
&lt;td&gt;Fail — a brand suffix pushed every post past it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;Sitemap honesty&lt;/td&gt;
&lt;td&gt;Is &lt;code&gt;lastModified&lt;/code&gt; a real content date or a build timestamp?&lt;/td&gt;
&lt;td&gt;Fail — build timestamp on every entry&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;Demo properties&lt;/td&gt;
&lt;td&gt;Do showcase/demo sites have a &lt;code&gt;metadataBase&lt;/code&gt;, a canonical, and a robots.txt that isn't a 404?&lt;/td&gt;
&lt;td&gt;Fail — our showcase's robots.txt returned 404&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Five domains, nine checks, and the only things that came through clean were the marketing site's JSON-LD, canonicals and llms.txt — the parts built deliberately. Even its sitemap was lying about dates, and the RSS feed it should have advertised didn't exist until the first wave added it. Everything that grew organically had a gap.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why your Next.js pages have no og:image
&lt;/h2&gt;

&lt;p&gt;The mechanism: Next.js merges route metadata &lt;strong&gt;shallowly&lt;/strong&gt;. When a page exports its own &lt;code&gt;openGraph&lt;/code&gt; object, it doesn't extend the layout's — it replaces it wholesale, and the same applies to &lt;code&gt;twitter&lt;/code&gt; and &lt;code&gt;alternates&lt;/code&gt;. Our root layout ships a site-wide OG cover via the &lt;code&gt;opengraph-image.png&lt;/code&gt; file convention, and we assumed that cover reached every page. It reached exactly the pages that declared no &lt;code&gt;openGraph&lt;/code&gt; of their own. Every page that called our &lt;code&gt;pageMeta&lt;/code&gt; helper without an explicit image — &lt;code&gt;/pricing&lt;/code&gt;, the comparisons, the feature pages, the tools — wiped the image out in the same stroke, and these were the pages we had put the most metadata care into. Blog posts survived only because they pass their own cover image to the same helper.&lt;/p&gt;

&lt;p&gt;The failure is invisible in the browser and in most SEO tooling, because the pages still had titles, descriptions and canonicals. What they emitted was a &lt;code&gt;twitter:card&lt;/code&gt; of &lt;code&gt;summary_large_image&lt;/code&gt; with no image behind it, so every share of &lt;code&gt;/pricing&lt;/code&gt; or a comparison page rendered as a bare text stub. We only caught it by grepping the prerendered HTML for &lt;code&gt;og:image&lt;/code&gt; during the audit.&lt;/p&gt;

&lt;p&gt;The fix is a constant and a default: put the image inside the helper, so no caller can forget it. The comment in &lt;code&gt;apps/marketing/src/lib/seo.ts&lt;/code&gt; is the whole postmortem:&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;// apps/marketing/src/lib/seo.ts&lt;/span&gt;
&lt;span class="cm"&gt;/** The site-wide OG cover (the app/opengraph-image.png file convention route).
 * Explicit fallback because a page-level `openGraph` object replaces the
 * layout's resolved metadata wholesale (Next merges shallowly) — without this,
 * every pageMeta page shipped no og:image/twitter:image at all. */&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;DEFAULT_OG_IMAGE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/opengraph-image.png&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 same shallow merge had already bitten us once in the same file, back in the first wave — the &lt;code&gt;alternates&lt;/code&gt; block re-declares the RSS &lt;code&gt;&amp;lt;link&amp;gt;&lt;/code&gt; on every page, because a page-level canonical would otherwise delete the feed reference the layout set. Same behavior, different casualty.&lt;/p&gt;

&lt;p&gt;Check number 1, generalized: if you use any metadata helper or page-level &lt;code&gt;openGraph&lt;/code&gt; in a Next.js App Router site with a root OG image, view source on a non-homepage page and search for &lt;code&gt;og:image&lt;/code&gt;. This combination — root image plus page-level metadata — is the default shape of a maturing Next.js site, which is why we're comfortable saying the bug is widespread. It costs you every social and chat-app share silently, and no console warns you.&lt;/p&gt;

&lt;h2&gt;
  
  
  robots.txt Disallow doesn't deindex — it does the opposite
&lt;/h2&gt;

&lt;p&gt;The counterintuitive one. Our operator dashboard should never appear in search results, and the reflex is to write &lt;code&gt;Disallow: /&lt;/code&gt; in its robots.txt. That reflex is wrong, and it's wrong in a way that leaves the pages &lt;em&gt;in&lt;/em&gt; the index.&lt;/p&gt;

&lt;p&gt;A robots Disallow controls &lt;strong&gt;crawling&lt;/strong&gt;, not &lt;strong&gt;indexing&lt;/strong&gt;. A &lt;code&gt;noindex&lt;/code&gt; meta tag controls indexing — but Google can only read the tag on pages it's allowed to fetch. Disallow a URL that anyone links to externally, and Google indexes it anyway, as a bare URL with no snippet ("Indexed, though blocked by robots.txt" in Search Console). Our marketing site links to the dashboard sign-in page, so a Disallow would have pinned that URL in the index with no crawlable signal to ever remove it.&lt;/p&gt;

&lt;p&gt;So the dashboard does the opposite. &lt;code&gt;apps/dashboard/src/app/robots.ts&lt;/code&gt;, rationale included:&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;// apps/dashboard/src/app/robots.ts&lt;/span&gt;
&lt;span class="c1"&gt;// Deliberately allow crawling: the layout serves a noindex robots meta on every&lt;/span&gt;
&lt;span class="c1"&gt;// page, and Google can only see that tag on pages it's allowed to fetch. A&lt;/span&gt;
&lt;span class="c1"&gt;// Disallow here would leave externally-linked URLs (the marketing site links to&lt;/span&gt;
&lt;span class="c1"&gt;// sign-in) indexed as bare URLs with no way to discover the noindex.&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;robots&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nx"&gt;MetadataRoute&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Robots&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="na"&gt;rules&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;userAgent&lt;/span&gt;&lt;span class="p"&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="na"&gt;allow&lt;/span&gt;&lt;span class="p"&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This file is itself a first-wave audit fix (commit &lt;code&gt;90c3567&lt;/code&gt;) — before it, the operator console had neither the meta tag nor the robots file. The internal admin console had carried a noindex meta since its first commit but no robots.txt at all, so the second wave gave it the same explicit file (commit &lt;code&gt;f8acf17&lt;/code&gt;), mirroring the rationale.&lt;/p&gt;

&lt;p&gt;Check number 2: for every property you want out of search results, confirm the noindex is &lt;em&gt;reachable&lt;/em&gt;. Disallow plus noindex is not belt-and-suspenders — the belt hides the suspenders.&lt;/p&gt;

&lt;h2&gt;
  
  
  Your product mints URLs on your domain — noindex them at the product level
&lt;/h2&gt;

&lt;p&gt;This is the checklist item that only shows up when your product is itself a website. Clanker Support serves an iframe-able full-page chat shell at &lt;code&gt;/embed/:key&lt;/code&gt; for hosts that can't run third-party script tags (the details are in &lt;a href="https://clankersupport.com/blog/shipping-an-embeddable-widget" rel="noopener noreferrer"&gt;everything that broke while shipping an embeddable widget&lt;/a&gt;). Every customer project gets one of these URLs — on &lt;strong&gt;our&lt;/strong&gt; API domain.&lt;/p&gt;

&lt;p&gt;Follow that to its bad ending: a customer embeds the iframe, their page links to our URL, a crawler finds it, and now a search for the customer's brand can surface &lt;code&gt;api.clankersupport.com/embed/&amp;lt;their-key&amp;gt;&lt;/code&gt; — a bare chat shell wearing their brand color — next to, or instead of, the customer's own site. Nobody involved wants that, and the customer can't fix it, because the page isn't theirs.&lt;/p&gt;

&lt;p&gt;Since the shell is served by a Hono route on workerd, not a Next.js page, the fix is a response header (commit &lt;code&gt;d276077&lt;/code&gt;, in &lt;code&gt;apps/api/src/routes/embed.ts&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;// apps/api/src/routes/embed.ts&lt;/span&gt;
&lt;span class="c1"&gt;// Iframe chrome, not content: per-project embed URLs on the api host must&lt;/span&gt;
&lt;span class="c1"&gt;// never appear in search results next to the customer's own site.&lt;/span&gt;
&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;x-robots-tag&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="s2"&gt;noindex&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;Check number 3, and the framing we'd push hardest: for a SaaS, index hygiene is a &lt;strong&gt;product decision&lt;/strong&gt;, not an SEO chore. Every URL pattern your product generates — embed shells, share links, preview pages, per-tenant subdomains — is a page you are publishing on your customers' behalf. Decide its index status when you design the feature, because by the time it ranks, it's an incident.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we ship for AI crawlers, and what we deliberately don't block
&lt;/h2&gt;

&lt;p&gt;The AI half of the audit had two parts: what we add, and what we refuse to subtract.&lt;/p&gt;

&lt;p&gt;What we added: &lt;code&gt;/llms-full.txt&lt;/code&gt;, the &lt;a href="https://llmstxt.org" rel="noopener noreferrer"&gt;llmstxt.org&lt;/a&gt; companion to the &lt;code&gt;/llms.txt&lt;/code&gt; link map we already served. Where &lt;code&gt;llms.txt&lt;/code&gt; is a table of contents, &lt;code&gt;llms-full.txt&lt;/code&gt; is the entire text of every blog post in one plain-markdown file, newest first, so an AI system can ingest the content without crawling each page. The builder (&lt;code&gt;apps/marketing/src/lib/llms-full-txt.ts&lt;/code&gt;) is 55 lines, pure, and unit-tested; the only subtle line rewrites root-relative links, because a markdown link like &lt;code&gt;/pricing&lt;/code&gt; means nothing once the text leaves our domain:&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;// apps/marketing/src/lib/llms-full-txt.ts&lt;/span&gt;
&lt;span class="c1"&gt;// Root-relative markdown links would be resolved against whatever&lt;/span&gt;
&lt;span class="c1"&gt;// domain serves this text, so make them absolute site URLs.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;absolute&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\]\((\/[^&lt;/span&gt;&lt;span class="sr"&gt;)&lt;/span&gt;&lt;span class="se"&gt;\s]&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;)\)&lt;/span&gt;&lt;span class="sr"&gt;/g&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="nx"&gt;siteUrl&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&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;Honesty about what this buys: llms.txt is an emerging convention, and consumption by the major engines is unproven. Google has said plainly that AI Overviews need no special AI files — ordinary indexable HTML is the input — while other engines are less explicit, and some tooling does fetch these files today. We treat the pair as cheap insurance: one static route, a pure function, six unit tests, and content we'd publish anyway in a format that costs a crawler one request instead of twenty.&lt;/p&gt;

&lt;p&gt;What we refused to subtract: the audit checked every robots surface on all five domains for AI-crawler blocks — GPTBot, ClaudeBot, PerplexityBot, Google-Extended — found none, and ratified that as policy rather than leaving it as an accident of defaults. Blocking AI crawlers is a defensible choice for a publisher whose content &lt;em&gt;is&lt;/em&gt; the product. Ours isn't; it's an open-source support agent, and the people who might use it increasingly ask an AI assistant what to use. An engine that has read our engineering posts can cite them; one that's blocked at robots.txt recommends whoever wasn't. The honest cost is that AI answers built on your content can substitute for visits to it. For a vendor blog, we'll take citation over control — being the source the answer names is the point of writing.&lt;/p&gt;

&lt;p&gt;Check numbers 4 and 5: know your AI-crawler stance instead of inheriting it from a robots.txt someone wrote in 2019, and if you publish llms.txt files, hold them to the same testing standard as any route.&lt;/p&gt;

&lt;h2&gt;
  
  
  The smaller line items
&lt;/h2&gt;

&lt;p&gt;Four more findings, one paragraph each, because they'll each cost someone a quiet month.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The docs subdomain had no baseline at all.&lt;/strong&gt; docs.clankersupport.com served no robots.txt, no sitemap, no canonicals, no OG or Twitter cards, and no structured data — the app was two weeks old and every one of those defaults to "missing" (commit &lt;code&gt;ac538e0&lt;/code&gt; adds all five, plus &lt;code&gt;TechArticle&lt;/code&gt; and &lt;code&gt;BreadcrumbList&lt;/code&gt; JSON-LD on every page). Subdomains grow faster than their metadata; audit every host you answer on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A brand suffix ate every title.&lt;/strong&gt; Appending "— Clanker Support Journal" pushed every blog post title past the ~60-character SERP cutoff, so Google truncates or rewrites them (commit &lt;code&gt;b433b72&lt;/code&gt; drops it, and adds a dedicated &lt;code&gt;seoDescription&lt;/code&gt; field because our comparison tldrs and migration intros ran 260–380 characters against a 160-character meta limit). Measure the title as rendered, suffix included.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sitemap &lt;code&gt;lastModified&lt;/code&gt; was a lie.&lt;/strong&gt; We stamped build time on every entry, which claims every page changed on every deploy. The comment in &lt;code&gt;seo.ts&lt;/code&gt; now enforces the rule: only blog entries carry the field, from real publish/update dates, because a timestamp on everything "teaches crawlers to distrust the field."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The showcase's robots.txt was a 404 page.&lt;/strong&gt; Our live-demo site returned HTML for &lt;code&gt;/robots.txt&lt;/code&gt;, had no &lt;code&gt;metadataBase&lt;/code&gt; (so its OG image resolved against nothing), and — a Next.js footnote worth knowing — an &lt;code&gt;opengraph-image&lt;/code&gt; file alone emits only &lt;code&gt;og:image&lt;/code&gt;; without explicit &lt;code&gt;openGraph&lt;/code&gt;/&lt;code&gt;twitter&lt;/code&gt; blocks there's no &lt;code&gt;og:title&lt;/code&gt; or card type around it (commits &lt;code&gt;7ad1f32&lt;/code&gt; and &lt;code&gt;5ad2bf7&lt;/code&gt;, one from each wave).&lt;/p&gt;

&lt;h2&gt;
  
  
  What we can't tell you yet
&lt;/h2&gt;

&lt;p&gt;The cross-domain wave merged three days ago, and even the oldest first-wave fixes have had two weeks in production — not enough for Search Console to say anything. So we have zero results data: no ranking movement, no AI citations to report, no before/after chart. Anyone who ships SEO changes on Thursday and reports wins on Sunday is selling something. What we can vouch for today is the mechanism behind each fix: the shallow merge is documented Next.js behavior we verified in prerendered HTML, the Disallow-hides-noindex trap is how Google has worked for years, and the embed-shell noindex closes a real path to ranking against our own customers. We'll report back when Search Console has something worth quoting, including if the answer is "nothing moved."&lt;/p&gt;

&lt;p&gt;If the llms.txt items made your own list, the &lt;a href="https://clankersupport.com/tools/llms-txt-generator" rel="noopener noreferrer"&gt;free llms.txt generator&lt;/a&gt; we host will build the link-map file from your page list — no sign-up attached. And if you run the nine checks and find your equivalent of the imageless Twitter card, we'd genuinely like to hear what it was: the whole audit started because we grepped our own HTML for a tag we were certain was there.&lt;/p&gt;

</description>
      <category>seo</category>
      <category>nextjs</category>
      <category>webdev</category>
      <category>ai</category>
    </item>
    <item>
      <title>Additive column can 500 every login (Drizzle + Better Auth + preview deploys)</title>
      <dc:creator>Omar Bni</dc:creator>
      <pubDate>Mon, 20 Jul 2026 10:45:12 +0000</pubDate>
      <link>https://dev.to/omar_bni_f6856a8bb0e021e9/additive-column-can-500-every-login-drizzle-better-auth-preview-deploys-3pg3</link>
      <guid>https://dev.to/omar_bni_f6856a8bb0e021e9/additive-column-can-500-every-login-drizzle-better-auth-preview-deploys-3pg3</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://clankersupport.com/blog/additive-migration-almost-broke-every-login" rel="noopener noreferrer"&gt;the Clanker Support blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Migration &lt;code&gt;0017_user_role.sql&lt;/code&gt; in our repo is one line of SQL under eighteen lines of comment, and the comment is the interesting part: it explains why the column it adds must never appear in our ORM schema. Declared the way every tutorial shows, that one additive column would have 500'd every authenticated request on any database that hadn't run the migration yet. "Additive nullable columns are always safe" is received wisdom, and on a modern stack it is false: ORMs like Drizzle and Prisma enumerate every mapped column on every SELECT, preview deploys run new code against old schemas, and auth libraries query your user table on every request — so one unmigrated column can fail every query that touches its table.&lt;/p&gt;

&lt;p&gt;To be precise about what actually happened, because this is easy to overclaim: no production outage. What we had was a string of preview deploys 500ing on columns that existed only in code, and one near-miss — that &lt;code&gt;role&lt;/code&gt; column — where the same mechanism pointed straight at the auth hot path. This is the failure mode we kept almost shipping, and the discipline that stopped it. Clanker Support is open source (&lt;a href="https://github.com/theopenco/llmchat" rel="noopener noreferrer"&gt;theopenco/llmchat&lt;/a&gt;), so every file, commit, and PR below is public.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why "additive columns are always safe" became folklore
&lt;/h2&gt;

&lt;p&gt;The belief was earned, and it predates ORMs. Adding a nullable column — or a &lt;code&gt;NOT NULL&lt;/code&gt; column with a default, the other blessed shape — rewrites nothing. In SQLite it's a metadata change; Postgres has done the same for defaulted columns since version 11. No lock, no backfill, no data risk. And the load-bearing clause: old code ignores columns it doesn't know about. &lt;code&gt;SELECT id, email FROM user&lt;/code&gt; does not care what else the table grew this week.&lt;/p&gt;

&lt;p&gt;That clause was true when column lists were handwritten. Then they started being generated from schema files that ship with the application code, and the clause quietly inverted: now the code can know about a column before the database does. Additive migrations are safe when the schema definition trails the database. They are dangerous when it leads — and on a modern deploy pipeline, it leads all the time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Your ORM selects every column you map
&lt;/h2&gt;

&lt;p&gt;Drizzle does not emit &lt;code&gt;SELECT *&lt;/code&gt;. An unprojected &lt;code&gt;select().from(user)&lt;/code&gt; expands to an explicit list of every column mapped on the table object in &lt;code&gt;packages/db/src/schema.ts&lt;/code&gt; — for our &lt;code&gt;user&lt;/code&gt; table, all seven of them, by name. Map an eighth column that the database doesn't have yet and every one of those queries throws &lt;code&gt;no such column&lt;/code&gt;, including queries whose calling code never reads the new field. The migration isn't what breaks. Reads that predate the feature are what break.&lt;/p&gt;

&lt;p&gt;This is not a Drizzle quirk. Prisma's generated client selects every scalar field by default unless you pass &lt;code&gt;select&lt;/code&gt;. Rails people know the mirror image of this rule from column &lt;em&gt;removal&lt;/em&gt; — you set &lt;code&gt;ignored_columns&lt;/code&gt; before dropping, because the schema cache still names the column — but explicit-projection ORMs make column &lt;em&gt;addition&lt;/em&gt; just as directional. Any ORM that generates its column lists from a checked-in schema has the same property: the table's every reader is coupled to the schema file's most recent line.&lt;/p&gt;

&lt;h2&gt;
  
  
  Preview deploys run new code against old schemas
&lt;/h2&gt;

&lt;p&gt;For the mismatch to bite, some environment has to serve new code against an old database. Our platform hands us that environment on every branch: production deploys apply the migrations in &lt;code&gt;apps/api/migrations/&lt;/code&gt;, preview deploys don't. A branch that adds a column and maps it in &lt;code&gt;schema.ts&lt;/code&gt; gets a preview whose code names a column its database will never have. We watched exactly this — previews returning 500s for a column only prod would ever get — which is the cheap version of the lesson, paid in red preview checks instead of pages.&lt;/p&gt;

&lt;p&gt;Previews are the guaranteed case, not the only one. A Vercel preview pointed at a shared staging database has the same gap, and so does a Neon branch-per-preview setup where the branch was snapshotted before your migration existed. So does every self-hosted install that pulls your code before running your migrations — and, potentially, production itself during the deploy window: our platform's docs don't specify whether migrations apply before the new worker starts serving traffic, so we defend against both orders rather than betting on one. The environments differ; the shape is identical: the schema file leads, the database trails, and the ORM faults on the gap.&lt;/p&gt;

&lt;h2&gt;
  
  
  The auth library that reads your user table on every request
&lt;/h2&gt;

&lt;p&gt;Here is the multiplier that turns a red preview into a near-catastrophe. We were adding a platform-admin &lt;code&gt;role&lt;/code&gt; to &lt;code&gt;user&lt;/code&gt; for our internal admin console — a column that gates three admin routes only our own team calls. Its natural blast radius is approximately zero. Its actual blast radius, had we mapped it in Drizzle, is documented in a comment we now keep on the table itself:&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;// packages/db/src/schema.ts&lt;/span&gt;
&lt;span class="c1"&gt;// NOTE: the PLATFORM-admin role column (migration 0017_user_role.sql) is&lt;/span&gt;
&lt;span class="c1"&gt;// deliberately NOT modeled on this Drizzle table. Better Auth's Drizzle&lt;/span&gt;
&lt;span class="c1"&gt;// adapter loads the session user with an UNPROJECTED `select().from(user)`&lt;/span&gt;
&lt;span class="c1"&gt;// (every column of this table object) on every getSession, so declaring&lt;/span&gt;
&lt;span class="c1"&gt;// `role` here would make that auth hot-path query reference a column a preview&lt;/span&gt;
&lt;span class="c1"&gt;// DB — which skips migrations — does not have, 500-ing ALL authenticated&lt;/span&gt;
&lt;span class="c1"&gt;// requests.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Better Auth's adapter hydrates the full user object on every session check. That is not a bug, and we want to be fair about it: the adapter can't know which subset of columns your application needs, so returning the whole row is a reasonable contract, and Better Auth core has otherwise been solid for us. But it means the &lt;code&gt;user&lt;/code&gt; table's column list is load-bearing for 100% of authenticated traffic, and any column you add to the mapping joins the hottest path in the system the moment you commit it. A column nobody reads would have taken down the inbox, the settings pages, sign-in — everything behind a session — on any lagging database.&lt;/p&gt;

&lt;p&gt;The transferable lesson is not "audit your auth library". It's that your dependencies' query shapes are production behavior you own. You can read every line of your own code and still not know which of your tables gets an unprojected read per request.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two PRs per schema change: migrate before you serve
&lt;/h2&gt;

&lt;p&gt;The fix is old — the expand/contract pattern, in miniature. Every schema change on the tables our hottest paths read — &lt;code&gt;message&lt;/code&gt;, &lt;code&gt;conversation&lt;/code&gt;, &lt;code&gt;user&lt;/code&gt; — ships as two pull requests. Phase 1 is the &lt;code&gt;ALTER TABLE&lt;/code&gt;, its comment, and a seed-contract test; &lt;code&gt;schema.ts&lt;/code&gt; is deliberately untouched, so no deployable code can name the column under any deploy ordering, in any environment. Phase 2 — the Drizzle mapping, the endpoints, the UI — merges only after phase 1 is live in production. We should admit the discipline is risk-scoped, not universal: lower-stakes columns on &lt;code&gt;project&lt;/code&gt; — settings fields read by a handful of routes — still went out as single PRs (&lt;code&gt;0016&lt;/code&gt;, &lt;code&gt;0020&lt;/code&gt;, &lt;code&gt;0021&lt;/code&gt;), which is a bet that nobody needs that table's preview to work that week. The hot tables don't get the bet. The migration files carry the reasoning in full, and they've become the best documentation in the repo:&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;-- apps/api/migrations/0022_message_reply_to.sql&lt;/span&gt;
&lt;span class="c1"&gt;-- PHASE 1 of 2 (deliberate migrate-before-serve split, mirroring 0014/0015).&lt;/span&gt;
&lt;span class="c1"&gt;-- Ploy's deploy ordering between "apply migrations" and "new Worker serves&lt;/span&gt;
&lt;span class="c1"&gt;-- traffic" is undocumented, so this PR ships ONLY the column — schema.ts is&lt;/span&gt;
&lt;span class="c1"&gt;-- intentionally NOT changed, so the live Worker never SELECTs a column that might&lt;/span&gt;
&lt;span class="c1"&gt;-- not exist yet (drizzle projects every column of `message`, and /v1/chat +&lt;/span&gt;
&lt;span class="c1"&gt;-- /v1/messages read it on the hottest paths; no read can 500 under any ordering).&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="nv"&gt;`message`&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="nv"&gt;`reply_to_message_id`&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We've run the split three times so far:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;0014&lt;/code&gt; conversation summaries&lt;/strong&gt; — migration in PR #76, feature in PR #77, merged 24 minutes apart on June 22.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;0015&lt;/code&gt; resolve attribution&lt;/strong&gt; — migration in PR #91, feature in PR #92, 41 minutes apart on June 29.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;0022&lt;/code&gt; quote-reply&lt;/strong&gt; — migration in PR #142 on July 12, feature in PR #143 two hours later that night, once the column was confirmed live (the phase-2 commit message records that "migration 0022 is already live in prod").&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's the honest cost accounting: two PRs instead of one, and between 24 minutes and a couple of hours of waiting. Against that, for the changes we split, there is no deployable commit where code names the column before its migration is live in prod — the class of 500 becomes unrepresentable. The long comments are part of the discipline, not decoration: a one-line &lt;code&gt;ALTER TABLE&lt;/code&gt; in its own PR looks like pointless ceremony six months later, and the comment is what stops the next person from helpfully collapsing it back into one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The column we keep out of the ORM entirely
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;user.role&lt;/code&gt; is the extreme case: phase 2 never came, on purpose. Because the &lt;code&gt;user&lt;/code&gt; table's mapping is read unprojected on every request, the column stays out of &lt;code&gt;schema.ts&lt;/code&gt; indefinitely, and the admin gate reads it with a raw SQL projection wrapped in a fallback (the &lt;code&gt;/admin/users&lt;/code&gt; listing uses the same guarded shape):&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;// apps/api/src/middleware/admin.ts&lt;/span&gt;
&lt;span class="k"&gt;try&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="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;db&lt;/span&gt;&lt;span class="p"&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;env&lt;/span&gt;&lt;span class="p"&gt;)&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="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;sql&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="s2"&gt;`role`&lt;/span&gt; &lt;span class="p"&gt;})&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;user&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;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;user&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;userId&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;limit&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="nx"&gt;role&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;rows&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;role&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&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;role&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&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 a database without the &lt;code&gt;0017&lt;/code&gt; migration, the read throws, &lt;code&gt;role&lt;/code&gt; degrades to &lt;code&gt;null&lt;/code&gt;, and the requester is a non-admin — a 403, never a 500. Failing toward least privilege is the correct direction for an admin gate anyway, so the defensive shape costs nothing.&lt;/p&gt;

&lt;p&gt;One loose end remained: Drizzle's &lt;code&gt;query.user.findFirst&lt;/code&gt; without a &lt;code&gt;columns&lt;/code&gt; option is also a select-everything of the row. Commit &lt;code&gt;e287d58&lt;/code&gt; swept the last two of those — billing checkout and project creation, each of which only ever read &lt;code&gt;.email&lt;/code&gt; — down to explicit projections:&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;// apps/api/src/routes/billing.ts&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;owner&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;db&lt;/span&gt;&lt;span class="p"&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;env&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findFirst&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;eq&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="nf"&gt;e&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;u&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;userId&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;columns&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&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 invariant that fell out is easy to state and easy to check in review: exactly two queries in the codebase name &lt;code&gt;role&lt;/code&gt; — the admin gate and the &lt;code&gt;/admin/users&lt;/code&gt; listing — and both are wrapped in a try/catch that degrades to least privilege. Everything else touching &lt;code&gt;user&lt;/code&gt; says which columns it wants. Once production and every preview convention has settled, the column can be folded into the schema like any other — the comment on the table says as much — but there is no hurry, because the current shape cannot break.&lt;/p&gt;

&lt;h2&gt;
  
  
  When a schema change needs the two-PR split
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Change&lt;/th&gt;
&lt;th&gt;Blast radius if code ships first&lt;/th&gt;
&lt;th&gt;What to do&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;New table&lt;/td&gt;
&lt;td&gt;None — no deployed code queries it&lt;/td&gt;
&lt;td&gt;One PR is fine&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;New column, mapped in the ORM&lt;/td&gt;
&lt;td&gt;Every ORM read of that table, via the generated column list&lt;/td&gt;
&lt;td&gt;Two PRs: migration alone, then mapping + feature&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;New column on a table a dependency reads unprojected (auth, sessions)&lt;/td&gt;
&lt;td&gt;Every authenticated request&lt;/td&gt;
&lt;td&gt;Two PRs — or keep the column out of the ORM behind a guarded raw projection&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dropping a column&lt;/td&gt;
&lt;td&gt;Deployed code still projects it during the rollout window&lt;/td&gt;
&lt;td&gt;Two PRs in reverse: remove the mapping first, drop later&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Self-hosted installs exist&lt;/td&gt;
&lt;td&gt;You never control when they migrate&lt;/td&gt;
&lt;td&gt;Treat every schema change as two-phase, always&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The first question to ask about any table is the one we didn't know to ask: who reads it that you didn't write? For &lt;code&gt;message&lt;/code&gt; it was our own hot paths, which we could see. For &lt;code&gt;user&lt;/code&gt; it was our auth library, which we couldn't — until we looked at the queries it actually emits.&lt;/p&gt;

&lt;p&gt;This is the second time that habit has paid for itself. When we &lt;a href="https://clankersupport.com/blog/cloudflare-workers-every-node-sdk-broke" rel="noopener noreferrer"&gt;moved the backend to workerd&lt;/a&gt;, the lesson was to audit transitive dependencies, not imports — the package that broke your deploy wasn't the one you installed. This one is the same lesson at the database layer: audit your dependencies' query shapes, not just your own reads. The code you didn't write is still your production behavior. The migration comments in &lt;code&gt;apps/api/migrations/&lt;/code&gt; are all public if you want the long-form version; they're better reading than most of our docs.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Is adding a nullable column a safe migration?
&lt;/h3&gt;

&lt;p&gt;Only if no deployed code selects it before it exists. The database operation is safe; the hazard is your ORM. Drizzle and Prisma generate explicit column lists from the schema definition, so a mapped-but-unmigrated column fails every read of that table — in previews that skip migrations, in self-hosted installs, and during the deploy window itself.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why does my preview deploy fail with "no such column"?
&lt;/h3&gt;

&lt;p&gt;Your branch maps a new column in the ORM schema, but the preview database never ran the branch's migration. The ORM names the column on every SELECT of that table, and the database rejects it. Ship the migration in its own PR first, and add the ORM mapping only after the column is live everywhere that serves traffic.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is the expand/contract migration pattern?
&lt;/h3&gt;

&lt;p&gt;Splitting a schema change so that every deployed version of the code works against both the old and new schema: add the column first (expand), deploy, then ship the code that uses it; for removals, delete the code references first, then drop the column (contract). Our two-PR split is the smallest useful version of it.&lt;/p&gt;

</description>
      <category>database</category>
      <category>sql</category>
      <category>devops</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Changelog: July 2026, so far — the platform month</title>
      <dc:creator>Omar Bni</dc:creator>
      <pubDate>Fri, 17 Jul 2026 23:40:54 +0000</pubDate>
      <link>https://dev.to/omar_bni_f6856a8bb0e021e9/changelog-july-2026-so-far-the-platform-month-1651</link>
      <guid>https://dev.to/omar_bni_f6856a8bb0e021e9/changelog-july-2026-so-far-the-platform-month-1651</guid>
      <description>&lt;p&gt;We usually write these when the month closes. July's first two weeks shipped more than most full months, so here's everything so far — with more to come before August.&lt;/p&gt;

&lt;p&gt;Clanker Support is an approved WordPress plugin. The plugin went through WordPress.org review and is now live in the plugin directory: install it, paste your project key under Settings → Clanker Support, and the widget is on every page — no code, and it survives theme changes. The launch post tells the whole story, including the zip file that was secretly a tar. A 1.0.1 followed within the week.&lt;/p&gt;

&lt;p&gt;A Shopify app, running end to end. The Shopify app is built and deployed: a zero-permission theme app embed that puts the agent on your storefront without touching your orders, customers, or products. The App Store listing is in Shopify's hands; until it lands, the one-line script tag works on any store today — the Shopify guide has both paths.&lt;/p&gt;

&lt;p&gt;The agent can now do things, not just say things. Agent integrations shipped: the agent can look up a customer's order on Shopify or book a meeting through Cal.com, right inside the conversation, with a "Working on it…" indicator while an action runs. We hardened this layer before shipping it — SSRF guards, per-conversation action limits, and an audit log of every action the agent takes — and scoped the agent to support-only via a base system prompt, so it stays your support agent even when a visitor tries to make it something else.&lt;/p&gt;

&lt;p&gt;Dark mode, everywhere. The widget now supports data-theme="light" | "dark" | "auto" | "host" — auto follows the visitor's OS, and host mirrors your site's own theme toggle live, so the widget flips the instant your page does. The dashboard got dark mode too, and inline embeds accept a theme parameter so a dark page never frames a white chat.&lt;/p&gt;

&lt;p&gt;The widget leads with chat. Conversations now start in the chat itself — the contact form is opt-in per project, for teams that want a name and email up front. Alongside it: an expandable large panel, admin-defined starter question chips (with a live chat preview in the dashboard while you edit them), an end-of-conversation rating prompt, and a proper "start a new conversation" flow.&lt;/p&gt;

&lt;p&gt;Say "human" and it listens. If a visitor explicitly asks for a person — "can I talk to a human", "agent please", "I don't want to talk to a bot" — the escalation button appears immediately, before the usual message threshold. The matcher errs toward showing the option: a false positive costs one extra button; a false negative traps a frustrated customer with a bot.&lt;/p&gt;

&lt;p&gt;Quote-reply in the chat. Visitors can reply to a specific earlier message, so "what about this one?" stays unambiguous in long conversations — for the visitor, the agent, and your team reading the thread later.&lt;/p&gt;

&lt;p&gt;A notification bell in the dashboard. New conversations, escalations, and new visitor messages across the whole workspace, in one feed — and clicking a notification opens that exact conversation, even if you're already in the inbox.&lt;/p&gt;

&lt;p&gt;An official React / Next.js package. @clankersupport/widget-rsc is on npm: one server component in your layout instead of a script tag. There's a tutorial if you're on Next.js or any React 19 app.&lt;/p&gt;

&lt;p&gt;A real docs site. Product docs now live at docs.clankersupport.com — a getting-started path, a page per dashboard surface with real screenshots (light and dark), and integration guides for WordPress, Shopify, and the React SDK.&lt;/p&gt;

&lt;p&gt;Free tools. An AI support savings calculator, CSAT calculator, canned response generator, and llms.txt generator — free, no signup, built because we kept needing them ourselves.&lt;/p&gt;

&lt;p&gt;Email that behaves. Mail sent to your team address now forwards into the inbox reliably (and stops bouncing retries), and escalation replies keep threading straight back into the conversation.&lt;/p&gt;

&lt;p&gt;That's two weeks. The Shopify listing decision, Slack notifications, and the public usage API are still in flight — see you at the end of the month.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>wordpress</category>
      <category>startup</category>
    </item>
    <item>
      <title>Add an AI support agent to Next.js, WordPress, Shopify, or any site</title>
      <dc:creator>Omar Bni</dc:creator>
      <pubDate>Wed, 15 Jul 2026 20:36:08 +0000</pubDate>
      <link>https://dev.to/omar_bni_f6856a8bb0e021e9/add-an-ai-support-agent-to-nextjs-wordpress-shopify-or-any-site-44ph</link>
      <guid>https://dev.to/omar_bni_f6856a8bb0e021e9/add-an-ai-support-agent-to-nextjs-wordpress-shopify-or-any-site-44ph</guid>
      <description>&lt;p&gt;You can add an AI support agent to any website by pasting one &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; tag before &lt;code&gt;&amp;lt;/body&amp;gt;&lt;/code&gt;. On Next.js there's a Server Components SDK on npm, WordPress gets a plugin, Shopify gets an app embed, and an iframe covers builders that allow none of the above. This guide shows all five installs with working code.&lt;/p&gt;

&lt;p&gt;Full disclosure up front: this is our product tutorial. We build &lt;a href="https://clankersupport.com/" rel="noopener noreferrer"&gt;Clanker Support&lt;/a&gt;, an open-source (MIT), self-hostable support agent that answers from your docs and escalates to a human when it should. Every snippet below is the real, current install path — including the two channels where we'll tell you plainly what's live and what's still on its way.&lt;/p&gt;

&lt;h2&gt;
  
  
  The script tag: works on every stack
&lt;/h2&gt;

&lt;p&gt;This is the install we designed first, and it's the one everything else wraps. Paste this just before the closing &lt;code&gt;&amp;lt;/body&amp;gt;&lt;/code&gt; tag of your site:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;script
    &lt;/span&gt;&lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"https://api.clankersupport.com/widget.js"&lt;/span&gt;
    &lt;span class="na"&gt;data-project=&lt;/span&gt;&lt;span class="s"&gt;"pk_your_project_key"&lt;/span&gt;
    &lt;span class="na"&gt;async&lt;/span&gt;
&lt;span class="nt"&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole install. &lt;code&gt;widget.js&lt;/code&gt; is a single self-contained file — React, the chat UI, markdown rendering, streaming, all inlined — served with a five-minute cache. The script mounts the widget into a shadow DOM appended to &lt;code&gt;document.body&lt;/code&gt;, so your site's CSS can't break the widget and the widget's styles can't leak into your page. It loads &lt;code&gt;async&lt;/code&gt;, so it never blocks your page render.&lt;/p&gt;

&lt;p&gt;Your project key is safe to expose in HTML. It only identifies which project answers the chat — clankersupport.com itself runs the widget with its real key committed in the repo. The dashboard generates this snippet pre-filled for you under &lt;strong&gt;Projects → your project → Widget → Install&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Configuration lives in exactly five &lt;code&gt;data-*&lt;/code&gt; attributes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;data-project&lt;/code&gt;&lt;/strong&gt; (required) — your project's public key. Without it the script throws instead of silently doing nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;data-api&lt;/code&gt;&lt;/strong&gt; (optional) — the API origin. Defaults to whatever origin served &lt;code&gt;widget.js&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;data-brand&lt;/code&gt;&lt;/strong&gt; (optional) — accent color; defaults to &lt;code&gt;#111827&lt;/code&gt;. Most people set this in the dashboard instead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;data-mode&lt;/code&gt;&lt;/strong&gt; (optional) — &lt;code&gt;bubble&lt;/code&gt; (default, the floating launcher) or &lt;code&gt;inline&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;data-escalation-threshold&lt;/code&gt;&lt;/strong&gt; (optional) — how many visitor messages before the widget offers a human. The agent default is 3.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There is no sixth attribute. Position, welcome message, starter questions — those are project settings in the dashboard, fetched at runtime, so you can change them without touching your site's HTML.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;data-api&lt;/code&gt; default is the detail we're most pleased with. Here's the actual resolution logic from the widget source:&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;apiUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="nx"&gt;script&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;api&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;script&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;src&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;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;script&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;origin&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="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;origin&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The widget derives its API origin from the URL that served the script. So if you &lt;a href="https://clankersupport.com/blog/the-case-for-self-hostable-ai-support" rel="noopener noreferrer"&gt;self-host&lt;/a&gt; — the whole product is MIT-licensed, bring your own model keys — you serve &lt;code&gt;widget.js&lt;/code&gt; from your own domain and the exact same snippet points at your own API. Zero config divergence between hosted and self-hosted.&lt;/p&gt;

&lt;p&gt;If you're on Rails, Django, Laravel, plain HTML, Astro, Vue, Hugo — anything that renders a &lt;code&gt;&amp;lt;/body&amp;gt;&lt;/code&gt; — this is your install, and you're done. Full reference: &lt;a href="https://docs.clankersupport.com/integrations/widget" rel="noopener noreferrer"&gt;docs.clankersupport.com/integrations/widget&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Next.js: the React Server Components SDK
&lt;/h2&gt;

&lt;p&gt;The script tag works fine in Next.js. But if you're on Next.js 15 / React 19, we ship a first-class package, &lt;code&gt;@clankersupport/widget-rsc&lt;/code&gt;, that plays properly with the App Router. Three steps.&lt;/p&gt;

&lt;p&gt;Install it — React 19 and React DOM are the only peer dependencies, and there are zero runtime dependencies:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; @clankersupport/widget-rsc
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Render it once in your root layout, before &lt;code&gt;&amp;lt;/body&amp;gt;&lt;/code&gt;:&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;ClankerSupport&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;@clankersupport/widget-rsc&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;default&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;RootLayout&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;children&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;html&lt;/span&gt; &lt;span class="na"&gt;lang&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"en"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
            &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;body&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;children&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
                &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ClankerSupport&lt;/span&gt; &lt;span class="na"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NEXT_PUBLIC_CLANKER_KEY&lt;/span&gt;&lt;span class="o"&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;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;body&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;html&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And put the key in &lt;code&gt;.env.local&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;NEXT_PUBLIC_CLANKER_KEY&lt;/span&gt;=&lt;span class="n"&gt;pk_your_project_key&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What you get over the script tag: &lt;code&gt;ClankerSupport&lt;/code&gt; is an async Server Component. It fetches your widget config (branding, privacy URL) on the server, cached and revalidated every five minutes, so the client never flashes the wrong branding. The fetch is wrapped in Suspense with a &lt;code&gt;null&lt;/code&gt; fallback and returns &lt;code&gt;null&lt;/code&gt; on any failure — if our API is slow or down, your page streams normally and the widget mounts with safe defaults. It fails soft; it never blocks your app.&lt;/p&gt;

&lt;p&gt;There's also a headless entry, &lt;code&gt;@clankersupport/widget-rsc/headless&lt;/code&gt;, with Radix-style unstyled primitives — &lt;code&gt;data-*&lt;/code&gt; state attributes, &lt;code&gt;asChild&lt;/code&gt; composition, and a &lt;code&gt;useClankerSupport&lt;/code&gt; hook — for teams that want the agent behind their own UI entirely.&lt;/p&gt;

&lt;p&gt;We wrote a full tutorial on how (and why) this package works: &lt;a href="https://clankersupport.com/blog/nextjs-ai-support-widget-server-component" rel="noopener noreferrer"&gt;an AI support widget as a React Server Component&lt;/a&gt;. Reference docs live at &lt;a href="https://docs.clankersupport.com/integrations/react-sdk" rel="noopener noreferrer"&gt;docs.clankersupport.com/integrations/react-sdk&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  WordPress: plugin via zip upload (today)
&lt;/h2&gt;

&lt;p&gt;Honest status first: the plugin has been approved for the WordPress.org plugin directory, but as we publish this (July 2026) the listing isn't live yet. Until it is, you install it the classic way — a zip upload. No FTP, no code edits, but also not yet a one-click search-and-install from wp-admin.&lt;/p&gt;

&lt;p&gt;The install today:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Build the zip from the plugin package in &lt;a href="https://github.com/theopenco/llmchat" rel="noopener noreferrer"&gt;the GitHub repo&lt;/a&gt;: &lt;code&gt;pnpm package&lt;/code&gt; inside &lt;code&gt;packages/wordpress-plugin&lt;/code&gt; emits &lt;code&gt;dist/clanker-support-&amp;lt;version&amp;gt;.zip&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;In wp-admin: &lt;strong&gt;Plugins → Add New → Upload Plugin&lt;/strong&gt;, pick the zip, activate.&lt;/li&gt;
&lt;li&gt;Under &lt;strong&gt;Settings → Clanker Support&lt;/strong&gt;, paste your project's public key and save.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The plugin (v1.0.1, GPLv2 or later, requires WordPress 5.8+ and PHP 7.4+) enqueues &lt;code&gt;widget.js&lt;/code&gt; asynchronously with the same &lt;code&gt;data-*&lt;/code&gt; attributes the dashboard snippet uses, on every public page and nowhere in wp-admin. Settings cover the floating-bubble toggle, the project key, the API URL (default &lt;code&gt;https://api.clankersupport.com&lt;/code&gt;, changeable if you self-host), brand color, and escalation threshold. The settings page runs a live connection check against the API and shows you a status pill, so a typo'd key fails loudly at save time instead of silently on your live site.&lt;/p&gt;

&lt;p&gt;If you want the chat in a page instead of (or as well as) the floating bubble, there's a shortcode that renders the full-page chat in an iframe:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[clanker_support]&lt;/span&gt;
&lt;span class="nn"&gt;[clanker_support width="500" height="700"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Default size is 400×600, and it works independently of the bubble toggle. The plugin stores exactly one option and one transient in your WordPress database — conversations live in Clanker Support, not in WP — and uninstalling removes both.&lt;/p&gt;

&lt;p&gt;The launch post has the longer story: &lt;a href="https://clankersupport.com/blog/wordpress-ai-support-plugin" rel="noopener noreferrer"&gt;our WordPress AI support plugin&lt;/a&gt;. Reference: &lt;a href="https://docs.clankersupport.com/integrations/wordpress" rel="noopener noreferrer"&gt;docs.clankersupport.com/integrations/wordpress&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Shopify: app embed now, App Store listing on the way
&lt;/h2&gt;

&lt;p&gt;Same candor here: the Clanker Support Shopify app exists, is deployed, and works — it's a theme app extension we've tested on a live storefront. The public Shopify App Store listing is on its way but not live yet as we publish (July 2026). Until it lands, any Shopify store can run the agent today with the script tag.&lt;/p&gt;

&lt;p&gt;The manual path today: &lt;strong&gt;Online Store → Themes → Edit code → &lt;code&gt;layout/theme.liquid&lt;/code&gt;&lt;/strong&gt;, paste the script tag from the first section just before &lt;code&gt;&amp;lt;/body&amp;gt;&lt;/code&gt;, save. Done — same widget, same dashboard.&lt;/p&gt;

&lt;p&gt;Once you have the app installed, the flow is nicer: paste your project key on the app's settings page in Shopify admin, then in the theme editor open &lt;strong&gt;App embeds&lt;/strong&gt;, toggle &lt;strong&gt;Clanker Support&lt;/strong&gt; on, and hit &lt;strong&gt;Save&lt;/strong&gt;. One thing Shopify makes per-theme: app embeds don't follow you when you publish a different theme, so re-enable the toggle after a theme switch.&lt;/p&gt;

&lt;p&gt;Two design decisions worth knowing before you install anything on a store:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Zero permission scopes.&lt;/strong&gt; The app requests no access to your orders, customers, or products. It stores only its own settings. If a support app asks for read access to your entire order history just to render a chat bubble, ask why.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A double-bubble guard.&lt;/strong&gt; If the manual script tag is already in your theme when you enable the app embed, the embed detects it and stands down. One widget, never two — we verified this in testing because we knew people would migrate from the manual install.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The widget loads after page load, so it doesn't drag on storefront performance scores. Uninstalling the app removes the embed and clears its stored data. Reference: &lt;a href="https://docs.clankersupport.com/integrations/shopify" rel="noopener noreferrer"&gt;docs.clankersupport.com/integrations/shopify&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Everything else: the iframe embed
&lt;/h2&gt;

&lt;p&gt;Some platforms won't let you add a script tag at all — locked-down site builders, sandboxed help-center pages, internal tools. For those, the API serves a CSP-hardened full-page chat at &lt;code&gt;/embed/&amp;lt;key&amp;gt;&lt;/code&gt; that you can iframe from anywhere:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;iframe&lt;/span&gt;
    &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"https://api.clankersupport.com/embed/pk_your_project_key"&lt;/span&gt;
    &lt;span class="na"&gt;width=&lt;/span&gt;&lt;span class="s"&gt;"400"&lt;/span&gt;
    &lt;span class="na"&gt;height=&lt;/span&gt;&lt;span class="s"&gt;"600"&lt;/span&gt;
    &lt;span class="na"&gt;title=&lt;/span&gt;&lt;span class="s"&gt;"Support chat"&lt;/span&gt;
    &lt;span class="na"&gt;style=&lt;/span&gt;&lt;span class="s"&gt;"border: 0; border-radius: 12px;"&lt;/span&gt;
    &lt;span class="na"&gt;loading=&lt;/span&gt;&lt;span class="s"&gt;"lazy"&lt;/span&gt;
&lt;span class="nt"&gt;&amp;gt;&amp;lt;/iframe&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The embed page mounts the widget in inline mode with your project's brand color and escalation threshold baked in server-side — it reads them from your project settings, so there are no attributes to pass and nothing to update when you rebrand. Its Content-Security-Policy locks down everything except the one thing an embed must allow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;default-src 'none'; script-src 'self'; style-src 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; base-uri 'none'; form-action 'none'; frame-ancestors *
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;frame-ancestors *&lt;/code&gt; because being iframed by any site is the point; everything else is shut. It also ships &lt;code&gt;noindex&lt;/code&gt;, a no-referrer policy, and denies camera/microphone/geolocation/payment outright.&lt;/p&gt;

&lt;p&gt;One more use for it: open the embed URL directly in a browser tab and you're talking to your agent — the fastest way to test your knowledge base before installing anything anywhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which install should you pick?
&lt;/h2&gt;

&lt;p&gt;Our honest decision guide, shortest answer first:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Any server-rendered or static site (Rails, Django, Laravel, Astro, plain HTML, Hugo, ...):&lt;/strong&gt; the script tag. It's the product's native install; everything else is a wrapper around it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Next.js 15 / React 19:&lt;/strong&gt; the &lt;code&gt;@clankersupport/widget-rsc&lt;/code&gt; SDK if you want server-fetched config, Suspense fail-soft behavior, or the headless primitives. The plain script tag in your layout is also completely fine — don't add a dependency you don't need.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WordPress:&lt;/strong&gt; the plugin, because the settings page, connection check, and shortcode earn their keep — but know it's a zip upload today, not a directory search result.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shopify:&lt;/strong&gt; script tag in &lt;code&gt;theme.liquid&lt;/code&gt; today; switch to the app embed when the listing is live (the double-bubble guard makes the migration safe).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Site builders with no script access:&lt;/strong&gt; the iframe embed. It's the fallback that works when nothing else is allowed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And when not to use us at all: if your support runs primarily over WhatsApp, SMS, or phone, we don't do those channels — we're web widget plus email threading. And there's no free hosted tier; if you want free, the answer is self-hosting with your own model keys, which is a genuine first-class path, not a demo.&lt;/p&gt;

&lt;h2&gt;
  
  
  After install: two things before you close the tab
&lt;/h2&gt;

&lt;p&gt;Whichever channel you installed through, the same product is behind it, and an agent with nothing to read is just an apology generator. Two setup steps make the difference:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Add knowledge sources.&lt;/strong&gt; In the dashboard, a project's knowledge base takes three source kinds: URLs (we crawl the page into a snapshot — there's a re-crawl button when your docs change), free-text snippets, and hand-written Q&amp;amp;A pairs for the questions you already answer weekly. There's no vector database in the pipeline — sources are byte-budgeted directly into the system prompt — and the models can search the live web when a question goes beyond your docs. Later, when an operator writes a particularly good reply in the inbox, you can promote it into the knowledge base straight from the thread, so the agent learns your best answers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Set your escalation email.&lt;/strong&gt; When a visitor asks for a human (or the agent decides it's out of its depth), the conversation escalates: an email goes to your project's notify address and, optionally, a message to a Slack webhook. The email's Reply-To is wired so that just replying from your inbox threads your answer straight back into the visitor's chat — no dashboard login required, though the dashboard inbox (tags, search, AI triage summaries, unread counts) is there when you want it. We wrote up how the email threading works in &lt;a href="https://clankersupport.com/blog/setting-up-email-threading" rel="noopener noreferrer"&gt;setting up email threading&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Visitors can rate individual answers thumbs up/down and leave a 1–5 CSAT when the conversation closes, so you'll know quickly whether the sources you added are pulling their weight.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  How do I add an AI support agent to my website without a developer?
&lt;/h3&gt;

&lt;p&gt;If you can paste one line of HTML before &lt;code&gt;&amp;lt;/body&amp;gt;&lt;/code&gt;, you can install it yourself — the dashboard generates the exact snippet with your key pre-filled. On WordPress it's a plugin upload with a settings page and no code at all. The only genuinely no-code-access path is the iframe embed, which needs just an embed block.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does a support widget slow down my site?
&lt;/h3&gt;

&lt;p&gt;Ours is designed not to: the script loads &lt;code&gt;async&lt;/code&gt; so it never blocks rendering, it's a single self-contained file cached for five minutes, and it mounts into a shadow DOM after the page is up. On Shopify specifically, the embed loads after page load so storefront performance scores aren't affected.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I use the same widget if I self-host?
&lt;/h3&gt;

&lt;p&gt;Yes — that's a deliberate design decision. The widget resolves its API origin from wherever &lt;code&gt;widget.js&lt;/code&gt; was served, so a self-hosted install uses the identical snippet pointed at your own domain. The whole product is MIT-licensed on &lt;a href="https://github.com/theopenco/llmchat" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;; self-hosting is free with your own model keys.&lt;/p&gt;

&lt;h3&gt;
  
  
  How much does a hosted AI support agent cost?
&lt;/h3&gt;

&lt;p&gt;As of July 2026, our hosted plans are Starter at $19/month (2,000 AI responses, hard stop), Growth at $89/month (12,000), and Scale at $299/month (50,000), with annual billing giving two months free. Pricing is per workspace — no per-seat fees and no per-resolution fees — and there's no free hosted tier. Details on &lt;a href="https://clankersupport.com/pricing" rel="noopener noreferrer"&gt;/pricing&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  How does the agent know what to answer?
&lt;/h3&gt;

&lt;p&gt;It answers from your project's knowledge base — crawled URL snapshots, text snippets, and hand-written Q&amp;amp;A pairs — combined with your system prompt, on web-search-capable models via LLM Gateway. When it can't answer, it escalates to your email and Slack instead of improvising.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>ai</category>
      <category>javascript</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Our AI support agent doesn't use RAG - here's the math</title>
      <dc:creator>Omar Bni</dc:creator>
      <pubDate>Mon, 13 Jul 2026 11:34:44 +0000</pubDate>
      <link>https://dev.to/omar_bni_f6856a8bb0e021e9/our-ai-support-agent-doesnt-use-rag-heres-the-math-1n8c</link>
      <guid>https://dev.to/omar_bni_f6856a8bb0e021e9/our-ai-support-agent-doesnt-use-rag-heres-the-math-1n8c</guid>
      <description>&lt;p&gt;Clanker Support's AI support agent has no vector database, no embeddings, and no retrieval pipeline. On every chat request we load every active knowledge source for the project and place it, budgeted to 80,000 characters, directly into the system prompt. For a support knowledge base measured in kilobytes, this beats RAG on simplicity, freshness, and recall — and we can show you exactly where it stops being true.&lt;/p&gt;

&lt;p&gt;This is not a "RAG is dead" post. RAG is the correct architecture for corpora that don't fit in a context window. Our argument is narrower and, we think, more useful: most per-project support knowledge bases are tiny, context windows are large, and building an embedding pipeline before you've hit the ceiling is complexity you pay for every day and benefit from never. Here's the code, the arithmetic, and the honest failure mode.&lt;/p&gt;

&lt;h2&gt;
  
  
  The entire retrieval pipeline is a WHERE clause
&lt;/h2&gt;

&lt;p&gt;Clanker Support is an open-source support widget (&lt;a href="https://github.com/theopenco/llmchat" rel="noopener noreferrer"&gt;github.com/theopenco/llmchat&lt;/a&gt;). When a visitor sends a message, the API needs to decide which knowledge to show the model. Here is the entirety of that decision, from &lt;code&gt;apps/api/src/routes/chat.ts&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;activeSources&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;db&lt;/span&gt;&lt;span class="p"&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;env&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findMany&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;and&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;eq&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="nf"&gt;a&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;e&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;projectId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;project&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="nf"&gt;e&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;active&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&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;No query embedding. No similarity search. No reranker. Every active source for the project, every time. The sources come in three kinds — &lt;code&gt;url&lt;/code&gt; (a one-shot snapshot of a single web page), &lt;code&gt;text&lt;/code&gt; (a pasted snippet), and &lt;code&gt;qa&lt;/code&gt; (a question/answer pair, either hand-written or promoted from a real operator reply in the inbox).&lt;/p&gt;

&lt;p&gt;Those sources then flow into a prompt builder that assembles one string: a hardcoded support-only guardrail, the operator's own system prompt, a free-text knowledge field, a &lt;code&gt;# Reference sources&lt;/code&gt; block, and finally an identity block for the visitor. The reference block is where the only "retrieval" decision in the codebase lives:&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;// Cap aggregate source content to keep system prompts bounded. ~80k chars&lt;/span&gt;
&lt;span class="c1"&gt;// ≈ 20k tokens — well below typical 128k context windows but leaves room&lt;/span&gt;
&lt;span class="c1"&gt;// for knowledge base + conversation history.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;MAX_SOURCES_CHARS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the "chunking strategy" is an even split and a slice:&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;// Distribute the budget across sources so a single huge page can't&lt;/span&gt;
&lt;span class="c1"&gt;// crowd out the rest.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;perSource&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;MAX_SOURCES_CHARS&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;usable&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rendered&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;usable&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;s&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;=&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;body&lt;/span&gt; &lt;span class="o"&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;content&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="nx"&gt;perSource&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="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content&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="nx"&gt;perSource&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="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content&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;floor(80000 / N)&lt;/code&gt; characters per source, an ellipsis if it overflowed, a &lt;code&gt;## Source N: &amp;lt;title&amp;gt;&lt;/code&gt; header on each, and an instruction to the model to cite the source title or URL when it uses one. A dozen lines of arithmetic where a RAG system would have an ingestion worker, a chunker, an embedding model, a vector store, and a retriever — each one a place for bugs to live and data to go stale.&lt;/p&gt;

&lt;h2&gt;
  
  
  How much knowledge fits in an 80k-character budget
&lt;/h2&gt;

&lt;p&gt;Let's do the honest math, using the same rough heuristic the code comment uses (~4 characters per token — real tokenization varies, so treat all token figures here as approximate).&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The aggregate budget&lt;/strong&gt; is 80,000 characters, roughly 20,000 tokens of reference material per request.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A URL source maxes out at 20,000 characters&lt;/strong&gt; of extracted text. The snapshot fetcher reads at most 200 KB of raw body, strips markup with regexes, and slices the result to 20k chars (all three limits sit at the top of &lt;code&gt;apps/api/src/lib/fetch-url.ts&lt;/code&gt;: &lt;code&gt;MAX_BYTES = 200_000&lt;/code&gt;, &lt;code&gt;MAX_CHARS = 20_000&lt;/code&gt;, &lt;code&gt;TIMEOUT_MS = 10_000&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;So the budget holds at most 4 full-size page snapshots.&lt;/strong&gt; At 5 or more, &lt;code&gt;floor(80k/N)&lt;/code&gt; drops below 20k and full pages start truncating each other.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;10 sources&lt;/strong&gt; → 8,000 chars (~2,000 tokens) each. &lt;strong&gt;20 sources&lt;/strong&gt; → 4,000 chars each. &lt;strong&gt;40 sources&lt;/strong&gt; → 2,000 chars — roughly 300 words — each.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For context on what real support KBs look like: a text snippet source caps at 50,000 characters at creation, and a promoted Q&amp;amp;A pair caps at 2,000 characters of question plus 8,000 of answer. A typical per-product support KB is a handful of doc pages, a pricing page, and a growing pile of Q&amp;amp;A pairs promoted from the inbox. That's tens of kilobytes. The budget swallows it whole, and the model sees &lt;em&gt;everything&lt;/em&gt; on &lt;em&gt;every&lt;/em&gt; question.&lt;/p&gt;

&lt;p&gt;That last part is the underrated win. RAG doesn't just add infrastructure — it adds a new failure mode: the retrieval miss, where the answer existed in your corpus but the top-k didn't surface it, and the model confidently answers without it. When the whole KB is in the prompt, recall is 100% by construction. There is nothing to miss.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it actually costs per message
&lt;/h2&gt;

&lt;p&gt;No free lunch. The whole knowledge base rides in the system prompt of &lt;em&gt;every&lt;/em&gt; request — the prompt is rebuilt and re-sent on every turn of the conversation. A visitor typing "hi" to a project with a full source budget costs roughly 20,000 input tokens of reference material before the operator prompt, the conversation history, or the message itself.&lt;/p&gt;

&lt;p&gt;We can see this directly because metering records the real prompt and completion token counts per response into a &lt;code&gt;usageEvent&lt;/code&gt; row. Prompt token counts grow linearly with KB size times message volume. That's the structural cost of prompt stuffing, and it's worth being clear-eyed about: RAG exists partly to &lt;em&gt;not&lt;/em&gt; pay this.&lt;/p&gt;

&lt;p&gt;Two things keep it bounded for us:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Input is the cheap direction.&lt;/strong&gt; Model pricing is heavily skewed toward output tokens — on the major providers' price lists as of mid-2026, output tokens run several times the per-token price of input — and our output is hard-capped:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Hard ceiling on a single support reply's completion — bounds per-response cost&lt;/span&gt;
&lt;span class="c1"&gt;// on the shared operator key. A support answer fits comfortably; the summary&lt;/span&gt;
&lt;span class="c1"&gt;// path caps far tighter (60).&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;MAX_CHAT_OUTPUT_TOKENS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That cap is pinned by a unit test — it also bounds the blast radius of a prompt injection along the lines of "write 5000 words…" (the code comment's own example).&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The budget is a ceiling, not a typical case.&lt;/strong&gt; A KB of the shape above sits far below 80k characters of active sources, so the per-message overhead is a fraction of the worst case.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There's a second cost people forget to weigh: the cost of the RAG pipeline you &lt;em&gt;didn't&lt;/em&gt; build. An embedding pipeline is not a one-time expense. It's re-embedding on every source edit, keeping the vector store in sync with the source-of-truth rows, versioning the embedding model, debugging why a chunk boundary split a refund policy mid-sentence, and explaining to an operator why the agent ignored the doc they just uploaded. Every one of those is a moving part that can silently drift. Our KB has exactly one representation — the text in the database — and what the model sees is a pure function of it. When something goes wrong, we read one assembled string.&lt;/p&gt;

&lt;p&gt;That single-string property compounds in a direction we didn't fully appreciate at first: security review. Because the prompt is one deterministic assembly, injection defenses are string-level and unit-testable — visitor-supplied identity is sanitized (control characters and fence glyphs stripped, length-capped) and fenced between markers explicitly labeled as unverified data, and the tests pin that the support-only guardrail is prepended on every assembly. Auditing "what can an attacker put in front of the model" is a code read, not a data-pipeline archaeology dig.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this breaks, and how it fails
&lt;/h2&gt;

&lt;p&gt;Honesty section. The failure mode is real and it's silent.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The ceiling is about 4 full-size pages.&lt;/strong&gt; Past that, the even split truncates every source, and the tail of each long page becomes invisible to the agent. There's no error, no warning — the model just doesn't know things that are technically "in" the knowledge base. Because the split is arithmetic rather than relevance-ranked, a question answered in the truncated tail of source 3 fails even though a smarter system holding the same budget would have surfaced that passage. This is precisely the problem retrieval solves, and we don't pretend otherwise.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;URL snapshots go stale.&lt;/strong&gt; The fetcher grabs one URL, once, at creation. Refresh is a manual re-crawl button in the dashboard — deploying new docs does not update the agent until someone clicks it. There's no scheduled re-fetch today. We've tripped over this ourselves: we dogfood the widget on our own site, and shipping a docs change is not the same as re-snapshotting it for the agent.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Long-tail docs sites don't fit.&lt;/strong&gt; If your product has 300 documentation pages, an even 266-character sliver of each is worse than useless. That's not a "tune the budget" problem; it's a "you need retrieval" problem.&lt;/p&gt;

&lt;p&gt;We mitigate the ceiling in two ways that are cheaper than embeddings, and we think both are interesting design points on their own.&lt;/p&gt;

&lt;h2&gt;
  
  
  How web-search models change the calculus
&lt;/h2&gt;

&lt;p&gt;Every model our agent can serve is web-search-capable, by construction. The allowed model list is generated from &lt;a href="https://clankersupport.com/blog/why-we-built-on-llm-gateway" rel="noopener noreferrer"&gt;LLM Gateway&lt;/a&gt;'s model catalog filtered to providers that advertise web search, and a guard in the chat route coerces any saved non-web-search model back to the default (&lt;code&gt;gpt-5.4-mini&lt;/code&gt;). So the agent can reach the live web when answering.&lt;/p&gt;

&lt;p&gt;This matters for the RAG question because a support KB is unusual among corpora: most of it is &lt;em&gt;already on the public web&lt;/em&gt;. Your docs site, your pricing page, your changelog — the things a support agent needs are the things you publish. When the snapshot in the prompt is stale or truncated, the model can go look at the actual page. The prompt-stuffed KB becomes the fast path and the grounding; live search is the backstop for freshness and depth.&lt;/p&gt;

&lt;p&gt;Two honest caveats. First, whether and when a model actually searches is up to the model and provider — "web-search-capable" is a capability flag, not a guarantee, so this is a mitigation rather than automatic RAG-over-the-web. Second, search only backstops &lt;em&gt;public&lt;/em&gt; knowledge; internal policies and unpublished answers still have to live in the KB proper. (Being self-hostable makes that second category more comfortable to store at all — the argument in &lt;a href="https://clankersupport.com/blog/the-case-for-self-hostable-ai-support" rel="noopener noreferrer"&gt;the case for self-hostable AI support&lt;/a&gt;.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Curation beats ingestion: promoting real answers into the KB
&lt;/h2&gt;

&lt;p&gt;The second mitigation is about what goes &lt;em&gt;into&lt;/em&gt; the budget. The highest-value knowledge a support agent can hold isn't a crawled page — it's the answer a human already gave to this exact question. Our inbox has a "promote to knowledge base" action on any operator reply: it takes the reply, pairs it with the nearest preceding visitor message as the question, and stores it as a &lt;code&gt;qa&lt;/code&gt; source. The stored content is literally:&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;content&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`Q: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;finalQuestion&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;\nA: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;finalAnswer&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;Two lines. Deduped by source message, so promoting the same reply twice returns the existing source. A promoted Q&amp;amp;A is small (10k characters max), dense, and pre-validated by an actual human answering an actual customer — the opposite of a 20k-character page snapshot that's mostly navigation boilerplate. A KB grown this way stays comfortably inside the budget far longer than a KB grown by snapshotting every page of your docs site, and it improves exactly where your visitors demonstrated the gaps. It's the same instinct behind &lt;a href="https://clankersupport.com/blog/reducing-support-tickets-with-ai-first-response" rel="noopener noreferrer"&gt;using AI as the first response and humans as the curriculum&lt;/a&gt;: the escalations teach the agent.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we'll build when a customer blows past the budget
&lt;/h2&gt;

&lt;p&gt;Our position is "you might not need RAG &lt;em&gt;yet&lt;/em&gt;," not "you don't need RAG." The trigger is concrete: when a real customer's KB meaningfully exceeds ~4 full pages of unique, non-promotable content — a genuine long-tail docs corpus — even splitting stops being defensible, and we'll build retrieval. When we do, it'll be the boring, proven shape: chunk sources at ingestion, embed chunks, embed the visitor's question at query time, put the top-k chunks into the same &lt;code&gt;# Reference sources&lt;/code&gt; block the prompt builder already renders. The prompt assembly, citation instruction, and injection fencing all stay; only the WHERE clause grows a brain.&lt;/p&gt;

&lt;p&gt;What we won't do is build it speculatively. Every week the pipeline doesn't exist is a week we don't debug sync drift, don't re-embed on edits, and don't explain retrieval misses. The constants in &lt;code&gt;llm.ts&lt;/code&gt; are doing the job a vector database would do, in twelve lines, with unit tests pinning the behavior. When the ceiling stops being theoretical for our users, the code knows exactly where retrieval slots in.&lt;/p&gt;

&lt;p&gt;If you want to poke at the real thing, the agent answering questions on &lt;a href="https://showcase.clankersupport.com" rel="noopener noreferrer"&gt;our live demo&lt;/a&gt; is running exactly the code quoted above, and the whole repo is MIT-licensed if you'd rather read the source than take our word for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Do I need a vector database for an AI support agent?
&lt;/h3&gt;

&lt;p&gt;Not if your knowledge base fits in the model's context window with room to spare. A typical per-product support KB — some doc pages, a pricing page, curated Q&amp;amp;A — is tens of kilobytes. We budget 80,000 characters (roughly 20k tokens) of sources per request and stuff them all in. You need a vector DB when your corpus is large enough that this either truncates badly or costs too much per message.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is prompt stuffing cheaper than RAG?
&lt;/h3&gt;

&lt;p&gt;Per message, no — you re-send the whole KB on every turn, so input tokens scale with KB size times message volume, where RAG sends only the retrieved chunks. In total cost of ownership, often yes for small KBs: you skip the embedding pipeline, vector store, sync logic, and the engineering time to keep them honest. Input tokens are also the cheap direction on most model pricing.&lt;/p&gt;

&lt;h3&gt;
  
  
  What happens when the knowledge base is too big for the prompt?
&lt;/h3&gt;

&lt;p&gt;In our implementation, each source gets an even share of the 80k-character budget — &lt;code&gt;floor(80000 / N)&lt;/code&gt; characters — and anything past its share is silently cut. Past about 4 full-size page snapshots, sources start truncating each other and the model can't see the tails. That silent truncation is the honest failure mode of this design, and it's the point where real retrieval earns its complexity.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can web search replace RAG for customer support?
&lt;/h3&gt;

&lt;p&gt;Partially. Support is unusual in that most of the corpus (docs, pricing, changelogs) is already public, so a web-search-capable model can fetch the live page when the in-prompt snapshot is stale or truncated. But search is model-discretionary — a capability, not a guarantee — and it can't reach internal or unpublished knowledge, so it's a backstop for a prompt-based KB rather than a substitute for retrieval at scale.&lt;/p&gt;

&lt;h3&gt;
  
  
  When should I add RAG to an LLM application?
&lt;/h3&gt;

&lt;p&gt;When you can name the failing query. If you can point at real questions that fail because the relevant passage didn't fit in the prompt — not hypothetically, but in your logs — retrieval will pay for itself. If you can't, you're building infrastructure to solve a problem you haven't got, and every part of it (chunking, embeddings, sync) is a maintenance surface that starts costing the day it ships.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>architecture</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Everything that broke while shipping an embeddable AI widget</title>
      <dc:creator>Omar Bni</dc:creator>
      <pubDate>Sun, 12 Jul 2026 16:28:47 +0000</pubDate>
      <link>https://dev.to/omar_bni_f6856a8bb0e021e9/everything-that-broke-while-shipping-an-embeddable-ai-widget-5h4g</link>
      <guid>https://dev.to/omar_bni_f6856a8bb0e021e9/everything-that-broke-while-shipping-an-embeddable-ai-widget-5h4g</guid>
      <description>&lt;p&gt;Here is what broke while shipping the Clanker Support widget as a one-script-tag embed: Shopify's Dawn theme hid it entirely with &lt;code&gt;div:empty { display: none }&lt;/code&gt;; a 62.5% root font-size shrank it to 10/16 scale straight through the shadow DOM; &lt;code&gt;document.currentScript&lt;/code&gt; was null when we read it too late; and our server had no filesystem to serve the bundle from. Each breakage became a rule; this post walks through all of them.&lt;/p&gt;

&lt;p&gt;An embedded widget is a strange artifact. It's a React app, but it runs inside a page built by someone else, styled by someone else, bundled by someone else, and served from infrastructure you'll never see. Every assumption a normal web app gets for free — sane resets, a 16px root, a predictable script lifecycle — is up for grabs. Shadow DOM helps less than you'd hope, and we'll get precise about exactly where it stops helping.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the widget is one file
&lt;/h2&gt;

&lt;p&gt;The widget is a Vite lib build, IIFE format only, one entry (&lt;code&gt;src/mount.tsx&lt;/code&gt;), one output (&lt;code&gt;widget.js&lt;/code&gt;). Two config lines do most of the work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;inlineDynamicImports: true&lt;/code&gt;&lt;/strong&gt; — no lazy chunks, ever. A second chunk means a second network fetch from a URL the bundle has to compute at runtime, on a page whose base URL, CSP, and bundler you don't control. One file has one failure mode.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;cssCodeSplit: false&lt;/code&gt;&lt;/strong&gt; — the CSS ships inside the JS (more on how below).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Single-file IIFE has a sharp edge, though: every transitive dynamic import in your dependency tree gets inlined whether you wanted it or not. Our markdown renderer, Streamdown, lazy-loads &lt;code&gt;mermaid&lt;/code&gt; to render diagram blocks. In an app that's a nice deferred chunk; in an inlined IIFE it's the whole mermaid library riding along in &lt;code&gt;widget.js&lt;/code&gt; for a feature support replies will never use. The fix is an alias to a stub:&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="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nl"&gt;alias&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="c1"&gt;// Streamdown lazy-loads mermaid for ```mermaid blocks. The widget is a&lt;/span&gt;
            &lt;span class="c1"&gt;// single inlined IIFE, so that lazy chunk gets inlined and would pull in&lt;/span&gt;
            &lt;span class="c1"&gt;// the whole (~MB) mermaid library. Support replies never contain&lt;/span&gt;
            &lt;span class="c1"&gt;// diagrams, so alias it to a no-op stub to keep widget.js small.&lt;/span&gt;
            &lt;span class="na"&gt;mermaid&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;fileURLToPath&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;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;./src/mermaid-stub.ts&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;meta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&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 stub is a no-op object: &lt;code&gt;initialize()&lt;/code&gt; does nothing, &lt;code&gt;render()&lt;/code&gt; returns &lt;code&gt;{ svg: "" }&lt;/code&gt;. If a diagram block ever appears, it renders nothing instead of costing every visitor the download.&lt;/p&gt;

&lt;p&gt;One more lib-mode gotcha: Vite keeps React's &lt;code&gt;process.env.NODE_ENV&lt;/code&gt; references in a lib build, and there is no &lt;code&gt;process&lt;/code&gt; in a browser. Without &lt;code&gt;define: { "process.env.NODE_ENV": JSON.stringify("production") }&lt;/code&gt;, the widget throws a ReferenceError on the very first page it's embedded in.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rule: ship a single-file IIFE, and audit what inlining drags in.&lt;/strong&gt; Every dynamic import in your dependency tree is a hidden passenger.&lt;/p&gt;

&lt;h2&gt;
  
  
  Shadow DOM isolates selectors, not the cascade
&lt;/h2&gt;

&lt;p&gt;We mount into a shadow root: append a host &lt;code&gt;&amp;lt;div&amp;gt;&lt;/code&gt; to &lt;code&gt;document.body&lt;/code&gt;, &lt;code&gt;attachShadow({ mode: "open" })&lt;/code&gt;, inject a &lt;code&gt;&amp;lt;style&amp;gt;&lt;/code&gt; element, render React into it. Host-page selectors can't reach inside; our selectors can't leak out. That's the sales pitch, and the part about &lt;em&gt;selectors&lt;/em&gt; is true.&lt;/p&gt;

&lt;p&gt;But two things pass straight through the shadow boundary: &lt;strong&gt;inherited properties&lt;/strong&gt; and &lt;strong&gt;unit resolution&lt;/strong&gt;. The host page's &lt;code&gt;font-family&lt;/code&gt;, &lt;code&gt;line-height&lt;/code&gt;, &lt;code&gt;text-align&lt;/code&gt;, and &lt;code&gt;letter-spacing&lt;/code&gt; all inherit into your shadow tree unless you pin them. So the top of our stylesheet is a wall of explicit values — &lt;code&gt;font-size: 16px&lt;/code&gt;, &lt;code&gt;line-height: 1.5&lt;/code&gt;, &lt;code&gt;letter-spacing: normal&lt;/code&gt;, &lt;code&gt;text-align: left&lt;/code&gt; on &lt;code&gt;:host&lt;/code&gt; — plus a &lt;code&gt;box-sizing: border-box&lt;/code&gt; reset on every element, because host pages love universal selectors and you don't get to assume &lt;code&gt;content-box&lt;/code&gt; never leaked in from a parent frame's expectations.&lt;/p&gt;

&lt;p&gt;We thought that wall made us safe. Then we tested the Shopify theme-app extension on a Dawn dev store, and the host page got us twice in one afternoon — once by reaching &lt;em&gt;around&lt;/em&gt; the shadow DOM, once by reaching &lt;em&gt;through&lt;/em&gt; it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The day the widget vanished on Shopify Dawn: div:empty
&lt;/h2&gt;

&lt;p&gt;First bug: on a stock Dawn store, the widget didn't render. Not broken — absent. No bubble, no errors, nothing.&lt;/p&gt;

&lt;p&gt;The diagnosis is the kind you only get by staring at computed styles. All of the widget's content lives in the shadow root. From the light DOM's point of view, the host &lt;code&gt;&amp;lt;div&amp;gt;&lt;/code&gt; we append to &lt;code&gt;document.body&lt;/code&gt; has no children — it is, structurally, empty. And Shopify's Dawn theme ships this in &lt;code&gt;base.css&lt;/code&gt;:&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;div&lt;/span&gt;&lt;span class="nd"&gt;:empty&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;none&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 perfectly reasonable rule for a theme to ship, and it hides your entire embed. The host page's stylesheet never touched a single element inside our shadow root — it didn't need to. It matched the one element we own in the light DOM and removed it from layout, shadow tree and all. Dawn is Shopify's default theme, so this was every Dawn-based store, which is a lot of stores.&lt;/p&gt;

&lt;p&gt;The fix is one small function (commit &lt;code&gt;e18586e&lt;/code&gt;, shipped as PR #111):&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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;createWidgetHost&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="nx"&gt;Document&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;HTMLDivElement&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;host&lt;/span&gt; &lt;span class="o"&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;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;div&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;host&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;llmchat-widget-root&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;host&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="nf"&gt;setProperty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;display&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="s2"&gt;block&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="s2"&gt;important&lt;/span&gt;&lt;span class="dl"&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;host&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;Why an &lt;em&gt;inline&lt;/em&gt; important declaration and not a rule in our stylesheet? Because our stylesheet lives in the shadow root, and the &lt;code&gt;div:empty&lt;/code&gt; rule matches a light-DOM element — a shadow style can't win that fight. In the cascade, an important inline declaration outranks any stylesheet rule, important or not. It's the one place we can plant a flag the host page cannot override short of JavaScript.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rule: your shadow host is &lt;code&gt;:empty&lt;/code&gt; in the light DOM — plan for it.&lt;/strong&gt; Either your host element has light-DOM content, or you defend its &lt;code&gt;display&lt;/code&gt; with an inline important declaration like we did. Hiding empty divs is a common theme pattern; assume it's out there.&lt;/p&gt;

&lt;h2&gt;
  
  
  38 minutes later: rem resolves through shadow DOM
&lt;/h2&gt;

&lt;p&gt;With the bubble finally visible on Dawn, the second bug was immediately obvious: the widget was tiny. The 56×56px launcher bubble measured 35×35. Panel text rendered around 10px. Everything was scaled by exactly 10/16.&lt;/p&gt;

&lt;p&gt;That fraction is the tell. Dawn — like a lot of themes and older CSS codebases — sets &lt;code&gt;html { font-size: 62.5% }&lt;/code&gt; so that &lt;code&gt;1rem = 10px&lt;/code&gt; for convenient arithmetic. And here's the part that surprises people: &lt;strong&gt;&lt;code&gt;rem&lt;/code&gt; resolves against the host page's root font-size even inside shadow DOM&lt;/strong&gt;. The shadow boundary doesn't intervene. &lt;code&gt;rem&lt;/code&gt; means "root em," and the root is &lt;code&gt;&amp;lt;html&amp;gt;&lt;/code&gt; — the host's &lt;code&gt;&amp;lt;html&amp;gt;&lt;/code&gt;. There's no shadow-local root to resolve against.&lt;/p&gt;

&lt;p&gt;Our stylesheet had 160 &lt;code&gt;rem&lt;/code&gt; values in it. Every one of them was silently multiplied by 10/16 on any 62.5%-root page. Note what &lt;em&gt;didn't&lt;/em&gt; break: text that inherited from our pinned &lt;code&gt;font-size: 16px&lt;/code&gt; was fine, which is exactly why the widget had looked correct everywhere else — the font-size pin protected inherited text while every &lt;code&gt;rem&lt;/code&gt;-denominated &lt;em&gt;dimension&lt;/em&gt; (padding, radii, the bubble itself) quietly depended on a root we don't own.&lt;/p&gt;

&lt;p&gt;The fix (commit &lt;code&gt;445af84&lt;/code&gt;, PR #113, 38 minutes after the Dawn fix) was mechanical: multiply all 160 values by 16 and write them as px. A 273-line diff of pure unit conversion, and a comment in the stylesheet so nobody "modernizes" it back:&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="c"&gt;/* Pin inherited properties so the host page's typography can't leak across
       the shadow boundary and distort the widget. Dimensions are px throughout
       (never rem): rem resolves against the HOST page's root font-size even
       inside shadow DOM — Shopify's Dawn sets html to 62.5%, which shrank the
       whole widget to 10/16 scale. */&lt;/span&gt;
&lt;span class="nt"&gt;line-height&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="err"&gt;1&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="err"&gt;5&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="nt"&gt;font-size&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="err"&gt;16&lt;/span&gt;&lt;span class="nt"&gt;px&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Inside an app you control, &lt;code&gt;rem&lt;/code&gt; is good practice — it respects user font-size preferences. Inside an embed, &lt;code&gt;rem&lt;/code&gt; is an unversioned runtime dependency on a value the host page sets. Those are different products with different rules.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rule: in an embed, ship px, not rem.&lt;/strong&gt; The two Dawn bugs make a matched pair, and together they're the thesis of this post: shadow DOM isolates &lt;em&gt;selectors&lt;/em&gt;. It does not isolate inheritance, and it does not isolate unit resolution. The host page reached around our shadow root (the &lt;code&gt;:empty&lt;/code&gt; match on the light-DOM host) and through it (rem resolving against the host root) on the same day, two PRs apart.&lt;/p&gt;

&lt;h2&gt;
  
  
  Things nobody tells you about script-tag embeds
&lt;/h2&gt;

&lt;p&gt;Two smaller breakages, both about the &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; tag itself rather than CSS.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;document.currentScript&lt;/code&gt; is a now-or-never API.&lt;/strong&gt; The widget reads its config from data attributes on its own script tag — &lt;code&gt;data-project&lt;/code&gt;, &lt;code&gt;data-api&lt;/code&gt;, &lt;code&gt;data-brand&lt;/code&gt;, &lt;code&gt;data-mode&lt;/code&gt;. To find "its own script tag" it uses &lt;code&gt;document.currentScript&lt;/code&gt;, which is only set during synchronous evaluation of the script. Defer your config read into a &lt;code&gt;DOMContentLoaded&lt;/code&gt; callback — the natural place, since you can't mount before the body exists — and &lt;code&gt;currentScript&lt;/code&gt; is null. So the config capture and the mount are split:&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;// document.currentScript is only set during synchronous script evaluation —&lt;/span&gt;
&lt;span class="c1"&gt;// it is null inside the DOMContentLoaded callback — so capture config now.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;resolveConfig&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="nx"&gt;currentScript&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;HTMLScriptElement&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&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;Config is resolved at top level, synchronously, the moment the script evaluates; only the DOM mount waits for &lt;code&gt;readyState&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Derive your API origin from the script's own src.&lt;/strong&gt; Where should the widget send chat requests? The obvious answer — hardcode the production API host — is a trap: every local dev embed, staging embed, and self-hosted install would silently talk to prod. The correct default was sitting in the script tag all along:&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;apiUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="nx"&gt;script&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;api&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;script&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;src&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;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;script&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;origin&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="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;origin&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Whatever origin served &lt;code&gt;widget.js&lt;/code&gt; is, by construction, an origin running our API — the API is what serves the widget bundle. Local dev loads the widget from localhost and talks to localhost; a self-hosted install (the whole thing is open source and &lt;a href="https://clankersupport.com/blog/the-case-for-self-hostable-ai-support" rel="noopener noreferrer"&gt;self-hostable&lt;/a&gt;) talks to itself; &lt;code&gt;data-api&lt;/code&gt; remains as an explicit override. No environment detection, no build-time host baking.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rule: capture &lt;code&gt;currentScript&lt;/code&gt; synchronously, and make the script's own origin your API default.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Serving widget.js from a server with no filesystem
&lt;/h2&gt;

&lt;p&gt;Our API runs on workerd — the Cloudflare-workers-compatible runtime — where there is no filesystem to read a built asset from. But &lt;code&gt;/widget.js&lt;/code&gt; has to come from somewhere, ideally the same origin as the API (see the previous rule).&lt;/p&gt;

&lt;p&gt;The answer is unglamorous: after &lt;code&gt;vite build&lt;/code&gt;, a script reads &lt;code&gt;dist/widget.js&lt;/code&gt; and writes it into a generated TypeScript module as one JSON-stringified constant. The API imports that module and serves the string from memory with &lt;code&gt;content-type: application/javascript&lt;/code&gt;, &lt;code&gt;x-content-type-options: nosniff&lt;/code&gt;, and &lt;code&gt;cache-control: public, max-age=300&lt;/code&gt;. The generated file is gitignored; the API's deploy pipeline builds the widget first, so the constant is always fresh.&lt;/p&gt;

&lt;p&gt;The five-minute cache is a deliberate embed-specific choice. Host pages pin your URL in their HTML forever — you can't cache-bust an asset whose URL is copy-pasted into &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; tags in HTML you don't control. Short max-age means a shipped fix (like either Dawn fix) reaches every embed within minutes, at the cost of more origin hits. For a support widget, that trade is easy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rule: an embed URL is immutable to you, so keep its cache lifetime short.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The iframe fallback and its upside-down CSP
&lt;/h2&gt;

&lt;p&gt;Some environments can't or won't run third-party script tags — strict CSPs, locked-down site builders, "no external JS" policies. For those we serve &lt;code&gt;/embed/&amp;lt;key&amp;gt;&lt;/code&gt;: a full-page chat shell designed to be iframed. (For hosts that &lt;em&gt;do&lt;/em&gt; give you a real integration surface, we've written up the &lt;a href="https://clankersupport.com/blog/nextjs-ai-support-widget-server-component" rel="noopener noreferrer"&gt;React Server Components install&lt;/a&gt; and the &lt;a href="https://clankersupport.com/blog/wordpress-ai-support-plugin" rel="noopener noreferrer"&gt;WordPress plugin&lt;/a&gt; separately.)&lt;/p&gt;

&lt;p&gt;Writing the CSP for that page was a small lesson in itself, because it's a normal CSP turned inside out:&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="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;content-security-policy&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="s2"&gt;default-src 'none'; script-src 'self'; style-src 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; base-uri 'none'; form-action 'none'; frame-ancestors *&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;Every app-hardening guide tells you to lock down &lt;code&gt;frame-ancestors&lt;/code&gt;. Here &lt;code&gt;frame-ancestors *&lt;/code&gt; is the entire point — being framed by arbitrary sites is the product — so everything &lt;em&gt;else&lt;/em&gt; gets locked to nothing: no forms, no base-URI tricks, scripts and connections from self only. &lt;code&gt;style-src 'unsafe-inline'&lt;/code&gt; looks alarming until you remember the widget's whole stylesheet is one &lt;code&gt;&amp;lt;style&amp;gt;&lt;/code&gt; element in a shadow root; that's the mechanism, not a compromise.&lt;/p&gt;

&lt;p&gt;One subtle line in that page: the widget script src is &lt;em&gt;relative&lt;/em&gt;, not an absolute URL built from the request. Behind a TLS-terminating proxy the worker sees &lt;code&gt;http://&lt;/code&gt;, and an absolute &lt;code&gt;http://&lt;/code&gt; script URL on an &lt;code&gt;https://&lt;/code&gt; page is mixed content — blocked before your code ever runs. A relative src inherits the page's real scheme and sidesteps the whole class of bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rules, collected
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Ship a single-file IIFE.&lt;/strong&gt; Inline dynamic imports; stub out heavyweight transitive lazy-loads (our mermaid alias) instead of shipping them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Define &lt;code&gt;process.env.NODE_ENV&lt;/code&gt; at build time.&lt;/strong&gt; Vite lib mode won't do it for you, and browsers have no &lt;code&gt;process&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pin every inherited property at your shadow root.&lt;/strong&gt; Font, line-height, letter-spacing, text-align, plus a box-sizing reset. Shadow DOM only isolates selectors.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your shadow host is &lt;code&gt;:empty&lt;/code&gt; in the light DOM.&lt;/strong&gt; Give it light-DOM content or defend &lt;code&gt;display&lt;/code&gt; with an inline important declaration; theme CSS that hides empty divs is real and widespread.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ship px, not rem.&lt;/strong&gt; Inside an embed, rem is a dependency on the host page's root font-size — even through shadow DOM.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Capture &lt;code&gt;document.currentScript&lt;/code&gt; synchronously.&lt;/strong&gt; It's null by the time &lt;code&gt;DOMContentLoaded&lt;/code&gt; fires.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Default your API origin to &lt;code&gt;new URL(script.src).origin&lt;/code&gt;.&lt;/strong&gt; Never a hardcoded host; keep an explicit override attribute.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Serve the bundle from your API's origin with a short max-age.&lt;/strong&gt; Embed URLs are pinned in HTML you don't control; five minutes is our propagation ceiling for fixes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If you offer an iframe mode, invert the CSP.&lt;/strong&gt; &lt;code&gt;frame-ancestors *&lt;/code&gt; on purpose, everything else &lt;code&gt;'none'&lt;/code&gt; or &lt;code&gt;'self'&lt;/code&gt;, and a relative script src so TLS-terminating proxies can't hand you mixed content.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All of this code is public — the widget, the stub, both Dawn fixes with their commit messages — in the &lt;a href="https://github.com/theopenco/llmchat" rel="noopener noreferrer"&gt;repo&lt;/a&gt;, and you can poke the live widget at &lt;a href="https://showcase.clankersupport.com" rel="noopener noreferrer"&gt;showcase.clankersupport.com&lt;/a&gt;. If you're building your own embed, steal the rules; we already paid for them, one Dawn dev store at a time.&lt;/p&gt;

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