Skip to content

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:

EventRule
message / replyUpsert 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.
historymode: 'initial' seeds or replaces the list; mode: 'older' prepends the older page.
transcriptpartial: 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 global window.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 the livekit-client package, so to use voice from it, make livekit-client resolvable (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.ai with 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 socket

Construction — PerfoxConversationOptions ​

OptionTypeRequiredMeaning
api_urlstringyesYour API origin. A trailing slash is stripped. Throws if absent.
site_idstringyes in practiceYour Site key. Sent as X-Perfox-Site on every request — Perfox rejects requests without it.
workflow_idstringnoPin a specific published agent. Omit to use the agent the Site is bound to.
user_contextWidgetUserContextnoInitial identity (see Identity). Also settable later via identify().
history_page_sizenumbernoMessages 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 ​

MethodSignatureWhat it does
onon(event, cb) → () => voidSubscribe. Returns an unsubscribe function.
offoff(event, cb) → voidUnsubscribe a specific callback.
initinit() → Promise<void>POST /init, open the reply socket, replay history, emit ready. Safe to call more than once; send() calls it for you.
sendsend(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.
loadOlderloadOlder() → Promise<void>Fetch the next older history page; emits history with mode: 'older'. Gate on hasOlderHistory.
stageFilesstageFiles(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.
removeStagedFileremoveStagedFile(local_id) → voidRemove an attached file before sending.
uploadupload(file) → Promise<void>Shorthand for stageFiles([file]).
transcribetranscribe(file) → Promise<string>Turn a recorded voice note into text for the composer, without sending or storing it.
listConversationslistConversations(limit?) → Promise<ConversationSummary[]>A verified visitor's past conversations, newest first ([] for anonymous or unverified visitors).
newConversationnewConversation() → Promise<void>Close the current thread and start a fresh one.
loadConversationloadConversation(id) → Promise<void>Switch to one of the visitor's past conversations and replay it.
fetchFullTranscriptfetchFullTranscript() → Promise<ChatMsg[]>Every message of the current conversation, oldest first — pair it with the exported transcript_to_markdown(messages) for an "Export chat" button.
startVoicestartVoice() → Promise<void>Ask for the microphone, then begin a browser voice session.
stopVoicestopVoice() → Promise<void>End the voice session.
setMicEnabledsetMicEnabled(bool) → voidMute or unmute the mic mid-call.
injectTextinjectText(text) → Promise<void>Push a typed line into an active voice session.
voiceAudioLevelvoiceAudioLevel() → { user, ai }Live audio levels (0–1) for drawing a voice visualiser.
callcall(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.
identifyidentify(user_context) → voidReplace 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.
updateupdate(partial) → voidShallow-merge fields into the current identity (attributes is deep-merged).
cancel_taskcancel_task(task_key) → Promise<void>Cancel a background task the agent started, unlocking the composer.
destroydestroy({ forget? }) → voidClose sockets and drop listeners. forget: true also forgets the conversation this browser would resume (and any pre-chat answers) — call it on logout.

Getters ​

GetterMeaning
conversationIdThe resolved conversation id.
customerIdThe resolved customer id.
tabIdThis 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.
uiThe 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.
isVoiceActiveWhether a voice session is live.
hasOlderHistoryWhether loadOlder() has more to fetch.

Events — PerfoxEventMap ​

The engine emits semantic events; your UI subscribes and reduces them into its own list.

EventPayloadNotes
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.
messageChatMsgThe 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.
replyChatMsgA 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.

transcript uses role, not speaker. The engine normalizes the raw voice wire frame (which uses speaker) into the same role: 'user' | 'assistant' vocabulary as every other event. If you read the voice data channel directly (Tier 3), the raw frame field is speaker — 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 (an external_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 code

Browser 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_hash server-side and rotate secrets without dropping active sessions.
  • User Context & Identity — the full WidgetUserContext shape 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.