Brandfine Docs
SDK

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.

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 each text is a verbatim substring of body. 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 generated clientId; 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 auth

In 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.

On this page