Skip to content

Building a chat surface — your three options ​

You've configured your Agent in Studio, and now you want customers to talk to it. The question is: how much of the chat UI do you want to build yourself?

For most teams the answer is "none of it." For others — a branded mobile app, a game, a proprietary CRM — the prebuilt bubble isn't the right fit. Perfox covers all three positions with the same backend, the same Agent, and the same tools underneath.

The three tiers ​

Each tier talks to the same backend, the same Agent, the same tools. What changes is how much of the front-end you write.

TierYou buildYou getReach for it when
1 — EmbedNothing (a <script> tag; appearance set in Studio)The prebuilt, self-contained widget bundle that boots into an isolated Shadow DOM bubbleYou want chat live today and the bubble UI is fine.
2 — Headless libraryYour UI@perfox/widget-core — the PerfoxConversation engine that handles all transport for you: init, send, the async reply push, history, voice, file attachments and dial-outYou want your own look and feel, but not your own networking, voice, or upload code.
3 — Raw protocolEverything — UI and transportThe documented REST (/api/public/widget/*) + WebSocket (/api/chat/connect) contractYou're on a platform the JS engine can't run in — native mobile, a game engine, or server-to-server.

All three share the same Agent — persona, MCP tools, knowledge base, guardrails, voice — configured once in Studio. The choice is pixels and bytes only; you never re-implement the agent.

The Tier-1 widget renders inside an isolated Shadow DOM so it never collides with your page's styles. Depending on the switches you turn on for the Web Chat trigger, it shows a history list of the visitor's past conversations, a "new chat" control that starts a fresh conversation, one-click export of the conversation as a Markdown transcript, a fullscreen mode, voice and file attachments — and it renders Mermaid diagrams, charts, tables and cards inline. @perfox/widget-core is the framework-free headless library that powers both the prebuilt widget and the Fox widget on perfox.ai, so a custom Tier-2 client and the built-in widget can never drift apart.

A worked example — Acme Diagnostics picks Tier 1 ​

Setup: Asha, the admin at Acme Diagnostics, sets her brand colour and bubble position in the Appearance section of the Web Chat trigger's Install tab, pastes the snippet from the same tab into the site's <head>, and publishes the Agent with a knowledge base of 200 lab FAQs.

Action: Priya visits acmediagnostics.com/results and clicks the chat bubble. She types "What does a high CRP reading mean?"

Result: The Agent responds with a plain-language explanation drawn from the knowledge base. Priya doesn't leave the page. Asha sees the conversation in Studio's Conversations view.

What just happened: The <script> tag loaded the widget bundle, which opened a session on acmediagnostics-api.perfox.ai and routed the message through the Agent. No code beyond the script tag, no custom UI, no server work.

The shared prerequisite — embed credentials (a Site) ​

Every tier authenticates with a Site: the embed credentials you generate on the Install tab of your agent's Web Chat trigger node. A Site carries a public Site key, an allowed-origins list, and one or more HMAC secrets. It's the credential that lets Perfox answer: "is this page allowed to talk to my workspace, and can I trust the identity it's claiming?"

  • The public Site key (sa_site_live_…) is sent on every request as the X-Perfox-Site header. On the WebSocket upgrade it rides as the site_id query parameter, because a browser can't set custom headers on a WebSocket upgrade. A request without it is rejected with 400 missing_site_id.
  • The Origin must match the site's allowlist — a case-insensitive exact match, and a missing Origin is rejected — or the request fails with 403 origin_not_allowed.
  • The site secret (sa_secret_live_…) is the HMAC key used to verify a signed visitor identity. It lives only on your server.

Verification enforces the identity path: no external_id yields anonymous; an external_id with a valid user_hash yields verified; an external_id without a user_hash yields self_asserted, unless Require HMAC identity verification is on for the site — in which case the request is rejected. The full HMAC spec is on Identity Verification (HMAC).

Identity and trust — one model everywhere ​

Two separate ideas are at work:

What it answersHow it's set
Trust level (anonymous / self_asserted / verified)"Is the identity this page claims genuine?"Per request, from the Site: a verified identity carries an HMAC user_hash your server signed. Only a verified visitor can list their past conversations, and with verification required, unsigned claims are refused.
Authenticated"Has this person proved who they are for account-level actions?"Every conversation starts unauthenticated. Your MCP server marks it authenticated through the auth broker, for example after an OTP check.

You produce the verified level identically in every tier: HMAC-SHA256-sign the canonical string <site_id>.<external_id> with the raw site secret as the key, server-side, and pass the hex digest as the user_hash field on the identity. The identity itself is a user-context object — all fields optional: name, phone, email, external_id (the stable customer anchor and the field HMAC signs), attributes (a free-form string-to-string map), user_hash, and tenant_session_token. The field set and its forwarding to MCP tools are documented on User Context & Identity.

The async reply model — the same for every builder ​

Open the reply WebSocket at /api/chat/connect (authenticated with the same Site key) and send with delivery: "async": POST /api/public/widget/send then returns 202 Accepted with { accepted: true, message_id, reply_id, server_time } immediately, freeing the connection. The assistant reply is pushed over the socket, correlated back to the 202 by reply_id, and the text may stream in ahead of the final reply. The reply still finds its way to the browser even under load. A client without a socket (for example server-to-server) omits delivery and gets the reply synchronously in the HTTP body. The full contract is on From Scratch (raw protocol).

Rotating a leaked or aging secret ​

Site secrets rotate with zero downtime from the Secrets table on the Install tab: a new active secret is minted (its raw value shown once), the old secret flips to expiring for a grace window, and both the active and expiring secrets verify inbound user_hash values until the window closes — so signatures your servers are still emitting keep working while you roll the new key out. The grace window defaults to 24 hours and can be set anywhere from 5 minutes to 7 days. When the window closes, the expiring secret is retired automatically. You can also revoke a secret immediately, with one exception: the last verifying secret can't be revoked — you must rotate to a new one first.

You can now embed a working chat surface at any level of control ​

Pick the tier that matches how much front-end you want to own, generate your embed credentials once, and your Agent is reachable — from a <script> tag all the way down to raw HTTP.

Where to go next: