# Clanker Support — full blog content > An AI-powered support agent for your site — install it with one script tag or one React Server Component (@clankersupport/widget-rsc on npm). It answers from your docs and sources, then escalates to your team. Open source (MIT) and self-hostable (bring your own keys); the hosted version has flat monthly plans from $19/mo (50% off right now) with no per-seat fees. This file concatenates the full text of every clankersupport.com blog post below, newest first. For a short link map of the whole site, see https://clankersupport.com/llms.txt. For the full product documentation in the same format, see https://docs.clankersupport.com/llms-full.txt. # Why nobody clicks your chat widget URL: https://clankersupport.com/blog/why-nobody-clicks-your-chat-widget Published: 2026-08-19 Category: Guides You installed the widget, checked the dashboard a week later, and found three conversations. Two were you. Here are five small changes that turn a decorative bubble into actual conversations. You did everything right. Picked a support tool, pasted the script tag, matched the brand color, told the team. A week later you open the dashboard and there are three conversations. Two of them are you, testing it. The widget isn't broken. It's being ignored, which is worse, because nothing shows up in an error log. Here's the thing about that little circle in the corner: clicking it is a small action that feels expensive. The visitor has to start a conversation with a stranger, in a blank box, with no idea what happens next or how long it takes. Most people look at that deal and go back to skimming your pricing page with a question still in their head. Every fix below removes one reason not to click. None of them takes more than a few minutes. ## 1. Stop waiting to be clicked A bare launcher makes the visitor do all the work: notice it, decide it's worth it, open it, compose a message. That first step is the most expensive one in the whole funnel, and the widget does nothing to help. Teaser bubbles flip it. A line or two of real text floats up above the closed launcher ("Questions about pricing? I can help"), and suddenly the visitor isn't starting a conversation. They're replying to one that's already open. Replying is cheap. Starting is not. In Clanker Support, each line of your welcome message becomes its own teaser bubble automatically. Write two short lines and you get a little opening move on every page. If a visitor dismisses them, they stay gone for the session. Nagging is the fastest way to teach people to hate the corner of your website. ## 2. Give people their first sentence Open any chat widget and you get a cursor blinking in an empty box. Now the visitor has to write. What do I call this thing? Is my question too vague? Am I about to talk to a bot that answers in riddles? Composing is work, and work loses to the back button. Starter questions fix this with one tap. Three or four chips under the greeting ("What's included in the free plan?", "Do you integrate with Shopify?") and the visitor's first message is already written. As a bonus, the chips quietly tell them what the agent is actually good at, which sets the conversation up to go well. Don't invent these. Pull them from your last month of tickets. The questions people ask by email are the questions they'd tap in the widget, if only you'd offer them. ## 3. Rewrite the greeting like a person wrote it "Hi! How can I help you today?" is what every widget on the internet says. Visitors have seen it a thousand times, so it reads as furniture. Generic greeting, generic expectations, no click. Compare: > Hi! How can I help you today? with: > Hey, I'm the Acme support agent. Pricing, setup, weird CSV imports: ask me anything. If I get stuck, a human takes over. The second one does three jobs in two sentences. It says what the agent covers, it sounds like someone actually typed it, and it answers the fear nobody voices out loud: "what if the bot can't help me?" You already know the answer to that objection. Put it in the greeting instead of hoping people find out on their own. ## 4. Ask for less before the conversation Some widgets open with a form. Name, email, sometimes a dropdown for "topic." Every field is a toll booth between a visitor and their question, and plenty of people just turn around. We ship with the pre-chat form off. The widget opens straight into the conversation, and you ask for an email only when there's a real reason, like an escalation that needs somewhere to send the reply. You will capture fewer email addresses up front this way. You'll also have far more conversations, and a conversation tells you more about a prospect than an email field they filled with "asdf@asdf.com" anyway. ## 5. Hand off to humans fast, and say so Most visitors test a widget before they trust it. The first question is a softball. What they're really checking is whether this thing loops them through canned answers forever, because everyone has been trapped in that maze before, and nobody goes back in twice. So make the exit visible and make it early. We default to offering a human handoff after three exchanges, and honestly, for high-stakes topics like billing you may want it sooner. It feels backwards to advertise the escape hatch from your own bot. It isn't. The visitor who sees a clear path to a person asks the hard question. The one who doesn't ask the easy question, gets a decent answer, and still leaves with the hard one unasked. We wrote more about scoping the handoff in [our guide to AI first-response layers](https://clankersupport.com/blog/reducing-support-tickets-with-ai-first-response). ## How you'll know it's working Three numbers, checked weekly, no dashboard archaeology required: - **Conversations started.** The raw count. This is the number the five fixes above move. - **Escalation rate.** The share of conversations that reach a human. High isn't bad, and neither is low. What you want is a number that matches your intent: a filter in front of your team, or a full first line of support. - **Ratings.** Thumbs on individual answers and the end-of-conversation score tell you whether the conversations you earned were worth having. The bubble in the bottom-right corner of this page has all five of these turned on, so you can judge for yourself how it feels from the visitor's side. Ask it something you'd normally dig through docs for. And if you'd rather your own corner stopped being decorative: it's [one script tag](https://app.clankersupport.com), and the first 7 days are free. # Your customers write in 12 languages. Your support agent answers in all of them. URL: https://clankersupport.com/blog/multilingual-ai-support-agent Published: 2026-08-11 Author: Omar Category: Announcements I spent an hour throwing 12 languages at our support agent — Spanish, Japanese, Arabic, even Moroccan Darija in Arabizi. It answered every one in the customer's language. There's no language setting. And now it does it out loud. Last night I spent an hour trying to break my own product. One conversation. Twelve languages. I asked our support agent the same question — free trial, pricing — in Spanish, then French, then Japanese, then Korean, then Arabic. Switching mid-thread. No warning, no pattern, a different alphabet almost every message. It answered. Every time. In theirs. ![The support agent answering a pricing question in Arabic, right-to-left, with sources cited](https://clankersupport.com/blog/multilingual-ai-support-agent-arabic.jpg) Look at the Arabic one. The whole conversation flips right-to-left — the question, the answer, the pricing list. It reads like it was written by someone who grew up writing Arabic. ## There is no language setting This is my favorite part. No dropdown. No locale file. No "multilingual add-on" line on your bill. You don't tell it what languages your customers speak — because you don't know. Nobody knows. Your next customer decides that, not your settings page. Your customer writes. The agent detects. The agent answers. Same knowledge, their language. That's the entire feature. ![The support agent answering in Spanish — with the next question, in French, already being typed below](https://clankersupport.com/blog/multilingual-ai-support-agent-spanish.jpg) That's Spanish answered in full — trial, pricing, sources — while the next question is already going in below it. In French. Same thread, no settings touched in between. My personal stress test was Moroccan Darija, typed the way we actually type it — Latin letters, numbers standing in for sounds no alphabet has. It answered the way my friends text me. If it handles that, it handles your customers anywhere. ## And now it talks There's a phone icon in the widget now. Your customer taps it and they're on a live voice call with your support agent. Same knowledge. Same languages. They talk, it talks back — out loud, in real time. (Scale plan.) I keep calling it just to hear it pick up. ## When it's stuck, you get a human One more thing, because it's the reason Clanker Support exists at all: when the agent can't answer, it doesn't improvise. It hands the conversation to your team — whole thread attached, nothing for the customer to repeat. An agent that speaks every language your customers do and still knows when to say "let me get you a person." That's the product. ## Try your language on it The chat bubble on this page — bottom right — is the live agent. Not a demo build, not a rehearsal. The same one from the video. Ask it something in your language. Right now. Darija welcome. And if it wins you over: one script tag, and it's on your site doing this for your customers — in theirs. Free for 7 days. → [Start your free trial](https://app.clankersupport.com) # We diffed our marketing site against our codebase. Six claims didn't survive URL: https://clankersupport.com/blog/marketing-site-codebase-audit Published: 2026-07-26 Category: Engineering We put the homepage in one tab and the repo in the other and checked every falsifiable sentence against the code that would have to make it true. Six claims failed, one page undersold us, and an adversarial re-pass caught overclaims we wrote during the honesty pass itself. Every fix shipped in one public pull request. 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. We build [Clanker Support](https://github.com/theopenco/llmchat), 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. 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. Every fix below shipped as [one public pull request](https://github.com/theopenco/llmchat/pull/156). You can read each diff. ## The six claims that failed the diff **1. "Run any model or provider."** 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: ```ts // packages/shared/src/models.ts // Loud failure, never a blank picker: if the generated snapshot is ever empty // (a botched regen), fail at import rather than silently offer no models. if (WEB_SEARCH_MODELS.length === 0) { throw new Error( "WEB_SEARCH_MODELS is empty — run `pnpm gen:web-search-models` to regenerate from @llmgateway/models", ); } ``` A curated catalog is a defensible design choice. We rewrote the copy to describe it, because it's what you get. **2. "How many exchanges before the bot hands off."** 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. **3. "Answers when it can. Hands off when it can't."** 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: ```ts // packages/widget/src/escalation-intent.ts /** * 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. * … */ ``` 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. **4. Infrastructure attributed to the wrong vendor.** 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. **5. "Self-host free, full feature set."** 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: ```ts // apps/api/src/lib/plan.ts export function internalEmails(env: Env): string[] { const raw = env.vars.INTERNAL_ACCOUNT_EMAILS; if (!raw) return []; return raw .split(",") .map((s) => s.trim()) .filter(Boolean); } ``` 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: [the docs](https://docs.clankersupport.com) 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. **6. SSO/SAML and audit logs listed on the enterprise tier.** 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. 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. ## One page undersold us 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. 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. ## The re-pass found 18 more, four written during the fix 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. They found 14 missed instances of the same six overclaims, spread across pages, docs, and meta descriptions the first pass never opened. 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. 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. ## Diff your homepage against your repo 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. 1. **Extract every falsifiable claim.** 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"). 2. **Assign each claim a code location.** 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. 3. **Classify: true / roadmap / false / understated.** 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. 4. **Fix everything in one public pull request.** The public part is the point. A private cleanup earns nothing; a public one is evidence you can hand a skeptical visitor for years. 5. **Re-audit the fix adversarially.** 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. 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. We've run a related exercise on our search presence before, in [our AI SEO audit checklist](https://clankersupport.com/blog/ai-seo-audit-checklist). 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. ## The cheapest credibility available 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: ```tsx // apps/dashboard/src/app/inbox/_components/InboxStats.tsx /** * …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 "—". */ ``` 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. 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. 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. ## FAQ ### How do you audit marketing claims against a codebase? 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. ### What is claim drift? 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. ### Should the fixes be public? 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. # Two messages, one sequence number: the concurrency bug that never threw URL: https://clankersupport.com/blog/two-messages-same-sequence-number Published: 2026-07-26 Category: Engineering An escalation marker and the AI reply it interrupted landed in the same conversation with the same sequence number. No exception, no log line, both inserts succeeded, and the thread quietly rendered out of order. This is the read-then-write bug that passes every test, the three-part fix whose deploy order is the point, and the five-second code search that tells you whether your codebase has it too. One day, two messages in the same support conversation both had `sequence = 2`. 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 `sequence` 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. 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 ([theopenco/llmchat](https://github.com/theopenco/llmchat)), so everything below links to real diffs. ## Two sins, one write path 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: ```ts // BEFORE: read early, write late const convo = await db.query.conversation.findFirst({ where: eq(conversation.id, conversationId), }); // ... 5–20 seconds of LLM streaming happens here ... await db.insert(message).values({ conversationId, content, sequence: convo.messageCount + 1, // computed from a read made earlier }); await db.update(conversation).set({ messageCount: convo.messageCount + 1 }); // absolute assignment ``` Sin one: `sequence` is computed from `messageCount` as it stood when this request read it. If any other writer inserts between the read and the insert, both writers saw `messageCount = 3` and both write `sequence = 4`. SQLite takes both rows without complaint. Sin two: the count update is an absolute assignment. Writer A sets `messageCount = 4`; 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. We had no shortage of concurrent writers, either. The chat handler persists the assistant's reply after the stream finishes, inside `waitUntil`, 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. ## Why every test passed Our tests exercised each writer in isolation: insert a user message, assert sequence 1; insert a reply, assert sequence 2. Green across the board. 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. 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. ## The fix shipped in three steps, and the order is the point 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. ### Clean the data first The backfill ([the public pull request](https://github.com/theopenco/llmchat/pull/161)) finds every conversation with at least one duplicated `(conversation_id, sequence)` pair, renumbers all of that conversation's messages with `ROW_NUMBER()`, then trues up the drifted `message_count`. Lightly trimmed: ```sql CREATE TABLE _seq_backfill AS SELECT id AS mid, ROW_NUMBER() OVER (PARTITION BY conversation_id ORDER BY sequence, created_at, id) AS new_seq FROM message WHERE conversation_id IN ( SELECT conversation_id FROM message GROUP BY conversation_id, sequence HAVING COUNT(*) > 1); UPDATE message SET sequence = (SELECT new_seq FROM _seq_backfill WHERE mid = message.id) WHERE id IN (SELECT mid FROM _seq_backfill); DROP TABLE _seq_backfill; UPDATE conversation SET message_count = (SELECT COUNT(*) FROM message m WHERE m.conversation_id = conversation.id) WHERE message_count <> (SELECT COUNT(*) FROM message m WHERE m.conversation_id = conversation.id); ``` Two details worth stealing. The `ORDER BY sequence, created_at, id` 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. ### One writer, and the database allocates the key [The writer refactor](https://github.com/theopenco/llmchat/pull/162) replaces every inline insert-and-bump in every route with one function: `insertMessage()` in `apps/api/src/lib/messages.ts`. Chat persist, operator reply, notes, escalation markers, inbound email replies: all of them now go through it. The core move is that the sequence is allocated by a scalar subquery inside the INSERT itself: ```ts db(env) .insert(message) .values({ conversationId: input.conversationId, role: input.role, content: input.content, // ... sequence: sql`(SELECT COALESCE(MAX(${message.sequence}), 0) + 1 FROM ${message} WHERE ${message.conversationId} = ${input.conversationId})`, }) .returning(); ``` That's the entire trick. SQLite (and D1, which is SQLite at the edge) serialize writers per statement, so `MAX(sequence) + 1` 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. The counter fix rides along: ```ts const bumped = await db(env) .update(conversation) .set({ messageCount: sql`${conversation.messageCount} + 1`, updatedAt: new Date(), }) .where(eq(conversation.id, input.conversationId)) .returning({ messageCount: conversation.messageCount }); ``` `message_count = message_count + 1` is commutative: two concurrent bumps produce +2 no matter how they interleave, where two absolute assignments produced +1. The `RETURNING` clause hands the post-bump count back to callers so nobody is tempted to re-derive it from a sequence number. ### The unique index that makes any regression loud The last deploy adds [the unique index](https://github.com/theopenco/llmchat/pull/163) that makes any regression loud: ```sql CREATE UNIQUE INDEX IF NOT EXISTS message_conv_seq_uidx ON message (conversation_id, sequence); ``` 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 [an "always safe" additive column from breaking every login](https://clankersupport.com/blog/additive-migration-almost-broke-every-login).) 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. ## Drizzle buries the error your retry path needs on `.cause` `insertMessage()` 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: ```ts // looks right, never matches in prod if (err instanceof Error && /unique constraint failed/i.test(err.message)) { ``` It never matches. Drizzle 0.45 wraps every driver error in a `DrizzleQueryError` whose message reads `"Failed query: insert into message ..."`. The actual `UNIQUE constraint failed: message.conversation_id, message.sequence` text lives on `err.cause`, 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. The version that works walks the cause chain, with a cycle guard because `cause` can technically point anywhere: ```ts function isUniqueViolation(err: unknown): boolean { const seen = new Set(); for (let e: unknown = err; e && !seen.has(e); ) { seen.add(e); const msg = e instanceof Error ? e.message : String(e); if (/unique constraint failed/i.test(msg)) { return true; } e = (e as { cause?: unknown }).cause; } return false; } ``` If you classify database errors through any ORM, check whether the string you match lives on `.message` or on `.cause.message`. `Error.cause` chains have been standard since ES2022, and most error-classification code we've read predates them. ## The five-second audit 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. 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 `count + 1` or `position + 1` 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. The repair, when you need it, is the same recipe in the same order: 1. **Backfill first**, deterministically and idempotently, so the data is clean. 2. **Let the database allocate the key atomically**, in the same statement as the write, through one writer function with no exceptions. 3. **Add the constraint last**, 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. All three diffs are small enough to read in one sitting, and every one of them is public on [our repo](https://github.com/theopenco/llmchat). ## FAQ ### How do you safely generate a per-group sequence number in SQLite or D1? Allocate it inside the INSERT itself with a scalar subquery: `sequence = (SELECT COALESCE(MAX(sequence), 0) + 1 FROM message WHERE conversation_id = ?)`. 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. ### Why isn't a unique index alone enough to fix a sequence race? 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. ### Why doesn't err.message contain "UNIQUE constraint failed" with Drizzle? Drizzle 0.45 wraps driver errors in a `DrizzleQueryError` whose message describes the failed query; the constraint text lives on `err.cause`. Walk the cause chain (with a cycle guard) when classifying database errors, or your retry and fallback paths will silently never run. # Every hosted plan now starts with a 7-day free trial URL: https://clankersupport.com/blog/7-day-free-trial Published: 2026-07-20 Category: Announcements New hosted Clanker Support subscriptions now begin with 7 free days — the full plan, every feature, applied automatically at checkout. Nothing is charged until the trial ends, you can cancel anytime, and a 14-day money-back guarantee backs it all up. 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. 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. ## The trial is the whole plan, not a preview 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. 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. ## Why we ask for a card We do ask for a card at checkout, and we'd rather explain that than hide it. 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. 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. ## When the seven days end 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. 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. ## Already a customer? 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. 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. ## Self-hosting stays free 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. ## How to start 1. **See it working first, with no signup.** The [live demo](https://showcase.clankersupport.com) is the real widget running in your browser — open it and ask it something. 2. **Pick a plan on [/pricing](https://clankersupport.com/pricing).** 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. 3. **Drop in the script tag.** One script tag on your site; most teams are live in about five minutes. Then let it take your real conversations for a week. ## Questions you might have ### Why do you require a card for a free trial? 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. ### What happens when the 7 days end? 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. ### Does usage during the trial cost anything? No. Trial usage is never billed, no matter how much of your plan's allowance you use. ### I already subscribe — do I get a trial if I switch plans? No — tier changes apply immediately without a new trial. The trial is for workspaces starting their first hosted subscription. ### Is self-hosting still free? Yes, forever. Open source, your own infrastructure, your own LLM keys. ## Start wherever you're comfortable There's a ladder here, and you can stop on any rung. Try the [live demo](https://showcase.clankersupport.com) 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. Pick your plan at [/pricing](https://clankersupport.com/pricing). The trial starts the moment you do. # 'Additive columns are always safe' is wrong on Drizzle, Prisma, and preview deploys URL: https://clankersupport.com/blog/additive-migration-almost-broke-every-login Published: 2026-07-20 Category: Engineering Everyone agrees an additive column is the one schema change that can't hurt you. Then a one-line ALTER TABLE on our user table turned out to be capable of 500ing every authenticated request — because Drizzle projects every mapped column, Better Auth reads the user table on every session check, and preview deploys skip migrations. Here is the two-PR discipline we ship our riskiest schema changes with, and the one column we keep out of the ORM entirely. Migration `0017_user_role.sql` 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. 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 `role` 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 ([theopenco/llmchat](https://github.com/theopenco/llmchat)), so every file, commit, and PR below is public. ## Why "additive columns are always safe" became folklore The belief was earned, and it predates ORMs. Adding a nullable column — or a `NOT NULL` 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. `SELECT id, email FROM user` does not care what else the table grew this week. 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. ## Your ORM selects every column you map Drizzle does not emit `SELECT *`. An unprojected `select().from(user)` expands to an explicit list of every column mapped on the table object in `packages/db/src/schema.ts` — for our `user` 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 `no such column`, including queries whose calling code never reads the new field. The migration isn't what breaks. Reads that predate the feature are what break. This is not a Drizzle quirk. Prisma's generated client selects every scalar field by default unless you pass `select`. Rails people know the mirror image of this rule from column _removal_ — you set `ignored_columns` before dropping, because the schema cache still names the column — but explicit-projection ORMs make column _addition_ 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. ## Preview deploys run new code against old schemas 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 `apps/api/migrations/`, preview deploys don't. A branch that adds a column and maps it in `schema.ts` 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. 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. ## The auth library that reads your user table on every request Here is the multiplier that turns a red preview into a near-catastrophe. We were adding a platform-admin `role` to `user` 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: ```ts // packages/db/src/schema.ts // NOTE: the PLATFORM-admin role column (migration 0017_user_role.sql) is // deliberately NOT modeled on this Drizzle table. Better Auth's Drizzle // adapter loads the session user with an UNPROJECTED `select().from(user)` // (every column of this table object) on every getSession, so declaring // `role` here would make that auth hot-path query reference a column a preview // DB — which skips migrations — does not have, 500-ing ALL authenticated // requests. ``` 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 `user` 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. 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. ## Two PRs per schema change: migrate before you serve The fix is old — the expand/contract pattern, in miniature. Every schema change on the tables our hottest paths read — `message`, `conversation`, `user` — ships as two pull requests. Phase 1 is the `ALTER TABLE`, its comment, and a seed-contract test; `schema.ts` 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 `project` — settings fields read by a handful of routes — still went out as single PRs (`0016`, `0020`, `0021`), 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: ```sql -- apps/api/migrations/0022_message_reply_to.sql -- PHASE 1 of 2 (deliberate migrate-before-serve split, mirroring 0014/0015). -- Ploy's deploy ordering between "apply migrations" and "new Worker serves -- traffic" is undocumented, so this PR ships ONLY the column — schema.ts is -- intentionally NOT changed, so the live Worker never SELECTs a column that might -- not exist yet (drizzle projects every column of `message`, and /v1/chat + -- /v1/messages read it on the hottest paths; no read can 500 under any ordering). ALTER TABLE `message` ADD COLUMN `reply_to_message_id` text; ``` We've run the split three times so far: - **`0014` conversation summaries** — migration in PR #76, feature in PR #77, merged 24 minutes apart on June 22. - **`0015` resolve attribution** — migration in PR #91, feature in PR #92, 41 minutes apart on June 29. - **`0022` quote-reply** — 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"). 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 `ALTER TABLE` 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. ## The column we keep out of the ORM entirely `user.role` is the extreme case: phase 2 never came, on purpose. Because the `user` table's mapping is read unprojected on every request, the column stays out of `schema.ts` indefinitely, and the admin gate reads it with a raw SQL projection wrapped in a fallback (the `/admin/users` listing uses the same guarded shape): ```ts // apps/api/src/middleware/admin.ts try { const rows = await db(c.env) .select({ role: sql`role` }) .from(user) .where(eq(user.id, userId)) .limit(1); role = rows[0]?.role ?? null; } catch { role = null; } ``` On a database without the `0017` migration, the read throws, `role` degrades to `null`, 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. One loose end remained: Drizzle's `query.user.findFirst` without a `columns` option is also a select-everything of the row. Commit `e287d58` swept the last two of those — billing checkout and project creation, each of which only ever read `.email` — down to explicit projections: ```ts // apps/api/src/routes/billing.ts const owner = await db(c.env).query.user.findFirst({ where: (u, { eq: e }) => e(u.id, userId), columns: { email: true }, }); ``` The invariant that fell out is easy to state and easy to check in review: exactly two queries in the codebase name `role` — the admin gate and the `/admin/users` listing — and both are wrapped in a try/catch that degrades to least privilege. Everything else touching `user` 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. ## When a schema change needs the two-PR split | Change | Blast radius if code ships first | What to do | | --------------------------------------------------------------------- | ----------------------------------------------------------- | --------------------------------------------------------------------------- | | New table | None — no deployed code queries it | One PR is fine | | New column, mapped in the ORM | Every ORM read of that table, via the generated column list | Two PRs: migration alone, then mapping + feature | | New column on a table a dependency reads unprojected (auth, sessions) | Every authenticated request | Two PRs — or keep the column out of the ORM behind a guarded raw projection | | Dropping a column | Deployed code still projects it during the rollout window | Two PRs in reverse: remove the mapping first, drop later | | Self-hosted installs exist | You never control when they migrate | Treat every schema change as two-phase, always | 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 `message` it was our own hot paths, which we could see. For `user` it was our auth library, which we couldn't — until we looked at the queries it actually emits. This is the second time that habit has paid for itself. When we [moved the backend to workerd](https://clankersupport.com/blog/cloudflare-workers-every-node-sdk-broke), 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 `apps/api/migrations/` are all public if you want the long-form version; they're better reading than most of our docs. ## FAQ ### Is adding a nullable column a safe migration? 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. ### Why does my preview deploy fail with "no such column"? 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. ### What is the expand/contract migration pattern? 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. # A 9-point AI SEO audit checklist, run on our own site first URL: https://clankersupport.com/blog/ai-seo-audit-checklist Published: 2026-07-20 Author: Ismail Ghallou Category: Guides Next.js merges route metadata shallowly, so every page built with our metadata helper — unless it passed a cover image of its own — shipped a Twitter card with no image for about a month. That was finding one of the SEO and AI-SEO audit we ran across our five domains this month, in two passes. Here is the whole audit as a checklist you can run on your own site, including the four findings that surprised us. For about a month, every page on our marketing site that used our own metadata helper without an image of its own — `/pricing`, every `/vs/*` comparison, every `/features/*` page — shipped a `summary_large_image` 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. 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, `llms.txt` and `llms-full.txt` 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. 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 [PR #148](https://github.com/theopenco/llmchat/pull/148) on July 17 — that one touched marketing, docs, showcase, admin and the api. Everything is public in [the repo](https://github.com/theopenco/llmchat), so every finding links to real code. Here is the checklist, then the four findings that were genuinely non-obvious. ## The checklist Run each question against every domain you operate — including subdomains you forgot you had. | # | Check | Ask yourself | Us, before the audit | | --- | -------------------- | --------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | | 1 | Social card images | Does the **rendered HTML** of every page contain `og:image` — not just the homepage? | Fail — every helper-built page without its own cover had none | | 2 | noindex reachability | Can crawlers fetch your noindex'd pages? A robots Disallow hides the tag | Fail — the dashboard got noindex + robots in wave one; admin's robots.txt came in wave two | | 3 | Product-minted URLs | Does every per-customer URL your product serves send noindex? | Fail — `/embed/:key` was indexable | | 4 | AI crawler access | Do any robots rules block GPTBot, ClaudeBot, PerplexityBot, or Google-Extended? | Pass — nothing blocked (now deliberate) | | 5 | llms.txt files | Do you publish a link map (`/llms.txt`) and a full-content file (`/llms-full.txt`)? | Half — link map yes, full-content no | | 6 | Secondary domains | Does every subdomain have robots, a sitemap, canonicals, OG tags, and structured data? | Fail — the docs subdomain had none of the five | | 7 | Title lengths | Do your titles survive the ~60-character SERP cutoff? | Fail — a brand suffix pushed every post past it | | 8 | Sitemap honesty | Is `lastModified` a real content date or a build timestamp? | Fail — build timestamp on every entry | | 9 | Demo properties | Do showcase/demo sites have a `metadataBase`, a canonical, and a robots.txt that isn't a 404? | Fail — our showcase's robots.txt returned 404 | 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. ## Why your Next.js pages have no og:image The mechanism: Next.js merges route metadata **shallowly**. When a page exports its own `openGraph` object, it doesn't extend the layout's — it replaces it wholesale, and the same applies to `twitter` and `alternates`. Our root layout ships a site-wide OG cover via the `opengraph-image.png` file convention, and we assumed that cover reached every page. It reached exactly the pages that declared no `openGraph` of their own. Every page that called our `pageMeta` helper without an explicit image — `/pricing`, 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. 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 `twitter:card` of `summary_large_image` with no image behind it, so every share of `/pricing` or a comparison page rendered as a bare text stub. We only caught it by grepping the prerendered HTML for `og:image` during the audit. The fix is a constant and a default: put the image inside the helper, so no caller can forget it. The comment in `apps/marketing/src/lib/seo.ts` is the whole postmortem: ```ts // apps/marketing/src/lib/seo.ts /** 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. */ const DEFAULT_OG_IMAGE = "/opengraph-image.png"; ``` The same shallow merge had already bitten us once in the same file, back in the first wave — the `alternates` block re-declares the RSS `` on every page, because a page-level canonical would otherwise delete the feed reference the layout set. Same behavior, different casualty. Check number 1, generalized: if you use any metadata helper or page-level `openGraph` in a Next.js App Router site with a root OG image, view source on a non-homepage page and search for `og:image`. 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. ## robots.txt Disallow doesn't deindex — it does the opposite The counterintuitive one. Our operator dashboard should never appear in search results, and the reflex is to write `Disallow: /` in its robots.txt. That reflex is wrong, and it's wrong in a way that leaves the pages _in_ the index. A robots Disallow controls **crawling**, not **indexing**. A `noindex` 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. So the dashboard does the opposite. `apps/dashboard/src/app/robots.ts`, rationale included: ```ts // apps/dashboard/src/app/robots.ts // Deliberately allow crawling: the layout serves a noindex robots meta on every // page, and Google can only see that tag on pages it's allowed to fetch. A // Disallow here would leave externally-linked URLs (the marketing site links to // sign-in) indexed as bare URLs with no way to discover the noindex. export default function robots(): MetadataRoute.Robots { return { rules: { userAgent: "*", allow: "/" }, }; } ``` This file is itself a first-wave audit fix (commit `90c3567`) — 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 `f8acf17`), mirroring the rationale. Check number 2: for every property you want out of search results, confirm the noindex is _reachable_. Disallow plus noindex is not belt-and-suspenders — the belt hides the suspenders. ## Your product mints URLs on your domain — noindex them at the product level 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 `/embed/:key` for hosts that can't run third-party script tags (the details are in [everything that broke while shipping an embeddable widget](https://clankersupport.com/blog/shipping-an-embeddable-widget)). Every customer project gets one of these URLs — on **our** API domain. 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 `api.clankersupport.com/embed/` — 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. Since the shell is served by a Hono route on workerd, not a Next.js page, the fix is a response header (commit `d276077`, in `apps/api/src/routes/embed.ts`): ```ts // apps/api/src/routes/embed.ts // Iframe chrome, not content: per-project embed URLs on the api host must // never appear in search results next to the customer's own site. c.header("x-robots-tag", "noindex"); ``` Check number 3, and the framing we'd push hardest: for a SaaS, index hygiene is a **product decision**, 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. ## What we ship for AI crawlers, and what we deliberately don't block The AI half of the audit had two parts: what we add, and what we refuse to subtract. What we added: `/llms-full.txt`, the [llmstxt.org](https://llmstxt.org) companion to the `/llms.txt` link map we already served. Where `llms.txt` is a table of contents, `llms-full.txt` 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 (`apps/marketing/src/lib/llms-full-txt.ts`) is 55 lines, pure, and unit-tested; the only subtle line rewrites root-relative links, because a markdown link like `/pricing` means nothing once the text leaves our domain: ```ts // apps/marketing/src/lib/llms-full-txt.ts // Root-relative markdown links would be resolved against whatever // domain serves this text, so make them absolute site URLs. const absolute = p.content.replace(/\]\((\/[^)\s]*)\)/g, `](${siteUrl}$1)`); ``` 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. 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 _is_ 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. 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. ## The smaller line items Four more findings, one paragraph each, because they'll each cost someone a quiet month. **The docs subdomain had no baseline at all.** 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 `ac538e0` adds all five, plus `TechArticle` and `BreadcrumbList` JSON-LD on every page). Subdomains grow faster than their metadata; audit every host you answer on. **A brand suffix ate every title.** Appending "— Clanker Support Journal" pushed every blog post title past the ~60-character SERP cutoff, so Google truncates or rewrites them (commit `b433b72` drops it, and adds a dedicated `seoDescription` 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. **Sitemap `lastModified` was a lie.** We stamped build time on every entry, which claims every page changed on every deploy. The comment in `seo.ts` 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." **The showcase's robots.txt was a 404 page.** Our live-demo site returned HTML for `/robots.txt`, had no `metadataBase` (so its OG image resolved against nothing), and — a Next.js footnote worth knowing — an `opengraph-image` file alone emits only `og:image`; without explicit `openGraph`/`twitter` blocks there's no `og:title` or card type around it (commits `7ad1f32` and `5ad2bf7`, one from each wave). ## What we can't tell you yet 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." If the llms.txt items made your own list, the [free llms.txt generator](https://clankersupport.com/tools/llms-txt-generator) 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. # We set a cache trap for our own support agent — in our own llms.txt headers URL: https://clankersupport.com/blog/knowledge-base-recrawl-cdn-cache Published: 2026-07-20 Category: Engineering URL knowledge sources are snapshots, and everyone knows snapshots go stale. What we missed is that the refresh itself can be served by any CDN cache between the crawler and the origin — so "Recrawl → success" can silently re-store the pre-deploy content. The trap on our own site was set by our own cache headers. URL knowledge sources go stale in two layers: they are point-in-time snapshots, so deploying new docs changes nothing until someone recrawls — and the recrawl itself can be answered by a CDN cache sitting between the crawler and the origin, silently re-storing the pre-deploy content. Defeating that second layer takes a unique cache-busting query parameter, `cache: "no-store"`, and no-cache request headers, in that order. The part that made us wince: the trap on our own site was set by our own hands. Our `llms.txt` — the plain-text index we publish so AI crawlers can read our content — is served with `cache-control: public, max-age=3600`. It is also a URL knowledge source for our own support agent. Click Recrawl within an hour of a deploy and the agent would have refreshed itself with the copy from before the deploy. PR #153 (commit `d905c42`, merged 2026-07-19) closes the hole; this post is the anatomy. ## The staleness layer we already documented We wrote up layer one in [why our support agent doesn't use RAG](https://clankersupport.com/blog/ai-support-agent-without-rag): a `url` source is fetched once, at creation, and stored as extracted text — at most 200 KB read, 20,000 characters kept, 10-second timeout (`MAX_BYTES`, `MAX_CHARS`, `TIMEOUT_MS` at the top of `apps/api/src/lib/fetch-url.ts`). Refresh is a manual Recrawl button in the dashboard. Deploying new docs does not update the agent until someone clicks it. We then demonstrated the failure mode on ourselves. We shipped backend SDKs — the pip, gem, and Composer packages — asked our own widget about them, and it had no idea they existed. The diagnosis was boring: nobody had recrawled the source. Layer one, working exactly as (badly) designed — the button fixes it in one click. But staring at what that button actually does — one plain `fetch` from a worker to a URL — surfaced the layer underneath. The click is not the whole path. ## Why a successful recrawl can still fetch stale content A `fetch` from your crawler to an origin is not a private conversation. If the URL sits behind a CDN — and docs sites, marketing sites, and anything on modern hosting almost always do — the response can come from an edge cache that stored it earlier, and the cache will keep serving that copy until its `max-age` runs out. Deploying new content does not evict it on its own. Your crawler gets a 200, a plausible body, and no header that screams "this is from before your deploy." Our refresh endpoint (`apps/api/src/routes/sources.ts`) then does the natural thing: stores the content and stamps `lastFetchedAt` with the current time. The dashboard's status chip reads Ready. Every signal an operator can see says fresh. The bytes are from an hour ago. That is the expensive property of this bug: it is indistinguishable from success. A failed crawl shows an error and keeps the old snapshot. A cache-poisoned crawl shows the same Ready chip and keeps the old snapshot too — it just launders the timestamp. ## Our own llms.txt would have poisoned our own agent The concrete instance lives in `apps/marketing/src/app/llms.txt/route.ts`: ```ts // apps/marketing/src/app/llms.txt/route.ts return new Response(body, { headers: { "content-type": "text/plain; charset=utf-8", "cache-control": "public, max-age=3600", }, }); ``` That header is correct for its audience. `llms.txt` exists to be hammered by AI crawlers, and an hour of edge caching is basic politeness. But the same file does double duty as a knowledge source for our own agent, and for that consumer the header means: any recrawl within an hour of a deploy re-stores the pre-deploy index, reports success, and re-dates the snapshot. Being exact about the blast radius: this is a _would have_, not a _did_. We found the hole while fixing the missing-recrawl problem above; we have no evidence a cache-poisoned recrawl ever fired, and no customer got a stale answer we can trace to it. It's a latent footgun we happened to catch while holding it. We wrote the cache header for crawlers in one app and the crawler it defeats in another, and neither file knew about the other until July. ## How to force a fresh fetch through CDN caches The fix, `fetchFresh` in `apps/api/src/lib/fetch-url.ts`, layers three defenses, strongest first: ```ts // apps/api/src/lib/fetch-url.ts const u = new URL(url); u.searchParams.set("__recrawl", crypto.randomUUID().slice(0, 8)); busted = u.toString(); // … const res = await fetchBypassingCache(busted, signal); if (res.ok) return res; // … return fetchBypassingCache(url, signal); ``` | Defense | What it does | Why it isn't sufficient alone | | -------------------------------------------------------------- | ---------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | | Unique `__recrawl` query param per crawl | Changes the cache key, forcing a miss on any cache that keys on the full URL | Some origins reject unknown params (signed URLs); a CDN configured to strip query strings ignores it | | `cache: "no-store"` on the fetch | Bypasses the requesting runtime's own HTTP cache | Governs only your side — no effect on CDNs in the path; older workerd throws on the option | | `cache-control: no-cache` + `pragma: no-cache` request headers | Asks shared caches to revalidate with the origin | The big CDNs don't honor request-side cache directives by default | The ordering is the lesson. The headers are what HTTP offers for exactly this situation, and they go last because in practice they do the least: Cloudflare and CloudFront, in their default configurations, ignore a client's `Cache-Control` request header entirely. The query param is a cruder tool — it doesn't ask the cache anything, it makes the cache's own key lookup miss — and that's precisely why it works everywhere caches key on the full URL, which is the default on every major CDN. Each crawl gets a fresh random value, because a stable buster would just get the busted URL cached instead. One of the five tests the fix added in `fetch-url.test.ts` pins exactly that: two crawls of the same URL must carry different `__recrawl` values. ## Why cache: no-store throws on older workerd Defense two has a runtime problem we've met before. Our API runs on workerd, where [the platform's constraints are build-your-own-adventure](https://clankersupport.com/blog/cloudflare-workers-every-node-sdk-broke), and `cache: "no-store"` is only accepted on compatibility dates from 2024-11-11 onward. Before that, the option doesn't get ignored — it throws: ```ts // apps/api/src/lib/fetch-url.ts try { return await fetch(url, { ...init, cache: "no-store" }); } catch (e) { if ( e instanceof Error && /the 'cache' field|unsupported cache mode/i.test(e.message) ) { console.warn("[fetch-url] runtime rejected the cache option", {…}); return fetch(url, init); } throw e; } ``` The saving grace is that workerd rejects the option _before any network I/O_ — "The 'cache' field on 'RequestInitializerDict' is not implemented" — so falling back to a plain fetch can never duplicate an in-flight request. The fallback keeps the busted URL and the no-cache headers, dropping only the option the runtime refused. This matters mostly for self-hosters, who run whatever compatibility date their config pins. The error match is deliberately narrow, and a test guards its edges with a hostile hostname: a DNS failure for `cdn.cachefly.net` contains the word "cache" but must not be misread as a compat rejection and swallowed into a retry. ## Don't let the cache-buster break a working source An extra query param is not free. Signed URLs — S3 presigned links, anything with a `sig=` — can reject a request whose params don't match the signature. A cache-busting fix that turns a previously-working source into a 403 is a regression wearing a safety vest. So `fetchFresh` retries: if the busted URL fails for any non-abort reason, it refetches the original URL untouched. That path can still be served stale by an edge cache — which is exactly as bad as before the fix, and no worse. And if even that fails, the refresh endpoint keeps the old snapshot and stamps `lastError` instead of blanking the content. The five tests pin the whole contract: a unique buster per crawl carrying the no-store option and both no-cache headers, the compat fallback, the original-URL retry for signed URLs, the narrow error match, and no retry after the 10-second timeout aborts. ## Your agent is only as fresh as the worst cache in the path None of this is specific to our no-RAG design. Any knowledge pipeline that ingests over HTTP — full RAG with embeddings, prompt-stuffed snapshots like ours, a nightly scraper feeding a vector store — has its freshness bounded by every cache between the crawler and the truth. The embedding step can't vectorize content the CDN didn't hand over. The transferable checklist, in the order that earns its keep: bust the cache key with a unique query param, because that defeats caches that ignore your headers; request `no-store` for your own runtime's cache, with a fallback if your runtime predates the option; send `cache-control: no-cache` anyway, for the caches that listen; and never let the buster regress a URL that worked without it. Then go audit your own properties for the recursive case — the content you publish _for_ AI consumers is the content most likely to sit behind a generous `max-age`, and it may well be feeding your own agent. And treat every "last fetched" timestamp in your pipeline with appropriate suspicion. It dates the fetch. It says nothing about the bytes. # Changelog: July 2026, so far — the platform month URL: https://clankersupport.com/blog/llmchat-changelog-july-2026 Published: 2026-07-14 Category: Changelog Two weeks in and July is already our densest month: an approved WordPress plugin, a Shopify app, agents that take actions, dark mode everywhere, and a smarter, chattier widget. 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. **Clanker Support is an approved WordPress plugin.** The plugin went through WordPress.org review and is now [live in the plugin directory](https://wordpress.org/plugins/clanker-support/): 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](https://clankersupport.com/blog/wordpress-ai-support-plugin) tells the whole story, including the zip file that was secretly a tar. A 1.0.1 followed within the week. **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](https://docs.clankersupport.com/integrations/shopify) has both paths. **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. **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. **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. **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. **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. **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. **An official React / Next.js package.** [`@clankersupport/widget-rsc`](https://www.npmjs.com/package/@clankersupport/widget-rsc) is on npm: one server component in your layout instead of a script tag. There's a [tutorial](https://clankersupport.com/blog/nextjs-ai-support-widget-server-component) if you're on Next.js or any React 19 app. **A real docs site.** Product docs now live at [docs.clankersupport.com](https://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. **Free tools.** An [AI support savings calculator, CSAT calculator, canned response generator, and llms.txt generator](https://clankersupport.com/tools) — free, no signup, built because we kept needing them ourselves. **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. 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. # Add an AI support agent to Next.js, WordPress, Shopify, or any site URL: https://clankersupport.com/blog/add-ai-support-agent-any-stack Published: 2026-07-11 Author: Ismail Ghallou Category: Guides One hub for every install path we ship: the universal script tag, the React Server Components SDK, the WordPress plugin, the Shopify app embed, and the iframe. Working code for each, plus a candid guide to picking one. You can add an AI support agent to any website by pasting one ` ``` That's the whole install. `widget.js` 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 `document.body`, so your site's CSS can't break the widget and the widget's styles can't leak into your page. It loads `async`, so it never blocks your page render. 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 **Projects → your project → Widget → Install**. Configuration lives in exactly five `data-*` attributes: - **`data-project`** (required) — your project's public key. Without it the script throws instead of silently doing nothing. - **`data-api`** (optional) — the API origin. Defaults to whatever origin served `widget.js`. - **`data-brand`** (optional) — accent color; defaults to `#111827`. Most people set this in the dashboard instead. - **`data-mode`** (optional) — `bubble` (default, the floating launcher) or `inline`. - **`data-escalation-threshold`** (optional) — how many visitor messages before the widget offers a human. The agent default is 3. 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. The `data-api` default is the detail we're most pleased with. Here's the actual resolution logic from the widget source: ```ts const apiUrl = script?.dataset.api ?? (script?.src ? new URL(script.src).origin : window.location.origin); ``` The widget derives its API origin from the URL that served the script. So if you [self-host](https://clankersupport.com/blog/the-case-for-self-hostable-ai-support) — the whole product is MIT-licensed, bring your own model keys — you serve `widget.js` from your own domain and the exact same snippet points at your own API. Zero config divergence between hosted and self-hosted. If you're on Rails, Django, Laravel, plain HTML, Astro, Vue, Hugo — anything that renders a `` — this is your install, and you're done. Full reference: [docs.clankersupport.com/integrations/widget](https://docs.clankersupport.com/integrations/widget). ## Next.js: the React Server Components SDK The script tag works fine in Next.js. But if you're on Next.js 15 / React 19, we ship a first-class package, `@clankersupport/widget-rsc`, that plays properly with the App Router. Three steps. Install it — React 19 and React DOM are the only peer dependencies, and there are zero runtime dependencies: ```sh npm install @clankersupport/widget-rsc ``` Render it once in your root layout, before ``: ```tsx import { ClankerSupport } from "@clankersupport/widget-rsc"; export default function RootLayout({ children }) { return ( {children} ); } ``` And put the key in `.env.local`: ``` NEXT_PUBLIC_CLANKER_KEY=pk_your_project_key ``` What you get over the script tag: `ClankerSupport` 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 `null` fallback and returns `null` 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. There's also a headless entry, `@clankersupport/widget-rsc/headless`, with Radix-style unstyled primitives — `data-*` state attributes, `asChild` composition, and a `useClankerSupport` hook — for teams that want the agent behind their own UI entirely. We wrote a full tutorial on how (and why) this package works: [an AI support widget as a React Server Component](https://clankersupport.com/blog/nextjs-ai-support-widget-server-component). Reference docs live at [docs.clankersupport.com/integrations/react-sdk](https://docs.clankersupport.com/integrations/react-sdk). ## WordPress: plugin via zip upload (today) 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. The install today: 1. Build the zip from the plugin package in [the GitHub repo](https://github.com/theopenco/llmchat): `pnpm package` inside `packages/wordpress-plugin` emits `dist/clanker-support-.zip`. 2. In wp-admin: **Plugins → Add New → Upload Plugin**, pick the zip, activate. 3. Under **Settings → Clanker Support**, paste your project's public key and save. The plugin (v1.0.1, GPLv2 or later, requires WordPress 5.8+ and PHP 7.4+) enqueues `widget.js` asynchronously with the same `data-*` 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 `https://api.clankersupport.com`, 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. 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: ``` [clanker_support] [clanker_support width="500" height="700"] ``` 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. The launch post has the longer story: [our WordPress AI support plugin](https://clankersupport.com/blog/wordpress-ai-support-plugin). Reference: [docs.clankersupport.com/integrations/wordpress](https://docs.clankersupport.com/integrations/wordpress). ## Shopify: app embed now, App Store listing on the way 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. The manual path today: **Online Store → Themes → Edit code → `layout/theme.liquid`**, paste the script tag from the first section just before ``, save. Done — same widget, same dashboard. 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 **App embeds**, toggle **Clanker Support** on, and hit **Save**. 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. Two design decisions worth knowing before you install anything on a store: - **Zero permission scopes.** 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. - **A double-bubble guard.** 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. 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: [docs.clankersupport.com/integrations/shopify](https://docs.clankersupport.com/integrations/shopify). ## Everything else: the iframe embed 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 `/embed/` that you can iframe from anywhere: ```html ``` 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: ``` 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 * ``` `frame-ancestors *` because being iframed by any site is the point; everything else is shut. It also ships `noindex`, a no-referrer policy, and denies camera/microphone/geolocation/payment outright. 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. ## Which install should you pick? Our honest decision guide, shortest answer first: - **Any server-rendered or static site (Rails, Django, Laravel, Astro, plain HTML, Hugo, ...):** the script tag. It's the product's native install; everything else is a wrapper around it. - **Next.js 15 / React 19:** the `@clankersupport/widget-rsc` 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. - **WordPress:** 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. - **Shopify:** script tag in `theme.liquid` today; switch to the app embed when the listing is live (the double-bubble guard makes the migration safe). - **Site builders with no script access:** the iframe embed. It's the fallback that works when nothing else is allowed. 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. ## After install: two things before you close the tab 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: **Add knowledge sources.** 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&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. **Set your escalation email.** 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 [setting up email threading](https://clankersupport.com/blog/setting-up-email-threading). 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. ## FAQ ### How do I add an AI support agent to my website without a developer? If you can paste one line of HTML before ``, 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. ### Does a support widget slow down my site? Ours is designed not to: the script loads `async` 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. ### Can I use the same widget if I self-host? Yes — that's a deliberate design decision. The widget resolves its API origin from wherever `widget.js` was served, so a self-hosted install uses the identical snippet pointed at your own domain. The whole product is MIT-licensed on [GitHub](https://github.com/theopenco/llmchat); self-hosting is free with your own model keys. ### How much does a hosted AI support agent cost? 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 [/pricing](https://clankersupport.com/pricing). ### How does the agent know what to answer? It answers from your project's knowledge base — crawled URL snapshots, text snippets, and hand-written Q&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. # Our AI support agent doesn't use RAG — here's the math URL: https://clankersupport.com/blog/ai-support-agent-without-rag Published: 2026-07-11 Category: Engineering Clanker Support has no vector database. We put the entire knowledge base into the system prompt on every request, and for KBs measured in kilobytes, the math says that's the right call. 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. 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. ## The entire retrieval pipeline is a WHERE clause Clanker Support is an open-source support widget ([github.com/theopenco/llmchat](https://github.com/theopenco/llmchat)). 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 `apps/api/src/routes/chat.ts`: ```ts const activeSources = await db(c.env).query.source.findMany({ where: (s, { and: a, eq: e }) => a(e(s.projectId, project.id), e(s.active, true)), }); ``` No query embedding. No similarity search. No reranker. Every active source for the project, every time. The sources come in three kinds — `url` (a one-shot snapshot of a single web page), `text` (a pasted snippet), and `qa` (a question/answer pair, either hand-written or promoted from a real operator reply in the inbox). 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 `# Reference sources` block, and finally an identity block for the visitor. The reference block is where the only "retrieval" decision in the codebase lives: ```ts // Cap aggregate source content to keep system prompts bounded. ~80k chars // ≈ 20k tokens — well below typical 128k context windows but leaves room // for knowledge base + conversation history. const MAX_SOURCES_CHARS = 80_000; ``` And the "chunking strategy" is an even split and a slice: ```ts // Distribute the budget across sources so a single huge page can't // crowd out the rest. const perSource = Math.floor(MAX_SOURCES_CHARS / usable.length); const rendered = usable .map((s, i) => { const body = s.content.length > perSource ? `${s.content.slice(0, perSource)}…` : s.content; ``` That's it. `floor(80000 / N)` characters per source, an ellipsis if it overflowed, a `## Source N: ` 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. ## How much knowledge fits in an 80k-character budget 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). - **The aggregate budget** is 80,000 characters, roughly 20,000 tokens of reference material per request. - **A URL source maxes out at 20,000 characters** 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 `apps/api/src/lib/fetch-url.ts`: `MAX_BYTES = 200_000`, `MAX_CHARS = 20_000`, `TIMEOUT_MS = 10_000`). - **So the budget holds at most 4 full-size page snapshots.** At 5 or more, `floor(80k/N)` drops below 20k and full pages start truncating each other. - **10 sources** → 8,000 chars (~2,000 tokens) each. **20 sources** → 4,000 chars each. **40 sources** → 2,000 chars — roughly 300 words — each. For context on what real support KBs look like: a text snippet source caps at 50,000 characters at creation, and a promoted Q&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&A pairs promoted from the inbox. That's tens of kilobytes. The budget swallows it whole, and the model sees _everything_ on _every_ question. 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. ## What it actually costs per message No free lunch. The whole knowledge base rides in the system prompt of _every_ 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. We can see this directly because metering records the real prompt and completion token counts per response into a `usageEvent` 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 _not_ pay this. Two things keep it bounded for us: - **Input is the cheap direction.** 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: ```ts // Hard ceiling on a single support reply's completion — bounds per-response cost // on the shared operator key. A support answer fits comfortably; the summary // path caps far tighter (60). const MAX_CHAT_OUTPUT_TOKENS = 2_000; ``` 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). - **The budget is a ceiling, not a typical case.** 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. There's a second cost people forget to weigh: the cost of the RAG pipeline you _didn't_ 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. 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. ## Where this breaks, and how it fails Honesty section. The failure mode is real and it's silent. **The ceiling is about 4 full-size pages.** 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. **URL snapshots go stale.** 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. **Long-tail docs sites don't fit.** 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. We mitigate the ceiling in two ways that are cheaper than embeddings, and we think both are interesting design points on their own. ## How web-search models change the calculus Every model our agent can serve is web-search-capable, by construction. The allowed model list is generated from [LLM Gateway](https://clankersupport.com/blog/why-we-built-on-llm-gateway)'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 (`gpt-5.4-mini`). So the agent can reach the live web when answering. This matters for the RAG question because a support KB is unusual among corpora: most of it is _already on the public web_. 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. 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 _public_ 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 [the case for self-hostable AI support](https://clankersupport.com/blog/the-case-for-self-hostable-ai-support).) ## Curation beats ingestion: promoting real answers into the KB The second mitigation is about what goes _into_ 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 `qa` source. The stored content is literally: ```ts const content = `Q: ${finalQuestion}\nA: ${finalAnswer}`; ``` Two lines. Deduped by source message, so promoting the same reply twice returns the existing source. A promoted Q&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 [using AI as the first response and humans as the curriculum](https://clankersupport.com/blog/reducing-support-tickets-with-ai-first-response): the escalations teach the agent. ## What we'll build when a customer blows past the budget Our position is "you might not need RAG _yet_," 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 `# Reference sources` block the prompt builder already renders. The prompt assembly, citation instruction, and injection fencing all stay; only the WHERE clause grows a brain. 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 `llm.ts` 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. If you want to poke at the real thing, the agent answering questions on [our live demo](https://showcase.clankersupport.com) 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. ## FAQ ### Do I need a vector database for an AI support agent? 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&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. ### Is prompt stuffing cheaper than RAG? 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. ### What happens when the knowledge base is too big for the prompt? In our implementation, each source gets an even share of the 80k-character budget — `floor(80000 / N)` 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. ### Can web search replace RAG for customer support? 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. ### When should I add RAG to an LLM application? 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. # We moved our SaaS backend to workerd and every Node SDK broke URL: https://clankersupport.com/blog/cloudflare-workers-every-node-sdk-broke Published: 2026-07-11 Updated: 2026-07-26 Category: Engineering The Resend SDK, the Stripe Node SDK, and Better Auth's passkey plugin all got cut from our workerd build — each over something the package dragged in, not code we call. Here is each casualty, the fetch-and-crypto.subtle code that replaced it, and an honest accounting of whether it was worth it. 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 `fetch` and `crypto.subtle`, and this post walks through the replacements. Clanker Support is an open-source, self-hostable AI support widget ([theopenco/llmchat](https://github.com/theopenco/llmchat)), so every file mentioned below is public. The API is a [Hono](https://hono.dev) app that deploys to workerd via the [Ploy platform](https://docs.meetploy.com) — 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. ## Why we left Node in the first place The short version: one JavaScript runtime everywhere, near-instant cold starts, and a deploy that either bundles or doesn't — no container image, no `node_modules` shipped to a server, no runtime surprise three requests in. Our repo's agent instructions state the constraint in one line: > The api ships to workerd. Avoid Node-only deps — they fail to bundle. That last clause is the interesting property. On workerd, an incompatible dependency is a **build-time** 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. The cost of that property is the rest of this article. ## Why Node SDKs break on workerd workerd implements the Web Platform: `fetch`, `Request`/`Response`, `crypto.subtle`, `TextEncoder`, streams. It does not give you Node's standard library, a filesystem, or long-lived module-scope state you can rely on across requests. 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. The pattern held so consistently that we'd summarize the whole migration as: **audit your transitive dependencies, not your imports.** ## Breakage 1: the Resend SDK — a mail client that drags in webhook crypto First casualty: the official `resend` 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 [how we set up email threading](https://clankersupport.com/blog/setting-up-email-threading)). The SDK itself is a thin API client. But it pulled in `svix` — 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 `lib/email.ts` is about as small as an integration gets: a raw `fetch` POST to `https://api.resend.com/emails` with a Bearer key and a JSON body. No client object, no retry machinery, no dependency tree. Dropping the SDK also produced a nicer dev story: when `RESEND_API_KEY` is unset, `sendEmail` logs the message and returns `{ id: "dev-noop" }`, so local development and self-hosted installs need zero email setup. One small detail we're fond of: the address validator deliberately rejects `$` anywhere in an email address. Not for RFC correctness — it catches unexpanded `$VAR` 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. 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. ## Breakage 2: the Stripe Node SDK — rewrite the form encoder and the webhook HMAC yourself The big one. Stripe's Node SDK pulls in Node built-ins that don't bundle on workerd, so `apps/api/src/lib/stripe.ts` opens with what has become our team's unofficial manifesto: ```ts // Stripe REST client for workerd. We deliberately do NOT use the Stripe Node // SDK — it pulls Node built-ins that don't bundle on workerd, and the api is // the one app that always deploys cleanly. Everything here is plain `fetch` // with form-encoded bodies + Web Crypto signature verification. ``` The whole file is just under 300 lines and covers everything our metered billing needs: `createCustomer`, `createCheckoutSession`, `createPortalSession`, `retrieveSubscription`, `reportMeterEvent`, and webhook signature verification. Three parts were genuinely annoying to reimplement. ### Part one: Stripe's bracketed form encoding Stripe's API doesn't take JSON. It takes form-encoded bodies with a bracket convention for nesting — `line_items[0][price]=price_123` — which the Node SDK normally hides from you. So the first thing we wrote was a recursive encoder: ```ts export function formEncode(obj: Record<string, unknown>): string { const parts: string[] = []; const walk = (key: string, val: unknown) => { if (val === undefined || val === null) return; if (Array.isArray(val)) { val.forEach((v, i) => walk(`${key}[${i}]`, v)); } else if (typeof val === "object") { for (const [k, v] of Object.entries(val)) walk(`${key}[${k}]`, v); } else { /* encodeURIComponent key=value */ } }; ``` 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 **not** 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. ### Part two: webhook verification without `constructEvent` Stripe signs webhooks with a `stripe-signature` header shaped like `t=<unix>,v1=<hex>`: an HMAC-SHA256 over `${t}.${rawBody}`. The Node SDK's `stripe.webhooks.constructEvent` does this for you; on workerd you do it with `crypto.subtle` and you compare the result in constant time, because a naive `===` on secrets leaks timing information: ```ts function timingSafeEqualHex(a: string, b: string): boolean { if (a.length !== b.length) return false; let diff = 0; for (let i = 0; i < a.length; i++) { diff |= a.charCodeAt(i) ^ b.charCodeAt(i); } return diff === 0; } ``` Our `verifyStripeSignature` also enforces a replay window: signatures older than 300 seconds are rejected, matching the default tolerance in [Stripe's own webhook docs](https://docs.stripe.com/webhooks). Then Resend's Svix-signed inbound-email webhooks needed the _same idea with different details_, in `lib/svix.ts`: Svix signs `${id}.${timestamp}.${rawBody}` instead of `${t}.${body}`, uses a base64-decoded `whsec_`-prefixed key instead of a raw string, and emits base64 signatures in a space-separated `v1,<sig>` list instead of hex. Our Svix verifier also uses `Math.abs` 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 `crypto.subtle` + constant-time compare, both fail-closed on a missing secret or malformed header. 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. ### Part three: making best-effort metering safe Billing is metered per AI response, and the meter report happens _after_ the response has streamed, inside `waitUntil` — 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 `usageEvent` we just inserted. ```ts // the usageEvent id is the idempotency key so a retry can't double-bill identifier: event?.id, ``` 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. ## Breakage 3: passkeys — killed by an ASN.1 parser three levels down The most instructive failure, because we never even called the offending code. We wanted passkey sign-in via `@better-auth/passkey`. That plugin depends on `@simplewebauthn/server`, which depends on `@peculiar/x509` and `asn1js` — X.509 certificate parsing for WebAuthn attestation. Somewhere down that chain the bundle broke. To be precise about what happened, because it's easy to overclaim: Better Auth itself runs fine on workerd. Only the passkey **plugin** was removed, before launch, so no user ever lost a feature. The `passkey` table still sits in our Drizzle schema for the day the ecosystem catches up. The lesson stands regardless: the dependency that kills your deploy is rarely the one in your `package.json`. It's the certificate parser your auth plugin's WebAuthn library needs for an attestation flow you might never have exercised. ## The quieter workerd-isms that bite without a bundler error 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. ### Env is a binding, so construct per request On Node you read `process.env` 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: ```ts export function createAuth(env: Env) { return betterAuth(buildAuthOptions(env)); } ``` 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. ### Module scope is per-isolate, so in-memory state is a mirage 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 `customStorage` hook, so the counters actually survive across isolates. ### No filesystem, so the widget ships as a string The worker serves our embeddable widget at `/widget.js`. 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: ```js const bundle = await readFile(source, "utf8"); const banner = "// Generated by `pnpm --filter @llmchat/widget build`. Do not edit.\n// prettier-ignore\n"; await writeFile( target, `${banner}export const widgetBundle: string = ${JSON.stringify(bundle)};\n`, ); ``` Yes: our production widget is a `JSON.stringify`'d string constant, served with `cache-control: public, max-age=300`. 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. ### KV isn't atomic, so decide your failure direction on purpose 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: ```ts // Atomicity: this is a read-modify-write, so heavy concurrency on one key can // overshoot `max` (a lost update undercounts the window). ``` 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: - **`rateLimit` on public widget endpoints — fails open.** Defense-in-depth limiting must not take every customer's embed down with the store. - **`reserveOnce`, the idempotency reservation — fails closed.** On a money-touching write path, a possible duplicate gets blocked, not waved through. - **`shouldSendHolding`, the escalation-ack throttle — fails open toward sending.** A duplicate "we got your message" email beats a silent void. - **The subscription check gating account deletion — fails closed.** If we can't verify there's no live subscription, the deletion waits. 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. ## What didn't break 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 `@llmgateway/ai-sdk-provider` — the pipeline behind every AI response — bundles cleanly (we've written about [why we built on LLM Gateway](https://clankersupport.com/blog/why-we-built-on-llm-gateway)). We keep a contingency note to swap the provider for a direct `fetch` if a future version ever pulls Node deps, but it hasn't happened. 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. ## Was it worth it? What we got: - **Deploys that can't half-work.** 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. - **A dramatically smaller dependency surface.** The Stripe integration went from an SDK and its tree to just under 300 lines we can read in one sitting. Every HTTP call our billing system makes is visible in one file. - **Web-standard portability.** Everything is `fetch` and `crypto.subtle`. The same code would run on Cloudflare Workers, Deno, or anything else that speaks the Web Platform — which matters for a product whose [pitch includes self-hosting](https://clankersupport.com/blog/the-case-for-self-hostable-ai-support). What we paid: - **We are now the maintainers of clients that vendors used to maintain.** When Stripe ships a new API version or deprecates an endpoint, nobody bumps a package for us — we read the changelog and edit `stripe.ts` ourselves. - **We own security-sensitive code most teams never touch.** Two webhook verifiers with constant-time comparison and replay windows are now _our_ code to get right, test, and keep right. - **The vendor's docs are the spec.** Undocumented SDK behavior — encoding quirks, retry defaults, the metered-price-quantity rule — has to be rediscovered by us, sometimes the hard way. 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. If you do make the jump, start where we should have: not with your `package.json`, but with `npm ls --all` and a hard look at what your dependencies' dependencies drag in. The SDK that breaks your deploy is never the one you imported. ## FAQ ### Does Hono work on workerd? 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. ### How do you verify Stripe webhooks without the Stripe SDK? Recompute the HMAC-SHA256 of `${timestamp}.${rawBody}` with `crypto.subtle` using your webhook secret, compare it to the `v1` value from the `stripe-signature` 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. ### How do you serve static assets from a worker with no filesystem? 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. ### Is workerd the same as Cloudflare Workers? 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. # Salesforce buying Fin (Intercom): what it means for your support bill URL: https://clankersupport.com/blog/salesforce-fin-intercom-acquisition-support-bill Published: 2026-07-11 Category: Guides The deal is signed, not closed, and Fin's pricing hasn't moved. Here's Salesforce's dated post-acquisition track record — and what to check in your contract before renewal. 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. We run [Clanker Support](https://github.com/theopenco/llmchat), 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. ## What actually happened The facts, as of July 11, 2026: - **May 12, 2026** — Intercom renamed the company **Fin**, after its AI agent. "Intercom" lives on as the platform name. - **June 15, 2026** — Salesforce announced a [definitive agreement to acquire Fin](https://www.salesforce.com/news/press-releases/2026/06/15/salesforce-signs-definitive-agreement-to-acquire-fin/) for approximately $3.6 billion. Per [CNBC's coverage](https://www.cnbc.com/2026/06/15/salesforce-ai-customer-service-fin-acquistion.html) and Salesforce's own materials, Fin brings 30,000+ customers, including names like Asana, Shutterstock, and Riot Games. - **Expected close** — [Intercom's announcement](https://www.intercom.com/blog/salesforce-signs-definitive-agreement-to-acquire-fin/) 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. - **The plan** — Salesforce says Fin's team and technology will fold into **Agentforce**, its AI-agent platform. 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&D. 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 [our Fin pricing teardown](https://clankersupport.com/blog/intercom-fin-pricing) and won't repeat it here. ## What happens between signing and close 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: - **Pricing usually holds still.** Repricing mid-deal creates noise nobody wants. Fin's pricing has not moved since the announcement, and third-party pricing trackers ([GetVoIP](https://getvoip.com/blog/fin-pricing/), for one) confirm the same as of July 2026. - **Contracts remain contracts.** Your existing agreement binds the surviving entity after close. The terms you sign _between now and close_ are the terms you'll live under during the integration — which is exactly why this window matters. - **Sales teams keep selling, and renewals keep renewing.** 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. - **Roadmaps get cautious.** Companies in this window tend to avoid big public commitments that could complicate integration planning. Again: pattern, not prophecy. The one thing you should _not_ do is assume the announcement itself changes anything. It doesn't. What changes things is the close, and everything Salesforce decides after it. ## Salesforce's pricing track record, with dates 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: - **December 2020 → July 21, 2021** — Salesforce announces, then closes, the Slack acquisition, widely reported at roughly $27.7 billion. - **July 18, 2022** — about a year after close, [Slack announces its first price increase since launching in 2014](https://techcrunch.com/2022/07/18/slack-is-increasing-prices-and-changing-the-way-its-free-plan-works/): 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. - **July 11, 2023** — Salesforce announces its first list-price increase in roughly seven years: [+9% on average](https://www.salesforceben.com/salesforce-announces-9-price-increase-effective-august-2023/) 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 _until renewal_. - **June 2025** — [Slack repricing round two](https://slack.com/blog/news/june-2025-pricing-and-packaging-announcement): Business+ rises from $12.50 to $15 per user/month, a new Enterprise+ tier appears, and AI features spread across paid plans. - **August 1, 2025** — another Salesforce list-price increase, [about +6% on average](https://www.salesforce.com/news/stories/pricing-update-2025/) on Enterprise and Unlimited editions, announced alongside Agentforce packaging. 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: **the protection you have is the contract you're holding when the change is announced.** 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. ## What could happen to Fin after the acquisition ### Fin becomes Agentforce's support agent 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 _stay_. ### Pricing moves after close 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 _your 2027–2028 renewals_ as the window where changes would land. Speculating on direction is pointless; securing the price you have is not. ### Roadmap gravity pulls toward Salesforce 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 _today_ 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. ## What to check in your contract this week The actionable core. Pull up your Fin/Intercom agreement and check five things: - **Renewal date vs. expected close.** 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. - **Term length.** 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 _was_ the protection. - **Price-protection language.** 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. - **Unit-price lock on the metered part.** 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. - **Data export and integration commitments.** 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. None of this is adversarial. It's the same hygiene you'd apply to any vendor whose ownership is about to change. ## Stay, hedge, or leave **Stay** 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 [alternatives roundup](https://clankersupport.com/blog/intercom-alternatives) 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. **Hedge** 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. **Leave** 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 _fine_ forcing function for a decision you'd half-made; it's a bad sole reason for one. 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 [alternatives roundup](https://clankersupport.com/blog/intercom-alternatives), and there's a [migration guide for Fin](https://clankersupport.com/docs/migrate/fin) if you want to see what moving actually involves. ## The bottom line 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. ## FAQ ### Did Salesforce buy Intercom? 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. ### Will Fin's pricing change after the Salesforce acquisition? 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. ### Is Fin shutting down for Zendesk and other non-Salesforce helpdesks? 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. ### Should I switch away from Fin before the acquisition closes? 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. ### What is Salesforce's track record with acquired products' pricing? 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. # Everything that broke while shipping an embeddable AI widget URL: https://clankersupport.com/blog/shipping-an-embeddable-widget Published: 2026-07-11 Updated: 2026-07-26 Category: Engineering A support widget runs inside a DOM you don't control. Here's every way host pages broke ours — :empty selectors, 62.5% root font-sizes, null currentScript — and the rule we extracted from each fix. Here is what broke while shipping the Clanker Support widget as a one-script-tag embed: Shopify's Dawn theme hid it entirely with `div:empty { display: none }`; a 62.5% root font-size shrank it to 10/16 scale straight through the shadow DOM; `document.currentScript` 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. 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. ## Why the widget is one file The widget is a Vite lib build, IIFE format only, one entry (`src/mount.tsx`), one output (`widget.js`). Two config lines do most of the work: - **`inlineDynamicImports: true`** — 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. - **`cssCodeSplit: false`** — the CSS ships inside the JS (more on how below). 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 `mermaid` 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 `widget.js` for a feature support replies will never use. The fix is an alias to a stub: ````ts resolve: { alias: { // Streamdown lazy-loads mermaid for ```mermaid blocks. The widget is a // single inlined IIFE, so that lazy chunk gets inlined and would pull in // the whole (~MB) mermaid library. Support replies never contain // diagrams, so alias it to a no-op stub to keep widget.js small. mermaid: fileURLToPath(new URL("./src/mermaid-stub.ts", import.meta.url)), }, }, ```` The stub is a no-op object: `initialize()` does nothing, `render()` returns `{ svg: "" }`. If a diagram block ever appears, it renders nothing instead of costing every visitor the download. One more lib-mode gotcha: Vite keeps React's `process.env.NODE_ENV` references in a lib build, and there is no `process` in a browser. Without `define: { "process.env.NODE_ENV": JSON.stringify("production") }`, the widget throws a ReferenceError on the very first page it's embedded in. **Rule: ship a single-file IIFE, and audit what inlining drags in.** Every dynamic import in your dependency tree is a hidden passenger. ## Shadow DOM isolates selectors, not the cascade We mount into a shadow root: append a host `<div>` to `document.body`, `attachShadow({ mode: "open" })`, inject a `<style>` 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 _selectors_ is true. But two things pass straight through the shadow boundary: **inherited properties** and **unit resolution**. The host page's `font-family`, `line-height`, `text-align`, and `letter-spacing` all inherit into your shadow tree unless you pin them. So the top of our stylesheet is a wall of explicit values — `font-size: 16px`, `line-height: 1.5`, `letter-spacing: normal`, `text-align: left` on `:host` — plus a `box-sizing: border-box` reset on every element, because host pages love universal selectors and you don't get to assume `content-box` never leaked in from a parent frame's expectations. 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 _around_ the shadow DOM, once by reaching _through_ it. ## The day the widget vanished on Shopify Dawn: div:empty First bug: on a stock Dawn store, the widget didn't render. Not broken — absent. No bubble, no errors, nothing. 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 `<div>` we append to `document.body` has no children — it is, structurally, empty. And Shopify's Dawn theme ships this in `base.css`: ```css div:empty { display: none; } ``` 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. The fix is one small function (commit `e18586e`, shipped as PR #111): ```ts export function createWidgetHost(doc: Document): HTMLDivElement { const host = doc.createElement("div"); host.id = "llmchat-widget-root"; host.style.setProperty("display", "block", "important"); return host; } ``` Why an _inline_ important declaration and not a rule in our stylesheet? Because our stylesheet lives in the shadow root, and the `div:empty` 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. **Rule: your shadow host is `:empty` in the light DOM — plan for it.** Either your host element has light-DOM content, or you defend its `display` with an inline important declaration like we did. Hiding empty divs is a common theme pattern; assume it's out there. ## 38 minutes later: rem resolves through shadow DOM 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. That fraction is the tell. Dawn — like a lot of themes and older CSS codebases — sets `html { font-size: 62.5% }` so that `1rem = 10px` for convenient arithmetic. And here's the part that surprises people: **`rem` resolves against the host page's root font-size even inside shadow DOM**. The shadow boundary doesn't intervene. `rem` means "root em," and the root is `<html>` — the host's `<html>`. There's no shadow-local root to resolve against. Our stylesheet had 160 `rem` values in it. Every one of them was silently multiplied by 10/16 on any 62.5%-root page. Note what _didn't_ break: text that inherited from our pinned `font-size: 16px` was fine, which is exactly why the widget had looked correct everywhere else — the font-size pin protected inherited text while every `rem`-denominated _dimension_ (padding, radii, the bubble itself) quietly depended on a root we don't own. The fix (commit `445af84`, 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: ```css /* 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. */ line-height: 1.5; font-size: 16px; ``` Inside an app you control, `rem` is good practice — it respects user font-size preferences. Inside an embed, `rem` is an unversioned runtime dependency on a value the host page sets. Those are different products with different rules. **Rule: in an embed, ship px, not rem.** The two Dawn bugs make a matched pair, and together they're the thesis of this post: shadow DOM isolates _selectors_. It does not isolate inheritance, and it does not isolate unit resolution. The host page reached around our shadow root (the `:empty` match on the light-DOM host) and through it (rem resolving against the host root) on the same day, two PRs apart. ## Things nobody tells you about script-tag embeds Two smaller breakages, both about the `<script>` tag itself rather than CSS. **`document.currentScript` is a now-or-never API.** The widget reads its config from data attributes on its own script tag — `data-project`, `data-api`, `data-brand`, `data-mode`. To find "its own script tag" it uses `document.currentScript`, which is only set during synchronous evaluation of the script. Defer your config read into a `DOMContentLoaded` callback — the natural place, since you can't mount before the body exists — and `currentScript` is null. So the config capture and the mount are split: ```ts // document.currentScript is only set during synchronous script evaluation — // it is null inside the DOMContentLoaded callback — so capture config now. const config = resolveConfig( document.currentScript as HTMLScriptElement | null, ); ``` Config is resolved at top level, synchronously, the moment the script evaluates; only the DOM mount waits for `readyState`. **Derive your API origin from the script's own src.** 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: ```ts const apiUrl = script?.dataset.api ?? (script?.src ? new URL(script.src).origin : window.location.origin); ``` Whatever origin served `widget.js` 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 [self-hostable](https://clankersupport.com/blog/the-case-for-self-hostable-ai-support)) talks to itself; `data-api` remains as an explicit override. No environment detection, no build-time host baking. **Rule: capture `currentScript` synchronously, and make the script's own origin your API default.** ## Serving widget.js from a server with no filesystem Our API runs on workerd — the Cloudflare-workers-compatible runtime — where there is no filesystem to read a built asset from. But `/widget.js` has to come from somewhere, ideally the same origin as the API (see the previous rule). The answer is unglamorous: after `vite build`, a script reads `dist/widget.js` 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 `content-type: application/javascript`, `x-content-type-options: nosniff`, and `cache-control: public, max-age=300`. The generated file is gitignored; the API's deploy pipeline builds the widget first, so the constant is always fresh. The short cache lifetime 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 `<script>` tags in HTML you don't control. The code sets a five-minute max-age (at the cost of more origin hits); in production our platform's edge cache rewrites it upward (`max-age=3600, s-maxage=14400`), so real propagation of a shipped fix (like either Dawn fix) is minutes to hours. Still far better than an immutable fingerprinted URL you can never re-point. For a support widget, that trade is easy. **Rule: an embed URL is immutable to you, so keep its cache lifetime short.** ## The iframe fallback and its upside-down CSP 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 `/embed/<key>`: a full-page chat shell designed to be iframed. (For hosts that _do_ give you a real integration surface, we've written up the [React Server Components install](https://clankersupport.com/blog/nextjs-ai-support-widget-server-component) and the [WordPress plugin](https://clankersupport.com/blog/wordpress-ai-support-plugin) separately.) Writing the CSP for that page was a small lesson in itself, because it's a normal CSP turned inside out: ```ts c.header( "content-security-policy", "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 *", ); ``` Every app-hardening guide tells you to lock down `frame-ancestors`. Here `frame-ancestors *` is the entire point — being framed by arbitrary sites is the product — so everything _else_ gets locked to nothing: no forms, no base-URI tricks, scripts and connections from self only. `style-src 'unsafe-inline'` looks alarming until you remember the widget's whole stylesheet is one `<style>` element in a shadow root; that's the mechanism, not a compromise. One subtle line in that page: the widget script src is _relative_, not an absolute URL built from the request. Behind a TLS-terminating proxy the worker sees `http://`, and an absolute `http://` script URL on an `https://` 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. ## The rules, collected - **Ship a single-file IIFE.** Inline dynamic imports; stub out heavyweight transitive lazy-loads (our mermaid alias) instead of shipping them. - **Define `process.env.NODE_ENV` at build time.** Vite lib mode won't do it for you, and browsers have no `process`. - **Pin every inherited property at your shadow root.** Font, line-height, letter-spacing, text-align, plus a box-sizing reset. Shadow DOM only isolates selectors. - **Your shadow host is `:empty` in the light DOM.** Give it light-DOM content or defend `display` with an inline important declaration; theme CSS that hides empty divs is real and widespread. - **Ship px, not rem.** Inside an embed, rem is a dependency on the host page's root font-size — even through shadow DOM. - **Capture `document.currentScript` synchronously.** It's null by the time `DOMContentLoaded` fires. - **Default your API origin to `new URL(script.src).origin`.** Never a hardcoded host; keep an explicit override attribute. - **Serve the bundle from your API's origin with a short max-age.** Embed URLs are pinned in HTML you don't control; the cache header is your only re-point mechanism. Ours asks for five minutes, and production's edge cache stretches that to hours — check what your platform actually serves, not what your code sets. - **If you offer an iframe mode, invert the CSP.** `frame-ancestors *` on purpose, everything else `'none'` or `'self'`, and a relative script src so TLS-terminating proxies can't hand you mixed content. All of this code is public — the widget, the stub, both Dawn fixes with their commit messages — in the [repo](https://github.com/theopenco/llmchat), and you can poke the live widget at [showcase.clankersupport.com](https://showcase.clankersupport.com). If you're building your own embed, steal the rules; we already paid for them, one Dawn dev store at a time. # The best AI support agents in 2026 (real pricing compared) URL: https://clankersupport.com/blog/best-ai-support-agents Published: 2026-07-07 Author: Ismail Ghallou Category: Guides The best AI support agents in 2026, compared on real pricing — per-resolution vs per-seat vs flat, which LLM each tool runs on, and open-source options. The best AI support agents in 2026 are Fin (the strongest resolution engine, at $0.99 per outcome), Chatbase (model choice on a budget), Chatwoot (open-source omnichannel), and Clanker Support (our product: flat pricing from $19/mo, self-hostable, any LLM). The right pick mostly depends on how you'd rather pay — per resolution, per seat, or flat. Most "best AI support agent" lists are written for enterprise CX buyers: analyst quadrants, "contact sales" links, no actual prices. This one is different in three ways — every entry has a real dollar figure or an honest "they don't publish one," names which LLM it runs on and whether you can switch it, and open source gets a real section instead of a footnote. ## What an AI support agent actually is An AI support agent reads your documentation, help center, and past answers, then writes original responses to customer questions — and escalates to a human when it can't answer. That's different from the decision-tree chatbot of the 2010s, which walked visitors through pre-scripted button flows ("Billing → Refunds → Here's an article"). A decision tree only handles paths someone built; an AI agent handles the long tail of oddly-phrased, never-seen-before questions that make up most of a real queue. The distinction matters for pricing too: bots were priced like software (flat or per seat); AI agents introduced per-resolution billing, turning a fixed cost into a variable one. ## How we picked (and our bias, disclosed) Everything here comes from public pricing pages, vendor docs, and each vendor's own claims, checked July 2026. We didn't run a synthetic benchmark or "test each tool on 500 tickets" — nobody writing listicles does, and we won't pretend to. Prices change: treat every number as "as of July 2026" and check pricing pages before deciding. One more thing: **Clanker Support is our product.** It's on this list, it's not ranked #1, and its limits are listed next to its strengths. The other tools get their genuine wins. ## The best AI support agents in 2026 ### 1. Fin — the resolution benchmark Fin is the agent everyone else measures against. It started as Intercom's AI product, ate the company (Intercom renamed itself Fin in May 2026), and in June 2026 Salesforce signed a definitive agreement to acquire it for roughly $3.6 billion (pending close). It works across chat, email, and phone, and runs standalone on top of Zendesk or Salesforce without Intercom seats. The catch is the meter. You pay $0.99 per "outcome," and outcomes include resolutions, procedure handoffs, and disqualifications — plus "assumed resolutions," where the customer simply stops replying. Qualification outcomes run $9.99 each, and there's a 50-outcome monthly minimum (about a $49 floor). To Fin's credit, it doesn't charge when a customer asks for a human. Full teardown: [Fin's pricing, explained](https://clankersupport.com/blog/intercom-fin-pricing); head-to-head: [Clanker vs Fin](https://clankersupport.com/vs/fin). - **Pricing:** $0.99 per outcome, $9.99 per qualification, ~$49/mo minimum; Intercom seats from $29/seat/mo billed annually - **Pricing model:** per resolution (usage-based) - **Underlying model / model choice:** Fin's own model, Apex, post-trained for support; no user-facing model switch - **Self-hostable:** no - **Best for:** high-volume teams that want maximum resolution rate and accept a variable bill ### 2. Zendesk AI agents — for teams already on Zendesk For teams already on Zendesk, its built-in AI agents are the path of least resistance — same data, same workflows. AI agents are included in every Suite and Support plan, billed on "automated resolutions": you pay only when the AI resolves a request without human escalation. What Zendesk does not publish is the price per resolution. The pricing page lists seats ($19/agent/mo for Support Team, $55 for Suite Team, billed yearly), but the per-unit AI rate is behind "contact sales." Third-party teardowns in mid-2026 put it around $1.50–$2.00 per automated resolution, less with committed volume — treat that as reported, not confirmed. - **Pricing:** seats from $19/agent/mo (yearly); per-resolution rate not published (third-party estimates ~$1.50–$2.00) - **Pricing model:** per seat + per resolution - **Underlying model / model choice:** not disclosed; no user-facing model switch - **Self-hostable:** no - **Best for:** existing Zendesk shops that want AI without changing helpdesks ### 3. Chatbase — model choice on a budget Chatbase is the pragmatic mid-market pick: build an agent on your content, deploy it to web, WhatsApp, Slack, or Messenger, and pick which LLM it runs on. There's a free plan (one agent, basic models) and paid plans from $40/mo on a credit system — read the fine print, because per Chatbase's docs premium models burn roughly 2–6 credits per message, so effective capacity depends on the model you pick. It's SOC 2 Type II certified and claims 10,000+ businesses. - **Pricing:** free plan; paid from $40/mo, credit-based - **Pricing model:** flat tiers with metered credits - **Underlying model / model choice:** yes — pick from OpenAI, Anthropic, Google, and others; heavier models consume more credits - **Self-hostable:** no - **Best for:** teams that want model flexibility and multi-channel reach without enterprise pricing Head-to-head: [Clanker vs Chatbase](https://clankersupport.com/vs/chatbase). ### 4. Tidio (Lyro) — SMB all-rounder on Claude Tidio is a long-running SMB live-chat suite, and Lyro is its AI agent — one of the few that publicly names its model: it runs on Anthropic's Claude — Tidio shipped one of the first Claude-powered support agents. Lyro answers on the web widget, email, Messenger, Instagram, and WhatsApp. Per Tidio's pricing page, the Lyro add-on starts at $32.50/mo for 50 AI conversations, on top of base plans from about $24/mo. Volume scales the bill quickly — the Plus tier starts at $749/mo — so model your volume before committing. - **Pricing:** Lyro add-on from $32.50/mo (50 AI conversations); base plans from ~$24/mo; high-volume tiers from $749/mo - **Pricing model:** flat tiers bucketed by conversation volume - **Underlying model / model choice:** Anthropic's Claude; not user-switchable - **Self-hostable:** no - **Best for:** small commerce teams that want chat, social channels, and AI in one tool ### 5. Crisp — the European all-in-one Crisp bundles shared inbox, CRM, knowledge base, and chat into one flat-priced product with European hosting — relevant if GDPR data residency is on your checklist. The free plan covers two seats but no AI; AI features start on the Mini plan at roughly €45/mo, with Essentials at €95 and Plus at €295. - **Pricing:** free 2-seat plan (no AI); AI from ~€45/mo; Essentials €95, Plus €295 - **Pricing model:** flat monthly - **Underlying model / model choice:** not disclosed; no user-facing switch - **Self-hostable:** no - **Best for:** European SMBs that want chat + CRM + AI in one flat-priced tool Feature-by-feature: [Clanker vs Crisp](https://clankersupport.com/vs/crisp). ### 6. eesel AI — pay-per-task on your existing helpdesk eesel takes the opposite approach to seats and subscriptions: since early 2026 it charges per task — $0.40 per support ticket, no platform fee, no per-seat fee, no monthly minimum, free until you've burned $50. It plugs into the helpdesk you already run (Zendesk, Freshdesk, Intercom, others) rather than replacing it. The model is abstracted away — you buy outcomes, not model access. - **Pricing:** $0.40 per ticket, no minimum; enterprise flat from $2,100/mo - **Pricing model:** per task (usage-based) - **Underlying model / model choice:** abstracted; no public model switch - **Self-hostable:** no - **Best for:** teams that want AI bolted onto an existing helpdesk with zero fixed cost ### 7. Chatwoot — the open-source omnichannel suite Chatwoot is the biggest open-source helpdesk (34,000+ GitHub stars): a full omnichannel inbox — WhatsApp, Instagram, email, Telegram — you can self-host for free or use as a cloud service from $19/agent/mo. Its AI layer, Captain, has fine print: per Chatwoot's docs it requires the Enterprise edition with a paid plan even self-hosted, and you supply your own OpenAI-compatible API key (custom endpoints supported, so local models work). Self-hosting means running a Rails + PostgreSQL stack, with the maintenance that implies. - **Pricing:** self-hosted community edition free; cloud from $19/agent/mo; Captain (AI) needs a paid Enterprise plan - **Pricing model:** open source + per seat (cloud) - **Underlying model / model choice:** bring your own OpenAI-compatible key on self-hosted; endpoint is configurable - **Self-hostable:** yes - **Best for:** teams that want a full open-source helpdesk and have the ops capacity to run it Here's [Clanker vs Chatwoot](https://clankersupport.com/vs/chatwoot) — short version: Chatwoot is a helpdesk with AI added; Clanker is an AI agent with an inbox added. ### 8. Clanker Support — flat pricing, any LLM, self-hostable (ours) Full disclosure: this is our product, so weigh this entry accordingly. Clanker Support is an AI support agent you install with one `<script>` tag before `</body>` (plus a React Server Component SDK for Next.js 15+, a WordPress plugin on wordpress.org, and a Shopify theme embed). It answers only from the knowledge base you give it — page URLs, text snippets, Q&A pairs — cites its sources, and when it can't help, it tells the visitor honestly and hands off: your team gets an email (and optionally Slack), the conversation lands in a team inbox with AI-written summaries and tags, and replies thread through email both ways. A visitor who asks for a human always gets one. Three things define its lane. Pricing is flat — [plans from $19/mo](https://clankersupport.com/pricing) (Growth $89, Scale $299, annual gets two months free), no per-seat or per-resolution fees, each tier with a monthly AI-response quota. It's model-agnostic — pick the LLM per project via LLM Gateway (OpenAI, Anthropic, Google, others) and swap with a config change, no code, no lock-in. And it's [open source](https://github.com/theopenco/llmchat) — self-hosting is free with your own LLM Gateway key, running serverless on Cloudflare-compatible infra with no Rails/Postgres stack to babysit. The honest limits: it covers the web widget and email only — no WhatsApp, Messenger, Instagram, or voice. There's no CRM, no product tours, no outbound campaigns. It's a newer product with a smaller ecosystem, and hosted has no free tier (self-hosting is the free path). [Try the live widget](https://showcase.clankersupport.com) before signing up for anything. - **Pricing:** flat from $19/mo; no per-seat or per-resolution fees; self-hosting free - **Pricing model:** flat monthly with an included response quota - **Underlying model / model choice:** yes — any LLM Gateway model, switchable per project with a config change - **Self-hostable:** yes (free, bring your own key) - **Best for:** developers and founders who want predictable cost, model control, and a five-minute install ## What AI support actually costs at volume The pricing model matters more than the sticker price. Here's the same workload — 100, 1,000, and 10,000 AI-handled conversations a month — under each model. These are illustrative worked examples that assume every conversation counts as one billable unit. **Per resolution.** Fin at $0.99 per outcome: 100 conversations ≈ $99/mo, 1,000 ≈ $990/mo, 10,000 ≈ $9,900/mo — before any Intercom seats. eesel at $0.40 per ticket: $40, $400, and $4,000 (its flat $2,100/mo enterprise plan wins past ~5,250 tickets). Zendesk's reported ~$1.50–$2.00 band, if accurate, would put 1,000 resolutions at $1,500–$2,000 plus seats. **Per seat.** Your bill tracks headcount, not volume — Intercom from $29/seat/mo (annual) or Chatwoot cloud at $19/agent/mo. Cheap with a small team, but it buys human capacity, not AI resolutions — the AI meter usually sits on top. **Flat.** Tidio and Chatbase sell volume buckets — flat until you outgrow the bucket, then you step up (Tidio's steps get steep). Clanker's tiers are flat with an included quota, so 10,000 conversations costs whatever your tier costs, not tier × volume — see [/pricing](https://clankersupport.com/pricing) for current quotas. The general rule: per-resolution pricing is fine at low volume and punishing at scale — it converts your best outcome (AI resolving more) into a bigger bill. Flat pricing inverts that: the more the agent resolves, the cheaper each resolution gets. ## Which LLM does it run on — and can you switch? Worth asking before you sign: Fin runs its own post-trained model (Apex) with no switch. Zendesk and Crisp don't say. Tidio's Lyro runs on Claude, fixed. eesel abstracts the model entirely. Chatbase and Clanker let you choose — Chatbase across frontier models at varying credit costs, Clanker across any LLM Gateway model at no price difference. Chatwoot's self-hosted Captain takes whatever OpenAI-compatible key you give it. If a better support model ships next quarter, only the switchable tools let you use it the same day. ## Open-source and self-hostable options If you need data control, no lock-in, or a $0 software bill, the field narrows fast. Chatwoot is the mature choice — a full omnichannel helpdesk — with the caveats above: Rails + Postgres to operate, and the AI layer paywalled behind Enterprise. Clanker Support is the lighter-weight one: the agent, widget, inbox, and email threading are all in [the repo](https://github.com/theopenco/llmchat), self-hosting is free with your own LLM key, and it deploys serverless. Papercups used to be the third name here, but development has slowed markedly. Longer list: our [open-source Intercom alternatives](https://clankersupport.com/blog/open-source-intercom-alternatives) guide. ## How to choose (and when to stay put) - **High volume and budget for the best resolution rate:** Fin — model the per-outcome bill first, including assumed resolutions. - **Already on Zendesk:** Zendesk AI — get the per-resolution rate in writing. - **Happy on Intercom today?** Stay. Migration costs are real, and the Salesforce deal doesn't change your product tomorrow. Switch when the meter outgrows the value — here's the [migration path](https://clankersupport.com/docs/migrate/intercom) when it does. - **Want model choice with WhatsApp/Slack channels:** Chatbase. - **SMB commerce with social channels:** Tidio. **European all-in-one:** Crisp. - **Keep your helpdesk, add AI with zero fixed cost:** eesel. - **Full open-source helpdesk and ops capacity to run it:** Chatwoot. - **Flat predictable pricing, any LLM, one-tag install, or free self-hosting:** Clanker Support — as long as web + email covers your channels. ## FAQ ### How do AI support agents work? An AI support agent connects a large language model to your knowledge base — docs, help-center pages, Q&A pairs. When a visitor asks something, it retrieves relevant content, writes an answer grounded in it, and escalates to a human when confidence is low. Good ones cite sources instead of guessing. ### How much does an AI support agent cost in 2026? Anywhere from $19/mo flat to thousands per month, depending on the pricing model. Per-resolution tools run $0.40 (eesel) to $0.99 (Fin) per conversation, so 1,000 resolutions cost $400–$990 a month. Flat-priced tools charge a fixed fee with an included quota. Self-hosted open-source tools cost only infrastructure plus your LLM API bill. ### Can AI replace human support agents? Not fully, and tools that claim otherwise oversell. AI agents handle the repetitive majority — documented questions, order status, how-tos. Vendors publish resolution rates to make the case: Fin, for example, cites around 76% of volume for its Apex model, though these are vendor figures, not independent benchmarks. Refunds, edge cases, and angry customers still need humans. The practical goal is fewer tickets reaching people, with an honest handoff when they do. ### What's the difference between a chatbot and an AI support agent? A chatbot follows pre-built decision trees — buttons and scripted flows that only cover paths someone designed. An AI support agent uses a language model to understand free-form questions and compose original answers from your documentation, escalating when it can't. Agents handle the unpredictable long tail; decision trees break on the first unexpected phrasing. ### Do AI support agents work with an existing helpdesk? Many do. eesel and Fin sit on top of Zendesk, Salesforce, and similar helpdesks; Zendesk's AI is native to its own suite. Standalone agents like Chatbase and Clanker Support ship their own inbox instead — Clanker threads escalations through email both ways, so your team can work replies from any mailbox. # The best Intercom alternatives in 2026 (an honest comparison) URL: https://clankersupport.com/blog/intercom-alternatives Published: 2026-07-07 Author: Ismail Ghallou Category: Guides Ten Intercom alternatives compared by pricing model — per-seat, per-resolution, flat monthly, and self-hosted — with honest cons and when to stay put. The best Intercom alternative depends on which pricing model you can live with: Zendesk or Freshdesk if you want a per-seat suite, Gorgias or Chatbase if usage-based billing fits your volume, Crisp or Clanker Support for flat monthly pricing, and Chatwoot if you want open source. Most teams leave over cost: seats from $29/month plus Fin's $0.99 per resolution. That is the short answer. The longer one: "best" is meaningless until you decide how you want to pay for support software, because the pricing model — not the feature checklist — determines what your bill looks like at 10x your current volume. So this guide groups the alternatives by pricing model. Two things up front. Disclosure: Clanker Support is our product; it sits in the flat-pricing section, is not ranked #1, and its weaknesses are listed as plainly as everyone else's. Methodology: this comparison is built from public pricing pages, docs, and each vendor's own claims, checked July 2026. Prices change — treat every number as "as of July 2026" and confirm on the vendor's pricing page. ## Why teams are leaving Intercom in 2026 Intercom renamed itself Fin in May 2026, after its AI agent, and on June 15, 2026 Salesforce signed a definitive agreement to acquire Fin for roughly $3.6 billion. The deal has not closed yet; an acquisition that size means roadmap and pricing uncertainty for at least a year. But the acquisition mostly accelerated a migration already underway, and the reason is the bill. Intercom runs two meters at once: - **Seats.** From $29 per seat per month on annual billing ($39 monthly), $85 ($99 monthly) for Advanced, $132 ($139 monthly) for Expert. Copilot, the AI assistant for your human agents, is another $29 per agent per month on annual billing. - **Fin resolutions.** Fin, the AI agent, costs $0.99 per "outcome" — a resolution, a procedure handoff, or a disqualification — plus $9.99 per qualification, with a 50-outcome monthly minimum (roughly a $49 floor). The detail that surprises people: "assumed resolutions," where the customer simply stops replying and leaves, are billable. Asking for a human is free. An illustrative worked example at those published rates: a five-person team on Advanced pays 5 × $85 = $425/month for seats, plus Copilot for everyone at 5 × $29 = $145. If Fin handles 600 billable outcomes that month, add $594. Total: about $1,164/month, roughly $14,000/year — and the Fin line grows with traffic, including conversations where the visitor just closed the tab. Full mechanics in [our Fin pricing teardown](https://clankersupport.com/blog/intercom-fin-pricing). None of this makes Intercom bad. It makes it expensive with an unpredictable AI line item, which is what sends people searching. ## How AI support pricing actually works in 2026 Every vendor's pricing page looks different, but there are only four underlying models. - **Per-seat.** You pay per human agent per month (Zendesk, Freshdesk, Help Scout, Intercom's base plans). Predictable if headcount is stable — but in 2026 nearly every per-seat vendor has bolted a usage-priced AI meter on top, so you often pay both. - **Per-resolution / usage-based.** You pay per AI resolution, ticket, conversation, or credit (Fin, Gorgias, Tidio's Lyro, Chatbase, Zendesk's AI agents). Cheap at low volume — but the bill scales with traffic, is hard to forecast, and the vendor decides what counts as "resolved." - **Flat monthly.** A fixed subscription per workspace, regardless of seats (Crisp, Clanker Support). The number on the pricing page is the number on the invoice; tiers include a usage quota, so check your volume fits. - **Self-hosted open source.** The software is free (Chatwoot, Clanker Support's open-source edition); you pay in infrastructure and your own time, plus LLM API costs if you run an AI agent. The most common billing surprise in 2026 is the hybrid: per-seat base plan plus usage-priced AI add-on — two meters at once, the structure Intercom, Zendesk, Freshdesk, and Help Scout now share. ## Per-seat suites: the classic helpdesks ### Zendesk The default enterprise answer, and genuinely the deepest omnichannel suite on this list. - **Pricing:** Suite Team $55/agent/month, Suite Professional $115 on annual billing, as of July 2026; Copilot is a $50/agent/month add-on; AI agents bill separately per automated resolution, with no flat rate published on the pricing page - **Pricing model:** per seat, plus per-resolution AI on top - **Self-hostable:** no - **Model choice:** no — Zendesk's own AI stack - **Best for:** mid-size and large teams that need mature workflows, SLAs, a big marketplace, and every channel under one roof - **Honest cons:** both meters — seats and resolutions; admin configuration is a real job; the entry price is nearly double Intercom's ### Freshdesk The budget per-seat suite. Same shape as Zendesk, lower sticker price. - **Pricing:** Growth $19/agent/month, Pro $55, Enterprise $89 on annual billing, as of July 2026, plus a limited free tier for 1–2 agents. The Freddy AI Agent includes 500 sessions on Pro and Enterprise, then $49 per 100 sessions; the Freddy Copilot add-on is priced separately. - **Pricing model:** per seat, plus AI sessions on top - **Self-hostable:** no - **Model choice:** no - **Best for:** teams that want a traditional ticketing suite at the lowest per-seat price, with room to grow into the wider Freshworks stack - **Honest cons:** the interesting AI is gated to Pro and above; AI sessions are yet another meter; breadth over depth across the product ### Help Scout The shared-inbox veteran, loved for being simple where the suites are heavy. - **Pricing:** a free plan covers up to 5 users and 100 contacts/month; paid plans run Standard $25, Plus $45, and Pro $75 per user/month as of July 2026 (annual discounts apply). Its AI answers feature bills at $0.75 per resolution. - **Pricing model:** per seat, with per-resolution AI on top - **Self-hostable:** no - **Model choice:** no - **Best for:** small teams doing primarily email support who want a tool the whole team understands in an afternoon - **Honest cons:** the AI is newer and shallower than the AI-first products here — and adopting it imports the same per-resolution unpredictability you were fleeing at Intercom ## Usage-based: pay per resolution, ticket, or conversation ### Fin standalone The twist most listicles miss: you can keep Fin and drop Intercom. Fin works standalone on top of Zendesk or Salesforce, no Intercom seats required. - **Pricing:** $0.99 per outcome (resolution, procedure handoff, or disqualification), $9.99 per qualification, 50-outcome monthly minimum — roughly a $49 floor — as of July 2026 - **Pricing model:** pure per-resolution - **Self-hostable:** no - **Model choice:** no — Fin runs on its proprietary Apex model - **Best for:** teams already on Zendesk or Salesforce that want the highest-profile resolution engine without Intercom's suite - **Honest cons:** assumed resolutions (the visitor leaves without replying) are billable; the bill scales with traffic; the pending Salesforce acquisition makes long-term pricing a guess ### Gorgias The ecommerce specialist. Charges per ticket, not per agent — unlimited seats on every plan. - **Pricing:** Starter $10/month for 50 tickets, Basic $50–60 for 300, Pro $300–360 for 2,000, Advanced $750–900 for 5,000 (lower figures are annual), as of July 2026. Its AI agent bills separately at $0.90–1.00 per automated interaction — and those interactions also count as tickets. - **Pricing model:** per ticket, plus per-AI-interaction - **Self-hostable:** no - **Model choice:** no - **Best for:** Shopify and ecommerce brands — the order-management integrations are the point - **Honest cons:** built for ecommerce, awkward outside it; two usage meters at once; per-ticket pricing punishes high-volume, low-value contact patterns ### Tidio SMB live chat with an AI agent (Lyro) bolted on as a metered add-on. - **Pricing:** free plan with 50 conversations; Starter from about $24/month and Growth from about $49/month on annual billing; the Lyro AI add-on starts around $32.50/month for 50 AI conversations; Plus starts at $749/month, as of July 2026 - **Pricing model:** tiered conversation quotas, plus a separate AI-conversation quota - **Self-hostable:** no - **Model choice:** no - **Best for:** small ecommerce and SMB sites that want chat plus basic automation running today - **Honest cons:** multiple separately-billed quotas (conversations, Lyro conversations, automation triggers); the jump from Growth (~$49) to Plus ($749) strands scaling teams in between ### Chatbase An AI-agent builder rather than a helpdesk: train an agent on your docs, deploy it across channels. - **Pricing:** free plan (1 agent, basic models); paid plans from $40/month on a credit-based system, as of July 2026 - **Pricing model:** subscription tiers with usage credits - **Self-hostable:** no - **Model choice:** yes — you can pick between multiple LLMs - **Best for:** getting a capable AI agent onto web, WhatsApp, Slack, and Messenger fast, with SOC 2 Type II compliance; it claims 10,000+ businesses - **Honest cons:** an agent platform, not a support inbox — human handoff and team triage are thin compared to helpdesks; credits are one more meter to watch We compare it to our own approach in [Clanker Support vs Chatbase](https://clankersupport.com/vs/chatbase). ## Flat monthly pricing: predictable bills ### Crisp The all-in-one European contender: chat, CRM, knowledge base, and campaigns in one box, priced per workspace instead of per seat. - **Pricing:** a free 2-seat plan (no AI); AI-inclusive plans from roughly €45/month (Mini), with Essentials at €95 and Plus at €295 per workspace, as of July 2026 - **Pricing model:** flat monthly, per workspace - **Self-hostable:** no - **Model choice:** no - **Best for:** SMBs that want chat, a lightweight CRM, and a knowledge base on one bill, with European hosting - **Honest cons:** the AI is younger than the dedicated AI-first products; tier jumps are chunky; all-in-one breadth means some modules are shallow ### Clanker Support Our product, so read this section knowing who wrote it. Clanker Support is an AI support agent installed with one script tag: it answers only from your knowledge base (page URLs, text snippets, Q&A pairs), cites its sources, and when it cannot help — or a visitor asks for a human — it hands off honestly: email and optional Slack notification, conversation landing in a team inbox, replies threading through email both ways. There is also a React Server Component SDK, a WordPress plugin, and a Shopify theme embed. - **Pricing:** flat plans from $19/month (Starter), $89 (Growth), $299 (Scale); annual gives two months free; no per-seat fees, no per-resolution fees — each tier includes a monthly AI-response quota, detailed on the [pricing page](https://clankersupport.com/pricing) - **Pricing model:** flat monthly - **Self-hostable:** yes — [open source](https://github.com/theopenco/llmchat), free to self-host with your own LLM Gateway key, running serverless on Cloudflare-compatible infrastructure with no Rails-and-Postgres stack to babysit - **Model choice:** yes — pick the LLM per project (OpenAI, Anthropic, Google, and others) and swap it with a config change, no code change - **Best for:** SaaS and developer-led teams that want a predictable bill, grounded answers with citations, and a clean human handoff - **Honest cons:** web widget and email only — no WhatsApp, Messenger, Instagram, or voice; no CRM, product tours, or outbound campaigns; it is a newer product with a small ecosystem; and the hosted version has no free tier (self-hosting is the free path) The [Intercom migration guide](https://clankersupport.com/docs/migrate/intercom) covers the move step by step; the [live demo](https://showcase.clankersupport.com) runs the real widget, not a mockup; and [Clanker Support vs Intercom](https://clankersupport.com/vs/intercom) has the feature-by-feature breakdown. ## Open source and self-hosted ### Chatwoot The established open-source support platform, and the right default if omnichannel on your own infrastructure is the requirement. - **Pricing:** the community edition is free to self-host; the paid cloud starts at $19/agent/month, as of July 2026 - **Pricing model:** free self-hosted, or per-seat cloud - **Self-hostable:** yes — a Rails + PostgreSQL stack you operate yourself - **Model choice:** not the core pitch — Chatwoot is inbox-first, not AI-first - **Best for:** WhatsApp, Instagram, Telegram, and email in one self-hosted inbox with full data ownership; real momentum, with 34,000+ GitHub stars as of July 2026 - **Honest cons:** you are signing up to run and upgrade a Rails and Postgres deployment; AI capabilities are lighter than the AI-first agents here Clanker Support also belongs in this category — same open-source, self-host-for-free deal, but AI-agent-first and serverless rather than inbox-first on Rails. Opposite trade-offs, unpacked in [Clanker Support vs Chatwoot](https://clankersupport.com/vs/chatwoot) and our [open-source Intercom alternatives guide](https://clankersupport.com/blog/open-source-intercom-alternatives). ## When to stay on Intercom An honest comparison owes you this section. Intercom (now Fin) is still the right choice if: - **You live on channels nobody here covers as well.** Fin runs across live chat, email, WhatsApp, SMS, phone, and Slack. If voice and WhatsApp are core channels, most alternatives on this list — ours included — do not compete. - **Resolution economics favor you.** If Fin genuinely deflects a large share of your volume, $0.99 per resolution can beat the agents you would otherwise hire. Per-resolution pricing is bad when unpredictable, not when high-deflection and measured. - **You use the whole platform.** Product tours, outbound messages, and campaigns are real products; replacing Intercom with three tools plus glue code changes the math. - **Switching costs exceed the savings.** A large team with years of macros and reporting should price the migration honestly first. If none of those describe you — mostly web and email support, modest or spiky volume, paying for seats and resolutions you barely use — that is exactly the profile that leaves. ## How to choose - **1–5 people, early-stage SaaS:** minimize the floor and the variance. A flat plan (Clanker Support from $19/month, Crisp from ~€45) or a free tier (Chatbase, Tidio, Help Scout) gets you live without a usage meter to babysit. - **Ecommerce:** Gorgias on Shopify for deep order integrations; Tidio for lighter chat automation. - **10+ agents, many channels:** Zendesk or Freshdesk, eyes open about the AI add-on meters; consider Fin standalone on Zendesk if raw deflection is the goal. - **Data ownership or compliance:** self-host — Chatwoot for omnichannel inbox depth, Clanker Support for an AI-first agent on serverless infrastructure. - **You mainly want the AI to answer well and hand off cleanly:** compare the AI-first products directly in our [best AI support agents guide](https://clankersupport.com/blog/best-ai-support-agents). ## FAQ ### Is there a free alternative to Intercom? Yes, several. Crisp has a free 2-seat plan (without AI), Chatbase and Tidio have free tiers, and Help Scout's free plan covers up to 100 contacts a month. For a permanently free option with full features, self-host an open-source tool: Chatwoot's community edition or Clanker Support's open-source edition, where you bring your own LLM key. ### Why is Intercom so expensive? Because two meters run at once. You pay per seat ($29–132 per agent per month on annual billing, as of July 2026), then Fin bills $0.99 for every resolution on top, with a 50-outcome monthly minimum. Copilot for human agents is another $29 per agent. Each line looks reasonable; the compounding is what shocks people at invoice time. ### What counts as a Fin resolution? Fin bills $0.99 per "outcome": a resolution, a procedure handoff, or a disqualification, with qualifications billed at $9.99. Crucially, "assumed resolutions" — where the customer leaves without replying — are billable. You are not charged when a customer asks for a human. There is a 50-outcome monthly minimum, roughly a $49 floor, as of July 2026. ### What is the best open-source Intercom alternative? Chatwoot is the established choice: an omnichannel inbox (WhatsApp, Instagram, Telegram, email) with 34,000+ GitHub stars, self-hosted on Rails and PostgreSQL. Clanker Support — our product — is the AI-agent-first alternative: serverless, model-agnostic, one-script install. Pick Chatwoot for channel breadth, Clanker Support if the AI agent and a predictable bill are the point. ### What is the best Intercom alternative for early-stage SaaS? Prioritize a low, fixed floor over features you will not use yet. Flat-priced tools (Clanker Support from $19/month, Crisp from about €45/month as of July 2026) keep the bill predictable while volume is spiky. If WhatsApp or Instagram support is essential from day one, look at Chatwoot or Crisp instead — the AI-first agents, ours included, skip those channels. # Intercom Fin pricing in 2026: the real math behind $0.99 URL: https://clankersupport.com/blog/intercom-fin-pricing Published: 2026-07-07 Author: Ismail Ghallou Category: Guides A primary-source teardown of Intercom Fin's $0.99-per-resolution pricing: billable outcomes, assumed resolutions, seat costs, and the real monthly math. Intercom Fin costs $0.99 per outcome — a resolution, a configured procedure handoff, or a lead disqualification — plus $9.99 per qualification, with a 50-outcome monthly minimum on non-Intercom helpdesks (about $49.50). Intercom seats stack on top, from $29 to $132 per seat per month on annual billing. Volume, not seats, drives the bill. That is the one-paragraph answer. The rest of this post is the line-by-line version: what the fine print actually says, what stacks on top, and what two realistic teams would pay. Everything here was checked in July 2026 against the vendor pages and pricing calculator — [fin.ai/pricing](https://fin.ai/pricing), [intercom.com/pricing](https://www.intercom.com/pricing), and both companies' help centers — not against other vendors' blog posts, which is where most Fin pricing explainers get their numbers. Prices change; treat the vendor pages as the source of truth and this post as the map. One disclosure up front: we build [Clanker Support](https://clankersupport.com/pricing), a flat-priced, open-source AI support agent. We compete with Fin in one lane and not in several others, and we'll be explicit about which is which. ## Fin pricing at a glance As of July 2026, per [fin.ai/pricing](https://fin.ai/pricing): - **Per outcome: $0.99.** An outcome is a resolution, a procedure handoff (a workflow you configured to end with a human), or a disqualification (Fin decides a prospect doesn't meet your criteria). - **Per qualification: $9.99.** When Fin matches a prospect to your qualification criteria and routes them, that single outcome costs ten times a resolution. - **Monthly minimum: 50 outcomes** when running Fin on a non-Intercom helpdesk — roughly a $49.50 floor at $0.99 each. Intercom's own pricing page confirms a "minimum monthly commitment" applies to standalone Fin. - **Human handoffs: free, mostly.** Per fin.ai/pricing: "You're not charged when a conversation is simply passed to your team without an outcome." The exception is a procedure you deliberately configured to end in a handoff — that is billable. - **Billed once per conversation.** However many questions Fin answers in one thread, it charges at most one outcome for it. - **No seat requirement for Fin itself.** Fin runs standalone on Zendesk, Salesforce, and other helpdesks without Intercom seats, with no setup or platform fees. - **Intercom seats (if you use their helpdesk): Essential $29** per seat per month billed annually ($39 monthly), **Advanced $85** ($99 monthly), **Expert $132** ($139 monthly), as of July 2026. The pricing page now routes you through a calculator rather than a flat price list, so confirm against your own configuration on [Intercom's pricing page](https://www.intercom.com/pricing). ## What counts as a billable outcome — the fine print The number that actually determines your bill is not $0.99. It is your **outcome rate**: what fraction of AI conversations end in a state Fin counts as billable. That definition lives in the help center, and it is worth quoting. Per [Fin's pricing-outcomes article](https://fin.ai/help/en/articles/13975800-fin-pricing-outcomes), a resolution is counted when the customer either "confirms the answer was satisfactory (confirmed resolution), or exits the conversation without requesting further assistance (assumed resolution)." And the window: "If a customer disengages from the conversation for 24 hours after Fin's last answer, it is considered an assumed resolution." Read that twice. **Silence is billable.** A customer who reads Fin's answer and closes the tab — satisfied, unsatisfied, or merely gone — counts as a resolution after 24 hours. This is the single most important line in Fin's pricing, and it is the one the $0.99 headline doesn't tell you. It is also the mechanic nearly every third-party Fin pricing explainer singles out, because it is the part customers say surprised them at invoice time. In fairness, the fine print also contains genuinely pro-customer carve-outs, and most teardowns skip these too: - **The clarifying-question exception.** If Fin's last message was a question rather than an answer and the customer never responds, "no resolution state is recorded and this is not a billable outcome." - **The return deduction.** If a resolved conversation is reopened by the customer "even across billing periods, that resolution will be deducted and not charged." - **Default escalations are free.** Handoffs triggered by Fin's default behavior or workspace rules aren't billed — only handoffs you explicitly built as procedures are. So the honest summary: the definitions are reasonable and the carve-outs are real, but the assumed-resolution mechanic means your bill is decided partly by customer behavior you can't observe, using a counter you don't control. ## What stacks on top Fin's per-outcome fee is the metered part. If your team also works inside Intercom's helpdesk, seats stack on top — as of July 2026, that's $29/$85/$132 per seat per month on annual billing (Essential/Advanced/Expert), or $39/$99/$139 monthly. Then the add-ons, per [intercom.com/pricing](https://www.intercom.com/pricing): - **Copilot** — the AI assistant for your human agents, distinct from Fin — is **$29 per agent per month billed annually** ($35 monthly) for unlimited usage, beyond a small free allowance of 10 Copilot conversations per agent per month. - **Proactive Support Plus** is **$99/month** with 500 messages included. - A **$99/month** conversation-analysis add-on includes 1,000 analyses monthly. - Email campaigns, SMS, WhatsApp, and phone are **pay-as-you-go** on top. None of these are hidden — they're on the pricing page. But "from $0.99 per resolution" and "what a 10-person support org pays Intercom per month" are very different numbers, which is why the next section does the arithmetic. ## The math: two illustrative teams These are worked examples, not benchmarks — the assumptions are stated so you can rerun them with your own numbers. The key variable is the billable-outcome rate: the share of AI conversations that end in a confirmed resolution, an assumed resolution, or a configured handoff. We'll bracket it at 50–70%, on the assumption that the remainder escalate by default (free) or end on an unanswered clarifying question (free). ### A 2-person team at 300 AI conversations a month - Outcomes: 150 to 210 conversations × $0.99 = **$148.50 to $207.90** - Seats: 2 × $29 (Essential, annual) = **$58** - Total: **$206.50 to $265.90 per month** - With Copilot for both agents (2 × $29 = $58): **$264.50 to $323.90 per month** Effective cost lands around $0.69 to $1.08 per AI-handled conversation once seats are included. At this scale Fin is not outrageous — the metered model is arguably at its best here, because a low-volume month produces a low bill (down to the ~$49.50 floor on standalone deployments). ### A 10-person team at 2,000 AI conversations a month - Outcomes: 1,000 to 1,400 × $0.99 = **$990 to $1,386** - Seats: 10 × $85 (Advanced, annual) = **$850** - Copilot: 10 × $29 = **$290** - Total: **$2,130 to $2,526 per month**, or roughly **$25,600 to $30,300 per year** Note what happened between the two examples: conversation volume grew about 6.7×, and the bill grew roughly 8× to 10× depending on configuration. Metered pricing plus per-seat pricing compounds. And this is before pay-as-you-go channels or the $99 add-ons. ## Why the bill grows when the AI gets better Per-outcome pricing has a clean sales pitch: you only pay when the AI succeeds. That alignment is real, and it deserves credit — a vendor that only earns on resolutions is motivated to resolve. But follow the incentive one step further. Every improvement you make — a better knowledge base, tighter procedures, each model upgrade Fin ships — raises the resolution rate, and the resolution rate is the billing rate. The reward for doing support well is a larger invoice. Under a flat or quota model, deflection improvements accrue to you; under per-resolution pricing, they're split with the vendor, indefinitely. The second-order problem is forecastability. Your bill is a function of ticket volume (seasonal, launch-driven, outage-driven) multiplied by an outcome rate that depends on Fin's own judgment calls and on the 24-hour silence rule. Finance teams can budget seats. Budgeting "how often will customers not reply to the bot" is harder. This isn't an accusation of bad faith — it is simply what the model does, and you should price it with your growth curve in mind, not your current one. ## The Salesforce acquisition: what it means if you're deciding now The corporate facts, since most pricing explainers bury them: Intercom renamed itself Fin earlier in 2026, and on June 15, 2026, [Salesforce signed a definitive agreement to acquire Fin](https://www.salesforce.com/news/press-releases/2026/06/15/salesforce-signs-definitive-agreement-to-acquire-fin/) for approximately $3.6 billion, [confirmed on Intercom's own blog](https://www.intercom.com/blog/salesforce-signs-definitive-agreement-to-acquire-fin/). The deal is expected to close in the final quarter of Salesforce's fiscal 2027 — around the turn of the calendar year — pending regulatory clearance. Salesforce says Fin's team and technology will join Agentforce, its AI-agent platform. Fin's CEO and co-founder Eoghan McCabe said he will stay on and that, with Salesforce's resources, "little will practically change." We won't speculate beyond that, and you should be wary of competitors who do. But if you are signing a contract today, three questions are legitimately on the table, acquisition or not: - **Pricing continuity.** Nothing published promises today's $0.99 survives the integration; nothing says it won't. Ask for term protection in writing if it matters to you. - **Roadmap gravity.** Post-close, Fin's roadmap presumably bends toward Agentforce and the Salesforce ecosystem. If you're a Salesforce shop, that may be good news. If you're not, ask where standalone deployments sit in the plan. - **Contract length.** An annual commitment signed now matures inside someone else's integration timeline. Shorter terms buy optionality. ## Three ways to price an AI support agent Most "Fin alternatives" posts pitch the same model at a lower unit price. The more useful comparison is between models, because the model determines how your costs behave as you grow. - **Per resolution (Fin's model).** Lowest floor, aligned incentives, unbounded ceiling. Best when volume is low or spiky and you want to pay nothing for a quiet month. Worst when the AI works, because success is metered. - **Cheaper per unit (credits and message packs).** Chatbase, for example, starts around $40/month on a credit system as of July 2026. The unit price is lower and the accounting simpler, but the shape is identical: costs scale with usage, and you're managing a credit balance instead of an outcome definition. - **Flat subscription or self-hosted.** A fixed monthly price with a usage quota, or open-source software you run yourself. Predictable and immune to the deflection tax; the tradeoff is a real floor even in quiet months, and quotas you should read before buying. This is the lane [Chatwoot](https://clankersupport.com/vs/chatwoot)'s self-hosted edition and our product occupy. We compare the specific tools in more depth in our [Intercom alternatives](https://clankersupport.com/blog/intercom-alternatives) and [open-source Intercom alternatives](https://clankersupport.com/blog/open-source-intercom-alternatives) posts. ## The flat-price alternative (disclosure: ours) Clanker Support is our product, so weight this section accordingly. - **Pricing:** flat monthly plans from $19/month (Starter), with Growth at $89 and Scale at $299; annual billing gives two months free. Each tier includes a monthly AI-response quota — details on [/pricing](https://clankersupport.com/pricing). No per-seat fees, no per-resolution fees. Your bill in a great month equals your bill in a quiet one. - **Pricing model:** flat subscription, or free if you self-host — the code is [open source](https://github.com/theopenco/llmchat) and runs serverless on Cloudflare-compatible infrastructure with your own LLM Gateway key. - **Setup:** one script tag before `</body>`, a React Server Components SDK, a WordPress plugin, or a Shopify theme embed. - **How it answers:** only from the knowledge base you give it — page URLs, text snippets, Q&A pairs — with cited sources, and an honest, visible handoff to your team when it can't help. Asking for a human always overrides the AI. - **Model choice:** pick the LLM per project (OpenAI, Anthropic, Google, and others) and swap it with a config change. The honest tradeoffs: we are web widget and email only — no WhatsApp, Messenger, or voice. There's no CRM, no product tours, no outbound campaigns. We're a newer product with a smaller ecosystem, and the hosted version has no free tier (self-hosting is the free path). If those are dealbreakers, we're not your tool. If flat pricing and no metering are the point, the [Fin comparison](https://clankersupport.com/vs/fin) and the [migration guide](https://clankersupport.com/docs/migrate/fin) cover the details. ## When Fin is worth it Genuinely, sometimes it is: - **Low or spiky volume.** At a few hundred conversations a month, per-outcome pricing with a ~$49.50 floor can undercut any subscription, ours included. - **Omnichannel requirements.** Fin operates across live chat, email, WhatsApp, SMS, phone, and Slack. If your support runs through channels beyond web and email, Fin does things we simply don't. - **You already live in Zendesk or Salesforce.** Fin runs standalone on other helpdesks with no Intercom seats, and the Salesforce acquisition will likely deepen that side of the story. - **Enterprise procurement.** A Salesforce-owned vendor with a mature compliance and integration ecosystem is an easier security-review conversation than a young open-source project. - **You qualify for the startup program.** Intercom's pricing page advertises up to 93% off plus a year of Fin free for early-stage companies — at that discount, the math above changes completely. If you're weighing the whole field rather than just Fin, start with the [full comparison](https://clankersupport.com/compare) or the [Intercom comparison](https://clankersupport.com/vs/intercom). ## FAQ ### How much does Intercom Fin cost per month? Fin costs $0.99 per billable outcome, per fin.ai/pricing as of July 2026, with a 50-outcome monthly minimum (about $49.50) on non-Intercom helpdesks. Total monthly cost depends on conversation volume and your outcome rate: illustratively, a team handling 2,000 AI conversations could pay roughly $990–$1,386 for Fin alone, before Intercom seats and add-ons. ### What counts as a resolution with Fin? Per Fin's help center, a resolution is counted when the customer confirms the answer helped, or exits without requesting further assistance — an "assumed resolution," triggered after 24 hours of silence following Fin's last answer. Procedure handoffs and disqualifications also bill at $0.99; qualifications bill at $9.99. Each conversation is charged at most once. ### Does Fin charge for human handoffs? Mostly no. Per fin.ai/pricing, "you're not charged when a conversation is simply passed to your team without an outcome," and default escalations are free. The exception is a procedure you explicitly configured to end in a handoff — completing that workflow counts as a billable $0.99 outcome. ### Can I use Fin without Intercom? Yes. As of July 2026, Fin runs standalone on Zendesk, Salesforce, and other helpdesks with no Intercom seat costs and no setup or platform fees, per fin.ai/pricing. The 50-outcome monthly minimum applies to these standalone deployments, so expect a floor of roughly $49.50 per month even at low volume. ### What does the Salesforce deal mean for Fin customers? Salesforce signed a definitive agreement on June 15, 2026 to acquire Fin for about $3.6 billion, expected to close around the turn of the year (Salesforce's fiscal Q4 2027) pending regulatory approval. Salesforce plans to fold Fin into Agentforce, and Fin's CEO says he will stay on with little operational change near-term. Nothing published guarantees pricing continuity either way, so ask for terms in writing. # 6 open-source Intercom alternatives you can self-host in 2026 URL: https://clankersupport.com/blog/open-source-intercom-alternatives Published: 2026-07-07 Author: Ismail Ghallou Category: Guides Six open-source Intercom alternatives you can self-host, compared on license, stack weight, built-in AI, and model choice — checked July 2026. 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. 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? ## Why developers are leaving Intercom 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 [our Fin pricing teardown](https://clankersupport.com/blog/intercom-fin-pricing); check Intercom's pricing page for current numbers. Cost isn't the only pressure. Intercom renamed itself Fin in May 2026, and on June 15, 2026 [Salesforce signed a definitive agreement to acquire Fin](https://www.salesforce.com/news/press-releases/2026/06/15/salesforce-signs-definitive-agreement-to-acquire-fin/) 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. 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. ## What open source actually buys you Three things, concretely: - **A license nobody can re-price.** 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. - **Your data, in your database.** 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. - **Your choice of LLM.** 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. What it does not buy you: someone else's pager. More on that below. ## The alternatives, compared 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. ### Chatwoot — the most complete open-source helpdesk 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. - **License:** MIT, with the `enterprise/` directory under a separate commercial license - **Stack & self-host weight:** Ruby on Rails + Vue + PostgreSQL + Redis — a real platform to operate, not a widget - **Built-in AI:** Captain (answers, summaries, agent copilot) — paid, credit-metered, not in the free community edition - **Model choice:** Captain is Chatwoot's managed AI feature; you're not picking the model per project - **Hosted option:** yes, cloud from $19 per agent/month as of July 2026 - **Best for:** teams that want a full omnichannel helpdesk and have the ops capacity to run a Rails stack We compare directly in [Clanker Support vs Chatwoot](https://clankersupport.com/vs/chatwoot) — and in plenty of scenarios, Chatwoot is the right call. ### Zammad — process-heavy ticketing done properly 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. - **License:** AGPL-3.0 - **Stack & self-host weight:** heavy — Rails plus PostgreSQL 13+, Redis 6+, a reverse proxy, and Elasticsearch (optional per the docs, but performance degrades significantly without it) - **Built-in AI:** not the pitch — this is tickets, queues, and process - **Model choice:** n/a - **Hosted option:** yes, Zammad sells hosted plans; pricing on their site - **Best for:** structured ticketing with SLAs, roles, and reporting — internal IT, agencies, regulated teams ### FreeScout — the lightweight shared inbox 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. - **License:** AGPL-3.0 - **Stack & self-host weight:** light — a PHP (Laravel) app; by far the easiest classic helpdesk on this list to run - **Built-in AI:** none in the core; AI isn't the project's focus - **Model choice:** n/a - **Hosted option:** no first-party cloud — self-hosting is the product - **Best for:** small teams that want email-first support on minimal infrastructure for near-zero recurring cost ### Tiledesk — open-source LLM agent flows 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. - **License:** MIT - **Stack & self-host weight:** moderate-to-heavy — a Node.js microservices architecture deployed via Docker Compose or Kubernetes/Helm; more moving parts than a single app - **Built-in AI:** yes — LLM-powered agent building is now the core product - **Model choice:** built around LLM integrations; check their docs for the currently supported provider list - **Hosted option:** yes, a managed cloud exists; pricing on their site - **Best for:** teams that want to design multi-step agent workflows rather than install a ready-made support agent ### Papercups — check the pulse before you commit 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. - **License:** MIT (Papercups); AGPL-3.0 + Commons Clause (Chaskiq — not OSI open source) - **Stack & self-host weight:** Elixir/Phoenix (Papercups); Rails + React + PostgreSQL + Redis (Chaskiq) - **Built-in AI:** none — both predate the AI-agent era of support tooling - **Model choice:** n/a - **Hosted option:** don't count on one for either project - **Best for:** teams with Elixir chops who want a small codebase to own and extend — eyes open ### Clanker Support — AI-first, one script tag, any model Disclosure first: Clanker Support is our product — read this entry knowing who wrote it. It's placed last on purpose. Clanker Support is not a helpdesk platform. It's an AI support agent you add with one `<script>` 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&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. - **License:** MIT ([github.com/theopenco/llmchat](https://github.com/theopenco/llmchat)) - **Stack & self-host weight:** serverless — runs on Cloudflare-compatible infrastructure (workerd, D1, KV). No Rails, no Postgres, no Elasticsearch to babysit - **Built-in AI:** the entire product — grounded answers with citations, honest escalation, per-message thumbs, and 1–5 CSAT - **Model choice:** any model via LLM Gateway — OpenAI, Anthropic, Google, and others — set per project, swappable with a config change - **Hosted option:** 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 [pricing](https://clankersupport.com/pricing) - **Best for:** developer-led teams that want an AI agent answering from their docs today, without adopting a platform 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 [live demo](https://showcase.clankersupport.com) before forming an opinion. ## The honest cost of self-hosting Every "open source is free" pitch skips this part. 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. 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. The honest framing: free license plus your ops time, or flat hosted fee plus someone else's. Pick deliberately. ## Is there an open-source alternative to Intercom Fin specifically? 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. 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 [Clanker Support vs Fin](https://clankersupport.com/vs/fin) and [our guide to AI support agents](https://clankersupport.com/blog/best-ai-support-agents). ## What a minimal self-hosted AI live chat setup looks like Strip the category to parts and you need five things: a widget on your site, a place to put knowledge (docs URLs, snippets, Q&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. 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. 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 `</body>`, and point the knowledge base at your docs; escalation lands in email and Slack out of the box — the [self-hosting docs](https://docs.clankersupport.com) walk through it. Either way the parts list is the same; the difference is how much of it you maintain. ## Which one should you pick? - **You want the full Intercom experience, open source:** Chatwoot. Most complete, most alive, biggest community. Budget for Rails ops, plus Captain credits if you want the AI. - **You want structured ticketing with process and audit trails:** Zammad. - **You want the cheapest sustainable email-first setup:** FreeScout. - **You want to build custom LLM agent flows:** Tiledesk. - **You want a minimal codebase to own and extend:** Papercups — clear-eyed about maintenance mode. - **You want an AI agent grounded in your docs, on your choice of model:** Clanker Support — self-host free, or flat from $19/month hosted. 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 [Intercom alternatives roundup](https://clankersupport.com/blog/intercom-alternatives). ## FAQ ### Is there a completely free open-source alternative to Intercom? 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. ### How hard is it to migrate off Intercom? 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 [Intercom migration guide](https://clankersupport.com/docs/migrate/intercom) covers the steps. ### What does self-hosting a support tool actually cost? 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. ### Are open-source support tools sustainable long-term? 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. ### What's the closest open-source equivalent to Intercom Fin? 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. # The Clanker Support WordPress plugin is approved — here's how it was built URL: https://clankersupport.com/blog/wordpress-ai-support-plugin Published: 2026-07-06 Category: Announcements Clanker Support is now an approved WordPress.org plugin. The story of building it, getting through the plugin review (including the zip that was secretly a tar), and how to put an AI support agent on your WordPress site in under a minute. Good news for the roughly 40% of the web: **Clanker Support is now an official WordPress plugin, approved for the WordPress.org plugin directory.** Install it, paste your project key under Settings → Clanker Support, save — and every page on your site gets a streaming AI support agent that answers from your knowledge base and hands off to a human when it should. This post is the whole story: why we built it, what it took to get through the WordPress.org review (spoiler: our zip file was secretly a tar), and how to use it. ## Why a plugin at all Adding a chat widget to WordPress has always meant one of two bad options: edit your theme and paste a script tag into `header.php` (which silently disappears the next time you switch or update themes), or install a generic "insert headers and footers" plugin and manage raw HTML in a settings box. Either way, you're maintaining code to use a product that was supposed to save you time. The plugin is the third option. It's deliberately thin — under the hood it enqueues the same `widget.js` embed our dashboard generates — but moving it into a plugin changes what it's like to live with: - **It survives your theme.** The settings live in your database and the widget is injected on every front-end page, whatever theme is active. - **No performance tax.** The script loads asynchronously and renders after your page is interactive. The plugin ships no JavaScript or CSS of its own to your visitors — one script tag is its entire front-end footprint, and nothing loads in the admin area. - **Always current.** The widget ships from the API, not from the plugin, so new widget features appear on your site without a plugin update. - **Reconfigure without touching HTML.** Brand color, escalation threshold, turning the bubble off — all settings saves, not snippet edits. ## What we built Everything lives on one screen under **Settings → Clanker Support**: ![The Clanker Support settings page in wp-admin: project key, floating widget toggle, brand color, escalation threshold, and API URL](https://clankersupport.com/blog/wordpress-plugin-settings.jpg) - **Project key** — the public key from your dashboard (Project → Embed). It's the same key the script embed exposes in your HTML, so it isn't a secret. - **Floating widget** — the site-wide launcher bubble, on by default. - **Brand color** — match the launcher and chat bubbles to your site. - **Escalation threshold** — how many visitor messages before "Talk to a human" appears. Leave it blank and the widget uses your project's server-side default. - **API URL** — for self-hosters (more on that below). The settings page also does something a pasted script tag never will: it checks itself. On load, the plugin verifies your project key server-side against the API and shows a status pill — connected, invalid key, or unreachable — so a typo'd key is caught on the settings screen, not discovered days later when you wonder why nobody's chatting. The result is cached for five minutes, and saving the settings re-checks immediately. And the floating bubble isn't the only placement. Drop the shortcode into any page or post — a contact page, a help center, a pricing FAQ — and the chat renders inline in a sandboxed iframe: ``` [clanker_support width="400" height="600"] ``` The shortcode works even when the site-wide bubble is toggled off, so you can offer chat only where it makes sense. Your visitors get the same full support loop as every Clanker Support embed: streaming AI answers grounded in your docs, human escalation that notifies your team by email and Slack, operator replies from the dashboard inbox appearing in the widget within seconds, and per-message ratings with an end-of-conversation CSAT prompt. ![How the WordPress plugin works: install and paste your key, the widget loads async from the Clanker API, visitors chat with AI answers and human handoff](https://clankersupport.com/blog/wordpress-plugin-flow.jpg) ## The build: shipped in a day, rebuilt the next The first version came together in an afternoon — a thin, pure-PHP injector, a settings page, the shortcode, a packaging script. We merged it, looked at it against what the WordPress.org directory actually expects, and pulled it back the same day. Because a plugin that works and a plugin that belongs in the directory are different artifacts. The rebuild that landed the next morning added everything reviewers (and WordPress conventions) expect: a proper class-based structure instead of one long bootstrap file, a translation template so the plugin is translatable, silence-is-golden `index.php` files in every directory, an uninstaller that removes the plugin's two stored values (its settings option and the connection-status cache — conversations live in your Clanker Support project, never in your WordPress database), directory listing assets, and a full `readme.txt` in the WordPress.org format. The readme deserves a special mention. Since the plugin is a connector to a hosted service, WordPress.org requires an explicit **external services disclosure**: what loads from where, what data is sent, and links to the terms and privacy policy. Ours spells out that the widget script comes from your configured API origin, that nothing about a visitor is sent until they interact with the widget — and that if you self-host, every one of those requests goes to your own deployment instead of ours. ## The review: three rejections' worth of lessons Before a human reviewer ever sees your plugin, automated checks run on the upload — and ours found things. **The zip that was secretly a tar.** Our packaging script built the upload zip with the system archiver, and on Windows that quietly falls apart: there's no `zip` CLI, `tar.exe` accepts `-a -cf plugin.zip` but can't actually write zip format — so it silently emits a TAR with a `.zip` name — and the PowerShell alternatives store backslash entry paths that unzip into garbage on Linux. The WordPress.org uploader rejected the artifact with the marvelously unhelpful "the plugin has no name." The fix: we threw out every external archiver and wrote a minimal zip writer on Node's built-in zlib — one deterministic code path on every OS — and the build now verifies the magic bytes of its own output. If your zip doesn't start with `PK`, it isn't a zip. **Plugin URI ≠ Author URI.** The submission checker requires the plugin's homepage and the author's homepage to be different URLs. Both of ours pointed at clankersupport.com. The Plugin URI now points at the plugin's home in our monorepo on GitHub. **Validate the readme where the validator can read it.** The [readme validator](https://wordpress.org/plugins/developers/readme-validator/) accepts a URL — but point it at a normal GitHub file link and it gets GitHub's HTML page, not your readme. We keep a byte-for-byte mirror of `readme.txt` at the package root and validate against the raw URL. With those fixed, the submission went in — and a few days later, the approval came through. Approval on WordPress.org means the plugin gets its own SVN repository and a directory listing, which brings the two things a GitHub zip can't: **one-click installs** from Plugins → Add New, and **automatic updates** for everyone who installs it. ## Using it 1. In your WordPress admin, go to **Plugins → Add New** and search for **"Clanker Support"** (or grab the zip from [GitHub](https://github.com/theopenco/llmchat/tree/main/packages/wordpress-plugin) and upload it under Add New → Upload Plugin). 2. Install and activate. 3. In your [Clanker Support dashboard](https://app.clankersupport.com), copy your project's public key (Project → Embed). 4. In WordPress, go to **Settings → Clanker Support**, paste the key, and save. Watch the status pill turn to connected. That's the whole setup. The bubble is live on every page; add the `[clanker_support]` shortcode wherever you want inline chat. And because Clanker Support is open source ([theopenco/llmchat](https://github.com/theopenco/llmchat)), the plugin treats self-hosters as first-class: point the API URL setting at your own deployment and everything — the widget script, the chat API, the inline embed, even the settings page's connection check — talks to your infrastructure instead of ours. Next on the roadmap: automatically identifying logged-in WordPress users so escalations arrive with a name and email attached, and WooCommerce context so the agent knows about the visitor's order. If either of those matters to your store, come tell us in [Discord](https://discord.gg/RnyjHWuTKP) — it directly shapes what we build first. # Add AI customer support to your Next.js app with one Server Component URL: https://clankersupport.com/blog/nextjs-ai-support-widget-server-component Published: 2026-07-02 Author: Ismail Ghallou Category: Guides A step-by-step tutorial for @clankersupport/widget-rsc: install the SDK, drop one component into your root layout, then restyle it with CSS or rebuild the UI entirely with headless primitives. Support widgets have shipped the same way since 2015: paste a script tag before `</body>`, hope it doesn't fight your framework. In a Next.js app that means no types, no server rendering, a mystery global mutating your DOM, and a launcher that pops in after hydration. We just shipped a better way. [`@clankersupport/widget-rsc`](https://www.npmjs.com/package/@clankersupport/widget-rsc) is Clanker Support as a native React Server Components package: one component in your root layout puts a streaming AI support agent on every page, server-rendered into your HTML. And because the whole widget is built on headless primitives, you can restyle it with plain CSS or replace our UI entirely. This tutorial takes you from `npm install` to a customized widget in about ten minutes. ## What you'll need - A Next.js 15+ app using the App Router (any React 19 RSC framework works; we'll use Next.js here). - A Clanker Support project and its public widget key — grab it from the dashboard under **Project → Embed**. Self-hosting the open-source [llmchat](https://github.com/theopenco/llmchat) stack works too; you'll just pass your own `apiUrl`. The key is public by design — it's the same key the script-tag embed exposes — so it's safe in client code and env files. ## Step 1: Install the SDK ```sh npm install @clankersupport/widget-rsc ``` React 19 and React DOM are the only peer dependencies. The package itself has zero runtime dependencies — the streaming protocol, API client, and storage layer are self-contained, so your bundle barely notices it. ## Step 2: Add the widget to your root layout Open `app/layout.tsx` and render `ClankerSupport` just before the closing body tag: ```tsx import { ClankerSupport } from "@clankersupport/widget-rsc"; export default function RootLayout({ children, }: { children: React.ReactNode; }) { return ( <html lang="en"> <body> {children} <ClankerSupport apiKey={process.env.NEXT_PUBLIC_CLANKER_KEY!} /> </body> </html> ); } ``` Add the key to `.env.local`: ```sh NEXT_PUBLIC_CLANKER_KEY=pk_your_project_key ``` Run `next dev` and open any page. A launcher bubble sits bottom-right; click it and you're chatting with your support agent — streaming answers from your knowledge base, with "Talk to a human" escalation and everything else the hosted widget does. Here's what makes this different from a script tag. `ClankerSupport` is an async Server Component: it fetches your widget config (branding, privacy URL) on the server, cached and revalidated every five minutes, so the client skips a round-trip and never flashes the wrong branding. The fetch is wrapped in Suspense with a `null` fallback and fails soft — if our API is slow or unreachable, your page renders normally and the widget simply appears with safe defaults. A support vendor should never be able to block your page. Now it can't. ## Step 3: Make it yours Everything visual is a typed prop: ```tsx <ClankerSupport apiKey={process.env.NEXT_PUBLIC_CLANKER_KEY!} brandColor="#16a34a" position="bottom-left" title="Acme Support" greeting="Hi! Ask us anything about Acme." escalationThreshold={2} /> ``` - `brandColor` drives the launcher, header, and user bubbles. - `position` docks the widget to either bottom corner. - `greeting` is the opening bubble (it personalizes automatically once a visitor gives their name; pass `null` to hide it). - `escalationThreshold` controls how many visitor messages appear before the "Talk to a human" option shows. Default is 3. ## Step 4: Restyle with CSS The default UI is plain, namespaced CSS — `.clanker-*` classes driven by custom properties, no shadow DOM — so your stylesheet always wins: ```css .clanker-root { --clanker-brand: #16a34a; --clanker-surface: #0b0f14; --clanker-text: #e5e7eb; --clanker-bubble: #1f2937; --clanker-border: #1f2937; } .clanker-panel { border-radius: 8px; } ``` That's a dark-mode widget in eleven lines, no configuration UI required. ## Step 5: Go headless when CSS isn't enough The styled widget is a thin composition over primitives we export from `@clankersupport/widget-rsc/headless` — the same pattern as Radix: unstyled semantic elements, `data-*` state attributes, `asChild` support, and full prop passthrough. ```tsx "use client"; import * as SupportChat from "@clankersupport/widget-rsc/headless"; export function HelpButton() { return ( <SupportChat.Root apiKey={process.env.NEXT_PUBLIC_CLANKER_KEY!}> <SupportChat.Trigger className="btn">Need help?</SupportChat.Trigger> <SupportChat.Panel className="panel"> <SupportChat.Messages> {(m) => <Bubble role={m.role}>{m.content}</Bubble>} </SupportChat.Messages> <SupportChat.EscalateButton>Talk to a human</SupportChat.EscalateButton> <SupportChat.Composer> <SupportChat.Input placeholder="Ask anything…" /> <SupportChat.Submit>Send</SupportChat.Submit> </SupportChat.Composer> <SupportChat.Branding /> </SupportChat.Panel> </SupportChat.Root> ); } ``` And when even components are too much structure, there's a single hook that exposes the whole state machine — messages, streaming status, sending, escalation, ratings, CSAT: ```tsx "use client"; import { useClankerSupport } from "@clankersupport/widget-rsc/headless"; export function SupportShortcut() { const { send, status, canEscalate, escalate } = useClankerSupport(); return ( <> <button onClick={() => send("Where is my order?")}>Track my order</button> {status === "streaming" && <TypingDots />} {canEscalate && <button onClick={escalate}>Talk to a human</button>} </> ); } ``` Build a ⌘K support palette, a docked sidebar, a help tab inside your settings page — the SDK handles the protocol (streaming, polling, escalation semantics, optimistic ratings) and you own every pixel. ## What you get out of the box Whichever layer you use, the behavior is the full Clanker Support loop: - Streaming AI answers grounded in your knowledge base (docs URLs, text snippets, Q&A pairs). - Human escalation that emails your team and posts to Slack, with the agent going quiet while a human owns the conversation. - Operator replies from the dashboard inbox appearing in the widget within seconds — no refresh. - Per-message thumbs ratings and an end-of-conversation CSAT prompt. - A privacy notice, identity capture, and conversations that survive reloads. One detail worth knowing if you're already using our script tag: the SDK uses the same browser storage keys, so switching to the React package keeps every existing visitor conversation and identity. Migration is deleting one script tag and adding one import. ## Self-hosting Everything above works against your own deployment of the open-source stack — pass your API origin and you're done: ```tsx <ClankerSupport apiKey="pk_…" apiUrl="https://support-api.your-domain.com" /> ``` The full API reference — every prop, primitive, and hook field — lives in the [package README](https://github.com/theopenco/llmchat/tree/main/packages/widget-rsc). If you build something with the headless layer, we'd genuinely like to see it — come show us in [Discord](https://discord.gg/RnyjHWuTKP). # Changelog: June 2026 — launch month URL: https://clankersupport.com/blog/llmchat-changelog-june-2026 Published: 2026-07-01 Category: Changelog We launched on Product Hunt. Also: smarter escalation, a full dashboard restyle, workspace-wide search, annual plans, and a security hardening pass. June was launch month. Here's everything that shipped. **We're live — and we launched on Product Hunt.** Clanker Support is officially open to everyone, and we spent launch day on Product Hunt answering questions. If you missed it, the [launch announcement](https://clankersupport.com/blog/clanker-support-is-live-on-product-hunt) covers what we built and why. To everyone who tried the widget, signed up, or asked us hard questions: thank you. **Escalation got a lot smarter.** This was the month's biggest product theme. When a conversation escalates to your team, the agent now passes along an in-chat summary of what's been discussed, so the customer isn't asked to repeat themselves. The agent is identity-aware — it stops re-asking for a name and email it already has. And once a conversation is escalated, the bot stays quiet instead of talking over your team. **Escalation emails the customer, and replies thread back.** When a conversation escalates, the customer gets an email — and their reply threads straight back into the same conversation in your inbox. No lost threads, even after they close the tab. **Visitors can resolve their own conversations.** Customers can mark a conversation resolved from the widget, and the inbox now records who resolved each conversation — visitor, agent, or your team. **The widget remembers returning visitors.** Visitor identity now persists across reloads, so returning customers skip the contact form and land back in their conversation. The greeting stays visible after the first message, and while a reply streams in, the latest turn stays pinned in view. **Privacy controls per project.** You can set a privacy policy URL per project, and the widget shows a consent notice above the composer until the visitor sends their first message. Useful if you operate in regions where that's not optional. **Dashboard restyle.** The whole dashboard got a design pass — inbox, projects, sources, settings, and billing — plus a new command bar shell. Same product, much easier on the eyes. **⌘K search.** Workspace-wide global search with deep links into inbox conversations. This checks off the conversation search we promised in the May changelog. **Inbox: AI triage summaries.** Every conversation in the inbox now shows a one-line AI summary, so you can scan a full queue without opening each thread. **Typed knowledge sources.** Sources are now typed — add plain text or structured Q&A pairs directly from the dashboard, with per-type rollups so you can see what the bot is drawing from. **Workspaces and annual plans.** You can create and delete workspaces, and the pricing page got a redesign: annual plans, an Enterprise tier, and richer quotas per plan. **Security hardening pass.** We swept conversation access paths for authorization gaps, added signature verification on inbound email webhooks, and made auth rate-limiting durable. Not glamorous, but this is the layer everything else sits on. Next up: Slack notifications and a public API for pulling usage data. # Clanker Support is live — and we're on Product Hunt today URL: https://clankersupport.com/blog/clanker-support-is-live-on-product-hunt Published: 2026-06-30 Category: Announcements Clanker Support is officially live and featured on Product Hunt. Here's what we built: an AI support agent that answers from your docs and hands off to a human the moment it can't. Today Clanker Support leaves the workshop. It's officially live, it's open to everyone, and — as of this morning — it's featured on Product Hunt. If you've been waiting for a reason to give your support inbox a real upgrade, this is it. We started Clanker Support with a complaint, not a pitch. Every "AI support" tool we tried had the same tell: faced with a question it couldn't answer, it made one up. Confident, fluent, and wrong. That's worse than no bot at all — a hallucinated refund policy costs you a customer and a chargeback. So we built the opposite. Clanker Support answers from your docs, your help center, and the sources you give it — and the moment it can't answer for real, it escalates instead of guessing. ## One script tag, then it's working Installation is one line of code before your closing `</body>` tag. No build step, no framework to fight. (_Update, July 2026:_ prefer a package? There's now an official React SDK — `@clankersupport/widget-rsc` on npm.) The widget mounts in an isolated shadow DOM, so it inherits your brand color without your styles leaking in or out. Most teams are live in about five minutes. From there it reads from the knowledge you paste in and stays on topic. Ask it something off-script and it won't improvise — it raises its hand. ## The hand-off is the whole point When the bot reaches the edge of what it knows — after however many exchanges you decide — it escalates. The full conversation lands in your team inbox with every message intact, an alert goes to your notification email, and the customer can keep the thread going over email. Nothing disappears into a black hole, and nobody has to re-explain their problem to a human who's seeing it cold. That's the line we care about: an agent that's genuinely useful when it can be, and honest the second it can't. ## Built to stay out of your way - **Any model.** Choose the model per project and swap it with a config change — no code edits — so routine questions run cheap and the hard ones run on something stronger. - **Open and self-hostable.** Bring your own keys and run it on your own infrastructure for free. If you'd rather not operate it, the hosted version handles all of that for you. - **Flat pricing.** Hosted plans start at $19 a month with no per-seat fees. Your bill doesn't grow every time you add a teammate. ## We're on Product Hunt today This is the part where we ask for a hand. Clanker Support is featured on Product Hunt right now, and the first day is the one that counts. If support that escalates instead of hallucinating sounds like something you've wanted, an upvote or a comment genuinely moves the needle for us — and we'll be answering every question over there all day. → [See Clanker Support on Product Hunt](https://www.producthunt.com/products/clanker-support) Or skip straight to the proof: drop the script tag on your site, paste in your docs, and watch the first conversation come through. We'd love to hear how it goes. # Introducing Clanker Support: AI support that actually escalates URL: https://clankersupport.com/blog/introducing-llmchat Published: 2026-05-20 Category: Announcements Today we're launching Clanker Support — a drop-in widget that answers from your docs and hands off to your team when the bot can't help. We built Clanker Support because we kept running into the same problem: AI chatbots that confidently hallucinate rather than admit they don't know something. Your customers deserve better, and so does your team. Clanker Support is a single script tag you drop on your site. The widget loads in a shadow DOM so your styles never leak, and you paste in your knowledge base or system prompt to keep the bot on-topic. When the bot genuinely can't help — after a configurable number of exchanges — it escalates. The full conversation lands in your inbox with context intact. Your team picks it up, replies by email, and the customer sees it threaded back into the same chat window. We've built it on LLM Gateway so you can swap underlying models without touching your integration. The whole stack is self-hostable on Ploy, D1, and KV. No surprise vendor lock-in. Get started free today. We'd love to hear how it goes. # How to reduce support tickets by 60% with an AI first-response layer URL: https://clankersupport.com/blog/reducing-support-tickets-with-ai-first-response Published: 2026-05-15 Author: Ismail Ghallou Category: Guides A practical guide to deploying an AI first-responder that handles the repetitive stuff so your team can focus on the conversations that matter. Most support tickets fall into a small set of categories: password resets, pricing questions, integration how-tos, and status page checks. These are table-stakes questions your docs already answer — they just require someone to read them on behalf of the customer. An AI first-response layer handles these instantly, 24/7, without burnout. The key is scoping it correctly. **Start with your knowledge base.** Export your top 20 FAQ answers, your docs navigation, and your pricing page copy. Paste this into your system prompt. This is your bot's working memory — keep it focused and current. **Set an escalation threshold that makes sense.** We default to 3 exchanges. If after three back-and-forths the customer still hasn't found their answer, they probably have a nuanced problem. Hand it off before frustration sets in. **Don't try to automate everything.** Billing disputes, angry customers, and enterprise deals should go straight to a human. Use the bot as a filter, not a replacement. Customers who get escalated quickly trust you more than customers who feel trapped in a bot loop. **Measure what matters.** Track escalation rate (% of conversations that reach a human), resolution rate (% of bot-only conversations where the user stopped asking), and customer satisfaction on both legs of the conversation. Teams that deploy this kind of layer consistently report 50–70% reduction in ticket volume within 30 days. The bot handles the easy stuff; your team handles the stuff that actually needs them. # Why we built on LLM Gateway instead of calling OpenAI directly URL: https://clankersupport.com/blog/why-we-built-on-llm-gateway Published: 2026-05-10 Category: Engineering Our reasoning for using a model abstraction layer from day one — and why it's already paid off twice. When we started Clanker Support, the obvious path was to call OpenAI's API directly. Every tutorial does it, the SDK is excellent, and it's what you know. We chose not to, and it's already paid off twice. The first time: GPT-4o pricing changed. We were able to re-evaluate and switch models for lower-cost use cases without touching our integration code. One config change. The second time: a customer needed to run on a self-hosted model for data residency reasons. We added their endpoint as a custom provider in LLM Gateway, pointed the project at it, and nothing else changed. LLM Gateway gives us a single interface — the OpenAI-compatible API — with routing, fallback, cost attribution, and usage metering on top. We use the Vercel AI SDK with their custom provider, which means our streaming code is the same regardless of what model is underneath. The practical implication for Clanker Support users: every project can run a different model. You might use Claude 3.5 Haiku for your high-volume support widget and GPT-4o for your enterprise tier. You get cost and usage per project without building that instrumentation yourself. We're believers in the principle that the model layer should be a runtime concern, not a compile-time one. LLM Gateway makes that real. # Setting up email threading for your support widget URL: https://clankersupport.com/blog/setting-up-email-threading Published: 2026-05-05 Author: Ismail Ghallou Category: Guides A step-by-step guide to configuring inbound email so customer replies thread back into the same conversation. When a conversation escalates in Clanker Support, we send an email to your configured notify address. What makes it useful rather than just a notification is what happens next: the customer can reply to that email, and their reply threads back into the conversation in your inbox. **Step 1: Configure your notify email.** In your project settings, set the "Notify email" field to wherever you want escalation alerts to land. This is your team inbox or a shared support address. **Step 2: Set up your inbound email domain.** Clanker Support uses Resend for both outbound and inbound email. You'll need to configure a receiving domain (e.g., inbound.yourdomain.com) and add the MX records Resend provides. This is a one-time DNS change. **Step 3: Set your inbound email local.** In project settings, set the "Inbound email local" field to a short identifier (e.g., "support" for support@inbound.yourdomain.com). This is the address customers reply to. **Step 4: Test it.** Create a test conversation, escalate it manually, and reply to the notification email from a different address. The reply should appear in your inbox within seconds. Once set up, the thread works both ways: you can reply from your inbox and the customer sees it in the widget. No separate helpdesk required for the basic case. # Changelog: May 2026 — knowledge base improvements and new model options URL: https://clankersupport.com/blog/llmchat-changelog-may-2026 Published: 2026-05-01 Category: Changelog Longer knowledge base support, model selection per project, and a handful of inbox quality-of-life improvements. Here's what shipped in May. **Longer knowledge base support.** We've increased the knowledge base character limit from 8k to 32k characters. Customers running large doc sets can now paste in significantly more content without hitting the cap. **Model selection per project.** You can now choose which LLM Gateway model to use on a per-project basis from the project settings page. We've pre-populated the dropdown with the models we've tested and recommend, but you can also enter a custom model ID. **Inbox: unread badge.** The conversation list now shows an unread badge on conversations with messages your team hasn't seen. Badge counts reset when you open the conversation. **Inbox: archive bulk action.** You can now select multiple conversations and archive them in one action. Useful for cleaning up resolved conversations at the end of a shift. **Widget: branded color inheritance.** The widget now picks up your brand color for the header and primary button. Set it once in project settings and it applies automatically. **Bug fix: email threading on reply.** Fixed an issue where customer email replies were occasionally creating duplicate messages in the conversation. Inbound email parsing is now more robust against non-standard reply formatting. Next up: Slack webhook notifications, conversation search, and a public API for pulling usage data. # The case for self-hostable AI support URL: https://clankersupport.com/blog/the-case-for-self-hostable-ai-support Published: 2026-04-28 Author: Ismail Ghallou Category: Engineering Why open architecture matters for tools that sit between you and your customers. Support tooling sits at a sensitive intersection: it handles customer PII, it's in the critical path of your customer relationships, and it's the first thing customers blame when something goes wrong. Locking that into a SaaS black box is a meaningful risk. We built Clanker Support to be self-hostable from day one. Here's what that means in practice. The stack is Ploy for deployment, D1 for the SQLite-compatible database, and KV for rate limiting and cache. All three run on Cloudflare's infrastructure. You can run the entire thing on your own Cloudflare account with your own domain, your own data residency, and your own billing. The code is open architecture — meaning you can read it, audit it, and understand exactly what's happening with your customers' conversations. There are no hidden webhooks, no data sold to third parties, no opaque enrichment pipelines. Self-hosting isn't for everyone. The managed version of Clanker Support handles infrastructure for you, and that's the right choice for most teams. But the option to take full control should exist, especially for regulated industries or companies with strict data handling requirements. We think AI tools that handle sensitive customer interactions should be auditable by default. Self-hostability is one way to make that real.