Skip to content

Embedding surface-app

Status: Current | Last reviewed: 2026-10-07

There are two ways an external system embeds TrueServe, managed as one External Clients table under Settings → Integrations → Embedding:

Type Who it's for Token minting Backing store
App The embedder has a backend that can hold a tsf_* API token Server-to-server via /api/embed/mint-session allowed_origins row
Site Backend-less static page/portal; no place to keep secrets Browser PKCE flow via /api/embed/authorize + /api/embed/token (visitor must be signed in to AI Manager) embed_public_clients row

Origins of active clients of either type are trusted for iframe embedding (surface-app CSP frame-ancestors) and API access (conversation-api CORS) — a Site's origins auto-trust while the client is active; there is no separate origin registration step. The New Client wizard on that tab helps pick the right type, and each row has a Generate snippet action producing the matching integration code (App → tokenFetcher against your backend; Site → self-contained PKCE page).

Snippets carry the public deployment URLs, never the dev server's localhost: the surface-app URL prefill and the Site snippet's ai-manager URL resolve EXTERNAL_SURFACE_APP_URL / EXTERNAL_AI_MANAGER_URL first, then derive https://<service>.<PUBLIC_FQDN>, and only fall back to the request host (externalServiceBaseUrl in lib/public-url.ts).

You embed surface-app by dropping a single iframe pointing at your tenant's surface URL. The iframe authenticates via a short-lived JWT in the ?t= query param. The host page exchanges postMessage with the iframe to push context and receive events.

When a page request includes ?t=, surface-app redirects through /api/session/exchange first. The exchange verifies and burns the JWT, stamps the httpOnly sfa_session cookie, removes t from the URL, and uses SameSite=None; Secure automatically for HTTPS browser-facing requests so embedded iframes can send the cookie back.

Minimal HTML

<iframe
  src="https://surface-app.<your-host>/en?t=<embedToken>&embed=true&parentOrigin=https://your-host.example"
  allow="microphone; clipboard-read; clipboard-write"
  width="420"
  height="640">
</iframe>

That's the whole rendering side. Three query params matter:

Param Purpose
t Short-lived JWT minted server-side. Authenticates this embed session.
embed=true Enables the postMessage bridge. Without this, surface-app runs as a standalone page and emits no events.
parentOrigin The origin (scheme + host + port) of the page hosting the iframe. Used as a fallback when the JWT's allowed_origins claim is absent.

Minting the embed JWT

The host's backend mints the JWT against ai-manager. Tokens are short-lived and single-use. The standard server-to-server route is /api/embed/mint-session; it is authorized by a normal tsf_* API token and does not require an embed client.

const response = await fetch(
  "https://ai-manager.<your-host>/api/embed/mint-session",
  {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${tenantApiToken}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      agentId: "ag_abc123",
      conversationId: optionalExistingConversationId,
      audienceOrigin: "https://your-host.example",
      ttlSec: 300,
    }),
  },
);

const { embedToken, iframeUrl } = await response.json();

The tenantApiToken is a tsf_* token with agents:run (agents:manage implies it). Catalog preview embed minting uses tools:manage. See Authentication. For attaching to a live Genesys call and consuming transcript events, use the Genesys handoff guide.

Warning: Never mint embed JWTs in browser code. The minting call requires a tenant-scoped API token; exposing that token to the browser leaks it to every visitor.

Use iframeUrl directly when you want ai-manager to assemble the surface-app URL, or build the iframe URL yourself with embedToken as the t query parameter.

allowed_origins enforcement

