Embed the widget
The Embed profile puts an OCP Surface on any page: a loader mounts an IFRAME (origin isolation), the page talks to it over origin-checked postMessage, and only the iframe holds the WSS leg to the Hub.
On a full Hub the widget is also the pairing surface — it shows the QR, runs the mandatory human approval, and then displays (or deliberately does not display) a conversation whose microphone and model are on the visitor's phone. The page is the screen; it is not required to be the transcript.
npm install @schwaizer/ocp-embed
The one-liner
<script defer src="https://hub.example/ocp.js" data-site="site_xxx"></script>
That is a complete integration. The loader mounts a launcher — a button, bottom right — and reveals it once the widget in the iframe reports ready, so nobody is offered a panel with nothing in it. Opening moves focus into the panel; closing puts it back on the button.
Optional attributes, all defaulted:
| Attribute | Default | What it does |
|---|---|---|
data-hub | script origin | Hub origin, for local development. |
data-label | Chat | Launcher text and the frame's title. YOU know the visitor's language; the Hub does not. |
data-position | bottom-right | Also bottom-left, top-right, top-left. |
data-offset | 16 | Pixels from both edges (capped at 200). |
data-launcher | on | off when you have your own trigger and call ocp.open() yourself. |
data-seal | off | RETIRED. require is rejected at config time and offer runs unsealed — the profile never functioned (SPEC/retired/sealing.md). Use data-transcript instead. |
data-transcript | mirror | phone-only mounts no composer and no transcript: this page never sends a question and never renders an answer. |
data-app-ios | none | App Store URL. Shown under the pairing code, to the visitor who has no app yet — every other path assumed the install had already happened somewhere off stage. |
data-app-android | none | The same, for Google Play. |
Before writing any privacy copy around those two, read SPEC/companion-provider.md §5 —
what sealing protects, and what it does not, are the same paragraph, and the
Hub serving this widget's own JavaScript is on the "does not" side. The
@schwaizer/ocp-embed README states both settings at the length they need.
Host page integration
import { EmbedHost } from '@schwaizer/ocp-embed';
const host = new EmbedHost({
config: { siteKey: 'site_xxx', hub: 'https://hub.example', allowedOrigins: ['https://shop.example'] },
messaging, // postMessage to the iframe + window message events
readDom: () => ({ // zero-config context capture
url: location.href,
title: document.title,
metaDescription: document.querySelector('meta[name=description]')?.getAttribute('content') ?? null,
selection: getSelection()?.toString() ?? null,
}),
});
host.start();
host.on((e) => analytics(e)); // ocp.state / ocp.event — lifecycle names and counts only
host.setContext(); // full replace; call again when the page changes
host.open();
host.on() also reports what happens to your own tools, which is worth
subscribing to before you need it: tool.started fires before the lookup, so
its ABSENCE is how you learn a call never arrived; tool.unknown names a
catalogue mismatch; tool.completed closes the pair; and tool.failed says the
tool threw. Tool names only — never arguments, never output. A paired phone once
asked a site to run a tool, the site ran it correctly in about a second, and the
visitor was told the site had failed; from the page there was no way to tell "the
request never came" from "it is still running", because the only event was the
one that fires when a tool throws. Three of those four names exist so those cases
stop looking alike.
Messages you send before the iframe reports ready are queued and flushed on
ready — the loader core handles that. Both directions of the bridge are
pinned to the Hub origin: a spoofed postMessage from any other origin is
dropped, and host messages target the exact Hub origin.
The same tools, for a browser that has its own agent
OCP answers from a device that is not the one showing the page. When the page and the microphone are the same machine, a browser-native agent is simpler, and this repository's README concedes that outright rather than competing for it.
The trap in conceding it is maintaining two tool catalogues. Don't:
import { registerSurfaceTools } from '@schwaizer/ocp-webmcp';
const registration = registerSurfaceTools(tools); // the SAME array you pass the Hub
// registration.supported → false on a browser with no WebMCP; it no-ops rather than throwing
// registration.skipped → [{ name: 'book_appointment', reason: 'write' }]
A tool declaring write: true is never registered, and there is no option to
allow it. Core §10 requires a human confirmation that expires to deny, OCP puts
that ceremony on the Surface, and WebMCP has nowhere to put one — the agent
decides when to call and your page receives the arguments with nothing in
between. A site that wants writes over WebMCP has to build its own consent step
first; that is a real thing to build rather than a boolean to flip.
Registration discloses nothing by itself — a name, a description and a JSON schema, handed to an agent already on the visitor's machine. The first call is the disclosure, and it is the one your privacy copy has to describe. If that copy says "the assistant", check that it does not mean only the paired phone: a browser agent calling the same tool sends the same values for a reason the text may not cover.
What your tool RETURNS, not just what it takes
tools.advertise describes a tool's input and says nothing about its output,
which leaves the party that computed a number with no way to say how it should
be written. Whoever receives it then invents that — and the receiver is
frequently a model about to read the answer out loud.
The first deployment to run this path computed a Swiss tax figure exactly,
returned it as raw API fields, and the visitor's phone read back
25,594.5 CHF: comma grouping in a country that groups with apostrophes, a
rappen on a whole-franc total, currency trailing. Three conventions, none of
them the site's.
So return the answer you would have written, alongside the raw fields (Core §9.1):
{
card: "Basel (BS), 2025: CHF 19'590 — 16.33% effective", // for reading
speech: "19590 Franken, ein effektiver Satz von 16,33 Prozent", // for a voice
figures: { total: "CHF 19'590", effectiveRate: "16.33%" }, // for checking
...raw // for everything else
}
Two forms, and merging them is the mistake the section exists to prevent.
CHF 19'590 is correct on a screen and unreadable to a text-to-speech engine,
which pronounces the apostrophe or spells out the digits it separates; 19590 Franken is right in the ear and wrong on the page. Both in the language the
page was served in — the tool rendering a figure for a page already knows which
that was, and the model does not. figures is what lets a companion verify that
the number it computed survived into the prose it generated, and ship your card
unchanged when it did not.
A tool that returns none of this is still conformant. It gets whatever the answerer makes of its raw fields, which is what the paragraph above describes.
Rules that keep T0 safe
- Site key + origin allowlist (exact origins or
*.suffix;*is dev-only). - The widget is cookie-less at T0; visitor continuity via localStorage with a rotating id.
ocp.identify(visitorJws)upgrades to T1 with a tenant-signed visitor identity — signed by YOUR backend, never minted in the browser.- Serve the loader with SRI hashes.