Are you an LLM? You can read better optimized documentation at /docs/custom-widget/headless-library.md for this page in Markdown format
Headless Library
Your designer wants a chat experience that matches your brand exactly — your colors, your layout, your animation style. You'd rather not rebuild the entire conversation engine from scratch. @perfox/widget-core gives you PerfoxConversation: a framework-free class that handles all transport (init, send, async replies, history, voice, file attachments, dial-out) so you write only the UI.
The prebuilt Perfox widget and the Fox widget on perfox.ai both use this same class, which means your custom UI gets identical behavior — nothing can drift.
The mental model
PerfoxConversation holds no rendered list. It owns the conversation's identity (conversation_id, customer_id) and the live socket, and it emits typed events. Your UI subscribes with on(...) and reduces those events into whatever it draws.
Three reducer rules cover every situation:
| Event | Rule |
|---|---|
message / reply | Upsert by id — a new id appends a bubble; a repeated id replaces it. This is how a streamed answer grows in place and is then replaced by the final reply, and how follow-up chips appear on a bubble that's already on screen. |
history | mode: 'initial' seeds or replaces the list; mode: 'older' prepends the older page. |
transcript | partial: true appends to the last same-role bubble; partial: false replaces it. |
Install
CDN (no build step)
Your API host (<slug>-api.perfox.ai) serves the library from two endpoints, with permissive CORS so they load cross-origin:
/widget/v1/widget-core.js— the UMD build, exposing a single globalwindow.PerfoxWidgetCore. Voice is bundled in, so it works with no other includes./widget/v1/widget-core.mjs— the ESM build. It loads voice on demand from thelivekit-clientpackage, so to use voice from it, makelivekit-clientresolvable (a bundler, or an import map).
html
<script src="https://example-api.perfox.ai/widget/v1/widget-core.js"></script>
<script>
const convo = new PerfoxWidgetCore.PerfoxConversation({
api_url: 'https://example-api.perfox.ai',
site_id: 'sa_site_live_xxxxxxxxxxxxxxxx',
});
</script>The UMD build exposes everything under the PerfoxWidgetCore global; you read PerfoxWidgetCore.PerfoxConversation off it. Both bundles ship in the same release as the prebuilt widget, so they are always available on your API host.
Replace
example-api.perfox.aiwith your API host throughout — the host the request lands on is how Perfox finds your workspace.
npm (bundled apps — React, Vue, Svelte, and others)
The same code is packaged as @perfox/widget-core, with the voice runtime (livekit-client) as an optional peer dependency loaded only when startVoice() is called — a text-only client never pulls voice code into its bundle. The package isn't on the public npm registry yet; until it is, use the CDN build above.
ts
import { PerfoxConversation } from '@perfox/widget-core';Quickstart
ts
const convo = new PerfoxConversation({
api_url: 'https://example-api.perfox.ai',
site_id: 'sa_site_live_xxxxxxxxxxxxxxxx', // from Studio → Web Chat trigger → Install
});
convo.on('ready', ({ ui, capabilities }) => console.log(ui, capabilities));
convo.on('history', ({ messages, mode }) => seed_or_prepend(messages, mode));
convo.on('message', (m) => upsert(m)); // your optimistic echo, streamed text, chip updates
convo.on('reply', (m) => upsert(m)); // the assistant turn
convo.on('state', ({ sending }) => set_typing(sending));
convo.on('error', ({ message }) => toast(message));
await convo.init(); // POST /init, open the reply socket, replay history, emit ready
await convo.send('Hi'); // optimistic `message` echo now; `reply` arrives async over the socketConstruction — PerfoxConversationOptions
| Option | Type | Required | Meaning |
|---|---|---|---|
api_url | string | yes | Your API origin. A trailing slash is stripped. Throws if absent. |
site_id | string | yes in practice | Your Site key. Sent as X-Perfox-Site on every request — Perfox rejects requests without it. |
workflow_id | string | no | Pin a specific published agent. Omit to use the agent the Site is bound to. |
user_context | WidgetUserContext | no | Initial identity (see Identity). Also settable later via identify(). |
history_page_size | number | no | Messages per history page. Default 20. |
The engine remembers the current conversation per Site in localStorage for about two hours, so a page reload resumes it and replays the whole transcript.
Methods
| Method | Signature | What it does |
|---|---|---|
on | on(event, cb) → () => void | Subscribe. Returns an unsubscribe function. |
off | off(event, cb) → void | Unsubscribe a specific callback. |
init | init() → Promise<void> | POST /init, open the reply socket, replay history, emit ready. Safe to call more than once; send() calls it for you. |
send | send(text, opts?) → Promise<void> | Send a user turn. Options: display (label to show instead of text), echo (default true; false sends silently with no user bubble), structured_data (machine-readable values from an interactive input), file_refs (explicit upload ids). Ready attached files ride along automatically. |
loadOlder | loadOlder() → Promise<void> | Fetch the next older history page; emits history with mode: 'older'. Gate on hasOlderHistory. |
stageFiles | stageFiles(files) → Promise<void> | Attach files to the next message. Each uploads and is processed in the background (≤ 5 files, ≤ 25 MB each; photos are shrunk first); progress arrives on staged_files. |
removeStagedFile | removeStagedFile(local_id) → void | Remove an attached file before sending. |
upload | upload(file) → Promise<void> | Shorthand for stageFiles([file]). |
transcribe | transcribe(file) → Promise<string> | Turn a recorded voice note into text for the composer, without sending or storing it. |
listConversations | listConversations(limit?) → Promise<ConversationSummary[]> | A verified visitor's past conversations, newest first ([] for anonymous or unverified visitors). |
newConversation | newConversation() → Promise<void> | Close the current thread and start a fresh one. |
loadConversation | loadConversation(id) → Promise<void> | Switch to one of the visitor's past conversations and replay it. |
fetchFullTranscript | fetchFullTranscript() → Promise<ChatMsg[]> | Every message of the current conversation, oldest first — pair it with the exported transcript_to_markdown(messages) for an "Export chat" button. |
startVoice | startVoice() → Promise<void> | Ask for the microphone, then begin a browser voice session. |
stopVoice | stopVoice() → Promise<void> | End the voice session. |
setMicEnabled | setMicEnabled(bool) → void | Mute or unmute the mic mid-call. |
injectText | injectText(text) → Promise<void> | Push a typed line into an active voice session. |
voiceAudioLevel | voiceAudioLevel() → { user, ai } | Live audio levels (0–1) for drawing a voice visualiser. |
call | call(phone) → Promise<void> | Outbound dial-out to the visitor's phone. Send the number with its country code; the server reads a number without one as Indian and rejects one it can't dial. |
identify | identify(user_context) → void | Replace identity. If the engine is on an anonymous conversation the visitor hasn't typed in yet, it re-inits onto the identified visitor's own conversation. |
update | update(partial) → void | Shallow-merge fields into the current identity (attributes is deep-merged). |
cancel_task | cancel_task(task_key) → Promise<void> | Cancel a background task the agent started, unlocking the composer. |
destroy | destroy({ forget? }) → void | Close sockets and drop listeners. forget: true also forgets the conversation this browser would resume (and any pre-chat answers) — call it on logout. |
Getters
| Getter | Meaning |
|---|---|
conversationId | The resolved conversation id. |
customerId | The resolved customer id. |
tabId | This browser tab's id, for targeting UI commands. |
capabilities | { voice_chat, dial_out, file_upload, show_history?, allow_new_chat?, allow_export? } — gate your buttons on these flags. |
ui | The appearance you configured: display_name, avatar_url, theme.primary_color, theme.bubble_position, greeting_text, default_open, start_fullscreen, starters, starters_subtitle, input_placeholder, next_actions_enabled, and pre_chat_form when you've set one up. |
isVoiceActive | Whether a voice session is live. |
hasOlderHistory | Whether loadOlder() has more to fetch. |
Events — PerfoxEventMap
The engine emits semantic events; your UI subscribes and reduces them into its own list.
| Event | Payload | Notes |
|---|---|---|
ready | { capabilities, ui, location_config?, conversation_id, customer_id, tab_id } | /init resolved. |
unavailable | { reason, status? } | /init failed — an HTTP error (reason is the error code), or a network failure (reason: 'init_network_error', status: 0). Show an "unavailable" state; the next init()/send() retries. |
history | { messages, has_more, mode: 'initial' | 'older' } | initial seeds; older prepends. |
message | ChatMsg | The optimistic user echo from send(); also the growing assistant text while a reply streams, and an existing bubble updated with follow-up chips. Upsert by id. |
reply | ChatMsg | A finished assistant turn — socket push, synchronous body, a server-initiated message, or a card/file/link shown during a voice call. |
error | { message, reply_id? } | A failed send, a socket error frame, or a rejected attachment. message is safe to show. |
state | { sending } | Transport-busy flag; drives a typing indicator. |
staged_files | { files: StagedFile[] } | The files attached to the composer, re-sent on every change. Each has local_id, file_name, mime_type, file_size, status (uploading → ready | failed), and once ready upload_id, summary, file_url. Enable Send only when all are ready. |
task_status | { task_key, status, label, mode, blocking, progress?, error_message? } | A background task the agent started. Upsert a status chip by task_key. |
input_locked | { locked, label? } | true while a blocking task is open — disable the composer and show label. |
voice | { active, status, error?, mode?, animation_style? } | status is a ready-to-show label ("Allow microphone access…", "Listening…"). error explains a call that failed to start or dropped. mode is the voice mode configured on the trigger. |
agent_busy | { busy, label? } | Live-lookup indicator during voice. |
transcript | { role, text, partial } | Live voice caption. The engine normalizes the raw wire field speaker to role. |
voice_activity | { role } | Someone is speaking — sent instead of captions in the Nova animation mode. |
ui_command | { event, payload, meta } | A command for the host page. The engine never acts on it; see UI-command bus. |
transcriptusesrole, notspeaker. The engine normalizes the raw voice wire frame (which usesspeaker) into the samerole: 'user' | 'assistant'vocabulary as every other event. If you read the voice data channel directly (Tier 3), the raw frame field isspeaker— see From Scratch.
ChatMsg — the normalized message
Every message, reply, and history row is a ChatMsg:
ts
interface ChatMsg {
id: string;
role: 'user' | 'assistant';
text: string;
attachments?: MediaAttachment[]; // signed `url` + durable `file_key`
charts?: ChartSpec[];
cards?: CardListSpec[];
rich?: RichBlock[]; // supersedes charts/cards when present
timestamp?: string; // ISO
suggested_actions?: string[]; // follow-up chips: label = prompt to send when tapped
}The async reply model
send() uses the same async model as the prebuilt embed. While its socket is open, POST /api/public/widget/send returns 202 Accepted with { accepted: true, message_id, reply_id, server_time } immediately, freeing the connection. The assistant reply is then pushed over the WebSocket at /api/chat/connect, correlated by reply_id — streaming in as message updates if the agent streams, then landing as one reply. This design is scale-safe — the turn may be processed by a different backend instance than the one holding your socket, and the reply still reaches the right connection. If a push goes missing, the engine recovers the reply from history on its own; if the socket can't be opened at all, send() falls back to a synchronous /send where the reply is the HTTP response body.
Identity and trust
WidgetUserContext (passed to identify() or update()) is all-optional: name (display only, not identity), phone (E.164 preferred, loose formats normalized), email, external_id (the stable customer anchor and the field HMAC verification signs), attributes (a free-form string→string map), user_hash (the server HMAC), and tenant_session_token (an opaque token forwarded to your MCP tools to skip in-chat login).
Each request derives a trust level: anonymous (no external_id), self_asserted (external_id without a user_hash), or verified (external_id plus a valid user_hash).
ts
// self_asserted — fine for greeting by name
convo.identify({ name: 'Asha Iyer', email: 'asha@example.com', external_id: 'cust_abc123' });
// verified — Perfox accepts the external_id as genuine
convo.identify({
name: 'Asha Iyer',
external_id: 'cust_abc123',
user_hash: '<hex from YOUR server>', // HMAC-SHA256("<site_id>.<external_id>") with the raw site secret
});The identity travels on every request, is saved onto the conversation's customer profile, and is forwarded to your MCP tool servers on each tool call as X-Sa-End-User-{Name,Phone,Email,External-Id} headers plus X-Sa-End-User-Attributes (JSON). The full HMAC spec, multi-language signing snippets, and secret rotation are on Identity Verification (HMAC).
Calling
identify()with an identity (anexternal_id, phone or email) while the engine is on an anonymous conversation the visitor hasn't typed in yet triggers a re-init onto that person's own conversation. If they've already started chatting, the current conversation simply carries on with the new identity. Neither happens during a live voice call — the switch waits until the call ends.
Worked example — trust upgrade in action
Setup. Acme Diagnostics builds a custom chat widget using @perfox/widget-core. Priya lands on the page before logging in.
Action. The widget initializes with no identity: Priya is anonymous. Before typing anything, she logs in. The app calls:
ts
convo.identify({
name: 'Priya Sharma',
external_id: 'cust_priya_88421',
user_hash: await fetch_user_hash_from_server('cust_priya_88421'),
});Result. The engine re-inits automatically and replays the conversation Priya already had open with Acme — started on her laptop yesterday. Her next message arrives as verified, and the agent's tools receive her external_id in the X-Sa-End-User-External-Id header, so they can look up her past reports without asking her to repeat herself.
What just happened. identify() saw a new identity on an anonymous, still-empty session, dropped the anonymous conversation, and re-ran /init as Priya — all without reloading the page.
Voice and dial-out
Both capabilities are opt-in, gated by a Web Chat trigger switch and surfaced on /init as capabilities flags. When a switch is on but something it needs is missing, the flag is false. Gate your buttons on the getter before rendering them:
ts
convo.on('voice', ({ active, status, error }) => set_voice_ui(active, error ?? status));
convo.on('transcript', ({ role, text, partial }) => caption(role, text, partial));
convo.on('agent_busy', ({ busy, label }) => set_hold_tone(busy, label));
await convo.startVoice(); // asks for the mic, then joins the voice session
convo.setMicEnabled(false); // mute without hanging up
await convo.injectText('actually, make it 3pm'); // type into an active voice session
await convo.stopVoice(); // hang up
await convo.call('+919812345678'); // dial-out; include the country codeBrowser voice runs on Perfox-managed voice infrastructure — nothing to configure on your end. The mic stays muted until the agent's greeting finishes. Each call is recorded (caller, AI and a combined stereo mix) and saved to the conversation in Studio. Dial-out places an outbound phone call through your connected phone number using the same persona and tools; the engine reports progress as reply bubbles and failures as error. See Voice & Dial-Out for voice modes and error codes.
Attachments
Gate the attach button on capabilities.file_upload:
ts
convo.on('staged_files', ({ files }) => render_chips(files)); // uploading → ready | failed
await convo.stageFiles([...input.files]); // from an <input type="file">, a drop or a paste
await convo.send('Here is my prescription'); // the ready files ride along; text may be ''Each file uploads to /api/public/widget/upload and is processed in the background while the visitor keeps typing — images by vision, PDF/Word/Excel by vision, OCR or structured extraction, audio by transcription, video as frames plus audio, and text formats as-is. The engine refuses more than 5 files or anything over 25 MB with an error event; the server refuses unsupported types. Only enable Send once every chip is ready. The agent sees each file's content (or its summary, for long documents) and a signed link on the turn it arrives. See File Upload.
Teardown
ts
convo.destroy(); // close sockets, drop all listeners
convo.destroy({ forget: true }); // also forget the conversation this browser would resume (logout)What you can build next
You now have a conversation engine that handles all transport — identity, async replies, history, voice, and attachments — while your UI owns every pixel.
- Overview (3 tiers) — see where this tier sits relative to the prebuilt embed and the raw protocol, so you choose the right starting point.
- From Scratch (raw protocol) — the same flow with no library, for non-JS platforms or when you want full control of the wire.
- Public Widget API — the REST and WebSocket endpoints this engine calls, with request and response shapes.
- Identity Verification (HMAC) — compute
user_hashserver-side and rotate secrets without dropping active sessions. - User Context & Identity — the full
WidgetUserContextshape and trust levels explained. - Voice & Dial-Out — how voice and dial-out are configured on your agent in Studio.
- File Upload — the upload endpoint, limits, and what the agent receives.