OCP Embed profile — normative specification (v1.0-draft)

Extends Core. The widget: <script defer src=".../ocp.js" data-site="KEY">.

Topology

  • Loader ≤15 kB, MINIFIED AND GZIPPED, measured on the published IIFE bundle; chat renders in an IFRAME (origin isolation). Host↔iframe via postMessage with strict origin checks in BOTH directions; iframe↔Hub over one of the two transport profiles below. The host page NEVER sees Hub traffic; the iframe NEVER accepts messages from anything but its declared Hub origin.
  • The Hub MUST serve the widget document with Content-Security-Policy: frame-ancestors derived from the site key's allowedOrigins, and MUST NOT send X-Frame-Options — which has no allowlist form, so any value strong enough to be worth setting blocks embedding outright. This makes the origin allowlist BROWSER-enforced rather than merely server-promised.
  • The loader MUST be served with SRI hashes; the widget MUST be cookie-less at T0 (visitor continuity via localStorage, rotating id). Storage access MUST be fault-tolerant: in a third-party frame it may be partitioned, blocked, or throw on mere access. Where it is unavailable the widget MUST fall back to an ephemeral in-memory id and remain FULLY FUNCTIONAL; a Hub MUST NOT assume visitor-id stability.

The launcher

The loader MUST mount its own launcher: a visible control that opens the panel. ocp.open() is a host API, and a profile whose only way in is host code is one whose widget most sites never show to anybody — openable in principle and unreachable in practice for every site that pasted the snippet and wrote nothing else, which is the whole population the one-liner exists for.

The requirements are all consequences of running on a page we do not control:

  • A REAL <button> — in the tab order, activated by Enter and Space, with an accessible name, aria-expanded for its state, and aria-controls naming the panel. Opening MUST move focus into the panel; closing MUST return it to the launcher when it was inside, and MUST leave it alone when it was not.
  • Styled DEFENSIVELY: all:initial first, and inline CSSOM properties only. A <style> element and a markup style= attribute are both blocked by a strict host style-src CSP; property assignment is not. Because all:initial also removes the UA focus ring, the launcher MUST draw its own.
  • REVEALED ONLY AFTER ocp.ready. A launcher that opens an empty panel is worse than one that appears a moment later. Where the widget never becomes ready — Hub unreachable, document blocked, frame-ancestors refusing this origin — no launcher appears at all, which is the honest outcome: an offer that cannot be fulfilled is not made.
  • Motion MUST respect prefers-reduced-motion.
  • SUPPRESSIBLE with data-launcher="off", for a host that has its own trigger and calls open() from it.
  • PLACEABLE with data-position (bottom-right default, plus the other three corners) and data-offset (pixels), because only the host knows what is already in that corner of its own page. data-label sets the launcher's text and the frame's title: the loader's own default is English, and the host is the only party that knows the visitor's language.

Every one of those attributes is optional. A tag carrying nothing but data-site MUST produce a working, visible launcher — that is the case the defaults exist for.

Host ↔ widget messages (contract: embed.ts)

Host → widget (hostToWidgetSchema):

  • ocp.setContext { context } — zero-config fields the loader captured (url, title, meta description, selection ≤8 KB) plus anything the host merges in.
  • ocp.open / ocp.close — show/hide the panel.
  • ocp.identify { visitorJws } — signed visitor identity (account-bound, T1+).
  • ocp.tools { tools } — the site's own tools, which the widget advertises to the Hub on its behalf (see Host tools).
  • ocp.actionResult { actionId, ok, output?, error? } — the host's answer to an ocp.action.

Widget → host (widgetToHostSchema):

  • ocp.ready — the iframe is mounted and listening; the host MUST queue any earlier host→widget messages and flush them on receipt (the loader core does this).
  • ocp.state { phase } — idle | connecting | live | error.
  • ocp.event { name, data? } — host analytics hook. Message CONTENT never leaves the iframe unless the host opts in.
  • ocp.action { actionId, name, input } — run one of the host's own tools.

A host→widget message MUST target the exact Hub origin; a widget→host message MUST be dropped unless event.origin equals the Hub origin.

