Skip to main content
Hat Boy Software

Adding an AI chat assistant to a static site

How we added an AI chat assistant to a static Hugo site: no new service, no CORS preflight, idle-based lead capture, hard limits and a kill switch.

Our site is static: Hugo pages served from storage, with no application server of its own. On October 10, 2026 we added an AI chat assistant to it. Conversations that include contact details become leads in our CRM. We didn’t build a new service for it: the chat runs on the backend that already answers our phone line, and shares that agent’s facts word for word.

One backend, one set of facts

Our AI phone receptionist’s backend already holds the company facts, the FAQ, the guardrails and the code that files leads in Omnisnia CRM, the same intake pipeline as our web form. A separate chat service would have meant a second copy that drifts. So the chat is two more routes on that backend: one for each message, and one the page calls when the visitor leaves.

The facts, FAQ and guardrails moved into one template that both prompts include. The risk was the phone agent, which was already live. Two tests cover it:

  • A golden test renders the phone prompt and compares it, byte for byte, with a saved copy. Moving text into the shared template couldn’t change a character the phone agent sees without failing a test.
  • A chat prompt test checks that the facts section from that saved phone prompt appears unchanged inside the chat prompt, and that the chat prompt never mentions the phone-only tools.

The chat gets two tools: one to file a lead and one to check office hours, with at most four tool rounds per visitor message. It can’t look up clients, open helpdesk cases or transfer calls. Each turn sends a system prompt of about 2,500 tokens, plus the conversation so far.

The widget

The widget is a Hugo partial and one small script, loaded with defer and a Subresource Integrity hash. Nothing calls the backend until the visitor sends a message; the first one goes with session_id: null, and the server returns a random session id.

There are no cookies. The session id and transcript live in the tab’s sessionStorage, so the conversation follows the visitor from page to page and is gone when the tab closes.

Two security rules come from the site, not the backend:

  • Text nodes only. Replies and the visitor’s own text are rendered with createTextNode. URLs become <a> elements built with the DOM API, and only http: and https: URLs are linked. Nothing goes through innerHTML. In testing, <img src=x onerror=alert(1)> rendered as text. The server also strips HTML tags and common Markdown from the model’s reply.
  • CSP first. The site’s Content Security Policy is enforced at the edge, and connect-src lists every host a page may call. We added the backend host to the policy before switching the chat on. In the other order, the browser would have blocked every message and visitors would only have seen the fallback.

CORS: JSON sent as text/plain

The first version posted JSON with Content-Type: application/json. That makes a cross-origin fetch a non-simple request, so the browser sends a CORS preflight (OPTIONS) first and only sends the real request if the preflight succeeds.

A preflight costs a round trip before every message, and it makes the chat depend on the backend’s OPTIONS handling as well as its POST handling. We avoided it. Per MDN’s CORS guide, a request is “simple” and skips the preflight when the method is GET, HEAD or POST, the script sets only CORS-safelisted headers, and Content-Type is application/x-www-form-urlencoded, multipart/form-data or text/plain (there are a couple of further conditions about request bodies). The widget sends the same JSON text, labeled as plain text:

fetch(API + "message", {
  method: "POST",
  // A CORS "simple request": no preflight.
  headers: { "Content-Type": "text/plain;charset=UTF-8" },
  body: JSON.stringify(body),
  credentials: "omit",
  cache: "no-store",
  signal: ctrl.signal
});

The tradeoffs:

  • The server must parse JSON whatever the Content-Type. Ours reads the raw body, caps it at 16 KB, and parses it as JSON. It already had to, because navigator.sendBeacon sends a string body as text/plain.
  • The request reaches the server before anyone checks the origin. Without a preflight, any page can make a visitor’s browser send this POST, just as any HTML form can. So the chat routes check the Origin header themselves: a request from an origin not on the allow-list gets a 403 before it reaches the model, and that covers the /end beacon too. The chat uses no cookies or credentials, so a forged request can’t act as anyone.
  • The response still needs Access-Control-Allow-Origin. The browser won’t let the script read the reply unless the response names the origin. Ours echoes the allowed origin and adds Vary: Origin.
  • Origin checks only stop browsers. A script outside a browser can send any Origin, or none. The limits below are what protect the backend from those.

In return, every message is one request, and there is no OPTIONS handler to get wrong.

Leaving the page isn’t leaving the chat

