Embed the widget
Voice, leads & persistence
The widget is more than a chat box. It captures leads inline, transcribes voice, persists across reloads, and shows a live "human is here" state when an operator takes over. This page covers each feature and how to configure it.
Conversation persistence
Every visitor gets an anon_id (a random string written to
localStorage on first visit). On reload, the widget calls
/v1/widget/init with the same anon_id and the
server resumes the most recent conversation if it's less than 24 hours
old.
The init response includes the last 30 messages so the chat log
rehydrates in the order the visitor left it. The "Clear conversation"
button (in the widget header) writes a cleared_at timestamp
on the conversation row — past messages stay in the database for
analytics + lead linkage but stop being shown to the visitor. It also
forgets the server-side LLM history cache, so the next turn truly
starts a fresh context instead of silently continuing the cleared
conversation.
The bar, the panel and the orb
The widget is a floating bar — an always-visible composer pill anchored to the bottom of the page, so a visitor can see somewhere to type without opening anything first. Once a conversation starts, the answer panel stacks above it with the header, message thread and cards; the bar stays put underneath. Before any messages exist, the greeting, starter-prompt chips and the bar share one card.
Closing from the bar's ⋯ menu collapses everything to a small round orb in the corner. An unread badge appears on the orb when a reply lands while it is closed, alongside a one-per-session chime, and the greeting teaser can reappear beside it to invite the visitor back.
Consecutive messages from the same author group: the avatar renders once per run (on the last message, where the bubble's tail is) and the repeated avatar slots stay in the layout so every bubble in a group keeps the same left edge.
The whole stack — panel, gap and launcher — is height-capped together
at calc(100dvh - 100px), and the panel is the only piece
allowed to shrink. Capping the panel alone let it run past the bottom
of a short window and hide the composer and launcher below the fold.
Window size & expand
The chat panel opens at a comfortable height that scales with the screen instead of shrinking to fit its content. A brand-new or short conversation no longer opens as a cramped box, and the starter-prompt chips sit above a message thread that always has room to breathe (the thread takes the vertical slack, not the chips).
An expand button in the panel header lets a visitor enlarge the window to near-fullscreen (and a touch wider) for longer conversations, then collapse it back. The state is per-session and purely client-side — nothing is stored. On phones the height already fills most of the screen, so expand mainly widens the panel there.
The panel width follows your configured bar_width theme
value when you have set one; otherwise it uses the panel's own
388px default (bar_width was sized for the
older full-width composer, and its 560px default makes a chat panel
wider than it wants to be). Expanded mode caps at 760px
(or calc(100vw - 24px) on narrow screens). The header
stays pinned so Close and Expand are always reachable.
Theme colours & contrast
You pick one accent colour; the widget derives the rest. Text and icons drawn on the accent (header, your visitor's own bubbles, the launcher, the send button) switch between white and near-black based on the accent's measured luminance, so a pale accent like amber no longer renders unreadable white text. The accent used as text on the panel surface (quick-reply chips, card buttons) is nudged toward the background's opposite until it clears the WCAG AA 4.5:1 contrast ratio — raw amber on white is only 1.9:1.
Both derivations run once per accent and colour scheme and are then cached, so they cost nothing on subsequent renders. Success, warning and error colours stay independent of your accent, so a green accent can never make a warning banner ambiguous.
Voice mic
A microphone button in the input area lets visitors dictate instead of
type. The widget uses the browser's built-in
SpeechRecognition API — no server-side speech model. While
recording the mic shows a sonar-ring animation; clicking again stops
and inserts the transcript into the input box (appended if there's
already text there, so you can dictate, edit, then dictate more).
Browsers without SpeechRecognition (Firefox, older Safari)
don't show the mic button. There's no fallback — the feature is
opportunistic.
Typing indicator stages
The pending bubble narrates what the server is doing instead of a static "Thinking…": Searching your site… during retrieval, Thinking… while the LLM composes, and a tool-specific label when a tool runs — Checking your order…, Creating your ticket…, Finding the right article…, Connecting you with a human… (unknown tools show Working on it…). Labels clear the moment the first token streams in.
Putting them in your agent's voice
The two main stages read as machinery — which is what one operator said out loud: his agent is called Guus, and he wanted the bubble to say "Guus is looking up the right information for you." rather than "Searching your site…". Both stages are editable per agent under Customize → Status messages: one field for the search stage, one for the writing stage, 80 characters each.
An empty field keeps the wording we ship, which is also the translated wording — so an agent that has never been touched behaves exactly as it always did. Your own sentence, by design, wins over the translation: someone who writes a Dutch line means that line, not our rendering of it, so it is shown to every visitor in every language. An agent that genuinely serves two languages is better off leaving these empty.
Tool labels (Checking your order… and friends) are not editable — they are set per tool on the server, and a wrong one would misreport what the agent is actually doing.
Mechanically this is an override merged over the copy dictionary the
widget receives at /v1/widget/init, so nothing in the
widget bundle knows the setting exists and no rebuild is involved.
Markup pasted into the field is stripped: the bubble renders text.
Stream resilience
If the SSE connection drops mid-turn without a done event
(typically a proxy's proxy_read_timeout killing an idle
stream during a slow model turn), the widget treats it as a failed
attempt: it retries transparently up to three times and, if every
attempt dies, shows a retry pill — the bubble never sits on
"Thinking…" forever.
Lead capture
The lead mail, and who gets it
Since card #570 the "New lead" mail says what the lead was about: the visitor's last question as Reason for contact, the last eight turns of the conversation (visitor / assistant), the agent, the website and the exact page the visitor was on, plus a link to the full conversation. Slack pushes carry the question too.
Recipients are set per agent under Customize → Lead notifications: up to ten extra addresses (they need no account — the mail goes straight to them), a switch for whether the workspace owners and admins still receive it (on by default, which is what every agent did before), and a switch for including the conversation in the mail — turn that off when the mail goes to inboxes that should not see chat content. An agent with nothing configured behaves exactly as before.
The widget can collect contact info inline without forcing the visitor away from the conversation. Lead capture fires when:
- A behavior rule of
kind=lead_capturematches. - The visitor explicitly asks to be contacted ("can someone call me?", "email me a quote").
- You wire a CTA button to the
lead_captureaction.
The form fields are configured per agent. The default is name + email;
you can add phone, company, and custom fields. Submitted leads are
POSTed to /v1/widget/leads (rate-limited per JWT) and
appear immediately in /app/inbox (the per-workspace lead list).
The "Thanks — we'll get back to you soon" confirmation auto-dismisses
after 5 seconds and has an explicit × for visitors who want it gone
sooner.
When the form is offered, and how (card #579)
Four triggers, all per agent under Customize → Lead form fields → When should the form appear?:
- After engagement — three messages from the visitor, or a buying signal, whichever first. The default.
- A buying signal in what the visitor types: price, quote, contact, demo, a call. The signal list covers English, Dutch, German and French — until card #579 it was English only, which for a Dutch shop meant this trigger had never fired ("offerte", "prijs", "terugbellen" were invisible to it). Every list is matched on every message, so a Dutch shop's English-speaking visitors are covered too.
- The agent's own judgement in Advisor mode: it
writes one line in its own voice and opens the form
(
<lead-form/>). - Silence after a real conversation — new. After at least three visitor messages and a reply, if nothing is typed for a while (default 45 seconds, 20–180), the agent asks once whether they would like to be contacted. This runs entirely in the browser: idleness is something only the browser can see, and watching for it costs the server nothing. The clock restarts on every keystroke and every new message, so it only ever fires into genuine silence.
Ask first. A form materialising mid-thread reads as a demand. With Introduce it with a message first switched on, every server-side trigger instead posts one line — yours, or the translated default "Would you like us to contact you about this? You can leave your details here." — with a Leave my details button and a No thanks. The form opens only when the visitor taps the button. The silence trigger always asks first; that is the point of it. A visitor who declines is not asked again in that conversation, by any path, and the form opening by any other route (a CTA, the offline-handoff fallback, the advisor) retires the invitation.
An agent with neither option switched on behaves exactly as before: same triggers, form opens directly. Only the Dutch, German and French signals change existing behaviour — deliberately, as a bug fix.
Smart capture from intent
The latest update (commit 9190aa5) adds intent-based capture:
the widget watches the conversation for phrases that suggest a real
sales intent — pricing questions, "is this right for…", "can I demo…" —
and offers the lead form proactively after a few turns. Threshold and
phrase list are tunable per agent.
Human takeover
When a workspace operator claims a conversation in /app/inbox,
the widget receives a Reverb event (conversation.takeover)
and updates the chat header to show a "Human is here" badge. From that
point, the AI stays paused — every visitor message goes to the operator,
every operator reply streams to the visitor. The visitor sees one
continuous thread; under the hood the message role flips
from assistant to human-agent and back.
See Inbox & human takeover for the operator side.
Citations
Whenever the agent answers from retrieved sources, citation chips appear
below the message. Click one to open the source URL in a new tab. The
chips are numbered ([1], [2]) matching inline
references in the response text — visitors who care can verify the
answer; visitors who don't see a clean reply.
Curated answers can include an optional citation URL too — useful when the canned answer is sourced from a specific page.
Streaming
Messages stream token-by-token over Server-Sent Events. The widget
reads the stream and appends tokens to the DOM in real time. If the
stream errors mid-flight (network blip, LLM timeout), the widget
auto-retries up to 3 times before showing an error state — and only
one user bubble appears even on retries (commit a576e1c).
Branding
The widget footer shows a "Powered by Pitchbar" link by default. It's
hidden for workspaces on a plan with the remove_branding
feature flag enabled — see Billing &
plans.
The brand label, URL, and logo all come from platform-admin
configuration (config('branding.*') + the optional
app_settings singleton overrides), so a self-hosted
deployment can rebrand the footer entirely.
Storage
The widget uses localStorage for:
anon_id— persistent visitor identifier.- Conversation cleared-state (which message IDs the visitor has hidden via "Clear").
No personally identifiable data is stored client-side. The JWT itself lives in memory — it's re-issued on every init.