Headless Live Chat
Build your own chat UI — createSession() exposes the conversation as typed, subscribable state while Brandfine owns transport, identity and threading.
install() gives you the floating bubble with zero effort. When chat
should live inside your page — a panel in your own design system,
your own layout — use the headless session instead:
const session = await bf.liveChat.createSession({
config, // LiveChatBootstrap from your server half — same as install()
visitor, // optional signed LiveChatVisitor — same identity rules
locale, // optional; defaults to <html lang>, then browser language
})The session shares the widget's storage, threading and identity semantics — a visitor who chats through the widget on one page and your inline panel on another continues the same conversation.
Creating a session doesn't create a conversation. It resumes one if
there is one (the same browser, or the same signed-in visitor) and
otherwise waits: the visitor's first send() creates it. The
session reaches OPEN right away either way, so you can render the
greeting and enable your send button immediately, and visitors who
open your chat UI and leave without writing never show up as empty
chats in your inbox.
The session object
session.messages // readonly ChatMessage[]
session.state // 'CONNECTING' | 'OPEN' | 'CLOSED' | 'ERROR'
session.send(body) // Promise<ChatMessage>
session.subscribe(fn) // fn(snapshot) on every change; returns unsubscribe
session.getSnapshot() // current LiveChatSessionSnapshot
session.close() // stop polling, abort work, drop listeners
session.reset() // fresh thread (identity switch on shared browser)Every snapshot carries what a UI needs — messages, status,
online, localized greeting / offlineMessage, sending,
error — so you never fetch config separately.
Message senders
message.sender is 'VISITOR' | 'AGENT' | 'SYSTEM' | 'BOT'.
BOT messages come from the workspace's
AI assistant when it's enabled —
render them on the business/agent side of your UI with an "AI"
label so visitors can tell bot from human. Treat any unknown
sender value the same way (agent-side, generic label) instead of
switching exhaustively: the server may add sender kinds before
your SDK version learns their names.
React example (~30 lines)
'use client'
import { useEffect, useRef, useState } from 'react'
import type { LiveChatBootstrap, LiveChatSession, LiveChatSessionSnapshot, LiveChatVisitor } from '@brandfine/client'
import { createBrandfineClient } from '@brandfine/client'
export function InlineChat({ config, visitor }: { config: LiveChatBootstrap; visitor?: LiveChatVisitor }) {
const [snap, setSnap] = useState<LiveChatSessionSnapshot | null>(null)
const ref = useRef<LiveChatSession | null>(null)
useEffect(() => {
let cancelled = false
const bf = createBrandfineClient({ apiKey: 'unused' }) // session uses config.publishableKey
void bf.liveChat.createSession({ config, visitor }).then((s) => {
if (cancelled) return void s.close()
ref.current = s
setSnap(s.getSnapshot())
s.subscribe(setSnap)
})
return () => { cancelled = true; ref.current?.close() }
}, [config, visitor])
if (!snap || snap.status === 'CONNECTING') return <p>Connecting…</p>
return (
<div>
{snap.greeting && <p><em>{snap.greeting}</em></p>}
{!snap.online && snap.offlineMessage && <p>{snap.offlineMessage}</p>}
<ul>{snap.messages.map((m) => <li key={m.id}><b>{m.sender}:</b> {m.body}</li>)}</ul>
{snap.status === 'OPEN' && (
<form onSubmit={(e) => { e.preventDefault(); const el = e.currentTarget.elements.namedItem('m') as HTMLInputElement; void ref.current?.send(el.value); el.value = '' }}>
<input name="m" disabled={snap.sending} />
</form>
)}
{snap.status === 'CLOSED' && <p>Conversation closed.</p>}
</div>
)
}Render message bodies as text, never as HTML.
Links in messages
Message bodies are plain text — never HTML or markdown — so the example above renders them as text and nothing can inject markup. Links still arrive, as data, in two forms:
- Bare urls typed into
body, by the assistant or by a human agent. - Named spans in
meta.links— when the assistant recommends a page, the server matches the page's title against the finished reply and sends[{ text, url }], where eachtextis a verbatim substring ofbody. The sentence reads exactly as written; the name becomes the anchor.
segmentChatMessage() turns both into segments you can map straight
onto your framework's elements:
import { segmentChatMessage, prettyChatUrl } from '@brandfine/client'
function Body({ m }: { m: ChatMessage }) {
return (
<p style={{ whiteSpace: 'pre-wrap' }}>
{segmentChatMessage(m.body, m.meta?.links).map((s, i) =>
s.type === 'link' ? (
<a key={i} href={s.href} target="_blank" rel="noopener noreferrer">
{s.text === s.href ? prettyChatUrl(s.href) : s.text}
</a>
) : (
s.text
),
)}
</p>
)
}Segments concatenate back to the exact body, so white-space: pre-wrap keeps every line break. prettyChatUrl() shortens a bare
url for display (no protocol, long paths elided) — keep href
complete. This is exactly what Brandfine's own widget renders.
Identity
A session takes the same signed visitor as
install() and produces an identical conversation in the inbox —
verified marker, externalId threading, anonymous downgrade on a bad
signature. There is no separate identity path to configure.
On a shared browser, call session.reset() when the signed-in user
changes — it drops the stored conversation token so one person's
thread can't continue under another's name. (Threading by
externalId is server-side, so history still follows each identity.)
Transport
Realtime WebSocket, automatically — when the API advertises a
realtimeUrl (it does on api.brandfine.co when enabled), the
session upgrades itself: messages arrive in milliseconds instead of
on a poll tick. You change nothing — same subscribe, same
snapshots, and the promise from the original release holds: the
session API did not change.
Polling is the invisible fallback (every 5 s by default,
pollIntervalMs option, min 1000). The session polls whenever the
socket can't serve it: no realtimeUrl (older API, or realtime
switched off), no WebSocket in the runtime, a socket that fails to
open, or repeated reconnect failures (5 strikes → polling for the
session's lifetime). During reconnect backoff (1s→30s with jitter)
polling covers the gap from the first millisecond — no window where
messages stall.
Reliability guarantees, both transports:
- No gaps, no duplicates. Every message carries a monotonic
per-conversation
eventId; reconnects and polls resume from the last one seen, so a laptop waking from sleep catches up exactly. - Idempotent sends.
send()attaches a generatedclientId; a retry after a lost response returns the original message rather than duplicating it. reset()closes the socket first, so on a shared browser nothing stays attached to the previous person's thread.- The socket authenticates with the same publishable key + conversation token as REST — in the first frame, never in the URL — and can do nothing REST couldn't.
Workspaces can optionally pin the socket to specific origins
(allowedOrigins in the Live Chat config — unset means any origin,
matching the REST posture).
Raw wire methods
Full control, no state — the same endpoints the session and widget use:
bf.liveChat.runtimeConfig(locale?) // publishable-key auth
bf.liveChat.startConversation(input) // publishable-key auth; { resumeOnly: true } never creates
bf.liveChat.sendMessage(conversationToken, { body }) // token auth
bf.liveChat.history(conversationToken, { after? }) // token authIn a browser, construct the client with the publishable key
(createBrandfineClient({ apiKey: config.publishableKey })) — never
the broad workspace key. The conversation token is a bearer
credential: don't log it and don't put it in URLs.