We wanted a lead even when a visitor gives an email address and then leaves before the assistant files anything. The obvious signal is pagehide, with a sendBeacon call to /end. But on a multi-page static site, pagehide fires on every click to another page and every reload, and the conversation carries on in the same tab afterwards. And a beacon sent as the tab closes may never arrive. So /end is only a hint:

  • /end records the time on the session and files nothing. A new message clears it.
  • A sweep files one fallback lead, with the transcript, when the session has been quiet for 600 seconds after the later of its last message and its last /end, the visitor typed an email address or phone number, and no lead was filed.
  • The sweep doesn’t depend on /end arriving: a quiet session with contact details is swept either way.
  • Claims in Redis make sure a fallback is never filed twice, or after a real lead, across replicas.

Timeline of one chat session. The visitor sends messages and moves between pages; each page change sends an end hint, which only records a time, and the next message clears it. If the visitor gave an email or phone number and no lead was filed, the session is checked once it has been quiet for 600 seconds after the later of the last message and the last end hint. If a lead was already filed, or no contact details were given, nothing is filed. Otherwise one fallback lead with the transcript is filed. An end hint delays the fallback lead; it never files one.

No long-running worker is needed. The sweep runs at the start of chat requests, and chat traffic starts a small background loop that stops when no session is waiting. Known gap: after a restart with no chat traffic, a due fallback waits for the next chat request.

Limits instead of a CAPTCHA

A public endpoint that calls a model costs money per message. We decided against a CAPTCHA and against third-party scripts, so limits do the work:

LimitValueWhat the visitor sees
Message length1,000 charactersThe input stops there; the server rejects anything longer
Messages per conversation40The conversation ends and the contact options show
Messages per IP addressA short-window cap“Try again in a few minutes” and the contact options
New conversations per IP addressAn hourly capThe same
Model tokens per UTC dayA fixed cap for the whole chat“Chat isn’t available right now” and the contact options, until midnight UTC

The per-IP limits need an address the client can’t fake. The first X-Forwarded-For entries are whatever the client sent; our ingress appends the address it accepted the connection from, so the backend uses the last entry. The address is hashed before it’s used as a counter key.

Two tabs can send on one session at once, so each session has a lock. A second message waits up to 30 seconds, then gets a 409 busy, and the widget gives the text back and asks the visitor to retry. Sessions expire 30 minutes after the last message; the widget starts a new one and resends once.

Every failure ends at a phone number

What the visitor sees for each response from the chat backend. 200: the reply. 400 invalid or 409 busy: the text is returned to the input with a short note. 404 session expired: a new session and one resend. 429 rate limited: try again later, plus phone, contact page and booking link. 503 unavailable, which covers the kill switch, the daily token cap, a model error and a backend fault: the same contact options. A network error or a 30-second timeout: the same contact options. No outcome leaves the visitor without a way to reach us.

The panel has a fallback block rendered into the page: our phone number, the contact page and the booking page. It’s also what the kill switch shows. One setting on the backend makes every chat call return 503, and the next message shows the fallback. No site release is needed. A separate site setting removes the widget completely at the next build.

Handing off to people

Asked for a person, the assistant offers three options in one reply: book a 30-minute intro meeting, call the office, or leave a message. A message becomes a lead flagged as wanting a person.

Support requests become leads tagged as unverified, never helpdesk cases. In a chat box, anyone can type any company name, so the team decides what becomes a case.

Every lead goes to Omnisnia CRM with the source web-form:chat, a summary (the transcript, for a fallback lead), the page the visitor was on and how they found the site. Asked about price, the assistant says cost depends on scope and the team will talk it through. It never quotes a figure.

The first reply always says the visitor is talking to an AI and that what they type is shared with our team. If the model leaves that out, the server adds a fixed sentence.

Checklist

  • Share prompt content through one template, and pin the existing agent’s rendered prompt with a golden test before you refactor.
  • If you send text/plain to skip a preflight, check Origin on the server, send no credentials, and still return Access-Control-Allow-Origin.
  • Treat pagehide and /end as hints. File fallback leads from server-side idle time.
  • Take the client IP from the hop your own ingress appends, not the first X-Forwarded-For entry.
  • Cap message length, messages per session, messages and sessions per IP, and tokens per day.
  • Render model output with text nodes, and update connect-src first.
  • Give every error a human fallback, and put the kill switch on the backend.

How we can help

We build AI assistants that work inside your existing systems, with the limits, fallbacks and tests designed in from the start. See our Applied AI Engineering services to find out how we can help.

Share this article

Related articles

Applied AI Engineering

Adding llms.txt and llms-full.txt to a Hugo site

How we generate llms.txt and llms-full.txt from Hugo content with custom output formats, the gotchas we hit, and why we did it while it is only a proposal.

← All articles

Not sure where to start? Start with an assessment.

A senior review of your app, cloud estate, or AI platform. Scoped and quoted before work starts. It ends in a prioritized plan, so you decide what to fix and when.

Talk to an engineer