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
postMessagewith 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-ancestorsderived from the site key'sallowedOrigins, and MUST NOT sendX-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-expandedfor its state, andaria-controlsnaming 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:initialfirst, and inline CSSOM properties only. A<style>element and a markupstyle=attribute are both blocked by a strict hoststyle-srcCSP; property assignment is not. Becauseall:initialalso 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-ancestorsrefusing 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 callsopen()from it. - PLACEABLE with
data-position(bottom-rightdefault, plus the other three corners) anddata-offset(pixels), because only the host knows what is already in that corner of its own page.data-labelsets 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 anocp.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.resultare 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 claimhubwould be asking the Hub to execute something on its behalf. - The host MUST treat
ocp.actionas 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.outputbecomes theaction.result.outputthe 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 returncard,speechandfiguresalongside 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.input →
turn.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-SiteMUST be present on every request. Envelopes carry no site key, andsidis 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.
sidMAY 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.heartbeatandsession.refreshare 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.cancelfor the in-flight turn.turn.cancelhas 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 livesidMUST abort the first withturn.error{TURN_ABORTED}on the first turn's stream. - CONTEXT. Carrying
context.setin the same body is what preserves Core's attribution rule — the Hub MUST echo therevit answered against asturn.done.contextRev.context.setremains a full replace with a monotonicrev, and the 32 KB context / 8 KB selection caps apply unchanged. - NOT IN THIS PROFILE:
action.request/action.result, Surface-sidetools.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
errorenvelope as anapplication/jsonbody, so a misconfigured deployment is diagnosable withcurl. Once the stream has begun the status is already200, so failures ride asturn.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 theframe-ancestorsheader 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.