<?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>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>
    <item>
      <title>We moved our SaaS backend to workerd and every Node SDK broke</title>
      <dc:creator>Omar Bni</dc:creator>
      <pubDate>Sat, 11 Jul 2026 17:51:25 +0000</pubDate>
      <link>https://dev.to/omar_bni_f6856a8bb0e021e9/we-moved-our-saas-backend-to-workerd-and-every-node-sdk-broke-2io9</link>
      <guid>https://dev.to/omar_bni_f6856a8bb0e021e9/we-moved-our-saas-backend-to-workerd-and-every-node-sdk-broke-2io9</guid>
      <description>&lt;p&gt;We ship Clanker Support's API to workerd — the open-source runtime underneath Cloudflare Workers — and three dependencies didn't survive the move: the Resend SDK, the Stripe Node SDK, and Better Auth's passkey plugin. None fell to code we call. Each fell to what the package dragged in — a webhook-crypto library, Node built-ins, an X.509 certificate parser. Each was replaced with plain &lt;code&gt;fetch&lt;/code&gt; and &lt;code&gt;crypto.subtle&lt;/code&gt;, and this post walks through the replacements.&lt;/p&gt;

&lt;p&gt;Clanker Support is an open-source, self-hostable AI support widget (&lt;a href="https://github.com/theopenco/llmchat" rel="noopener noreferrer"&gt;theopenco/llmchat&lt;/a&gt;), so every file mentioned below is public. The API is a &lt;a href="https://hono.dev" rel="noopener noreferrer"&gt;Hono&lt;/a&gt; app that deploys to workerd via the &lt;a href="https://docs.meetploy.com" rel="noopener noreferrer"&gt;Ploy platform&lt;/a&gt; — Ploy's bindings are D1-compatible SQLite and KV-compatible state, and the runtime constraints are the same ones you'd hit on Cloudflare Workers proper.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why we left Node in the first place
&lt;/h2&gt;

&lt;p&gt;The short version: one JavaScript runtime everywhere, near-instant cold starts, and a deploy that either bundles or doesn't — no container image, no &lt;code&gt;node_modules&lt;/code&gt; shipped to a server, no runtime surprise three requests in. Our repo's agent instructions state the constraint in one line:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The api ships to workerd. Avoid Node-only deps — they fail to bundle.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That last clause is the interesting property. On workerd, an incompatible dependency is a &lt;strong&gt;build-time&lt;/strong&gt; failure, not a 3 a.m. page. The bundler hits a Node built-in or a native addon it can't resolve, and the deploy dies right there. It's brutal, but it's brutal in CI instead of in production.&lt;/p&gt;

&lt;p&gt;The cost of that property is the rest of this article.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Node SDKs break on workerd
&lt;/h2&gt;

&lt;p&gt;workerd implements the Web Platform: &lt;code&gt;fetch&lt;/code&gt;, &lt;code&gt;Request&lt;/code&gt;/&lt;code&gt;Response&lt;/code&gt;, &lt;code&gt;crypto.subtle&lt;/code&gt;, &lt;code&gt;TextEncoder&lt;/code&gt;, streams. It does not give you Node's standard library, a filesystem, or long-lived module-scope state you can rely on across requests.&lt;/p&gt;

&lt;p&gt;Most vendor SDKs were written for Node first. Even when the SDK's public API is portable, what sits underneath it usually isn't — and the whole tree is what the bundler has to swallow. Every casualty we hit followed the same shape: the top-level package looked innocent, and something it dragged in was not — sometimes a Node built-in one level down, sometimes an npm package three levels down.&lt;/p&gt;

&lt;p&gt;The pattern held so consistently that we'd summarize the whole migration as: &lt;strong&gt;audit your transitive dependencies, not your imports.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Breakage 1: the Resend SDK — a mail client that drags in webhook crypto
&lt;/h2&gt;

