Are you an LLM? You can read better optimized documentation at /docs/custom-widget/ui-commands.md for this page in Markdown format
Drive your host page from a conversation
Priya is chatting with Acme Support's widget about a billing discrepancy. After resolving it, the agent says "I've updated your invoice — let me take you there now" and the browser navigates to /accounts/invoices. Priya didn't click a thing. That's the UI-command bus.
Your agent — or your own server — can reach out and drive the page that embeds the widget. Answer a docs question and jump the reader to the exact section; verify a login and flip the page to its signed-in state; prefill a form the visitor was struggling with. Perfox acts as a pure broker: it carries a { event, payload } command to the host page and the host decides what to do. Perfox never interprets the command.
This page is for developers integrating a custom widget who want to wire up host-page reactions. You should already have the widget embedded and a workflow configured with a Web Chat trigger.
Two producers, one transport
A command can originate two ways, and both ride the same signed frame down to the host:
The command is an imperative side-effect, not a chat message: it renders no bubble and is never persisted or replayed in /history — replaying a navigate on scroll-back would hijack the page.
Turn it on
The bus is off by default. In Studio, open your agent, click the Web Chat trigger node and go to the Install tab. In the Client-side UI commands (co-pilot) section:
- Tick Let the agent drive the host page (navigate, highlight, prefill…).
- In Allowed events, type the event names that may fire, comma-separated — for example
navigate, user_authenticated. - Publish the agent.
The allow-list is enforced twice: it becomes the list of events the agent's tool can emit (the agent physically cannot emit an un-listed event), and it is re-checked on the server endpoint. With the switch off, every command is rejected 403 ui_commands_disabled; an event that isn't listed is rejected 403 event_not_allowed.
Producer A — the agent (drive_ui)
drive_ui is a built-in tool offered to the agent whenever the bus is enabled. When and how to drive the UI is authored in the Persona system prompt, not hardcoded — for example, "after answering from a doc, navigate the reader to its URL." It emits the same signed frame as the server path.
Producer B — your server (POST /conversations/:id/events)
Your backend can drive a command directly, out of band:
http
POST /conversations/{conversation_id}/events HTTP/1.1
Host: example-api.perfox.ai
Authorization: Bearer sk_xxxxxxxxxxxx # or the per-call X-Sa-Auth-Callback-Token
Content-Type: application/json
{ "event": "navigate", "payload": { "url": "/pricing" } }Authenticate with a Perfox API key (sk_…) or, from inside one of your MCP tool calls, the per-conversation X-Sa-Auth-Callback-Token Perfox sent with that call. The body accepts an optional correlation_id (echoed back, and on the command's meta), an idempotency_key (a retried POST with the same key returns 200 { accepted: true, deduped: true } and sends nothing), and a target: { tab_id } to address a single browser tab. Controls: missing event → 400 missing_event; unknown conversation → 404 conversation_not_found; bus off or event not allow-listed → 403; more than 10 commands a second for one conversation → 429 rate_limited. Success returns 202 { accepted: true, correlation_id }.
Receive it on the host page
A custom widget built on the headless engine subscribes with conversation.on('ui_command', …). With the embed SDK, register a handler per event name (or listen to every command with Perfox('on', 'ui:command', cb)):
js
Perfox('on:ui', 'navigate', ({ event, payload, meta }) => {
window.location.assign(payload.url);
});Perfox('on:ui', …) is default-deny: a command whose event name has no registered handler is ignored (with a console warning), so a page that wires nothing gets zero behavior. The built-in navigate handler additionally drops cross-origin URLs unless you opt out with { same_origin_only: false } or an allowed_hosts list.
A worked example
Setup. Asha is building a support widget for Acme Support. She ticks Let the agent drive the host page on the Web Chat trigger's Install tab and allows navigate, prefill_form. In the Persona prompt she writes: "When a customer asks about an invoice, call drive_ui to navigate them to /accounts/invoices."
Action. Priya opens the widget and types "Where can I see my invoice?" The agent finds the answer and calls drive_ui with { "event": "navigate", "payload": { "url": "/accounts/invoices" } }. Perfox signs the frame and relays it over the WebSocket to the widget. The host page's registered handler fires: window.location.assign('/accounts/invoices').
Result. Priya's browser moves to the invoices page — without her clicking anything.
What just happened. The agent used the drive_ui built-in to emit a navigate event. Because navigate was in allowed_events, the platform accepted it, signed it, and the widget re-emitted it. The host page's handler, registered with Perfox('on:ui', 'navigate', …), executed the navigation. If navigate had not been listed, the platform would have returned 403 and nothing would have moved.
Signatures and trust
The widget lives in the same window as your page (Shadow DOM), so it cannot hold a verification secret — meta.verified is always false in the browser. Integrity in the browser rests on three controls: the authenticated endpoint, the allow-list you set in Studio, and your per-command opt-in. Any script on the page can call window.Perfox(...), so treat a command as a request, not a fact.
For a sensitive action (login, billing), verify server-side. Every frame is signed by Perfox (meta.sig, an HMAC that binds the conversation, the event, its nonce, its payload and an expiry). Forward { event, payload, nonce, sig } to your own backend, which confirms it before acting:
http
POST /conversations/{conversation_id}/events/verify HTTP/1.1
Authorization: Bearer sk_xxxxxxxxxxxx
{ "event": "user_authenticated", "nonce": "…", "payload": { … }, "sig": "…" }
→ { "valid": true }/events/verify takes the same credentials as /events, needs sig, event and nonce (else 400 missing_fields), and returns valid: false for a tampered, expired or foreign command. If signing is unavailable, frames ship unsigned and /events/verify returns 503 signing_not_configured.
The built-in event registry
These events have a defined payload shape; the embed SDK logs a console warning when a built-in command arrives with a malformed payload (and still delivers it). You may also emit your own event names — the broker doesn't own their contract, your host page does.
| Event | Payload |
|---|---|
navigate | { url, replace? } |
scroll_to | { selector?, anchor? } |
highlight | { selector, duration_ms? } |
open_modal | { modal_id, props? } |
close_modal | { modal_id } |
prefill_form | { form_id, fields } |
trigger_login | { redirect_after? } |
refresh_data | { resource, id? } |
Targeting one tab
When the same conversation is open in more than one tab, the widget mints a per-tab id (kept in sessionStorage) and echoes it on the ready event. Pass it back as target: { tab_id } on the server call and only that tab acts; every other tab drops the frame. This is a client-side convenience filter — the relay still fans the frame to all tabs of the conversation — so treat it as UX scoping, not a security boundary.
See also
You can now drive your host page from both the agent and your own server, with a signed frame and a per-event allow-list you control.
- How-to: OTP login → host UI — a full end-to-end walkthrough of verifying a login server-side and flipping the page to its authenticated state, which builds directly on the trust model above.