Skip to main content

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 statesawait DialogBrain.ready resolves 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

  1. Open the DialogBrain dashboard and go to Settings → LiveChat Widgets.
  2. Open the widget you want to configure (or create a new one).
  3. In the Display mode section, pick Headless.
  4. 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.widgetKeydata-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

MethodReturnsNotes
readyPromise<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()voidSynchronous 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()voidTears 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()voidResets unread to 0. Explicit so getState() stays a pure getter.
on(event, cb)() => voidReturns 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

MethodPossible reject codes
readyconfig_failed (in the error event, then ready rejects)
senddestroyed (after destroy() called)
startVoicepermission_denied, no_voice_agent, dispatch_failed, livekit_failed, destroyed
identifyinvalid_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 to document.body for the agent's TTS; browsers only allow that to play if the call originated from a real user-initiated event. Don't call startVoice() 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 first widgetKey wins; 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 in window.DialogBrain so cached references (const db = window.DialogBrain) don't suddenly become undefined. 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() and identify() drain in arrival order without serial gating. Design your UI so the visitor can't fire 50 send()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.