Skip to content

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:

  1. Tick Let the agent drive the host page (navigate, highlight, prefill…).
  2. In Allowed events, type the event names that may fire, comma-separated — for example navigate, user_authenticated.
  3. 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.

EventPayload
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.