All Engineering posts

Product

Designing a chat widget you can paste anywhere

A one-line install, a sandboxed runtime, and a security model that survives someone copying your snippet. The decisions behind an embeddable AI assistant.

Priyank Gandhi ยท ยท 7 min read

The hardest constraint when shipping a chat widget for small business websites is that you don't control where it runs. The customer pastes a <script> tag into a site you've never seen. That site might be a vanilla static HTML page, a Webflow build, a WordPress theme, a React SPA. It might have strict Content Security Policies. It might be on http, on a domain with no www, on a subdomain. It might be loaded inside the customer's own CMS preview.

The widget has to work in all of those. It has to look correct, stay out of the host page's way, and โ€” critically โ€” refuse to be impersonated when someone copies the snippet onto a different site. Here's how we thought through the design.

The two-line install

Customers paste one line. Inside that line, two pieces of identity:

<script src="https://your-platform.com/widget.js" data-business="42" async></script>

The script tag itself is the loader. The data-business attribute identifies whose chat this is. Everything else โ€” colors, position, language overrides โ€” is optional data-* attributes the loader knows how to read.

There are richer install patterns. You could ship a full SDK that the customer initializes by hand. You could require an HMAC-signed token instead of a plain ID. We considered both. We picked the one-liner because:

Don't run the chat UI in the host page

The script tag is a loader. It doesn't render the chat itself. What it does:

  1. Mount a small floating launcher button on the host page.
  2. When the launcher is clicked, lazily inject an iframe that points at our domain.
  3. Pass postMessage events between the host page and the iframe.

All of the chat UI runs inside the iframe. The host page sees nothing but the launcher and the iframe element. There are three reasons this matters:

Isolation. The host page might have CSS that styles all button elements, font choices that overflow our layout, JavaScript that listens to every click. Inside an iframe, our CSS doesn't leak out and theirs doesn't leak in. We control our DOM completely.

Sandboxing. With sandbox="allow-scripts allow-same-origin allow-forms allow-popups" (deliberately omitting allow-top-navigation), the iframe is allowed to script and store data, but it can't redirect the host page. If we ever shipped a bug that called window.top.location = "...", the browser would refuse it.

Origin separation. The chat runs on our origin. That means localStorage keys for session IDs are scoped to us, not to the customer's site. A visitor opening two tabs of smileclinic.com will see both iframes share the same session ID because both iframes are on our domain. That gives us cross-tab continuity for free.

The tradeoff is a small postMessage protocol between iframe and host (close, open, optional unread count). It's a few dozen lines of code on each side. Worth it.

The transport: WebSocket, not polling

The chat backend is a real-time bidirectional channel. We considered three options:

We picked WebSocket. Two reasons:

Token streaming. When the model is generating a reply, we want the user to see tokens appear one at a time, not wait for the full response and then dump it. SSE could do this, but as soon as you also want the client to influence the active turn (cancel, send a follow-up while typing, submit an inline form), you want bidirectional.

Cross-tab sync. Multiple browser tabs of the same site should see the same conversation in real time. Server-side broadcast over WebSockets makes this trivial โ€” every socket bound to the same session ID gets every event. With polling each tab would have to fetch independently and reconcile.

The cost of WebSocket is a small dependency on the server: vanilla HTTP frameworks often don't ship WebSocket support without an extra library. (We learned this the hard way when a deploy returned 404 on every WebSocket upgrade until we added the right package.) Worth knowing about and fixing once.

Identity without a login

Most chat widgets force the visitor to identify themselves before they can chat โ€” name, email, sometimes a phone number. We didn't want that. Friction at the front of the funnel is what kills conversion on small-business websites.

So visitors are anonymous by default. The widget mints a UUID, stores it in the iframe's localStorage, and uses it as the session key. The server has a row keyed on (business_id, session_id). No phone, no email, no account.

When does identity actually matter? When the visitor wants to book or schedule something โ€” at that point we need a name and a phone. The assistant asks for it inline, the widget renders a form, the values are sent back, the row is upgraded in place. If a record already exists for that phone (because they messaged us on another channel), the rows are merged server-side.

That moves the "give us your info" moment from the front of the funnel to the back, where the visitor already has intent. Conversion goes up. The conversation history stays attached to whatever identity the visitor ends up with.

The security model: origin allowlists, enforced by the browser

Here's the part that's easy to get wrong. The snippet is public. Anyone can copy it and paste <script data-business="42"> onto a different site. Without protection, an attacker can run your customer's chat assistant on their malicious site and phish your customer's visitors.

The fix is an explicit allowlist of origins per business. The site at smileclinic.com is allowed to embed business 42's widget. Any other origin isn't.

The right place to enforce that is the browser, not your server, via a Content-Security-Policy: frame-ancestors header on the iframe document. When the iframe loads, the browser checks the header against the parent's origin. If the parent isn't in the list, the iframe simply doesn't render โ€” no JavaScript runs, no WebSocket connects, the attacker's page shows an empty rectangle.

Two important properties of this approach:

The seed list matters. When a business first installs the widget, the install path should automatically register the origin of their site (and the www / non-www companion) so the widget works on day one without manual setup. Manual additions handle the edge cases โ€” staging domains, alternate URLs, custom subdomains.

Defense in depth: the WebSocket handshake should also verify the parent-origin claim against the same allowlist. CSP is the primary defense; the WebSocket check catches scripted clients (curl, wscat) that try to connect outside a browser.

What we'd do differently next time

A few things we'd revisit if starting over:

The general lesson

The hard part of an embeddable widget isn't the chat. It's everything around it: install path, sandboxing, identity, origin trust, host-page compatibility. If you get those right, the chat itself is just another product surface โ€” and you can keep iterating on it without breaking the widget.

Build with us

Want this for your business?

NovaBuildBot ships a full AI-managed site + chat assistant for your business. No code, no infra, paid monthly.

Start on Telegram โ†’