@schwaizer/ocp-embed

The OCP Embed widget loader core — the tested backbone of the <script defer src=".../ocp.js" data-site="KEY"> widget:

  • captureContext — zero-config page context (URL, title, meta description, text selection ≤ 8 KB) with no host wiring. Sent when the widget reports ocp.ready and re-read on a client-side navigation (popstate, pushState, replaceState), because a route change rewrites all three with no reload.
  • EmbedHost — the origin-checked host↔iframe postMessage bridge (both directions pinned to the Hub origin), the host API (setContext / open / close / identify / on), and a ready-gated send queue that flushes on ocp.ready. A host that calls setContext itself wins field by field over what was captured, and keeps winning until its next call.
  • createLauncher — the floating button that opens the panel, so that a site which pasted the snippet and wrote no JavaScript still has a widget its visitors can reach. A real <button> with aria-expanded, its own focus ring (all:initial removes the UA's), and no motion for anyone who asked for none. Revealed only once the widget says ocp.ready. Turn it off with data-launcher="off"; move it with data-position / data-offset; name it with data-label.
  • hostAllowed / originAllowed — the site's origin allowlist (exact, *.suffix, or * for dev only).

Against a Hub with pairing, this widget is the pairing surface, not the microphone: it draws the QR, runs the human approval the protocol requires, and then shows — or deliberately does not show — a conversation whose microphone and model are on the visitor's phone. That is the case the loader is for: a kiosk, a TV, a shared terminal, a screen the visitor does not own. Where the page and the microphone are the same machine, a browser-native agent needs none of this.

DOM and messaging are injected, so the logic is unit-testable in node. The browser entry (real document / window / iframe wiring, the ≤ 15 kB <script> payload with SRI, and the in-iframe chat UI that holds the WSS leg) is the thin wrapper on top — see ../ocp-protocol/SPEC/embed.md.

Keeping the conversation off the relay

Two settings, both on the site's own <script> tag, both optional, both absent meaning exactly the behaviour that existed before they did. They belong here rather than on the Hub because a Hub-side switch would let the relay decide, per visitor, whether that visitor's session is readable (SPEC/retired/sealing.md — RETIRED).

<script defer src="https://hub.example/ocp.js"
        data-site="KEY"
        data-seal="offer"
        data-transcript="mirror"></script>

data-sealoff (default) · offer · require. With offer or require the widget generates an ephemeral P-256 key per session, appends its public key to the pairing deeplink in the browser so the Hub never sees it, and seals turn.input, turn.delta, turn.citation, context.set, action.request and action.result with AES-256-GCM. The approval screen becomes a comparison: the phone shows one six-digit code, the page shows several, and the tap that approves is the check. require means the widget sends no conversation content in the clear, whatever the Hub says.

What that is worth, stated as narrowly as it is true. Sealing means no conversation content crosses the relay leg, whatever terminates TLS in front of it, or the Hub's logs and storage in plaintext, and the Hub holds no key material at any point. It does not defend against a Hub that serves modified widget code: the Hub serves this widget, per request, and the six digits cannot detect that because the same bundle renders them. It does not stop the relay dropping, stalling or truncating. And the Hub still sees which tools were called, in what order and when, the site's full tool catalogue, every confirm and its answer, turn boundaries, and ciphertext sizes — which on a medical, legal or financial site is itself a disclosure. A deployment where the Hub operator and the site operator are the same party gets no protection from that party, and must say so.

data-transcriptmirror (default) · phone-only. phone-only needs no cryptography: the page mounts no composer and no transcript, sends no question and renders no answer. That is the Surface's half and the only half this attribute controls — the reference Companion mirrors turn.input and every turn.delta back by default, so under the shipped settings those frames still cross the relay and still arrive here, where this page drops them. The relay gets turn boundaries and nothing else only when the Companion's mirroring is also off; set both ways it is stronger than sealing, and that is the comparison the retired sealing profile itself made. Use it wherever the visitor does not need the conversation on screen. The site's own tools still run on the page, and their arguments still cross the relay — which is what data-seal is for; the two compose.

Sealing over a scanned QR is the only out-of-band channel this version defines, so a deployment that sets data-seal="require" must keep the assistant reachable some other way for a visitor who cannot aim a camera (§3.2).

The visitor who has no app yet

Everything on the pairing panel presumes the companion app is already installed: the QR encodes a deeplink an absent app cannot claim, the same-device link opens an app that is not there, and the spoken code needs a screen inside the app to be typed into. Two more optional attributes close that gap, and absent — the default — the panel is exactly what it was before they existed:

<script defer src="https://hub.example/ocp.js"
        data-site="KEY"
        data-app-ios="https://apps.apple.com/app/id…"
        data-app-android="https://play.google.com/store/apps/details?id=…"></script>

data-app-ios / data-app-android — https store pages for the companion app. When set, the pairing panel adds an install link under the deeplink: the App Store one on an iPhone, the Google Play one on an Android device, both where the device cannot be placed (a desktop, an iPad reporting itself as a Mac). The link opens in a new tab — the loader widens the frame's sandbox with allow-popups only when these attributes are configured, so an unconfigured widget keeps its no-popups posture. Anything that is not an https URL is refused at boot, because the value becomes a link visitors tap. The line is an offer, never a gate: typing in the panel needs no app, and the copy says what tapping does rather than implying the page is broken without an install.

License: Apache-2.0.