Host tools

Tools are declared by the HOST PAGE, not the iframe, because they are the site's own functionality: only the host can read its own forms, call its own same-origin APIs, or navigate itself. The widget is sandboxed on the Hub's origin and could not invoke any of them if it wanted to. This is also the profile's economics — the visitor's paired phone brings the model, the site brings its calculator, and the answer cites the site's own number instead of whatever the model believes.

  • STREAMING PROFILE ONLY. action.request / action.result are not carried by the minimal profile below, so a Hub whose answers depend on host-page tools MUST use WSS.
  • The widget stamps location: 'surface' itself and MUST NOT accept a location from the host — a host that could claim hub would be asking the Hub to execute something on its behalf.
  • The host MUST treat ocp.action as UNTRUSTED INPUT and re-check its own rules. The Hub has already re-authorised the call against the advertised catalogue and gated writes behind a confirm (Core §9, §10), but the host is the only party that knows what its tools actually do; a host that executes whatever arrives here has delegated its authority to a phone.
  • WHAT THE HOST RETURNS IS THE ANSWER'S RAW MATERIAL. ocp.actionResult.output becomes the action.result.output the Hub relays to the peer that asked, and that peer is frequently a model composing a sentence — often one that will be SPOKEN. Core §9.1 is therefore normative for this path in particular: a host tool that computes a figure SHOULD return card, speech and figures alongside its raw fields, because this is the exact seam where a site's own number stops being the site's and becomes whatever an answerer makes of it. A host that returns bare API JSON has left its conventions — grouping, currency, language — to a party that has no way to know them.

Host API (loader core: @schwaizer/ocp-embed)

ocp.setContext(…), ocp.open(), ocp.close(), ocp.identify(visitorJws), ocp.on(listener). Zero-config context (URL, title, meta description, user text selection) is captured with no host wiring (captureContext).

open() and close() move focus as the launcher section requires, so a host driving the panel from its own trigger inherits that behaviour rather than having to reimplement it.

Widget ↔ Hub transport

Two profiles. A Hub MUST implement at least one and MAY implement both. No negotiation is specified because none is needed: the Hub serves the widget document (Topology), so it tells its own widget which transport to use.

Streaming profile — WSS

The default, and the only one that supports host-page tools. Core frames over one long-lived socket: session.open, context.set, turn.inputturn.delta* / turn.citation*turn.done | turn.error, with turn.cancel for barge-in and session.heartbeat / session.refresh keeping the session alive.

The widget document

The loader mounts an iframe at a fixed, versioned path so that a loader and a Hub built independently agree without configuration:

GET {hub}/embed/v1?site=<siteKey>

A Hub serving the Embed profile MUST serve the widget document there. The path is versioned rather than configurable because it is the one contract the one-line <script> snippet cannot express — a site owner pastes a tag and a site key, and nothing else. A Hub that mounts the document elsewhere is not reachable by a stock loader, whatever else it implements correctly.

site is required and is the same site key carried by X-OCP-Site on turns; the Hub resolves it to the origin allowlist it stamps into frame-ancestors.

Minimal profile — HTTP + SSE

For a site owner who wants the widget answering from their own backend without running a WebSocket. One endpoint:

POST {hub}/ocp/v1/turn
Content-Type: application/json
Accept: text/event-stream
X-OCP-Site: <siteKey>

The request body is a JSON ARRAY of client→Hub envelopes for this one turn: an optional context.set followed by exactly one turn.input. These are the same envelopes as the streaming profile — this profile changes the transport, not the messages.

The response is 200, Content-Type: text/event-stream, Cache-Control: no-store. Each SSE event's data: is exactly one OCP envelope, JSON-encoded, identical in shape to the corresponding WSS frame: zero or more turn.delta, zero or more turn.citation, then exactly one terminal turn.done or turn.error. No event: names are used. Clients read the body as a stream (fetch + ReadableStream); EventSource cannot issue a POST and is not usable here.

