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 bareimport, so a<script>tag withouttype="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.redand the API/WS fromapi.hotline.red. Substitute your own origins if you self-host: locally that's the widget dev server for the bundle andhttp://localhost:8003for the API/WS. Every CSP example calls out exactly which directive maps to which origin.
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.
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.
<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).
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 bydiscoverConfig. An earlier revision of this page namedwindow.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.
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 athttp://localhost:8001underpnpm dev) embeds the widget all three ways across deliberately buggy/interactive pages — use it as a live reference.
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.
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:
script-src https://cdn.hotline.red — load the bundle. The widget's
lazy-loaded capture chunks (snapDOM/rrweb in the ESM build) are served from the
same origin, so no extra entry is needed. Mode 2 (floating snippet) and mode 3
(bookmarklet) use an inline <script> — for those, either add a nonce-… /
'sha256-…' for that inline block or prefer mode 1 (a plain external
<script src>, no inline code).connect-src https://api.hotline.red wss://api.hotline.red — the REST calls
(config, feedback, upload ticket + blob) use https://; the live agent
(/v1/agent) and, if you embed the dashboard, the realtime channel
(/v1/realtime) are Socket.IO WebSockets, hence wss://. Include both
schemes for the API origin.img-src data: blob: — snapDOM rasterizes the DOM into an image the widget
may preview before upload; that uses data:/blob: URLs. Only needed at L1+.style-src 'unsafe-inline' — the widget applies inline styles inside its
closed Shadow DOM. If your policy forbids 'unsafe-inline', the widget chrome may
render unstyled; a nonce-based style strategy can be used instead if you build the
widget yourself.worker-src blob: — rrweb's replay machinery may use a worker created from a
blob: URL. Only relevant at L3 (replay).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).
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.
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:
allowedOrigins list (configured in the dashboard).allowedOrigins, and echoes the caller's exact Origin
in Access-Control-Allow-Origin (never * for ingest), with Vary: Origin. A
preflight (OPTIONS) is answered 204; an origin that isn't allowlisted (or an
unknown/disabled key) gets 403.Content-Type, Authorization,
X-Hotline-Key, and X-Requested-With; allowed methods are GET, POST, PUT, OPTIONS.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.
| 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 |
cdn. / api. origins come from when
you self-host or deploy.