A chat widget where the CMS holds a loader and nothing else — plus the four failure modes that cost me the most time: buffered SSE, base64 truncation that passes a syntax check, render-time escaping, and a balanced-but-wrong tag tree.
Canonical_url
https://dhseadev.online/2026/08/06/ai-answers-desk-val-town-groq/
I maintain a small site where I write up the internal tooling I build — process-serving
automation, Chrome extensions, desktop utilities. It had a static answers page. The
obvious next step was letting people ask the things that page doesn't cover.
Two constraints shaped the whole design:
- No server for me to maintain.
- The API key never reaches a browser, and never enters the CMS database.
What follows is the architecture I landed on, and — more usefully — the four things that
actually cost me time. Three of those four are general enough to bite you on a completely
different stack.
The shape
The CMS side is 481 bytes. One block, and all it does is:
window.DHSEA_ASK_ENDPOINT = "https://…";
// then inject <script src="…/widget.js">
That's it. No plugin. No key in the database. No logic in the page.
Everything else — the ~19KB widget, the styles, the chat logic, the proxy — lives in a
single serverless file with three routes:
| Route | Job |
|---|---|
GET /health |
Diagnostics. Written so it can never 500. |
GET /widget.js |
Serves the widget itself. |
POST / |
Chat proxy to the model API. |
The API key is an env var on the serverless platform. Because the widget is served from
the same function rather than pasted into the CMS, shipping a widget change is one
external deploy and I never touch the site.
/health deserves a note. A health check that can throw is worse than no health check,
because it fails in exactly the situation you're using it to diagnose. Mine returns
{ok, keyConfigured, store, storeError, fatal, day, dayReq, dayTokens, …} — errors
become fields, not exceptions.
Budget, because it's my card
Public endpoint plus paid inference is a bad combination unless you bound it up front:
- per-IP: 4/min, 30/day
- global: 12/min, 1500/day
- hard ceiling: 300k tokens/day
Counters live in the platform's SQLite so they survive cold starts — in-memory counters
on a serverless host reset when the instance recycles, which means no rate limit at all
under exactly the traffic pattern you care about.
On top: an origin allowlist. My domain gets 200. A lookalike domain gets 403. A
request with no Origin header gets 403. Test all three; the lookalike case is the one
naive startsWith checks fail.
Four things that cost me the most time
1. Your platform may buffer SSE
The model API streams tokens properly. My serverless host buffers, so what reached the
browser was roughly two chunks — not a stream.
Total round trip is under a second, so it reads fine. But I'd already started building a
token-by-token typewriter UI that was never going to work on that host.
Verify streaming end to end on your actual deployment target, not on the vendor's
docs page. The vendor streams. That tells you nothing about what your host does with it.
2. A truncated base64 payload passes a syntax check
The widget is base64-embedded into the serverless file by a build script. If a copy step
truncates the blob:
- the file still parses —
node --checkis happy -
atobfails at runtime - the panel renders blank
A syntax check attests the file, never the payload. So: never hand-copy the payload
(a build script does it file-to-file), and verify the deploy against the live artifact:
curl -s https://…/widget.js | shasum -a 256
…compared against the same hash computed on the local build. Same byte length is not
the same bytes. Compare hashes. Mine matched at 19,260 chars — that match is the
evidence the deploy worked, not the fact that the paste didn't throw.
3. Verify the served bytes, not the saved content
My CMS applies escaping at render time. So the stored content was byte-clean while the
live page was dead and the console was empty.
Reading back what you just saved proves the write succeeded. It proves nothing about
what the visitor receives. Fetch the rendered output and assert on that.
This generalizes past CMSs: anywhere a transform sits between your write and the user —
a bundler, a minifier, a template engine, a CDN — the artifact you validated is not the
artifact they get.
4. Balanced tags are not a correct tree
This is my favorite, because every mechanical check I had passed it.
A page with opening and closing div counts matching exactly — 14 and 14. And six
sections nested inside a seventh, because one closing tag had been deferred to the end of
the document.
Balance counting cannot catch this. Absence and misplacement are different bugs, and
counting only detects absence. Parse it instead:
const doc = new DOMParser().parseFromString(html, "text/html");
doc.querySelectorAll(".qa .qa").length; // 6 → nested. Should be 0.
doc.querySelectorAll(".in > .qa").length; // should equal total .qa
That assertion is the one that catches it, and it's the one I now run.
The through-line
Every one of these four is the same mistake wearing a different hat: I checked the
thing I could reach instead of the thing the user gets.
The saved content instead of the served page. The file's syntax instead of the payload's
integrity. The vendor's streaming behavior instead of my host's. The tag count instead
of the tree.
Each check passed. Each one attested something adjacent to what I actually needed to know.
Whatever sits between your code and your user — a renderer, a host, an encoder, a build
step — is the boundary your verification has to cross, or it isn't verification.
The result is live at dhseadev.online/ask, sitting next
to the static answers it was built to extend. If you
break it, I'd like to hear how.
Top comments (0)