StoreFleet
HomeBlog › The AI Chat Widget We Bolted Onto a Static Site (and What Broke First)

The AI Chat Widget We Bolted Onto a Static Site (and What Broke First)

Adding an AI chat widget to a static landing page: why it needs a proxy, and why a system-role message erased the product.

Linh Nguyen · Updated

On this page
  1. Why a static site can't just call the agent
  2. The endpoint that looked right and wasn't
  3. A client-generated ID is not an identity
  4. The system role that erased the product
  5. Where I landed

On 15 July 2026, a few hours after wrapping a two-day sprint on email analytics, I shipped something much smaller on paper: a floating chat widget on our own marketing site. Visitors can now type a question and get an answer from an agent instead of reading through the blog or filling a lead form. It sounds like an afternoon of wiring a button to an API. It took most of a day, and every hour of that went into three problems that had nothing to do with the button.

The landing site is static — built with Vite, deployed to Cloudflare Pages. The agent backend is GoClaw, the same system that runs our internal Discord operator (our Discord agent for store support is the same family). Those two facts alone rule out the naive version.

Why a static site can't just call the agent

The obvious implementation is: widget calls the agent's webhook URL directly from the browser. That's one fetch call and no backend code. It's also a straight path to shipping the webhook secret inside the JS bundle, where anyone with devtools open can read it and call the agent as us, on our bill, with no rate limit we control.

So there has to be a server hop. Since the site is static, "server" here means a Cloudflare Pages Function — functions/api/chat.js, a single onRequestPost handler that reads the Pages environment (where the secret actually lives, set via wrangler secret or the dashboard) and forwards the request. Locally, Pages Functions don't run under vite dev, so I wrote a matching Vite middleware that intercepts /api/chat in dev. Both call the same file, scripts/chat-proxy-core.mjs. I split it out on the first pass, not as forethought — after writing the validation logic once for prod, I didn't want to write it a second time slightly differently for dev and have the two silently drift apart. That's the kind of bug that only shows up when a customer hits something that only breaks in production.

The endpoint that looked right and wasn't

GoClaw exposes an OpenAI-compatible /v1/chat/completions route, which is what I reached for first — it's the interface every LLM tool already knows how to call. It didn't work for this: that endpoint starts a fresh session on every request and only reads the last message in the array, so a visitor's second message arrives with no memory of the first. Fine for a stateless completion, wrong for a conversation.

The fix was to drop down to GoClaw's native webhook endpoint, /v1/webhooks/llm, and pass a session_key that both proxy calls (dev and prod) forward unchanged:

body: JSON.stringify({
  input: buildInput(message, payload?.lang),
  session_key: sessionId,
  user_id: sessionId,
  mode: 'sync'
})

The widget generates that key once per browser tab with crypto.randomUUID(), prefixes it web-, and keeps it in sessionStorage rather than localStorage — a session should survive a page change, not follow the visitor home. One Safari-in-private-mode edge case I only caught while testing: private browsing can throw on sessionStorage.setItem, and if that happens the widget still works, it just loses continuity on reload instead of crashing.

A client-generated ID is not an identity

session_key is generated in the browser, which means the proxy receives a string it did not choose. If the proxy just forwarded whatever arrived, a visitor could hand-craft a session_key matching an internal agent's session and read or influence a conversation that isn't theirs. The fix is a strict shape check before anything touches the upstream call:

const SESSION_ID_RE = /^web-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;

Anything that doesn't match web-<uuid-v4> gets a 400 before it goes anywhere near GoClaw. It's five lines of code, and it's the difference between "a visitor has their own sandboxed conversation" and "a visitor can address someone else's."

The system role that erased the product

The widget needs to answer in the visitor's language — English or Vietnamese, whichever the site is currently rendering. The textbook way to tell a chat model that is a system role message alongside the user turn. I tried it. The agent came back convinced StoreFleet was a retail shop selling products, not the tool we built to run several Shopify stores at once. Sending a system-role entry in GoClaw's input array overrides the agent's own system prompt outright — it doesn't add to it, it replaces it, and with it goes every piece of product knowledge the agent normally has.

The working version just prepends a plain-text instruction to the user's own message before it's sent — (Reply entirely in English.) or the Vietnamese equivalent — so it rides along as something the agent reads, not a directive that swaps out who it thinks it is.

Where I landed

The whole thing shipped as 8 files and 636 lines, and a good third of those lines are input validation that has nothing to do with chat UI: a 2000-character cap on messages, a 90-second upstream timeout before the proxy gives up and returns a "try again" error, and no CORS headers at all, because the widget only ever calls same-origin — there's no reason for another site to be able to hit this endpoint, so I didn't leave the door unlocked by default.

None of that is impressive-looking work. It's also the majority of what it actually takes to put a conversational agent in front of anonymous internet traffic responsibly. The demo-able part — a bubble that opens, streams text, looks nice — was maybe two hours. The part where a stranger's browser talks to a real agent that can burn real API spend, and where I had to prove to myself it can't be handed someone else's conversation or tricked out of knowing what it's supposed to know, was the rest of the day. If you're scoping "add an AI chat widget" as a small task on a roadmap, budget for the second part; it's not optional and it doesn't show up in a screenshot. It's the same lesson our AI customer support work already taught us on the Discord side — the interesting failures were never in the reply text.

Run dozens of Shopify stores from one dashboard

Message us on Discord — the AI agent and the team reply right in chat — or email us. Free demo on your own Shopify store, no account needed.