@memberjunction/realtime-widget
A droppable, embeddable public customer-support widget (text today; voice in W4) for any
third-party website. A single <script> tag plus one mount element opens a support
conversation with a pinned MJ agent — no MJ login required — by minting a short-lived
anonymous guest session and reusing MemberJunction’s unified agent pathway
(ConversationsRuntime + the GraphQL data provider). It does not reimplement chat or agent
dispatch.
Part of the Agent Bridges & Public Widget program
(phases W3+). See public-web-widget.md.
Embedding (the entire integration)
Section titled “Embedding (the entire integration)”<div data-widget-key="pk_live_xxx" data-api-url="https://api.yourco.com" data-title="Acme Support" data-greeting="Hi! How can we help?"></div><script type="module" src="https://cdn.yourco.com/mj-widget.js"></script>The loader reads the data-* attributes, calls POST /widget/session to mint a guest JWT, and
mounts <mj-support-widget> inside a shadow DOM so host CSS cannot bleed in or out. See
examples/blank-host.html.
The host page must be served over HTTPS (or from localhost). The widget binds its guest session to a
Web Crypto key and signs each token refresh with it, and browsers expose Web Crypto (crypto.subtle) only in a
secure context. On a plain http:// page no widget is shown, and the console says why ([mj-widget] The support widget needs a secure context …). There is no fallback: a session that cannot prove the key could be renewed by
anyone who copied its token.
The refresh proof and the refresh timer run on the server’s clock, which the widget learns from each token’s
iat, so a visitor whose device clock is minutes off keeps one session instead of restarting it at each refresh.
Programmatic mount
Section titled “Programmatic mount”import { mountWidget } from '@memberjunction/realtime-widget';await mountWidget({ widgetKey: 'pk_live_xxx', apiUrl: 'https://api.yourco.com', mountTarget: '#support' });Content Security Policy (host sites)
Section titled “Content Security Policy (host sites)”If your site sends a Content-Security-Policy header (it should), the widget needs four allowances:
the script bundle, the API origin (GraphQL over HTTPS and the realtime WebSocket for voice), and —
because the UI renders inside a shadow DOM with inlined token styles — style-src 'unsafe-inline'.
Replace https://api.yourco.com with your MJAPI origin and https://cdn.yourco.com with wherever you
host mj-widget.js:
Content-Security-Policy: script-src 'self' https://cdn.yourco.com; connect-src 'self' https://api.yourco.com wss://api.yourco.com; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'self';Notes:
connect-srcmust include BOTHhttps://(the GraphQL mint + agent calls) andwss://(the realtime voice socket). Voice silently fails to connect if thewss:origin is omitted.style-src 'unsafe-inline'is required because the shadow-DOM component injects its design-token stylesheet inline. The shadow root keeps it isolated from your page’s styles; it does not widen your page’s own CSP exposure to inline<style>you didn’t author.img-src data:covers the inline SVG/emoji affordances.- Voice also needs the browser mic permission; on a strict
Permissions-Policy, allowmicrophone=(self "https://cdn.yourco.com")(or your embed origin) or the mic toggle is hidden and the widget falls back to text.
Architecture
Section titled “Architecture”host page → <mj-support-widget> (shadow DOM, design-token-scoped) ├─ WidgetSessionClient → POST /widget/session (guest JWT, refreshed before expiry) └─ IWidgetTransport ├─ RuntimeWidgetTransport (prod): setupGraphQLClient(guest token) + ConversationsRuntime │ → AgentRunner.processMessage({ explicitAgentId: <pinned> }) ← D5 └─ MockWidgetTransport (tests / offline demo)- Auth (D1, pluggable): the guest session is an
anonymous-embedmagic-link token minted by MJServer’sWidgetSessionService, validated by the sameAuthProviderFactorypath. Strategies:Anonymous(default),MagicLinkUpgrade,HostIdentity— selected per widget instance (W5). - Guardrails (D5): the widget always passes the per-instance pinned
explicitAgentId, and the synthesized guest principal carries the restricted Widget Guest role (read/create/update only on Conversations + Conversation Details). Pinning alone is not enough — see the W0 findings. - Isolation: shadow DOM +
all: initialon:host;--mj-chat-*tokens are injected into the shadow root, never<head>.
Scripts
Section titled “Scripts”npm run build # tsc → dist/ (ESM), then the bundle below, so a build never leaves a stale mj-widget.jsnpm run bundle # esbuild → dist/embed/mj-widget.js (browser ESM, code-split into dist/embed/chunks)npm run bundle:min # minified variantnpm run test # vitest (jsdom): session client, element, loaderBuild & bundle status (overnight session)
Section titled “Build & bundle status (overnight session)”npm run build✅ andnpm run test✅ (15 tests: mint, shadow-DOM isolation, send/receive, pinned-agent pass-through, refresh math, loader mount).npm run bundle✅ produces a self-contained browser ESM (no unresolved node built-ins). It is currently large (~2.9 MB minified) because it bundles the full runtime + GraphQL provider; tree-shaking / code-splitting the bundle is W6 packaging hardening, tracked, not done here.- Live end-to-end (
blank-host.htmlagainst a running MJAPI completing a real turn) requires MJAPI booted withwidget.enabled=true— Auth0-gated in the overnight sandbox. The code path is complete and ready; it was not faked.
Not built here (anti-drift)
Section titled “Not built here (anti-drift)”No new chat engine, no new realtime/voice driver, no new auth subsystem, no second agent-dispatch path — all reused. Voice (W4), magic-link upgrade + host identity (W5), and bundle/abuse hardening (W6) build on this package.