LiveChat Widget — Headless Install
The DialogBrain livechat widget can run in three display modes — chat, voice_only, and headless. The same JS bundle handles all three; the mode is picked per-widget in the dashboard.
In headless mode the bundle ships zero UI from us. You wire your own buttons, inputs, and message list to a window.DialogBrain.* JavaScript API. Use it when you need full design-system control. The chat/voice_only modes ship a polished bubble with accessibility, mobile keyboard handling, and error UI included — pick those if you don't have a strong reason to roll your own.
When not to use headless
Headless hands you the data engine and nothing else. You become responsible for:
- Accessibility — keyboard navigation, ARIA roles, focus management, screen-reader announcements
- Mobile keyboard handling — viewport shifts, soft-keyboard intersections, iOS Safari sticky-CTA quirks
- Error UI — every reject case (
permission_denied, network down, voice agent missing, destroyed) - Loading states —
await DialogBrain.readyresolves after config + session initialisation; until then your buttons should appear disabled or queue calls
About 95% of customers should pick chat or voice_only. If that's you, skip this page and copy the embed snippet from the dashboard instead.
1. Set display mode to "Headless" in the dashboard
- Open the DialogBrain dashboard and go to Settings → LiveChat Widgets.
- Open the widget you want to configure (or create a new one).
- In the Display mode section, pick Headless.
- Save.
Without this step the bundle reads display_mode = 'chat' from the backend and mounts the default chat bubble — your install will appear to "do nothing" even though the script loaded.
2. Embed snippet
<script src="https://dialogbrain.com/livechat/widget.js"
data-widget-key="your-widget-key" async></script>
The script is CSP-strict-friendly: the data-widget-key attribute means you don't need 'unsafe-inline' for an inline configuration script.
Widget-key resolution order: window.DialogBrainLiveChat.widgetKey → data-widget-key on the loading <script> → bail with console.error if neither is present.
If you have a CSP, allow:
script-src 'self' https://dialogbrain.com;
connect-src https://dialogbrain.com https://api.dialogbrain.com wss://api.dialogbrain.com;
3. Wire your UI
window.DialogBrain is installed synchronously at script-eval time. You can subscribe and call methods before the bundle has even fetched the widget config — pre-init calls to send, startVoice, and identify queue up and drain once ready resolves.
<button id="talk">Talk to support</button>
<input id="msg" placeholder="Ask anything..." />
<button id="send">Send</button>
<div id="messages"></div>
<script>
// Subscribe BEFORE ready — listeners survive init
DialogBrain.on('message', (m) => {
const el = document.createElement('div');
el.textContent = `${m.role}: ${m.text}`;
document.getElementById('messages').appendChild(el);
});
DialogBrain.on('voice_state', ({ state, error }) => {
if (state === 'error') {
console.error(`[voice ${error.code}]`, error.message);
}
});
document.getElementById('send').onclick = async () => {
const input = document.getElementById('msg');
await DialogBrain.send(input.value); // queues if pre-init
input.value = '';
};
document.getElementById('talk').onclick = () =>
DialogBrain.startVoice().catch((e) =>
console.error(`[voice ${e.code}]`, e.message),
);
// Optional: identify a known visitor so support sees who they are
// await DialogBrain.identify({
// email: 'ada@example.com',
// name: 'Ada Lovelace',
// custom: { plan: 'pro', org_id: '42' },
// });
</script>
API reference
Methods
| Method | Returns | Notes |
|---|---|---|
ready | Promise<void> | Resolves once the bundle has initialised. Rejects on hard config error (404, 401). |
send(text) | Promise<{ message_id: string }> | Pre-init calls drain serially — the bundle awaits the full response of each before starting the next. |
startVoice() | Promise<void> | Resolves once voice_state reaches 'active'. Rejects with {code, message} (see error codes below). |
stopVoice() | void | Synchronous and idempotent. The voice_state events ending then ended fire asynchronously within ~2 s. |
identify(fields, opts?) | Promise<void> | Allow-listed fields: {email?, name?, phone?, custom?}. Unknown top-level keys reject with {code:'invalid_field', field}. opts.identityHash? is stored for future HMAC verification — today always treated as unverified. |
destroy() | void | Tears down the socket + voice room. After destroy: cached refs still resolve (window.DialogBrain stays in place), but async methods reject {code:'destroyed'}. To re-init, reload the page — re-inserting the <script> is a no-op while the neutered object remains. |
getState() | sync snapshot | {connected, voice_state, messages, unread, identified}. Pre-init returns an empty default with the same shape. messages is a ring buffer capped at 50; server-side conversation history is the source of truth. |
markRead() | void | Resets unread to 0. Explicit so getState() stays a pure getter. |
on(event, cb) | () => void | Returns an unsubscribe function. The same listeners Map is reused across pre-init and post-init — subscriptions registered before ready stay valid. |
Events
type Event =
| { type: 'ready' }
| { type: 'message'; payload: Message }
| { type: 'voice_state'; payload: { state: VoiceState; error?: VoiceError } }
| { type: 'unread'; payload: { count: number } } // capped at 99
| { type: 'error'; payload: { code: 'config_failed' | 'socket_disconnected'; message: string } }
type Message = {
id: string
role: 'agent' | 'visitor'
text: string
timestamp: string // ISO 8601
metadata?: Record<string, unknown>
}
type VoiceState =
| 'idle' | 'requesting_permission' | 'connecting'
| 'active' | 'ending' | 'ended' | 'error'
type VoiceError = {
code: 'permission_denied' | 'no_voice_agent' | 'dispatch_failed' | 'livekit_failed'
message: string
}
The error event is reserved for hard infrastructure failures (config_failed, socket_disconnected). Voice failures travel through voice_state (state 'error' with an error payload), not as a separate error event — so you handle them in a single listener.
Voice state machine
A voice call cycles through these states (emitted via the voice_state event):
idle → requesting_permission → connecting → active → ending → ended
↓
error
requesting_permission and ending are imperative emits at the moment you call startVoice() / stopVoice(). connecting, active, and ended come from the underlying LiveKit room. error can fire from connecting (mic-permission denied, no voice agent, dispatch failed, LiveKit handshake failed) or from a mid-call drop.
Error handling
| Method | Possible reject codes |
|---|---|
ready | config_failed (in the error event, then ready rejects) |
send | destroyed (after destroy() called) |
startVoice | permission_denied, no_voice_agent, dispatch_failed, livekit_failed, destroyed |
identify | invalid_field ({field: string}), destroyed |
Every reject is {code, message} — match on code, show message only in dev consoles.
Caveats
- Autoplay:
startVoice()must run inside the task lineage of a user gesture (a click handler). Headless mode attaches a hidden<audio>element todocument.bodyfor the agent's TTS; browsers only allow that to play if the call originated from a real user-initiated event. Don't callstartVoice()from a timer. - Double-load guard: re-embedding
widget.js(e.g. via CMS regions or A/B variant tools) is a no-op — the firstwidgetKeywins; subsequent inits log a debug message and exit. This prevents two competing WebSockets and double-fired events on the same session. destroy()semantics: the object stays inwindow.DialogBrainso cached references (const db = window.DialogBrain) don't suddenly becomeundefined. All async methods after destroy reject{code:'destroyed'}, sync methods return empty defaults. To re-init, reload the page.- Pre-init queue order:
send()drains serially (each call awaits the previous response).startVoice()andidentify()drain in arrival order without serial gating. Design your UI so the visitor can't fire 50send()s before the bundle initialises.
Bundle size
The headless code path is a separate webpack chunk lazy-loaded only when display_mode='headless'. Chat and voice_only widgets do not download it.
Headless still ships React + the four core hooks (visitor session, socket, messages, LiveKit voice) — it's a chrome-less wrapper around the same data engine, not a "lightweight script". If you need a sub-10 KB pixel-style snippet, this mode is not what you want; consider a server-rendered chat fallback instead.