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.
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:
- It's the format every other widget uses. Intercom, Crisp, Drift, Tidio. Customers know what it is. They paste it once.
- HMAC tokens don't actually protect against copying. A signed token bound to a business ID is still copyable. The right protection is server-side origin enforcement (see below), not a smarter token.
- Optional configuration via
data-*is enough. If we ever need more, we can add the SDK form as a second install path without breaking the simple one.
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:
- Mount a small floating launcher button on the host page.
- When the launcher is clicked, lazily inject an iframe that points at our domain.
- Pass
postMessageevents 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:
- REST poll โ the simplest. Client sends a message, polls for replies.
- Server-Sent Events โ server can push, client can't.
- WebSocket โ both sides push, single long-lived connection.
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:
- It's enforced before any of your code executes. You don't have to trust a parent-claim parameter or a header you can spoof. The browser does the check.
- It's per-business. The CSP header is generated dynamically based on which business ID the iframe was loaded for. You're not maintaining a global list of allowed origins; each business has its own.
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:
- Allowlists strict from day one, no permissive default. We considered a "permissive until set" mode and ruled it out at the last minute. Glad we did. Anything that lets the widget render on unspecified origins is a vulnerability waiting to be discovered.
- Server-rendered SPA shell from minute one. Cache the iframe's HTML aggressively, but never cache the shell with the wrong CSP. We had an early bug where the SPA shell was cached without a per-business header. Fixable, but easier if you set the rule up front: SPA shell is per-request, asset bundles are immutable.
- Build the widget for the worst host page you can imagine. Sticky navs at
z-index: 2147483647. CSS that styles allbuttons. Iframes that get unloaded and reloaded by SPAs. Test on Squarespace, Wix, raw HTML, and a couple of WordPress themes before you ship. The "it works on my own site" bar is low.
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.
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 โ