CRM embed components¶
Status: Current | Last reviewed: 2026-10-12
The new trueServeMfeWorkspace wrapper selects workspaceOnly=true and
hideInput=true. It renders only Copilot surfaces placed in layout.region:
"workspace", with stable view navigation and native reviewed-form actions.
It requires an existing conversation and never falls back to launching an
agent. The Takeda DevSDO app stages it as
takedaWorkspace; a managed-package release is a separate deployment.
How TrueServe embeds into a host CRM — Salesforce, ServiceNow, or anything else — and why there is exactly one embed UI (surface-app) rather than a per-host reimplementation. Read this before adding a component to a host platform, and before proposing a second rendering path.
This is both the architecture reference and the decision record for the 2026-07 consolidation, which collapsed four generations of experiments into one shape. Decision record explains what was removed and why, so the same ideas don't get rebuilt.
The one architecture¶
A host component is a thin, admin-configurable wrapper around a sandboxed <iframe> of surface-app, pinned to a display mode.
flowchart TB
subgraph HOSTS["Host platforms"]
sf["Salesforce<br/>wrapper LWCs (App Builder)"]
snow["ServiceNow<br/>ui-core components (UI Builder)"]
custom["Custom app<br/>iframe or PKCE snippet"]
end
subgraph WRAPPER["What a wrapper owns"]
wiring["record wiring · token mint · diagnostics"]
end
subgraph SURFACE["apps/surface-app (the one embed UI)"]
modes["display modes: transcript · suggestions · chat"]
render["transcript · suggestions · A2UI catalogs · i18n · branding"]
end
capi[("conversation-api<br/>SSE + REST")]
sf --> wiring
snow --> wiring
custom --> SURFACE
wiring -->|"iframe src = embed URL"| SURFACE
wiring <-->|postMessage bridge| SURFACE
modes --> render
SURFACE --> capi
The split is the whole point:
| Layer | Owns | Per host? |
|---|---|---|
| surface-app | Rendering: transcript, suggestions, A2UI cards (dynamic catalog import), i18n, branding, attachments | No — one codebase |
| Wrapper component | Record wiring, server-side token minting, admin properties, on-component diagnostics | Yes — thin |
| conversation-api | Conversation state, SSE, REST | No |
Adding a host means writing a wrapper (a few hundred lines), not porting a UI. Anything a wrapper renders itself becomes a new UI codebase that will drift.
The embed contract¶
Every wrapper on every host builds the same URL and speaks the same bridge, both documented in full in Embedding surface-app and postMessage protocol. What follows is what a wrapper specifically needs.
URL¶
<surfaceUrl returned by the selected AI Manager>/<locale>?embed=true&t=<jwt>&agentId=…&conversationId=…
&surfaceConfigId=…&sessionVars=…&embedId=…&parentOrigin=…&hideContext=true&hideToolCalls=true
+ display-mode params
Display-mode presets — these are what turn a display mode into a "component", and they are identical across hosts:
| Component | Params |
|---|---|
| Transcript | viewMode=transcript&hideInput=true |
| Agent Assist / suggestions | hideInput=true&showSuggestions=true&railOnly=true |
| Copilot / full chat | (none — transcript + composer + A2UI) |
| Wrap-Up Summary | viewMode=transcript&hideInput=true |
Kept in lockstep in three places, not two: SURFACE_PRESETS in c/trueServeMfe, SURFACE_PRESETS in embed-bridge.js, and DISPLAY_MODE_MAP in c/trueServeSurface — that third copy is keyed on the human-readable App Builder labels ("Transcript Only", "Wrap-Up Summary", …) rather than the surface slugs, which is why a grep for SURFACE_PRESETS misses it. A wrapper is exactly "TrueServe Surface pinned to one display mode", so a new preset belongs in all three: the two slug tables, and the label table plus its datasource list in trueServeSurface.js-meta.xml (a value absent from datasource can't be picked in App Builder).
Note: Wrap-Up Summary deliberately omits
showSuggestions. surface-app auto-opens the suggestions rail whenever a copilot card exists (showSuggestionsParam || copilotRailCards.length > 0), so the persisted summary opens the panel by itself; setting the param would open an empty "AI Suggestions" panel on a call that produced no summary.viewMode=transcriptdoes the other two jobs a wrap-up view needs — it locks the view against surface-app's multimodal auto-switch, and it strips inline A2UI from the transcript stream so the summary card can only land in the rail. The only two legalviewModevalues aretranscriptandmultimodal(ViewModein sdk.ts).Note: the transcript preset sets no debug or event-display param, so a wrapper gets no pipeline events. Speaker/timestamp meta lines and the Call started / Call ended bookends are deliberately independent of that — they are derived client-side precisely so a transcript embed shows them. Don't add
showEventsto a preset to get them. See surface-app for why no server event carries a conversation-start signal, and why a replayed conversation's end time comes from its last content rather than the STATUS it arrives with.
Token¶
surface-app requires a short-TTL, single-use embed JWT on ?t=, minted by ai-manager POST /api/embed/mint-session, which itself needs a tsf_ API token. That API token must never reach the browser and must never be a component property. Each host mints server-side:
| Host | Mint path |
|---|---|
| Salesforce | Apex TrueServeSurfaceController.mintEmbedToken over the TrueServe External Credential (the Named Credential holds the secret) |
| ServiceNow | A Scripted REST resource or Flow Action in the scoped app, bound to a UI Builder data resource that feeds the token property |
| Custom app | Your own backend route, or the Site/PKCE flow for backend-less pages |
The token is audience-bound to the embedding origin and its jti is burned on exchange, so mint one per mount. The mint response supplies surfaceUrl; the Salesforce wrapper uses that origin instead of accepting a separate domain property. The token and iframe host come from the same TrueServe deployment.
The Salesforce controller requests ttlSec: 3600 for a bounded one-hour CRM
session. The API default is 120 seconds, and the surface cookie stores the same
JWT, so its authorization cannot outlive that expiry. Using the default for a
longer voice call caused late form commands/reconnects to return 401. Other
embedding clients retain their selected token lifetime.
On exchange, surface-app removes t from the browser-facing URL and makes the verified JWT sub the agent hint. It adds a missing hint and replaces any configured fallback that differs from the signed subject. This selects the correct token from the multi-agent session cookie when several components share a page; the hint is derived from the signed token, not trusted from host input.
HTTPS embed sessions set both sfa_session and sfa_session_nonce with
HttpOnly; Secure; SameSite=None; Partitioned. Partitioning scopes the cookies
to the embedding top-level site and allows the session to survive third-party
cookie blocking. Without it, a valid exchange can return 302 while the browser
rejects both cookies; the clean iframe URL then returns 401 and never reports
ready. Check blocked-cookie reasons as well as token-exchange status when
diagnosing a loading timeout. HTTP development retains SameSite=Lax.
Transcript and Agent Assist treat an existing conversationId as authoritative. ai-manager resolves its persisted agent after a tenant-scoped lookup and signs the embed for that owner; their configured agent remains only a fallback when the record field is empty.
Copilot has a separate binding contract when both values are present. The wrapper mints against the configured Copilot agentId with no primary conversationId, causing surface-app to create a fresh Copilot conversation. The source call is placed in the signed session metadata as relatedConversationId, with related_conversation_id retained as a compatibility alias for older published prompts. Apex validates that id against the Salesforce record and overwrites either same-named client/Flow value before minting. The Copilot pipeline can bind a transcript-retrieval tool or prompt context to that variable. This avoids the ownership 403 that correctly occurs when a translation agent owns the source conversation while still allowing a separate Copilot to discuss it.
Agent Assist supports two runtime patterns. Live sidecars/c copilots merge cards into the source conversation event stream. Asynchronous channels use a tenant record-backed Agent Assist profile, resolved by stable slug from the JWT; hybrid mode combines both catalogs and card streams in the same rail.
Salesforce wrappers that display the source conversation add preserveConversation=true. surface-app still checks the supplied conversation status, but it does not replace a completed/terminal record-bound call with a new text conversation. Contextual Copilot instead omits the source conversationId entirely and creates a separate chat, so the preservation flag does not apply to it.
Bridge¶
host → iframe : { source: "trueserve-host", type, payload }
iframe → host : { source: "trueserve-surface-app", type, payload }
Outbound types surface-app accepts: context-update, set-capabilities, set-assist-snapshot, set-view-mode, set-event-display. Inbound: ready, connection, error, conversation-event, external-event, and the legacy host-action.
Note:
trueserve-hostis the generic outbound source name — new hosts use it. The inbound allow-list in bridge.ts also carries historical per-host names (trueserve-sfdc,trueserve-dashboard,trueserve-gallery,trueserve-ai-manager) for compatibility; don't add another. The security boundary is the origin check, not that list.
The bridge is optional for conversation rendering and required for pushing context in and getting conversation or profile-component events out. A Salesforce Host Record rail deliberately keeps its Context Flow snapshot out of the URL: up to 64 explicitly named outputs travel through the origin-checked set-assist-snapshot message, avoiding URL-size limits and access-log exposure for large bodies and disposition JSON. set-capabilities remains for live-agent host actions and legacy profiles; catalog-inherited profile components emit external-event without a profile or host capability toggle.
Note: surface-app does not emit a "suggestion selected" event. It relays raw
SurfaceAppEvents overconversation-event, so the wrapper decides what is worth surfacing to the host platform. Smart replies ride the unifiedsuggestionevent withdata.suggestion_type === "smart_reply"(older orchestrator builds sentknowledge_suggestion— accept both). SeesmartReplyTextin the ServiceNow bridge.
Self-documenting diagnostics¶
A record may open while the phone is ringing, before the answer-time Copilot
conversation exists. The Salesforce MFE core retries that exact
conversation not found mint response once per second for at most 60 seconds,
keeping the same record and conversation binding. It shows Waiting for the
call to connect… and cancels the pending bootstrap when the component leaves
the page. Permission failures, missing agents/catalogs, and other errors still
fail immediately. Temporary network failures and HTTP 502/503/504 retry within
the same bounded mint window. This wait is independent of the 30-second iframe
ready timeout and never creates a replacement conversation.
For a missing iframe ready, Salesforce automatically remints and remounts at
most twice, with short backoff and a fresh single-use token each time. The
loading stencil remains visible during recovery. A late ready cancels a
scheduled retry. After the bounded attempts, the diagnostic error and manual
Retry remain available. Detaching and reattaching a Lightning console tab clears
the destroyed-frame latch so the tab can reconnect.
The consolidation's headline requirement: a component never renders an empty box. Every host component represents the same states, because "blank widget" was the largest support cost of the previous generations — a load failure, a misconfigured property, and a working-but-inert component were indistinguishable. Salesforce keeps technical detail behind the App Builder Show Debug Information property so an end-user page does not expose setup or token diagnostics.
| State | Trigger | Salesforce default | Salesforce with debug enabled |
|---|---|---|---|
| Config error | Missing/inactive connection, credential failure, mint failure | SLDS Can't connect illustration + Retry | Underlying error text + Retry |
| Configured but inert | Conversation-id field is empty, with no agent-id fallback | Neutral Nothing to display state | Field name and both ways to fix it |
| Config warning (non-blocking) | e.g. a context Flow outside the TrueServe_Context_ allowlist |
Hidden; the embed still loads | Exact rename or configuration correction |
Load timeout (30 s per attempt, no bridge ready) |
Frame mounted, session never came up after two automatic recoveries | SLDS Can't connect illustration + Retry | Ordered checklist + exact URL tried + Retry |
| Connection details | Normal widget lifecycle | Hidden | Surface host, agent, resolved conversation, connection state, last event time |
The checklist is ordered by likelihood and each item is independently verifiable: host reachable → CSP Trusted Site (SF) / URL allow-list (SN) → the host origin registered under ai-manager Settings → Embedding → browser console (a "Refused to frame" error is a CSP block; a 4xx on /api/session/exchange is a rejected token).
Implementations: buildTimeoutChecklist in c/trueServeMfe, and configProblem / readyTimeoutChecklist in embed-bridge.js rendered by create-surface-component.js.
Salesforce¶
Five embed components in the Agent Studio for Salesforce managed package: c-true-serve-mfe-transcript (labelled TrueServe Conversation), c-true-serve-mfe-suggestions, c-true-serve-mfe-copilot, c-true-serve-mfe-wrapup (the four display-mode presets, sharing TrueServeMfeBase in c/trueServeMfe), plus c-true-serve-surface for full control. Shared resolution/mint/context-Flow logic lives in c/surfaceResolver; the Apex is TrueServeSurfaceController — a new preset wrapper needs no Apex change.
Wrap-Up Summary shows a copilot's post-call summary beside the transcript it was written from. Nothing in the component knows about copilots: a copilot agent with summarization.post_call enabled extracts its summary while the pipeline tears down — too late to publish live — so the orchestrator persists it as a copilot_summary conversation event carrying a render-ready A2UI envelope, and conversation-api replays that row as a UI_RENDER on the historical SSE stream. Its layout: {region: "rail", order: -100} routes it to the Agent Assist rail pinned above any per-turn worker cards. The wrapper just opens the record's conversation read-only and the summary arrives with the replay, so point it at the same Conversation ID Field the transcript wrapper uses. See Copilots.
Note: the wrap-up surface is a record-page/utility-bar wrapper, not a modal. The SCV connector's
wrapUpStartedevent was the obvious trigger and is unusable for this: it carries only a vendor call id (the connector never holds a TrueServe conversation id), it is published on a message channel inside a different, namespaced package withisExposed: false, and there is noLightningModalor quick-action precedent anywhere inembedded-surface. Resolving the conversation from the record and replaying its stored summary needs none of that.It also resolves its mint target the opposite way from Copilot.
resolveEmbedTargetspecial-cases only"copilot"(fresh session against the copilot agent, record call asrelatedConversationId); every other surface — wrap-up included — takes the record conversation as the primary target, which is exactly right, because a summary is a property of the conversation that stored it.
The admin property set starts with a TrueServe Connection picklist backed by TrueServe_Connection__mdt. Each record groups the AI Manager and Conversation API Named Credential names. Subscriber credentials normally supply their own Authorization header through the attached External Credential; the two External Credential metadata fields are optional legacy overrides for the packaged Default. Agent ID and/or Conversation ID Field select the target. The remaining properties cover context Flow outputs, branding, height, and debug information. The server supplies the token and surface URL; @salesforce/i18n/locale supplies the locale.
The package ships a Default metadata record pointing at its existing TrueServeSurfaceAPI and TrueServeOrchesteratorVanguard credentials. Admins can add records such as Shimla, Vanguard, and Richmond, then select them per component or per Lightning app. TrueServeSurfaceController resolves the record on each call and uses its Named Credential pair for conversation lookup, session updates, and minting. It validates each configured metadata API name before constructing a callout: endpoint.
Five Salesforce-specific traps, each of which cost a debugging session:
- Every wrapper must declare its own
@apiproperties. App Builder validatestargetConfigagainst the public properties on the component's own module and does not follow a base class imported from another bundle. DeclaringtrueserveConnectiononly onTrueServeMfeBasefails deployment. The prototype chain still resolves the property at runtime, so each wrapper repeats the declaration. - A design property can never be deleted once a page version referenced it. Salesforce refuses the deploy, and FlexiPage retains versions the Metadata API can't reach. The old
trueserveDomainandsurfaceAppBaseUrlproperties remain as labelled no-ops, as do the five retired Native-mode properties onc-true-serve-surface. - The context-Flow allowlist is enforced twice. Only Flows named
TrueServe_Context_*may be invoked — client-side inisAllowedContextFlowNameand server-side inTrueServeSurfaceController.isAllowedContextFlow. A non-conforming Flow is refused silently by design, which is why the wrapper raises a non-blocking warning naming the rename. - Render callbacks must not retry bootstrap failures, and array-valued wire parameters must keep their identity. App Builder renders a newly dropped component before its required properties are filled in. Retrying from
renderedCallbackafter the config/mint error updates component state creates a tight error → render → retry loop. The three preset wrappers therefore remember the last attempted configuration until a property changes or the admin presses Retry. Both wrapper implementations also memoize thegetRecordfieldsarray; returning a new array from a reactive getter reprovisions the wire on each render. - Embedded iframes need an explicit outer height before flex stretching. An inline iframe reserves text-baseline descent space, while
height: 100%cannot resolve against an auto-sized App Builder host. Each lean wrapper therefore sets--trueserve-component-heightfrom its validated design property (500px by default), uses that on the outer flex column, and lets the block-level iframe fill the remaining frame host without an intrinsic-height gap. - Composed workspaces can opt into measured height.
trueServeMfeWorkspace.autoHeightenables the App Builder option Fit Composed Workspace Content. The surface observes its natural content root and sendscontent-resizewith a finite height through the existing embed bridge. The core host accepts it only after its normal origin, source-window and embed-ID checks, clamps it to 200–2000 px, and retains the configured height until a valid measurement arrives. Fixed-height embeds are unaffected.
Per-connection setup requires two Named Credentials, their External Credential principal grants, a CSP Trusted Site covering the returned surface-app host, and the Lightning origin registered under that environment's ai-manager Settings → Embedding. The External Credential supplies an Authorization custom header using its encrypted BearerToken; subscriber-created Named Credentials must list trueservedev under Allowed Namespaces so packaged Apex can call them. App Builder reads active connection records through TrueServeConnectionPickList, a VisualEditor.DynamicPickList data source.
ServiceNow¶
Three Now Experience UI Framework components in sn/servicenow-embed — x-<prefix>-assistant-transcript / -suggestions / -copilot — registered for the UI Builder picker in now-ui.json, all three produced by one factory (create-surface-component.js) over the shared embed-bridge.js. The suggestions component dispatches the TRUESERVE_SUGGESTION#SELECTED UI Builder action.
Because Now components render into shadow DOM, the iframe is reached via host.shadowRoot; the message listener is wired on COMPONENT_DOM_READY and removed on COMPONENT_DISCONNECTED.
Two ServiceNow-specific traps:
- The scope and tag prefix are instance-specific. ServiceNow assigns each instance a vendor prefix and rejects any other on deploy. Deploying to a different instance means replacing the prefix across folder names,
now-ui.json,src/index.js, and everycreateCustomElementtag. - Cross-origin
postMessageis blocked by default. URL-config rendering works with no setup, but the event relay (including the Suggestion Selected action) is silently dropped unless an admin adds the surface-app origin toglide.ui.concourse.onmessage_enforce_same_origin_whitelist. An empty property blocks all cross-origin messages.
Note: the ready-watchdog timer handle lives in the
COMPONENT_DOM_READYclosure, not in component state. The listener is created once, so astate._timerread inside it would forever see the value state held at registration and the timeout would fire even after a successfulready.
Decision record: the 2026-07 consolidation¶
Four generations of experiments had accumulated, three of them re-implementing the same UI. All are now removed.
| Generation | What it was | Verdict |
|---|---|---|
Gen 0 — trueServeChat + legacy SDK |
Hardcoded URL, manual conversation-id entry | Deleted |
Gen 1 — embeddedSurface, nativeSurfacePanel, nativeSurfaceSentiment, trueserveBridge |
First iframe + first SLDS attempt + a driver/subscriber bridge | Deleted |
Gen 2 — trueServeSurface Native mode + surfaceEventStore + surfaceAnalysis + trueServeSentimentGauge |
Transcript and suggestion cards re-implemented in SLDS, driven by the embed SDK through a hidden iframe | Native mode deleted; Iframe mode kept as c-true-serve-surface |
Gen 3 — <lwc-shell> MFE wrappers + apps/assistant-embed + four Lit elements |
A Salesforce Micro-Frontends shell over a second, Lit-based UI implementation | Wrappers kept and repointed; the shell, the app, and the elements archived |
Four decisions worth not re-litigating:
surface-app is the only embed UI. Gen 2 (SLDS) and Gen 3 (Lit) each rebuilt the transcript and suggestion cards. Both drifted behind surface-app immediately, and neither could render A2UI: @trueserve/a2ui-react is React and the industry catalogs are locked to shadcn + next-intl, so they do not port framework-free. surface-app already loads catalogs dynamically, so pointing the wrappers at it made the "A2UI in a framework-free element" problem disappear instead of solving it. The wrappers are display-mode presets over the same app the standalone web experience uses.
The Salesforce MFE <lwc-shell> is not used. The Developer-Preview shell keeps its frame visibility: hidden until an MFE-protocol handshake that surface-app does not speak — pairing the two is a guaranteed permanent blank, which is exactly the symptom that opened this work. The wrappers use the plain sandboxed iframe trueServeSurface had always used, so vendorLwcShell is archived. Revisit only if the shell reaches GA and surface-app implements the bridge protocol.
apps/assistant-embed is gone, not productionized. It was a lean Vite host app on port 3522 composing the Lit elements, never deployed to production, and in dev it was broken by an env-var collision (Overmind pre-sets PORT, so Vite bound 6800 while nginx proxied 3522 → 502). Since every host now iframes surface-app there is no second embed host left to route: the Procfile entry, start script, nginx vhost, and port allocation are all removed. Source archived under .backup/assistant-embed-2026-07-31/.
shared/trueserve-elements is phone-only now. The four assistant elements (<trueserve-session>, -transcript, -suggestions, -copilot) plus the shared theme, session registry, and cross-origin resolver were archived with the host app. <trueserve-phone> stays: it wraps a different product surface — the ai-manager softphone — that has no surface-app equivalent, and ai-manager serves its IIFE at /embed/trueserve-phone.js. Dropping the assistant elements also dropped the package's @trueserve/sdk dependency, so it is one fewer re-pack target in the SDK release chain. See Embed the demo phone.
The SDK's cross-origin token seam (authToken / setAuthToken() in sdk.ts) survives the archive and is unrelated to this decision — it is opt-in and used by any SDK consumer talking to a foreign conversation-api. Likewise the conversation-api null-origin CORS allowance is no longer needed by these components (the <lwc-shell> sandbox lacked allow-same-origin, giving the document an opaque origin; a plain iframe has it), but it stays for third-party hosts that sandbox us the same way. The origin-bound embed JWT is the real auth boundary there, not CORS.
What is still open¶
- A bridge-delivered token. Today's
?t=JWT is short-TTL and single-use, the accepted pattern across every embed path. A variant delivering it over postMessage instead of the URL would keep it out of browser history and referrer headers. Not built; not blocking. - A ServiceNow reference mint implementation. The README documents the Scripted REST / Flow Action shape, but the repo ships no scoped-app artifact for it — each instance writes its own.