&lt;p&gt;First casualty: the official &lt;code&gt;resend&lt;/code&gt; npm package. We use Resend to send escalation emails — a visitor clicks "talk to a human", the operator gets an email, and replies thread back into the conversation (we wrote up that loop in &lt;a href="https://clankersupport.com/blog/setting-up-email-threading" rel="noopener noreferrer"&gt;how we set up email threading&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;The SDK itself is a thin API client. But it pulled in &lt;code&gt;svix&lt;/code&gt; — a webhook-verification library — as a dependency, and that was enough to make us drop it. Sending an email over Resend's API is one HTTP call, so the replacement in &lt;code&gt;lib/email.ts&lt;/code&gt; is about as small as an integration gets: a raw &lt;code&gt;fetch&lt;/code&gt; POST to &lt;code&gt;https://api.resend.com/emails&lt;/code&gt; with a Bearer key and a JSON body. No client object, no retry machinery, no dependency tree.&lt;/p&gt;

&lt;p&gt;Dropping the SDK also produced a nicer dev story: when &lt;code&gt;RESEND_API_KEY&lt;/code&gt; is unset, &lt;code&gt;sendEmail&lt;/code&gt; logs the message and returns &lt;code&gt;{ id: "dev-noop" }&lt;/code&gt;, so local development and self-hosted installs need zero email setup.&lt;/p&gt;

&lt;p&gt;One small detail we're fond of: the address validator deliberately rejects &lt;code&gt;$&lt;/code&gt; anywhere in an email address. Not for RFC correctness — it catches unexpanded &lt;code&gt;$VAR&lt;/code&gt; environment references before they end up in a Reply-To header, where Resend would reject the whole send with a 422. Cheap paranoia, one regex.&lt;/p&gt;

&lt;p&gt;The webhook-verification side of Resend (inbound email replies are Svix-signed) got hand-rolled too — more on that below, because it pairs with Stripe.&lt;/p&gt;

&lt;h2&gt;
  
  
  Breakage 2: the Stripe Node SDK — rewrite the form encoder and the webhook HMAC yourself
&lt;/h2&gt;

&lt;p&gt;The big one. Stripe's Node SDK pulls in Node built-ins that don't bundle on workerd, so &lt;code&gt;apps/api/src/lib/stripe.ts&lt;/code&gt; opens with what has become our team's unofficial manifesto:&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;// Stripe REST client for workerd. We deliberately do NOT use the Stripe Node&lt;/span&gt;
&lt;span class="c1"&gt;// SDK — it pulls Node built-ins that don't bundle on workerd, and the api is&lt;/span&gt;
&lt;span class="c1"&gt;// the one app that always deploys cleanly. Everything here is plain `fetch`&lt;/span&gt;
&lt;span class="c1"&gt;// with form-encoded bodies + Web Crypto signature verification.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The whole file is 289 lines and covers everything our metered billing needs: &lt;code&gt;createCustomer&lt;/code&gt;, &lt;code&gt;createCheckoutSession&lt;/code&gt;, &lt;code&gt;createPortalSession&lt;/code&gt;, &lt;code&gt;retrieveSubscription&lt;/code&gt;, &lt;code&gt;reportMeterEvent&lt;/code&gt;, and webhook signature verification. Three parts were genuinely annoying to reimplement.&lt;/p&gt;

&lt;h3&gt;
  
  
  Part one: Stripe's bracketed form encoding
&lt;/h3&gt;

&lt;p&gt;Stripe's API doesn't take JSON. It takes form-encoded bodies with a bracket convention for nesting — &lt;code&gt;line_items[0][price]=price_123&lt;/code&gt; — which the Node SDK normally hides from you. So the first thing we wrote was a recursive encoder:&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;formEncode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&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="p"&gt;,&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="kr"&gt;string&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;parts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&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;walk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&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="nx"&gt;val&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;val&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;val&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="k"&gt;return&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="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;val&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nx"&gt;val&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;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="nf"&gt;walk&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;key&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;i&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;v&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;val&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;object&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;val&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nf"&gt;walk&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;key&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;k&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;v&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="cm"&gt;/* encodeURIComponent key=value */&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;Twenty-odd lines, but it encodes every request the billing system makes, including checkout sessions with nested line items. One Stripe-specific landmine it helped us respect: metered prices must &lt;strong&gt;not&lt;/strong&gt; include a quantity on the line item — usage arrives later via meter events, and Stripe rejects the session otherwise. That rule now lives as a comment next to the code instead of somewhere inside an SDK.&lt;/p&gt;

&lt;h3&gt;
  
  
  Part two: webhook verification without &lt;code&gt;constructEvent&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Stripe signs webhooks with a &lt;code&gt;stripe-signature&lt;/code&gt; header shaped like &lt;code&gt;t=&amp;lt;unix&amp;gt;,v1=&amp;lt;hex&amp;gt;&lt;/code&gt;: an HMAC-SHA256 over &lt;code&gt;${t}.${rawBody}&lt;/code&gt;. The Node SDK's &lt;code&gt;stripe.webhooks.constructEvent&lt;/code&gt; does this for you; on workerd you do it with &lt;code&gt;crypto.subtle&lt;/code&gt; and you compare the result in constant time, because a naive &lt;code&gt;===&lt;/code&gt; on secrets leaks timing information:&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;timingSafeEqualHex&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="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&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="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&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="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&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;diff&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;diff&lt;/span&gt; &lt;span class="o"&gt;|=&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;charCodeAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;^&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;charCodeAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;diff&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Our &lt;code&gt;verifyStripeSignature&lt;/code&gt; also enforces a replay window: signatures older than 300 seconds are rejected, matching the default tolerance in &lt;a href="https://docs.stripe.com/webhooks" rel="noopener noreferrer"&gt;Stripe's own webhook docs&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Then Resend's Svix-signed inbound-email webhooks needed the &lt;em&gt;same idea with different details&lt;/em&gt;, in &lt;code&gt;lib/svix.ts&lt;/code&gt;: Svix signs &lt;code&gt;${id}.${timestamp}.${rawBody}&lt;/code&gt; instead of &lt;code&gt;${t}.${body}&lt;/code&gt;, uses a base64-decoded &lt;code&gt;whsec_&lt;/code&gt;-prefixed key instead of a raw string, and emits base64 signatures in a space-separated &lt;code&gt;v1,&amp;lt;sig&amp;gt;&lt;/code&gt; list instead of hex. Our Svix verifier also uses &lt;code&gt;Math.abs&lt;/code&gt; on the timestamp delta, so it rejects future-dated signatures too — the Stripe one only checks age. Two webhook schemes, two hand-rolled verifiers, both &lt;code&gt;crypto.subtle&lt;/code&gt; + constant-time compare, both fail-closed on a missing secret or malformed header.&lt;/p&gt;

&lt;p&gt;Would we have gotten those cross-scheme details right without reading both vendors' verification docs line by line? No. That's the real cost of leaving the SDK behind: the vendor's docs become your spec, not their code.&lt;/p&gt;

&lt;h3&gt;
  
  
  Part three: making best-effort metering safe
&lt;/h3&gt;

&lt;p&gt;Billing is metered per AI response, and the meter report happens &lt;em&gt;after&lt;/em&gt; the response has streamed, inside &lt;code&gt;waitUntil&lt;/code&gt; — best effort, never blocking the reply. Best effort plus retries usually equals double-billing, so every meter event carries an idempotency identifier: the database row id of the &lt;code&gt;usageEvent&lt;/code&gt; we just inserted.&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;// the usageEvent id is the idempotency key so a retry can't double-bill&lt;/span&gt;
&lt;span class="nx"&gt;identifier&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The row insert is the source of truth; the Stripe meter event is a projection of it. If the meter call fails, the row still exists and the retry carries the same identifier, so Stripe deduplicates it. We use Stripe's Billing Meters API, not the older subscription-item usage records it deprecated in 2024 — one nice thing about a hand-rolled client is that there's no SDK version pinning you to yesterday's endpoint.&lt;/p&gt;

&lt;h2&gt;
  
  
  Breakage 3: passkeys — killed by an ASN.1 parser three levels down
&lt;/h2&gt;

&lt;p&gt;The most instructive failure, because we never even called the offending code. We wanted passkey sign-in via &lt;code&gt;@better-auth/passkey&lt;/code&gt;. That plugin depends on &lt;code&gt;@simplewebauthn/server&lt;/code&gt;, which depends on &lt;code&gt;@peculiar/x509&lt;/code&gt; and &lt;code&gt;asn1js&lt;/code&gt; — X.509 certificate parsing for WebAuthn attestation. Somewhere down that chain the bundle broke.&lt;/p&gt;

&lt;p&gt;To be precise about what happened, because it's easy to overclaim: Better Auth itself runs fine on workerd. Only the passkey &lt;strong&gt;plugin&lt;/strong&gt; was removed, before launch, so no user ever lost a feature. The &lt;code&gt;passkey&lt;/code&gt; table still sits in our Drizzle schema for the day the ecosystem catches up.&lt;/p&gt;

&lt;p&gt;The lesson stands regardless: the dependency that kills your deploy is rarely the one in your &lt;code&gt;package.json&lt;/code&gt;. It's the certificate parser your auth plugin's WebAuthn library needs for an attestation flow you might never have exercised.&lt;/p&gt;

&lt;h2&gt;
  
  
  The quieter workerd-isms that bite without a bundler error
&lt;/h2&gt;

&lt;p&gt;Bundle failures are loud. The subtler category is code that bundles fine and then behaves differently, because workerd's execution model isn't Node's.&lt;/p&gt;

&lt;h3&gt;
  
  
  Env is a binding, so construct per request
&lt;/h3&gt;

&lt;p&gt;On Node you read &lt;code&gt;process.env&lt;/code&gt; at module scope and build your clients once. On workerd (under Ploy, and under Cloudflare Workers the same way), env arrives as a binding on each request. So the auth instance is constructed per request:&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;createAuth&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="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;betterAuth&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;buildAuthOptions&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every library that says "initialize the client once at startup" in its README is quietly assuming a runtime you no longer have. There is no startup.&lt;/p&gt;

&lt;h3&gt;
  
  
  Module scope is per-isolate, so in-memory state is a mirage
&lt;/h3&gt;

&lt;p&gt;Better Auth ships a built-in rate limiter that defaults to in-memory storage. On workerd that memory is per-isolate: your traffic fans out across many short-lived isolates, each with its own empty counter map, which makes an in-memory rate limiter effectively decorative. Not a bundling failure — the code runs — just a silently useless one, which is worse. We force the limiter on and back it with the KV-style state binding via Better Auth's &lt;code&gt;customStorage&lt;/code&gt; hook, so the counters actually survive across isolates.&lt;/p&gt;

&lt;h3&gt;
  
  
  No filesystem, so the widget ships as a string
&lt;/h3&gt;

&lt;p&gt;The worker serves our embeddable widget at &lt;code&gt;/widget.js&lt;/code&gt;. There's no filesystem to serve it from, so a post-build script embeds the compiled bundle into the worker as a generated TypeScript module:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bundle&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;readFile&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;banner&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;// Generated by `pnpm --filter @llmchat/widget build`. Do not edit.&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;// prettier-ignore&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;writeFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;target&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;banner&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;export const widgetBundle: string = &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bundle&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;;\n`&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;Yes: our production widget is a &lt;code&gt;JSON.stringify&lt;/code&gt;'d string constant, served with &lt;code&gt;cache-control: public, max-age=300&lt;/code&gt;. It sounds like a hack until you notice what it buys — the asset is versioned atomically with the API that serves it, and there's no runtime read that can fail.&lt;/p&gt;

&lt;h3&gt;
  
  
  KV isn't atomic, so decide your failure direction on purpose
&lt;/h3&gt;

&lt;p&gt;Our rate limiter is a fixed-window counter doing a read-modify-write on the state binding, and the code says exactly what that means:&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;// Atomicity: this is a read-modify-write, so heavy concurrency on one key can&lt;/span&gt;
&lt;span class="c1"&gt;// overshoot `max` (a lost update undercounts the window).&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We looked at building an atomic increment path and rejected it as fragile. Instead, every limiter-shaped thing in the codebase declares which way it fails when the state store is unavailable, and the answers differ on purpose:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;rateLimit&lt;/code&gt; on public widget endpoints — fails open.&lt;/strong&gt; Defense-in-depth limiting must not take every customer's embed down with the store.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;reserveOnce&lt;/code&gt;, the idempotency reservation — fails closed.&lt;/strong&gt; On a money-touching write path, a possible duplicate gets blocked, not waved through.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;shouldSendHolding&lt;/code&gt;, the escalation-ack throttle — fails open toward sending.&lt;/strong&gt; A duplicate "we got your message" email beats a silent void.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The subscription check gating account deletion — fails closed.&lt;/strong&gt; If we can't verify there's no live subscription, the deletion waits.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this is workerd-specific wisdom, exactly. But losing the comfort of a Node process forced us to write each decision down, and the codebase is better for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What didn't break
&lt;/h2&gt;

&lt;p&gt;Honesty requires the counterweight: most of the stack was fine. Hono runs natively on workerd. Drizzle talks to the D1-compatible SQLite binding without complaint. Zod is pure JS. Better Auth core works. The Vercel AI SDK v6 plus &lt;code&gt;@llmgateway/ai-sdk-provider&lt;/code&gt; — the pipeline behind every AI response — bundles cleanly (we've written about &lt;a href="https://clankersupport.com/blog/why-we-built-on-llm-gateway" rel="noopener noreferrer"&gt;why we built on LLM Gateway&lt;/a&gt;). We keep a contingency note to swap the provider for a direct &lt;code&gt;fetch&lt;/code&gt; if a future version ever pulls Node deps, but it hasn't happened.&lt;/p&gt;

&lt;p&gt;The story is not "nothing works on workerd". It's that the failures concentrate in vendor SDKs with deep dependency trees, and you can't predict them by reading your own import statements.&lt;/p&gt;

&lt;h2&gt;
  
  
  Was it worth it?
&lt;/h2&gt;

&lt;p&gt;What we got:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Deploys that can't half-work.&lt;/strong&gt; If it bundles, it runs. The API is the one app in our monorepo that always deploys cleanly, and that's not luck — the runtime rejects the entire class of "works on my machine, dies on the server" dependency problems at build time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A dramatically smaller dependency surface.&lt;/strong&gt; The Stripe integration went from an SDK and its tree to 289 lines we can read in one sitting. Every HTTP call our billing system makes is visible in one file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Web-standard portability.&lt;/strong&gt; Everything is &lt;code&gt;fetch&lt;/code&gt; and &lt;code&gt;crypto.subtle&lt;/code&gt;. The same code would run on Cloudflare Workers, Deno, or anything else that speaks the Web Platform — which matters for a product whose &lt;a href="https://clankersupport.com/blog/the-case-for-self-hostable-ai-support" rel="noopener noreferrer"&gt;pitch includes self-hosting&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What we paid:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;We are now the maintainers of clients that vendors used to maintain.&lt;/strong&gt; When Stripe ships a new API version or deprecates an endpoint, nobody bumps a package for us — we read the changelog and edit &lt;code&gt;stripe.ts&lt;/code&gt; ourselves.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;We own security-sensitive code most teams never touch.&lt;/strong&gt; Two webhook verifiers with constant-time comparison and replay windows are now &lt;em&gt;our&lt;/em&gt; code to get right, test, and keep right.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The vendor's docs are the spec.&lt;/strong&gt; Undocumented SDK behavior — encoding quirks, retry defaults, the metered-price-quantity rule — has to be rediscovered by us, sometimes the hard way.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On balance: yes, for us, clearly — but the honest framing is that we traded operational risk for maintenance responsibility. That trade favors a small team with a simple API surface and strong tests. If your backend touches twenty vendor APIs with fast-moving surfaces, hand-rolling twenty clients is a much worse deal, and a Node runtime that just runs the official SDKs is a defensible choice.&lt;/p&gt;

&lt;p&gt;If you do make the jump, start where we should have: not with your &lt;code&gt;package.json&lt;/code&gt;, but with &lt;code&gt;npm ls --all&lt;/code&gt; and a hard look at what your dependencies' dependencies drag in. The SDK that breaks your deploy is never the one you imported.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Does Hono work on workerd?
&lt;/h3&gt;

&lt;p&gt;Yes, natively — Hono was designed for Web-standard runtimes and is arguably at its best there. Drizzle (against a D1-compatible SQLite binding), Zod, Better Auth core, and the Vercel AI SDK v6 all bundle and run fine for us too.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do you verify Stripe webhooks without the Stripe SDK?
&lt;/h3&gt;

&lt;p&gt;Recompute the HMAC-SHA256 of &lt;code&gt;${timestamp}.${rawBody}&lt;/code&gt; with &lt;code&gt;crypto.subtle&lt;/code&gt; using your webhook secret, compare it to the &lt;code&gt;v1&lt;/code&gt; value from the &lt;code&gt;stripe-signature&lt;/code&gt; header with a constant-time comparison, and reject signatures older than a tolerance window (we use 300 seconds). It's about 50 lines; the scheme is fully documented by Stripe.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do you serve static assets from a worker with no filesystem?
&lt;/h3&gt;

&lt;p&gt;Embed them at build time. Our post-build script writes the compiled widget bundle into a generated TypeScript module as a string constant, and the worker serves it from memory with a five-minute cache header. Atomic versioning with the API comes free.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is workerd the same as Cloudflare Workers?
&lt;/h3&gt;

&lt;p&gt;workerd is the open-source runtime that powers Cloudflare Workers. We deploy to it via the Ploy platform rather than Cloudflare directly, but the compatibility constraints in this post — no Node built-ins, no filesystem, per-isolate memory, env as a binding — apply the same way on either.&lt;/p&gt;

</description>
      <category>cloudflare</category>
      <category>node</category>
      <category>webdev</category>
      <category>serverless</category>
    </item>
    <item>
      <title>Salesforce buying Fin (Intercom): what it means for your support bill</title>
      <dc:creator>Omar Bni</dc:creator>
      <pubDate>Sat, 11 Jul 2026 17:44:43 +0000</pubDate>
      <link>https://dev.to/omar_bni_f6856a8bb0e021e9/salesforce-buying-fin-intercom-what-it-means-for-your-support-bill-3l4e</link>
      <guid>https://dev.to/omar_bni_f6856a8bb0e021e9/salesforce-buying-fin-intercom-what-it-means-for-your-support-bill-3l4e</guid>
      <description>&lt;p&gt;Salesforce signed a definitive agreement on June 15, 2026 to acquire Fin — the company formerly named Intercom — for approximately $3.6 billion. The deal has not closed, and Fin's published pricing is unchanged as of July 2026. Nothing changes on your bill today. But Salesforce's post-acquisition track record is public and dated, and it says your renewal terms deserve attention now, not after close.&lt;/p&gt;

&lt;p&gt;We run &lt;a href="https://github.com/theopenco/llmchat" rel="noopener noreferrer"&gt;Clanker Support&lt;/a&gt;, an open-source support agent, so we watch this market closely — and yes, we compete with Fin at the low end. Discount accordingly. Everything below is sourced and dated, and most of it is the kind of contract homework we'd recommend whether or not you ever look at an alternative.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually happened
&lt;/h2&gt;

&lt;p&gt;The facts, as of July 11, 2026:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;May 12, 2026&lt;/strong&gt; — Intercom renamed the company &lt;strong&gt;Fin&lt;/strong&gt;, after its AI agent. "Intercom" lives on as the platform name.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;June 15, 2026&lt;/strong&gt; — Salesforce announced a &lt;a href="https://www.salesforce.com/news/press-releases/2026/06/15/salesforce-signs-definitive-agreement-to-acquire-fin/" rel="noopener noreferrer"&gt;definitive agreement to acquire Fin&lt;/a&gt; for approximately $3.6 billion. Per &lt;a href="https://www.cnbc.com/2026/06/15/salesforce-ai-customer-service-fin-acquistion.html" rel="noopener noreferrer"&gt;CNBC's coverage&lt;/a&gt; and Salesforce's own materials, Fin brings 30,000+ customers, including names like Asana, Shutterstock, and Riot Games.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Expected close&lt;/strong&gt; — &lt;a href="https://www.intercom.com/blog/salesforce-signs-definitive-agreement-to-acquire-fin/" rel="noopener noreferrer"&gt;Intercom's announcement&lt;/a&gt; says "the fourth quarter of Salesforce's fiscal year 2027." That's roughly November 2026 through January 2027 — around the turn of the calendar year. The transaction is subject to regulatory clearances, which is standard deal boilerplate; no regulator has publicly announced a review as of this writing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The plan&lt;/strong&gt; — Salesforce says Fin's team and technology will fold into &lt;strong&gt;Agentforce&lt;/strong&gt;, its AI-agent platform.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Fin CEO Eoghan McCabe's framing, from the same announcement: "With the resources of Salesforce this will only accelerate. And yet little will practically change." He also said he'll remain CEO and co-founder Des Traynor will keep running R&amp;amp;D.&lt;/p&gt;

&lt;p&gt;That quote is doing a lot of work, so it's worth saying what's verifiably true alongside it: as of mid-July 2026 the deal is signed but not closed, Fin's published pricing is unchanged, and there have been no announced packaging changes. If you want the full teardown of how Fin's $0.99-per-outcome model actually bills — assumed resolutions, the 50-outcome minimum, the add-on stack — we wrote that up separately in &lt;a href="https://clankersupport.com/blog/intercom-fin-pricing" rel="noopener noreferrer"&gt;our Fin pricing teardown&lt;/a&gt; and won't repeat it here.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happens between signing and close
&lt;/h2&gt;

&lt;p&gt;Between a definitive agreement and closing, the acquired company legally operates as an independent business. In practice, for a SaaS vendor in this window, a few patterns are typical — none of which we can promise apply here, but all of which are worth knowing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pricing usually holds still.&lt;/strong&gt; Repricing mid-deal creates noise nobody wants. Fin's pricing has not moved since the announcement, and third-party pricing trackers (&lt;a href="https://getvoip.com/blog/fin-pricing/" rel="noopener noreferrer"&gt;GetVoIP&lt;/a&gt;, for one) confirm the same as of July 2026.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Contracts remain contracts.&lt;/strong&gt; Your existing agreement binds the surviving entity after close. The terms you sign &lt;em&gt;between now and close&lt;/em&gt; are the terms you'll live under during the integration — which is exactly why this window matters.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sales teams keep selling, and renewals keep renewing.&lt;/strong&gt; If your renewal lands before the expected close (before roughly the turn of the year), you're negotiating with a company that has strong incentive to show clean retention numbers to its acquirer. That's leverage — the noun — for you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Roadmaps get cautious.&lt;/strong&gt; Companies in this window tend to avoid big public commitments that could complicate integration planning. Again: pattern, not prophecy.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The one thing you should &lt;em&gt;not&lt;/em&gt; do is assume the announcement itself changes anything. It doesn't. What changes things is the close, and everything Salesforce decides after it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Salesforce's pricing track record, with dates
&lt;/h2&gt;

&lt;p&gt;This is the part our earlier posts didn't cover, and it's the evidence that should anchor your planning. None of it proves anything about Fin specifically — these were separate decisions Salesforce attributed to product investment, not to acquisitions. But if you're trying to guess the pricing culture Fin is joining, this is the public record:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;December 2020 → July 21, 2021&lt;/strong&gt; — Salesforce announces, then closes, the Slack acquisition, widely reported at roughly $27.7 billion.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;July 18, 2022&lt;/strong&gt; — about a year after close, &lt;a href="https://techcrunch.com/2022/07/18/slack-is-increasing-prices-and-changing-the-way-its-free-plan-works/" rel="noopener noreferrer"&gt;Slack announces its first price increase since launching in 2014&lt;/a&gt;: Pro goes from $8 to $8.75 per user/month billed monthly ($6.67 to $7.25 annual), effective September 1, 2022. The same change cut the free plan to 90 days of message history. Annual subscribers could lock the old price by renewing before the deadline — remember that detail.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;July 11, 2023&lt;/strong&gt; — Salesforce announces its first list-price increase in roughly seven years: &lt;a href="https://www.salesforceben.com/salesforce-announces-9-price-increase-effective-august-2023/" rel="noopener noreferrer"&gt;+9% on average&lt;/a&gt; across Sales Cloud, Service Cloud, Marketing Cloud, Industries, and Tableau, effective August 2023. Enterprise went $150→$165 per user/month; Unlimited $300→$330. Locked multi-year contracts were unaffected &lt;em&gt;until renewal&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;June 2025&lt;/strong&gt; — &lt;a href="https://slack.com/blog/news/june-2025-pricing-and-packaging-announcement" rel="noopener noreferrer"&gt;Slack repricing round two&lt;/a&gt;: Business+ rises from $12.50 to $15 per user/month, a new Enterprise+ tier appears, and AI features spread across paid plans.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;August 1, 2025&lt;/strong&gt; — another Salesforce list-price increase, &lt;a href="https://www.salesforce.com/news/stories/pricing-update-2025/" rel="noopener noreferrer"&gt;about +6% on average&lt;/a&gt; on Enterprise and Unlimited editions, announced alongside Agentforce packaging.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two honest readings of this record. The skeptical one: Salesforce raises list prices regularly, and acquired products participate — Slack's first-ever increase landed about a year after the deal closed, with a second round in 2025. The charitable one: these were measured increases on products that shipped real features, existing contracts were honored until renewal, and Slack customers were given a price-lock window before the 2022 change took effect. Both readings agree on the practical conclusion: &lt;strong&gt;the protection you have is the contract you're holding when the change is announced.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;One more piece of context, because Fin is headed into Agentforce: Salesforce already sells usage-metered AI. Agentforce add-ons start at $125/user/month on Enterprise and Unlimited, and Agentforce 1 Editions run $550/user/month with a million Flex Credits per year — credits that meter agent "actions" (per Salesforce's Agentforce pricing page, as of 2025–2026). Our read — and this is analysis, not announcement — is that Fin's per-outcome pricing is philosophically at home in that catalog. A consumption-priced support agent slots neatly next to a consumption-priced agent platform. That cuts both ways: it's an argument the model survives, and an argument it eventually gets re-packaged in Salesforce's terms.&lt;/p&gt;

&lt;h2&gt;
  
  
  What could happen to Fin after the acquisition
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Fin becomes Agentforce's support agent
&lt;/h3&gt;

&lt;p&gt;This is the stated plan and the most likely path. Fin's Apex model (which Fin says resolves ~76% of its volume — their figure) and its Operator agent become part of the Agentforce family. For enterprise Salesforce shops, this is genuinely good: procurement through an existing vendor, Service Cloud adjacency, real resources behind the product. If you're a Salesforce-first organization, the acquisition is arguably a reason to &lt;em&gt;stay&lt;/em&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pricing moves after close
&lt;/h3&gt;

&lt;p&gt;Nothing published promises today's $0.99-per-outcome survives the integration; nothing says it won't. The Slack precedent suggests any move comes on the scale of a year after close, not day one — which, given an expected close around the turn of 2026/27, points at &lt;em&gt;your 2027–2028 renewals&lt;/em&gt; as the window where changes would land. Speculating on direction is pointless; securing the price you have is not.&lt;/p&gt;

&lt;h3&gt;
  
  
  Roadmap gravity pulls toward Salesforce
&lt;/h3&gt;

&lt;p&gt;Fin currently runs standalone on Zendesk, Freshdesk, HubSpot Service Hub, and Salesforce Service Cloud. Third-party analysts (Help Desk Migration's 2026 write-up, for one) argue the realistic risk isn't a switch-off at close — it's slower investment in the non-Salesforce integrations over time as the center of gravity shifts. If you run Fin on Zendesk today, the product working &lt;em&gt;today&lt;/em&gt; tells you little about how much attention that integration gets in 2028. This is the quietest scenario and, for standalone-helpdesk customers, probably the most important one.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to check in your contract this week
&lt;/h2&gt;

&lt;p&gt;The actionable core. Pull up your Fin/Intercom agreement and check five things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Renewal date vs. expected close.&lt;/strong&gt; If you renew before roughly January 2027, you're negotiating pre-close, with a vendor motivated to show clean retention. If you renew after, you're negotiating with Salesforce's playbook.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Term length.&lt;/strong&gt; A 12-month renewal signed in late 2026 carries you through most of the likely integration turbulence. Salesforce's own 2023 increase honored locked multi-year contracts until renewal — term length &lt;em&gt;was&lt;/em&gt; the protection.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Price-protection language.&lt;/strong&gt; Look for a renewal uplift cap ("renewal pricing shall not increase by more than X%"). If it's not there, ask for it now. Pre-close is the best moment you will ever have to get this clause cheaply.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unit-price lock on the metered part.&lt;/strong&gt; Seat caps don't help if the per-outcome rate is what moves. Ask for the $0.99 outcome rate (and the qualification rate) to be fixed for the term, in writing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Data export and integration commitments.&lt;/strong&gt; Confirm what you can export (conversations, KB content, reporting) and in what format. If you run Fin standalone on another helpdesk, ask your rep — in email — about the support commitment for that integration through 2027.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this is adversarial. It's the same hygiene you'd apply to any vendor whose ownership is about to change.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stay, hedge, or leave
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Stay&lt;/strong&gt; if you're getting real value at today's rates and either (a) you're a Salesforce shop, in which case the acquisition likely improves your position, or (b) your contract now includes an uplift cap and a locked outcome rate through 2027. Fin is a genuinely capable product with real resources behind it — our &lt;a href="https://clankersupport.com/blog/intercom-alternatives" rel="noopener noreferrer"&gt;alternatives roundup&lt;/a&gt; says exactly that in its "when to stay" section. Ripping out a working support stack because of a headline is how teams burn a quarter for nothing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hedge&lt;/strong&gt; if you're renewing into the post-close window, or you run Fin standalone on a non-Salesforce helpdesk. Hedging is cheap: sign the shorter term with price protection, export your knowledge-base content so it isn't stranded, and run a two-week trial of one alternative so a migration is a known quantity instead of a panic project. You're not leaving — you're pricing your exit option while it's inexpensive.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Leave&lt;/strong&gt; only if you were already unhappy — with per-outcome billing unpredictability, with cost at your volume, with the fit — and the acquisition merely resolves your indecision. An acquisition is a &lt;em&gt;fine&lt;/em&gt; forcing function for a decision you'd half-made; it's a bad sole reason for one.&lt;/p&gt;

&lt;p&gt;Disclosure, since we're in the alternatives paragraph: Clanker Support is our product. It's an open-source, self-hostable AI support agent — self-hosting is free with your own model keys, and hosted plans are priced per workspace ($19/month for 2,000 AI responses on Starter, as of July 2026), with no per-seat or per-resolution fees. It's a widget-plus-inbox, not an Intercom-scale suite, so it's a fit for teams who want predictable pricing more than platform breadth. We keep an honest field guide of ten options — including Zendesk, Chatwoot, and Help Scout — in the &lt;a href="https://clankersupport.com/blog/intercom-alternatives" rel="noopener noreferrer"&gt;alternatives roundup&lt;/a&gt;, and there's a &lt;a href="https://clankersupport.com/docs/migrate/fin" rel="noopener noreferrer"&gt;migration guide for Fin&lt;/a&gt; if you want to see what moving actually involves.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bottom line
&lt;/h2&gt;

&lt;p&gt;Signed, not closed. Pricing unchanged as of July 2026. A stated plan to fold Fin into Agentforce around the turn of the year. And a public, dated record showing that Salesforce honors existing contracts, raises list prices on a regular cadence, and repriced its last big acquisition about a year after close — with a lock-in window for customers who were paying attention. Be one of the customers who was paying attention: check your renewal date, get the uplift cap, lock the unit rate, export your KB. That work is worth doing in every scenario, including the one where nothing changes at all.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Did Salesforce buy Intercom?
&lt;/h3&gt;

&lt;p&gt;Salesforce signed a definitive agreement on June 15, 2026 to acquire Fin — the company formerly named Intercom, renamed on May 12, 2026 — for approximately $3.6 billion. The deal has not closed; it's expected to close in Salesforce's fiscal Q4 2027, roughly November 2026 through January 2027, subject to regulatory clearances.&lt;/p&gt;

&lt;h3&gt;
  
  
  Will Fin's pricing change after the Salesforce acquisition?
&lt;/h3&gt;

&lt;p&gt;Nobody outside the deal knows. Fin's published pricing — $0.99 per outcome, $9.99 per qualification, 50-outcome monthly minimum — is unchanged as of July 2026, and nothing announced promises it survives the integration or says it won't. The relevant precedent: Slack's first price increase came about a year after its acquisition closed, not immediately.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is Fin shutting down for Zendesk and other non-Salesforce helpdesks?
&lt;/h3&gt;

&lt;p&gt;There's no announcement of that, and Fin sells standalone on Zendesk, Freshdesk, HubSpot Service Hub, and Salesforce Service Cloud today. The risk analysts flag is slower long-term investment in non-Salesforce integrations, not a switch-off at close. If you run Fin standalone, get your integration's support commitment in writing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should I switch away from Fin before the acquisition closes?
&lt;/h3&gt;

&lt;p&gt;Not because of the acquisition alone. If Fin works for you, the cheaper move is to hedge: renew before close with a price-protection clause and a locked outcome rate, export your knowledge-base content, and trial one alternative so you know your migration cost. Leave only if you were already unhappy with the pricing model or the fit.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is Salesforce's track record with acquired products' pricing?
&lt;/h3&gt;

&lt;p&gt;Contracts get honored until renewal, and list prices move on a cadence: Slack's first-ever increase came about a year post-close (July 2022, Pro $8→$8.75), with a second repricing in June 2025 (Business+ $12.50→$15); Salesforce raised list prices ~9% in 2023 and ~6% in 2025 across core editions. Those platform-wide increases were attributed to product investment, not acquisitions — but they describe the pricing culture Fin is joining.&lt;/p&gt;

</description>
      <category>saas</category>
      <category>ai</category>
      <category>startup</category>
      <category>customersupport</category>
    </item>
    <item>
      <title>6 open-source Intercom alternatives you can self-host in 2026</title>
      <dc:creator>Omar Bni</dc:creator>
      <pubDate>Wed, 08 Jul 2026 04:30:39 +0000</pubDate>
      <link>https://dev.to/omar_bni_f6856a8bb0e021e9/6-open-source-intercom-alternatives-you-can-self-host-in-2026-5ele</link>
      <guid>https://dev.to/omar_bni_f6856a8bb0e021e9/6-open-source-intercom-alternatives-you-can-self-host-in-2026-5ele</guid>
      <description>&lt;p&gt;The strongest open-source Intercom alternatives in 2026 are Chatwoot (the most complete omnichannel helpdesk), Zammad (process-heavy ticketing), FreeScout (lightweight shared inbox), Tiledesk (LLM agent flows), Papercups (maintenance mode), and Clanker Support (our AI-first agent: one script tag, any LLM, serverless self-hosting). The right pick depends on whether you need a helpdesk or an AI agent that answers and escalates.&lt;/p&gt;

&lt;p&gt;That's the short version. The longer one matters, because these six tools share little beyond a public repo: some are full Rails platforms, some are single-purpose widgets, some haven't shipped in years — and only a couple can answer the question people actually ask in 2026: can it do what Fin does, without Fin's bill?&lt;/p&gt;

&lt;h2&gt;
  
  
  Why developers are leaving Intercom
&lt;/h2&gt;

&lt;p&gt;As of July 2026, Intercom starts at $29 per seat per month on annual billing ($39 if you pay monthly), and Fin — its AI agent — bills $0.99 per resolution on top. The definition of "resolution" is broad: it includes "assumed resolutions," where the customer simply leaves without replying. There's also a 50-outcome monthly minimum — a floor of roughly $50 a month (50 × $0.99 = $49.50, illustrative) before Fin has demonstrably resolved anything. The full math is in &lt;a href="https://clankersupport.com/blog/intercom-fin-pricing" rel="noopener noreferrer"&gt;our Fin pricing teardown&lt;/a&gt;; check Intercom's pricing page for current numbers.&lt;/p&gt;

&lt;p&gt;Cost isn't the only pressure. Intercom renamed itself Fin in May 2026, and on June 15, 2026 &lt;a href="https://www.salesforce.com/news/press-releases/2026/06/15/salesforce-signs-definitive-agreement-to-acquire-fin/" rel="noopener noreferrer"&gt;Salesforce signed a definitive agreement to acquire Fin&lt;/a&gt; for roughly $3.6 billion (the deal hasn't closed). If part of your support stack's roadmap just became a Salesforce integration question, wanting an exit you control is rational.&lt;/p&gt;

&lt;p&gt;And the structural issue: per-resolution pricing means your vendor profits from counting generously, and a closed product means you never choose which model answers your customers.&lt;/p&gt;

&lt;h2&gt;
  
  
  What open source actually buys you
&lt;/h2&gt;

&lt;p&gt;Three things, concretely:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A license nobody can re-price.&lt;/strong&gt; MIT or AGPL code can't be acquired out from under you, moved to per-resolution billing, or sunset by a new owner. Worst case, you pin a version or fork. Not a hypothetical benefit in the year your incumbent got acquired.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your data, in your database.&lt;/strong&gt; Conversations, customer emails, and knowledge content sit in Postgres, MySQL, or SQLite that you control. Migrations become a schema problem, not an export-negotiation problem.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your choice of LLM.&lt;/strong&gt; The axis almost nobody comparing these tools writes about. Closed AI support products bundle a proprietary model into an opaque per-resolution price. Open-source AI-era tools let you bring your own key — pick the model, swap when a better one ships, pay your provider per token, at cost.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What it does not buy you: someone else's pager. More on that below.&lt;/p&gt;

&lt;h2&gt;
  
  
  The alternatives, compared
&lt;/h2&gt;

&lt;p&gt;Method note: this comparison is built from each project's public repo, license file, docs, and pricing pages, checked in July 2026 — no synthetic benchmarks. Where a vendor's numbers appear, verify them on their pricing page before you budget.&lt;/p&gt;

&lt;h3&gt;
  
  
  Chatwoot — the most complete open-source helpdesk
&lt;/h3&gt;

&lt;p&gt;If you want the closest thing to a full Intercom replacement, it's Chatwoot — no contest. Live chat, shared email inbox, WhatsApp, Instagram, Telegram — a genuine omnichannel desk with 34,000+ GitHub stars and steady releases. Its AI agent, Captain, handles FAQ-style answers and agent assists — but as of July 2026 Captain is a credit-metered paid feature, not part of the free self-hosted community edition. If your reason for self-hosting is "free AI support," that combination doesn't exist here.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;License:&lt;/strong&gt; MIT, with the &lt;code&gt;enterprise/&lt;/code&gt; directory under a separate commercial license&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stack &amp;amp; self-host weight:&lt;/strong&gt; Ruby on Rails + Vue + PostgreSQL + Redis — a real platform to operate, not a widget&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Built-in AI:&lt;/strong&gt; Captain (answers, summaries, agent copilot) — paid, credit-metered, not in the free community edition&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Model choice:&lt;/strong&gt; Captain is Chatwoot's managed AI feature; you're not picking the model per project&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hosted option:&lt;/strong&gt; yes, cloud from $19 per agent/month as of July 2026&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Best for:&lt;/strong&gt; teams that want a full omnichannel helpdesk and have the ops capacity to run a Rails stack&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We compare directly in &lt;a href="https://clankersupport.com/vs/chatwoot" rel="noopener noreferrer"&gt;Clanker Support vs Chatwoot&lt;/a&gt; — and in plenty of scenarios, Chatwoot is the right call.&lt;/p&gt;

&lt;h3&gt;
  
  
  Zammad — process-heavy ticketing done properly
&lt;/h3&gt;

&lt;p&gt;Zammad is a mature helpdesk/ticketing system (5,700+ stars) with strong workflow, SLA, and audit features — the kind of tool an IT department or regulated support org loves. It is emphatically not an AI-first product.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;License:&lt;/strong&gt; AGPL-3.0&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stack &amp;amp; self-host weight:&lt;/strong&gt; heavy — Rails plus PostgreSQL 13+, Redis 6+, a reverse proxy, and Elasticsearch (optional per the docs, but performance degrades significantly without it)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Built-in AI:&lt;/strong&gt; not the pitch — this is tickets, queues, and process&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Model choice:&lt;/strong&gt; n/a&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hosted option:&lt;/strong&gt; yes, Zammad sells hosted plans; pricing on their site&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Best for:&lt;/strong&gt; structured ticketing with SLAs, roles, and reporting — internal IT, agencies, regulated teams&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  FreeScout — the lightweight shared inbox
&lt;/h3&gt;

&lt;p&gt;FreeScout is the anti-platform: a free, self-hosted help desk and shared mailbox (closer to a Help Scout alternative than an Intercom one), 4,400+ stars, demonstrably alive — its latest release shipped in July 2026. The economics are honest: a free AGPL core plus optional paid modules (WhatsApp, Telegram, Slack, dozens more) sold as one-time lifetime licenses.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;License:&lt;/strong&gt; AGPL-3.0&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stack &amp;amp; self-host weight:&lt;/strong&gt; light — a PHP (Laravel) app; by far the easiest classic helpdesk on this list to run&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Built-in AI:&lt;/strong&gt; none in the core; AI isn't the project's focus&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Model choice:&lt;/strong&gt; n/a&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hosted option:&lt;/strong&gt; no first-party cloud — self-hosting is the product&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Best for:&lt;/strong&gt; small teams that want email-first support on minimal infrastructure for near-zero recurring cost&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Tiledesk — open-source LLM agent flows
&lt;/h3&gt;

&lt;p&gt;Tiledesk started as an open-source live chat and has pivoted hard toward AI: the project now describes itself as an open-source alternative to Voiceflow for building LLM-powered agents with human-in-the-loop handoff. If you want to visually design conversation flows — automated answers here, human handoff there — this is the tool aimed at exactly that.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;License:&lt;/strong&gt; MIT&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stack &amp;amp; self-host weight:&lt;/strong&gt; moderate-to-heavy — a Node.js microservices architecture deployed via Docker Compose or Kubernetes/Helm; more moving parts than a single app&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Built-in AI:&lt;/strong&gt; yes — LLM-powered agent building is now the core product&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Model choice:&lt;/strong&gt; built around LLM integrations; check their docs for the currently supported provider list&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hosted option:&lt;/strong&gt; yes, a managed cloud exists; pricing on their site&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Best for:&lt;/strong&gt; teams that want to design multi-step agent workflows rather than install a ready-made support agent&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Papercups — check the pulse before you commit
&lt;/h3&gt;

&lt;p&gt;Papercups earns its place here mostly as a caution. It's a pleasant, minimal open-source live chat (MIT, Elixir/Phoenix, 6,000+ stars) — but the repo states plainly that it's in maintenance mode: no major new features planned, pull requests and bug fixes still accepted. Chaskiq, another Intercom-style project common in these roundups, warrants similar care: its license is AGPL-3.0 with a Commons Clause attached (source-available, not OSI-approved open source) and development has slowed markedly — the most recent tagged release dates to late 2023.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;License:&lt;/strong&gt; MIT (Papercups); AGPL-3.0 + Commons Clause (Chaskiq — not OSI open source)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stack &amp;amp; self-host weight:&lt;/strong&gt; Elixir/Phoenix (Papercups); Rails + React + PostgreSQL + Redis (Chaskiq)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Built-in AI:&lt;/strong&gt; none — both predate the AI-agent era of support tooling&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Model choice:&lt;/strong&gt; n/a&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hosted option:&lt;/strong&gt; don't count on one for either project&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Best for:&lt;/strong&gt; teams with Elixir chops who want a small codebase to own and extend — eyes open&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Clanker Support — AI-first, one script tag, any model
&lt;/h3&gt;

&lt;p&gt;Disclosure first: Clanker Support is our product — read this entry knowing who wrote it. It's placed last on purpose.&lt;/p&gt;

&lt;p&gt;Clanker Support is not a helpdesk platform. It's an AI support agent you add with one &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; tag (shadow DOM, no style bleed) — plus a React Server Components SDK, a WordPress plugin, and a Shopify theme embed. It answers only from the knowledge you give it — page URLs, text snippets, Q&amp;amp;A pairs — and cites its sources. When it can't help, it says so visibly and escalates: email (and optionally Slack) to your team, and the conversation lands in a team inbox with AI-written summaries, tags, and search. Email replies thread back into the conversation in both directions, and a visitor who asks for a human always gets one — that request overrides the escalation threshold.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;License:&lt;/strong&gt; MIT (&lt;a href="https://github.com/theopenco/llmchat" rel="noopener noreferrer"&gt;github.com/theopenco/llmchat&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stack &amp;amp; self-host weight:&lt;/strong&gt; serverless — runs on Cloudflare-compatible infrastructure (workerd, D1, KV). No Rails, no Postgres, no Elasticsearch to babysit&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Built-in AI:&lt;/strong&gt; the entire product — grounded answers with citations, honest escalation, per-message thumbs, and 1–5 CSAT&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Model choice:&lt;/strong&gt; any model via LLM Gateway — OpenAI, Anthropic, Google, and others — set per project, swappable with a config change&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hosted option:&lt;/strong&gt; flat plans from $19/month (annual = two months free), no per-seat or per-resolution fees; each tier includes a monthly AI-response quota — details on &lt;a href="https://clankersupport.com/pricing" rel="noopener noreferrer"&gt;pricing&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Best for:&lt;/strong&gt; developer-led teams that want an AI agent answering from their docs today, without adopting a platform&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What we don't do, stated plainly: web widget and email only — no WhatsApp, Messenger, Instagram, or voice. No CRM, no product tours, no outbound campaigns. It's a newer product with a smaller ecosystem than Chatwoot's. And the hosted version has no free tier — self-hosting is the free path. If you need omnichannel, pick Chatwoot — we mean that. Poke the real widget on our &lt;a href="https://showcase.clankersupport.com" rel="noopener noreferrer"&gt;live demo&lt;/a&gt; before forming an opinion.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest cost of self-hosting
&lt;/h2&gt;

&lt;p&gt;Every "open source is free" pitch skips this part.&lt;/p&gt;

&lt;p&gt;Self-hosting Chatwoot or Zammad means operating a Rails application: a VPS, PostgreSQL, Redis, possibly Elasticsearch, plus backups, upgrades, security patches, and email deliverability (SPF, DKIM, bounces — the part everyone underestimates). None of it is hard; all of it is recurring. A few engineer-hours a month babysitting the stack usually costs more than a flat hosted plan — the line item just moves from "software" to "engineering time," where it's harder to see.&lt;/p&gt;

&lt;p&gt;FreeScout sits at the cheap end of the trade: one PHP app. The serverless route — how Clanker Support is built — removes most of it: no server to patch, no database process to back up. You bring an LLM Gateway key, deploy, and your marginal cost is model tokens.&lt;/p&gt;

&lt;p&gt;The honest framing: free license plus your ops time, or flat hosted fee plus someone else's. Pick deliberately.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is there an open-source alternative to Intercom Fin specifically?
&lt;/h2&gt;

&lt;p&gt;Not a clone — and that's arguably the point. Fin bundles a proprietary model, a resolution-counting system, and a $0.99-per-outcome price into one product. As of July 2026 it charges for "assumed resolutions" (the customer left without replying) and carries the ~$50 monthly minimum — though it doesn't charge when the customer asks for a human.&lt;/p&gt;

&lt;p&gt;The open-source answer decomposes the bundle instead of cloning it. On the AI axis there are three real options: Chatwoot's Captain (capable, but credit-metered and paid), Tiledesk's agent builder (if you want to design flows yourself), and Clanker Support (answers from your knowledge base with citations, escalates honestly, and lets you choose the model — paying your provider per token instead of per "resolution"). The economics differ in kind: token costs fall as models get cheaper; per-resolution fees scale with however your vendor defines success. Head-to-head details are in &lt;a href="https://clankersupport.com/vs/fin" rel="noopener noreferrer"&gt;Clanker Support vs Fin&lt;/a&gt; and &lt;a href="https://clankersupport.com/blog/best-ai-support-agents" rel="noopener noreferrer"&gt;our guide to AI support agents&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a minimal self-hosted AI live chat setup looks like
&lt;/h2&gt;

&lt;p&gt;Strip the category to parts and you need five things: a widget on your site, a place to put knowledge (docs URLs, snippets, Q&amp;amp;A), an inference path to some LLM, an escalation hatch to a human channel, and an inbox where a human triages what the AI couldn't handle.&lt;/p&gt;

&lt;p&gt;The classic path: provision a VPS, run Docker Compose for a Rails or Node platform, configure Postgres and Redis, wire up SMTP, then bolt AI on top — often as a paid feature with its own configuration. Doable; plan a weekend plus ongoing care.&lt;/p&gt;

&lt;p&gt;The serverless path, ours as the example: deploy the open-source repo to Cloudflare-compatible infrastructure, add your LLM Gateway key, paste one script tag before &lt;code&gt;&amp;lt;/body&amp;gt;&lt;/code&gt;, and point the knowledge base at your docs; escalation lands in email and Slack out of the box — the &lt;a href="https://docs.clankersupport.com" rel="noopener noreferrer"&gt;self-hosting docs&lt;/a&gt; walk through it. Either way the parts list is the same; the difference is how much of it you maintain.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You want the full Intercom experience, open source:&lt;/strong&gt; Chatwoot. Most complete, most alive, biggest community. Budget for Rails ops, plus Captain credits if you want the AI.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You want structured ticketing with process and audit trails:&lt;/strong&gt; Zammad.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You want the cheapest sustainable email-first setup:&lt;/strong&gt; FreeScout.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You want to build custom LLM agent flows:&lt;/strong&gt; Tiledesk.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You want a minimal codebase to own and extend:&lt;/strong&gt; Papercups — clear-eyed about maintenance mode.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You want an AI agent grounded in your docs, on your choice of model:&lt;/strong&gt; Clanker Support — self-host free, or flat from $19/month hosted.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The case for staying put: if you depend on Intercom's omnichannel breadth, its outbound and product-tour tooling, or you're a Salesforce shop that stands to gain from the acquisition, Fin remains polished and migration has real switching costs. Leave because the pricing or lock-in bothers you — not because a blog post said so. The wider field, open source and not, is in our &lt;a href="https://clankersupport.com/blog/intercom-alternatives" rel="noopener noreferrer"&gt;Intercom alternatives roundup&lt;/a&gt;.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Is there a completely free open-source alternative to Intercom?
&lt;/h3&gt;

&lt;p&gt;Yes, with a caveat. Chatwoot's community edition, FreeScout, and self-hosted Clanker Support all cost nothing in license fees. But "free" excludes your server and the AI: model usage needs an API key everywhere, and Chatwoot's Captain AI is a paid feature even self-hosted. Budget hosting plus per-token model costs — still typically far below per-seat-plus-per-resolution pricing.&lt;/p&gt;

&lt;h3&gt;
  
  
  How hard is it to migrate off Intercom?
&lt;/h3&gt;

&lt;p&gt;Easier than it looks. Export your conversation history from Intercom, rebuild your help content as knowledge sources in the new tool, swap the embed script, and run both widgets in parallel for a week while you compare answers. The knowledge rebuild is the real work; the widget swap is minutes. Our &lt;a href="https://clankersupport.com/docs/migrate/intercom" rel="noopener noreferrer"&gt;Intercom migration guide&lt;/a&gt; covers the steps.&lt;/p&gt;

&lt;h3&gt;
  
  
  What does self-hosting a support tool actually cost?
&lt;/h3&gt;

&lt;p&gt;The license is free; the total cost isn't. Count a server (or serverless infrastructure), an LLM API key if you want AI answers, and — the big one — engineer time for upgrades, backups, and email deliverability. A Rails platform needs far more care than a single PHP app or serverless worker. If upkeep eats hours monthly, flat hosted plans are often cheaper.&lt;/p&gt;

&lt;h3&gt;
  
  
  Are open-source support tools sustainable long-term?
&lt;/h3&gt;

&lt;p&gt;Judge each project, not the category. Look at release dates, commit activity, and how the maintainers earn money — Chatwoot, Zammad, and FreeScout all ship regularly and fund themselves through hosting, support, or paid modules. Papercups is in maintenance mode and Chaskiq has slowed. The floor is real, though: permissively licensed code can be forked and pinned, if you have the engineers.&lt;/p&gt;

&lt;h3&gt;
  
  
  What's the closest open-source equivalent to Intercom Fin?
&lt;/h3&gt;

&lt;p&gt;Nothing replicates Fin's bundled model and per-resolution billing — by design. Chatwoot's Captain is the closest inside a full helpdesk (paid, credit-based). Tiledesk is closest for custom agent building. Clanker Support is closest as a drop-in AI agent: grounded answers with citations, honest human escalation, your choice of LLM, and per-token economics instead of $0.99 per counted resolution.&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>selfhosted</category>
      <category>ai</category>
      <category>aie</category>
    </item>
  </channel>
</rss>