Normative rules:

  • SITE KEY. X-OCP-Site MUST be present on every request. Envelopes carry no site key, and sid is absent on a first turn, so a Hub serving more than one site would otherwise have no way to know which configuration — allowed origins, quotas, budgets — to apply. A Hub MUST reject a missing or unknown site key before any stream opens.
  • SESSION. sid MAY be absent on a first request; the Hub MUST then mint one and set it on every envelope it emits, and the client SHOULD echo it on later turns. session.open, session.heartbeat and session.refresh are NOT part of this profile — a request-scoped stream has no idle period to keep alive.
  • CANCELLATION is the client aborting the HTTP request. A Hub MUST treat client disconnect as turn.cancel for the in-flight turn. turn.cancel has no encoding here, because there is no channel left to send it on.
  • CONCURRENCY is unchanged (Core §8): one in-flight turn per sid. A second turn for a live sid MUST abort the first with turn.error{TURN_ABORTED} on the first turn's stream.
  • CONTEXT. Carrying context.set in the same body is what preserves Core's attribution rule — the Hub MUST echo the rev it answered against as turn.done.contextRev. context.set remains a full replace with a monotonic rev, and the 32 KB context / 8 KB selection caps apply unchanged.
  • NOT IN THIS PROFILE: action.request / action.result, Surface-side tools.advertise, and pairing. A Hub whose answers depend on invoking tools ON THE HOST PAGE MUST use WSS. Tools the Hub calls on its own backend are invisible to the protocol and entirely unaffected.
  • ERRORS BEFORE THE STREAM STARTS are an ordinary HTTP status with a single error envelope as an application/json body, so a misconfigured deployment is diagnosable with curl. Once the stream has begun the status is already 200, so failures ride as turn.error.

Origin, in this profile especially

The widget document is same-origin with the Hub, so the Origin header on the POST is the HUB'S OWN origin. It says nothing about which page is doing the embedding and MUST NOT be used as an allowlist control. The embedding origin is enforceable only by frame-ancestors on the widget document (Topology) plus site-key validation. A same-origin Origin header is not evidence of anything.

Deployment note (non-normative): SSE needs an unbuffered response path, and reverse proxies and CDNs commonly buffer. X-Accel-Buffering: no covers nginx; CDN behaviour must be verified per deployment, because a buffered stream is indistinguishable from a hung Hub.

T0 protections

  • Site-key + origin allowlist (originAllowed: exact, *.suffix, or * for dev only); per-visitor/IP quotas; challenge before first session; tenant budgets with hard cutoffs.
  • Client-side allowlist checks (hostAllowed) are ADVISORY. A page that embeds the widget controls its own JavaScript, so nothing it reports about itself is a control. Enforcement is the Hub's: site-key validation plus the frame-ancestors header above.

Status

Loader core (context capture, origin-checked bridge, host API) is implemented and unit-tested in @schwaizer/ocp-embed, and the browser entry now builds to an IIFE bundle whose min+gzip size and SRI hash are produced and gated in CI (4.1 kB against the 15 kB budget). The launcher above is implemented and mounted by default. The in-iframe chat UI holding the Hub leg is the remaining piece. NOTHING here has yet been exercised in a real browser: the tests inject the DOM, which is what makes them unit tests — and the launcher is the part of this profile where that gap bites hardest, since focus, :focus-visible and prefers-reduced-motion are all things only a browser really decides.

The MINIMAL profile has a reference implementation in @schwaizer/ocp-hub/embed: turns and terminal-frame discipline, sid, contextRev, barge-in, pre-stream refusals, per-visitor quotas, and the widget document with the frame-ancestors header this spec requires. Unit-tested, not browser-verified, and with per- process barge-in and quota state — adequate for one instance, and documented as inadequate for several.

The WSS profile's reference implementation is examples/hub-server — a Hub that runs no model, brokers the QR pairing between this widget and a visitor's phone, and relays the answer back. The minimal one came first because it is the smaller of the two and the one a site owner can stand up in an ordinary HTTP handler, which is the point of this profile existing; the WSS one is what a screen the visitor does not own needs, because the phone cannot answer without it.