Skip to content

surface-app

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

The shared customer page keeps its multimodal components mounted beneath a temporary authentication dialog. It polls the authorized page-state proxy and dismisses verification only on a server-confirmed result. Auth-first links then show the multimodal landing page at the same URL. See Customer Authentication.

The end-user surface for voice and chat. Hosts the multimodal A2UI iframe per conversation.

Run tests in Agent Studio reuses this surface as a passive chat viewer. hideInput=true suppresses conversation creation, editing, UI actions and avatar activation. Normal transcript and multimodal rendering remain available; supervisor cards also receive readOnly and live UI state. Leaving the viewer only closes its event subscription. Agent-scoped branding requests include X-Agent-Id, so switching between caller and supervisor works with multiple tokens in the shared session cookie. Cookie-less patient links fetch branding through the share-code BFF. It first validates the share, then verifies the protected conversation's agent and tenant before fetching the branding set. Query-string agent IDs cannot change that scope. The A2UI-only shell honors the branding set's show_header flag, including when a patient catalog supplies its own header. The Surgery Partners demo also provides representative actions that update the original patient's multimodal plan after the AI voice leg ends. The shared A2UI renderer disables native form controls, rejects data/action callbacks and withholds proxy calls in read-only mode. Pointer blocking alone does not prevent keyboard activation.

