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.
Voice mic
A microphone button in the input area lets visitors dictate instead of type. Where the browser's own speech recognition works (Chrome, Edge, Safari), the widget uses it and the words appear in the input box as the visitor speaks. While recording the mic shows a sonar-ring animation; clicking again stops. The text is appended if there's already something in the box, so visitors can dictate, edit, then dictate more.
Browsers without a speech service
Some browsers have the speech-recognition API but not the speech service behind it, so recognition fails every time: Brave, Arc, Dia and other Chromium-based browsers, and iPhones with Siri switched off. Firefox has no such API at all. In all of these the widget records the clip itself and has your AI provider transcribe it:
- The visitor clicks the mic. The first time, the browser asks for access to the microphone.
- Clicking the mic again stops the recording. A recording also stops by itself after 30 seconds.
- While the clip is transcribed, the mic shows a spinner.
- The text lands in the input box for the visitor to check and send. Nothing is sent on its own.
Once the browser's own recognition has failed, the widget remembers it for the rest of that page, so the next click records straight away.
Transcription runs on the first provider in your chat order that offers
it: Cloudflare Workers AI with
@cf/openai/whisper-large-v3-turbo (change it with
CLOUDFLARE_TRANSCRIBE_MODEL), or OpenAI with
whisper-1 (OPENAI_TRANSCRIBE_MODEL). The
agent's default language goes along as a hint. OpenRouter has no
transcription API, so an install that runs on OpenRouter alone can't
transcribe: in these browsers the mic then shows "Voice input failed".
Clips go to POST /api/v1/widget/transcribe (see
Widget API), are at most
2 MB, and are never stored: the audio is passed to the provider
and dropped, and the transcript only exists in the visitor's input box
until they send it. A conversation can transcribe 10 clips a minute and
60 an hour. With the AI &
data-protection notice switched on, the microphone isn't opened
until the visitor accepts it.
The mic is greyed out only where a browser can neither recognise
speech nor record audio — in practice on pages served over plain
http://, where browsers block the microphone.
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 best answer…, Connecting you with a human… (unknown tools show Working on it…). Labels clear the moment the first token streams in.
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 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.
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 PoriFyx Chat" 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.