The JWT carries an allowed_origins claim - a whitelist of origins permitted to:

  • Embed this token in an iframe (enforced by surface-app's frame-ancestors CSP).
  • Send postMessages that surface-app will accept.

bridgeListenForParentMessages rejects events whose event.origin is not in allowed_origins. A compromised sibling iframe on the host page cannot inject context updates or set-capabilities messages.

For /api/embed/mint-session, the list is derived from the audienceOrigin you pass at mint time. Set it tightly - usually just one origin.

The active-origins endpoint vs the CSP

Separately from the per-token claim above, /api/allowed-origins/active (unauthenticated, ~30s cache) is what surface-app reads to build its frame-ancestors CSP directive and what conversation-api reads for CORS. Its response is the union of:

  • App clients — active allowed_origins rows with Iframe/API enabled.
  • Site clients — origins of active embed_public_clients rows (both iframe and API). Deactivating a Site withdraws its origin trust in one step; no separate origin row is needed.
  • Built-ins — auto-allowed in code, not stored as rows. getBuiltinOriginPatterns() (lib/schema-allowed-origins.ts) injects localhost, 127.0.0.1, and a wildcard derived from the deployment's own hosts — the surface-app URL (EXTERNAL_SURFACE_APP_URL/NEXT_PUBLIC_SURFACE_APP_URL) and ai-manager's own origin (APP_URL/NEXT_PUBLIC_APP_URL/EXTERNAL_AI_MANAGER_URL), e.g. surface.acme.com → *.acme.com, guarded against public-suffix wildcards and deduped across sources. Deriving from ai-manager's origin is what lets the Studio frame surface-app for the agent-editor Preview/chat tester even where the surface-app URL vars are unset (e.g. KMS, where the emulator subdomain isn't provisioned but APP_URL=https://studio.<domain> is): studio and surface-app are siblings under the same parent domain, so both yield the same *.<domain> wildcard that covers surface-app.<domain>. Without it the Preview iframe is blocked with a frame-ancestors CSP violation and the recipient window's origin reads as 'null'. These keep local dev and the deployment's own domain working even if an admin deletes rows, so they are intentionally not shown in the External Clients table.

An origin must resolve through this union or the browser refuses to frame surface-app regardless of a valid token.

Site clients (PKCE public-client flow)

Site clients cover embedders with no backend to hold an API token. They register a client_id, redirect URIs, origins, and optionally an agent allow-list, then obtain an embed JWT via PKCE through /api/embed/authorize (requires an interactive logged-in AI Manager user — the human-in-the-loop consent gate) and /api/embed/token. Prefer the App flow (/api/embed/mint-session) whenever a backend exists.

Manage them in the same External Clients table. Clients are fully editable and the Active toggle is a real kill-switch — an inactive client is rejected at /api/embed/authorize and its origins drop out of the CSP/CORS union within the cache window. Existing tokens already issued remain valid until they expire. The row-level Generate snippet action emits a complete self-contained PKCE page (verifier generation, authorize redirect, code exchange, widget render).

Ready-made CRM wrappers

Don't build this by hand for Salesforce or ServiceNow. Both ship drag-and-drop components that wrap exactly this flow — the URL below, a server-side mint, and on-component diagnostics — as three display-mode presets:

Component Query params it adds
Transcript viewMode=transcript&hideInput=true
Agent Assist / suggestions hideInput=true&showSuggestions=true&railOnly=true
Copilot / full chat (none)

surface-app stays the only embed UI — the wrappers add record wiring and token minting, never rendering. See CRM embed components for the architecture and the per-host traps, Agent Studio for Salesforce and sn/servicenow-embed for the packages. You still register the host's origin as an App client below.

Microphone permission

For voice and multimodal agents, the iframe needs microphone access. The allow="microphone" attribute on the iframe element is required; without it, even a user who grants microphone permission on the parent page cannot get the iframe past the permission prompt.

CSP on the host

If your host page has Content Security Policy, allow:

  • frame-src https://surface-app.<your-host>; so the iframe loads.
  • connect-src rules for the conversation-api endpoint if you call it directly.

Testing the embed

The simplest manual smoke test:

  1. Mint a token via the backend route above.
  2. Open https://surface-app.<your-host>/en?t=<embedToken>&embed=true&parentOrigin=https://your-host.example directly in a browser.
  3. The iframe should load and emit a ready message - visible in the browser console with a postMessage listener.

For end-to-end testing inside your host page, see postMessage protocol for the events you should expect to receive.

See also