The A2UI rendering layer (renderer, component registry, runtime catalog loader, surface-state store) lives in the shared @trueserve/a2ui-react package (shared/a2ui-react/) since 2026-06-12 — surface-app consumes it as a file: tgz dep; only the industry catalog components (components/a2ui/catalogs/) remain in-app (they use surface-app's shadcn UI + i18n). After editing the package, npm run release there and restart.

Source

apps/surface-app

Runtime

Aspect Value
Language TypeScript
Framework Next.js 16
Port 3002

What it does

  • Renders a chat or voice surface against surface_configs rules.
  • Mounts the A2UI multimodal iframe; pipes user clicks back as messages.
  • Connects to conversation-api via SSE for streamed assistant frames.
  • Renders reasoning/tool trace events when feature_flags.show_reasoning=true or debug event mode is active.
  • Renders user-facing chat events (identity verified, UI actions like "Photos attached", payment outcomes) as centered SLDS-style timeline lines via chat-event-display.tsx — always on, not gated behind debug toggles; the SDK parses CHAT_EVENT SSE entries (both copies). See a2ui-rendering.md.
  • As a read-only CRM viewer (hideInput=true), shows the caller's A2UI surfaces in the multimodal tab as a live non-interactive replica — readOnly mode on @trueserve/a2ui-react, fed by UI_STATE SSE updates; camera controls are hidden. Surfaces and their filled-in state replay post-call from persisted conversation_events rows.
  • For voice, opens a WebRTC session via janus and streams to media-proxy.
  • Hosts the Customer Authentication ceremony: when a share-code session carries session_params.auth_mode, customer-auth-panel.tsx drives WebAuthn via @simplewebauthn/browser (OTP entry as fallback). The per-code server routes under app/api/share/[code]/auth/ proxy to conversation-api — the browser never holds a conversation id, so conversation binding is structural. See customer-authentication.md.

Agent Assist panel

The shared suggestions panel starts directly with its cards. It does not render an “AI Suggestions” or assist-profile title bar, icon, or header placeholder during loading. The initial AgentAssistRailLoadingShell also starts with card skeletons, keeping the loading and loaded layouts aligned. This applies to Salesforce and other hosts embedding surface-app, including rail-only and transcript-with-suggestions views. Individual card titles, actions, and the empty-state message remain part of the card experience.

Transcript meta and lifecycle bookends

Every transcript row carries a meta line under the bubble — speaker, then time — and the transcript is bracketed by Call started / Call ended rows (Chat started/ended for text agents). Both are rendered by transcript-meta.tsx (MessageMetaInfo, TranscriptBookend) and appear in every view, including CRM embeds that never turn on event display.

Four things about this are worth knowing before changing it.

The bookends are derived client-side because no event carries them. transcript-lifecycle.ts exists to record why all four plausible server signals are unavailable: SurfaceAppEventType.ConversationStart/ConversationEnd have listeners but conversation-api never emits either; PipelineEventKind has no CallStarted member (it ends at CallEnded = 18); STATUS_STARTED is reachable only via ServiceStatusFrame(CONNECTED), which exactly two services emit (Moshi, Agentforce), so a translation or any other S2S agent never produces one; and the SDK exposes a conversation's createdAt only on the response to createConversation, which a CRM embed attaching to an existing conversation never sees. So start = the first transcript entry's timestamp. For the end, STATUS-ended is primary (it reaches every surface and is the only signal carrying a human reason) with the CALL_ENDED pipeline event as a fallback — that one is gated behind the debug-event subscriptions, which CRM transcript presets never enable.

Both of conversation-api's replay paths close with a synthetic STATUS-ended stamped at the replay clock. The Redis path says "Replay complete", historical_sse_stream() says "Historical playback complete" — and each stamps int(time.time() * 1000) when the stream is replayed, not when the call ended. Trusting that timestamp would date a transcript reopened the next day to right now, below messages from a day earlier. So both messages are recognized (isSyntheticEndStatus), the message is suppressed as a reason, and the timestamp is discarded in favour of deriveLastContentAt(entries); with no content to fall back on there is no end bookend at all, because an untimed one would sort above the "Call started" row. The payload shape those functions key on is pinned by test_sse_status_payload.py, which drives the real process_sse_outgoing_entry rather than a paraphrase — note the STATUS fields are nested under data, since that function always returns {"type", "data"}.

A STATUS event carries no identity, and the SDK used to discard it as a duplicate. conversation-api's process_sse_outgoing_entry starts processed_entry as an empty dict, and exactly two of its arms — STATUS and ENABLE_MULTIMODAL — assign neither eventId nor conversationId (UI_RENDER assigns eventId only; every other arm assigns both). Those keys are therefore genuinely absent, even though SurfaceAppEvent declares all five ids non-optional and SurfaceAppEventType has no STATUS member at all — which is why transcript-lifecycle.ts matches a structural StatusEventLike with a string compare instead of the enum. The SDK's applyEventToState de-duplicated on existing.eventId === event.eventId && existing.conversationId === event.conversationId, which for two id-less events is undefined === undefined twice, so the first id-less event ever seen suppressed every later one — and ENABLE_MULTIMODAL lands near session start, so it routinely swallowed the STATUS-ended at the end of the call. STATUS bypasses the transcript-merge block entirely (getTranscriptPriority covers only the three transcript kinds), so that dedup was the only thing dropping it: no "Call ended" bookend ever rendered. The fix gates the id comparison on hasEventIdentity(event) in both SDK copies — apps/surface-app/lib/trueserve/sdk.ts and shared/trueserve-sdk/src/sdk.ts (re-pack + reinstall that one into ai-manager). A translated call publishes STATUS-ended once per flow — observed live as seq 15 and 21 — so two are correct rather than a duplicate.

The same signal answers a second question. hasConversationEnded in page.tsx derived solely from CallEnded, i.e. from pipelineEvents, which is permanently empty on a CRM embed — so it was always false there and the trailing typing/speaking indicator span forever after a call ended (canSendText and the editor gate were wrong the same way). It now ORs ConversationEnd, isConversationEndStatus(event), and CallEnded, and deliberately uses the predicate rather than conversationEndMark: a replay sentinel with no content to date it to yields no bookend while the conversation is still over.

Also note the end reason is chosen by frame type, not by how the call ended — "Call ended normally" if isinstance(frame, EndFrame) else "Call cancelled" — and a translation session always tears down on a CancelFrame. So GENERIC_END_MESSAGES suppresses both strings as reasons; a ServiceStatusFrame diagnostic ([GoogleS2ST] …) or a transfer reason still shows.

Translation speaker labels are pure client-side. _translation_role() in the orchestrator sets the role from the flow direction, not the frame type (incoming = caller, outgoing = agent; both channels of one flow share it), and _handle_translation_fragment publishes source as "transcription" or "translation". So (entry.role, entry.source) alone yields "Caller Translation" / "Agent Translation" with no server change — that mapping is transcript-speaker.ts, kept separate from the render sites because page.tsx has two of them and the agentPerspective swap must not reach the label.

Gotcha: appending the meta line as a MessageContent child renders it inside the tinted bubble — messageContentVariants' default contained variant owns that background. Both render sites wrap MessageContent (dropped to max-w-full) and the meta line in a flex max-w-[80%] min-w-0 flex-col gap-1 column, with the avatar staying a row sibling.

Dependencies

Service Purpose
conversation-api SSE channel + REST history
multimodal-service A2UI iframe origin
janus WebRTC for voice
ai-manager Auth verification

Vanguard

overmind restart surface-app

The launcher raises its inherited soft open-file limit to 65536 (or the hard limit if lower), preserving an already higher limit. If Salesforce embeds stop loading and the surface log reports EMFILE or Too many open files, check the actual Next.js process's /proc/<pid>/limits: Overmind can inherit a soft limit of 1024 even when a new shell has a higher limit. Restart only surface-app and verify its startup limit. An unauthenticated :3002/api/session/me request should return 401; follow it with a freshly minted Salesforce embed and confirm rendering. There is no /api/health route. Also check fs.inotify.max_user_instances: the shared host reached 4092 of 4096 instances on 2026-10-01, making fresh watchers fail even after the file limit was raised. The launcher attempts to raise that ceiling to 8192 without lowering an existing higher value; this step requires passwordless sudo.

Warning: CONVERSATION_API_URL must point at conversation-api :8080, not async-agent :8000. Wrong port silently breaks A2UI rendering via SSE reconnect storm.

Embed session enforcement

Protected pages require a signed embed JWT. The proxy.ts middleware redirects a page load carrying ?t=<JWT> to /api/session/exchange, which verifies the token against ai-manager's JWKS and stamps the httpOnly sfa_session cookie. Page loads without a valid session get the 401 "Embed session required" HTML page.

HTTPS session and nonce cookies are partitioned by the embedding top-level site (Secure; SameSite=None; Partitioned). This supports browsers that block unpartitioned third-party cookies while retaining signed-token verification. For an iframe loading failure, inspect the response after the exchange and browser cookie-block reasons: the exchange can succeed even when its cookies are rejected. Local HTTP development retains Lax, non-Secure cookies.

The cookie is multi-agent (v2 format): it holds one token per agent id (sub claim, which keeps the version suffix, e.g. 4049:draft), so concurrent embeds — or switching between agents in the ai-manager tester — accrete tokens rather than clobbering. Disambiguation therefore requires an agent hint: pickToken returns the single token when only one is present, but with 2+ tokens and no hint it returns null → /api/session/me 409s → the proxy 401s.

Gotcha (fixed 2026-07-09). The embed iframe URLs carry the agent hint as pipelineId (falling back to legacy agentId). The client page reads that precedence, and so must the proxy's hasValidEmbedSession — it originally read only agentId, so after a user switched agents in the tester (two tokens in the cookie) the proxy verified hintless and a perfectly valid session 401'd as "Embed session required". Any new session check that queries /api/session/me must pass pipelineId ?? agentId ?? x-agent-id.

Production

Helm chart at charts/surface-app. The iframe origin must be allowlisted in allowed_origins for the tenant; missing rows fall back to frame-ancestors 'self' and kill the embed.

The static embed SDK files intentionally use wildcard CORS because partner sites fetch them as public loader assets and they carry no credentials. Public share-code SSE remains capability-based, but cross-origin reads now echo only origins present in the active ai-manager origin allowlist.

See also

Hybrid Agent Assist card ordering

When conversation cards and an assist-profile record snapshot share a rail, live conversation cards appear first. groupCopilotRailCards receives the materialized record surface IDs; it preserves explicit layout.order and arrival order within each source. Record-only profiles keep their existing order. This prevents a tall record transfer summary from pushing current guidance below the visible panel. Reload an existing embed to load this frontend correction; no Salesforce metadata or catalog/profile changes are required.