@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.readyand 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
postMessagebridge (both directions pinned to the Hub origin), the host API (setContext/open/close/identify/on), and a ready-gated send queue that flushes onocp.ready. A host that callssetContextitself 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>witharia-expanded, its own focus ring (all:initialremoves the UA's), and no motion for anyone who asked for none. Revealed only once the widget saysocp.ready. Turn it off withdata-launcher="off"; move it withdata-position/data-offset; name it withdata-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-seal — off (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-transcript — mirror (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.