Embedding the widget & Content-Security-Policy

This guide is for host-site developers putting the Hotline widget on their website. It covers the three embed modes and the exact Content-Security-Policy (CSP) directives a strict host needs so the widget can load, capture, and talk to the Hotline backend.

The widget is a single vanilla-TS bundle built with tsup into one artifact plus its content-hashed chunks:

File Format Use
hotline.js ESM, minified, code-split every embed — always <script type="module">
hotline-loader.js classic IIFE, ~1KB tag managers, and anywhere type="module" cannot be set
chunk-*.js ESM chunks (snapDOM / rrweb, lazy) fetched by the entry as needed

hotline-loader.js exists because a tag manager can only inject a classic script, and a classic tag cannot execute the module above. The loader is classic, so a tag manager can run it, and it injects the module tag itself. Its CSP needs are the same as mode 1 — one script-src entry for the CDN, no inline script, no nonce.

There is no IIFE build and no hotline.mjs. An earlier revision of this page described both; neither exists. The entry's first bytes are a bare import, so a <script> tag without type="module" silently never executes and you get no widget and no error. Every snippet below therefore sets it.

The core stays under 25 KB gzip; snapDOM (~16 KB) and rrweb (~110 KB) are loaded only via lazy import() when the instance's capture level needs them — so they appear in your CSP budget only at L1+ / L3.

Hosts in this guide. Hotline serves the bundle from cdn.hotline.red and the API/WS from api.hotline.red. Substitute your own origins if you self-host: locally that's the widget dev server for the bundle and http://localhost:8003 for the API/WS. Every CSP example calls out exactly which directive maps to which origin.


Table of contents


The three embed modes

The same hotline.js bundle serves all three. The widget detects its mode at runtime during boot, then: guards against a double-load → resolves its public key → fetches config from GET /v1/config/:publicKey → resolves the capture level → lazy-activates the needed capture modules → mounts a closed Shadow DOM UI (so your page CSS never bleeds in or out) → opens the live /v1/agent WebSocket only when voice/text-agent mode actually starts.

1. Script embed (recommended)

Drop one tag on any page. The public key is read from the script tag's data-public-key attribute.

<script
  type="module"
  src="https://cdn.hotline.red/hotline.js"
  data-public-key="pk_live_xxxxxxxxxxxxxxxx"
  async
></script>

This mode renders the default floating launcher in the corner configured by the instance's branding.position.

1b. Tag manager (classic loader — no inline script, no nonce)

<script
  src="https://cdn.hotline.red/hotline-loader.js"
  data-public-key="pk_live_xxxxxxxxxxxxxxxx"
  async
></script>

Prefer this over mode 2 below: it needs no inline <script>, so a strict CSP needs no nonce or hash — just the same script-src entry mode 1 already requires. It also loads /c/<key>.js, so the widget's config arrives without any cross-origin request (connect-src is then only needed for feedback submission).

2. Floating snippet

Functionally the same artifact, but loaded via a tiny inline loader that injects the script — useful when you want to set config inline (e.g. from a tag manager) before the bundle loads:

<script>
  window.__HOTLINE__ = { publicKey: 'pk_live_xxxxxxxxxxxxxxxx' };
  (function () {
    var s = document.createElement('script');
    s.type = 'module'; // required — the bundle is an ES module
    s.src = 'https://cdn.hotline.red/hotline.js';
    s.async = true;
    document.head.appendChild(s);
  })();
</script>

The global is window.__HOTLINE__, read by discoverConfig. An earlier revision of this page named window.HotlineConfig, which no code has ever read — set it and the widget finds no public key and never boots.

Because this uses an inline <script>, a strict CSP needs either a nonce/hash on that inline block or you should prefer mode 1 (no inline script). See CSP directives.

3. Bookmarklet

For internal QA / one-off sessions on a page you don't control, a bookmarklet injects the bundle on demand:

javascript:(function(){var s=document.createElement('script');s.type='module';s.src='https://cdn.hotline.red/hotline.js';s.setAttribute('data-public-key','pk_live_xxxxxxxxxxxxxxxx');document.body.appendChild(s);})();

The widget detects it was injected (no surrounding embed markup) and boots in bookmarklet mode. Note: a site's own CSP still applies to a bookmarklet-injected script — on a strict-CSP site the bookmarklet may be blocked (this mode is intended for your own/test properties, like the bundled demo-host).

The demo-host app (apps/demo-host, served at http://localhost:8001 under pnpm dev) embeds the widget all three ways across deliberately buggy/interactive pages — use it as a live reference.


What the widget talks to

To author a correct CSP you need to know every origin the widget reaches. With the default hosts:

Purpose Method Origin When
Load the bundle <script src> cdn.hotline.red always
Fetch config GET /v1/config/:publicKey api.hotline.red on boot
Submit feedback POST /v1/feedback api.hotline.red on submit / implicit filing
Upload ticket POST /v1/uploads/ticket api.hotline.red L1+ (screenshot/replay/audio)
Upload blob PUT /v1/uploads/blob api.hotline.red L1+
Live voice/text agent WS /v1/agent api.hotline.red (wss://) only when the agent starts

The widget UI is rendered in a closed Shadow DOM, mostly via inline styles scoped to the shadow root, so it does not rely on loading external stylesheets from your origin.


Content-Security-Policy directives

If your site sends a CSP, add the Hotline origins to these directives. Replace cdn.hotline.red / api.hotline.red with your own origins if self-hosting.

Content-Security-Policy:
  script-src  'self' https://cdn.hotline.red;
  connect-src 'self' https://api.hotline.red wss://api.hotline.red;
  img-src     'self' data: blob:;
  style-src   'self' 'unsafe-inline';
  worker-src  'self' blob:;

Directive-by-directive:

If you also embed the dashboard under a strict CSP (operator side, not the host-site widget), it additionally needs connect-src to reach api.hotline.red over both https:// and wss:// (the /v1/realtime Socket.IO channel).


Per-capture-level CSP

You can keep the policy minimal if your instances run at a low capture level.

Capture level Required additions beyond script-src + connect-src
L0 Text only none
L1 Diagnostics img-src data: blob: (snapDOM screenshot)
L2 Context same as L1 (timeline/console are JSON over connect-src)
L3 Replay L1 + worker-src blob: (rrweb)

connect-src (and wss:// for the voice agent) is required at every level because config fetch + feedback submit always happen, and the agent socket opens whenever voice/text-agent is enabled regardless of capture level.


The host side: allowed Origins (CORS)

CSP is enforced by the browser on the host page; the backend independently enforces a per-instance Origin allowlist on the public API. These are two halves of the same boundary:

So even with a permissive CSP, the widget can only POST feedback from a domain you've added to the instance's allowedOrigins. Add every origin the widget runs on (including http://localhost:<port> while developing) to that list, or ingest will 403 in the browser.


Troubleshooting

Symptom Likely cause Fix
Bundle never loads; console CSP error on script-src cdn.hotline.red (or your bundle origin) not in script-src Add it; or for inline modes add a nonce/hash
Feedback POST blocked by CSP API origin missing from connect-src Add https://api.hotline.red
Voice agent never connects wss:// scheme missing from connect-src Add wss://api.hotline.red
Feedback POST returns 403 (not a CSP error) Page origin not in the instance allowedOrigins Add the origin in the dashboard config
Screenshot blank / CSP error img-src data: blob: missing (L1+) Add data: blob: to img-src
Replay broken at L3 worker-src blob: missing Add worker-src blob:
Widget chrome renders unstyled style-src 'unsafe-inline' blocked Allow inline styles for the shadow root, or self-build with nonces

Related