Implement a Hub
The Hub is the trust broker: it owns sessions, pairing, turns, tool routing, confirms, and audit. Everything else (Surfaces, Companions, the Provider) talks through it and only through it.
Decide first which Hub you are building, because the two have opposite privacy
properties and the protocol distinguishes them on the wire so that your users
can be told which one they are talking to (SPEC/core.md §5.2):
- A relay. It runs no model. The visitor's paired phone answers, on-device
or through a provider under the visitor's own key, and the Hub settles
answerMode: 'companion'. Questions cross it, in plaintext unless the session seals.examples/hub-serveris this Hub, complete. - A Hub that answers. It settles
answerMode: 'hub'and reads every question, deliberately, because it is paying for the inference. This is a legitimate deployment — a workspace product answering for its own members — and it cannot be a sealing Hub for those turns, since a Hub that answers has to read the question.
State which you are. A Hub that relays and a Hub that answers are indistinguishable to a visitor who is not told.
npm install @schwaizer/ocp-protocol
The contract you are implementing
@schwaizer/ocp-protocol gives you the wire truth: classifyMessage() (with
the forward-compat rule built in — an unknown type is ignore, never a
rejection; a KNOWN type whose payload fails validation is reject, never a
silent ignore), every message schema, and the golden conformance vectors. Your
Hub is conformant when the vector suite passes against it.
parseMessage() says the same three things by returning null and throwing.
Use it in a client, where one frame is in hand and try/catch is honest; prefer
classifyMessage() on a server. A throw inside a frame handler your socket
listener dispatched without awaiting is an unhandled rejection, and Node exits
the process on one — which ends every session you are serving, not just the
one that sent the bad frame. This Hub shipped that bug; see the CHANGELOG.
Minimum viable Hub, in order
-
Discovery — serve
/.well-known/ocp.json(issuer,jwks_uri,ws_endpoint, profiles) and your signing keys at the JWKS URL. -
Surface leg — authenticated WS;
session.open→ create a session, answersession.linkedwith the negotiated capabilities and asid. -
Pairing —
POST /ocp/pair/start(mint the ≤90 s single-use QR JWS) →POST /ocp/pair/claim(verify, burn the nonce ATOMICALLY, hold a pending claim) →pair.approval_requestto the Surface → on approval mint the companion tokencnf-bound to the device key →pair/resulthands it over. Every failure isPAIR_DENIED, identically shaped. Optionally alsoPOST /ocp/pair/claim-by-code(SPEC/companion.md§2.1): mint a short code beside the deeplink, resolve it to the SAME jws, and reuse the claim path unchanged. It is a second way to reach one token, never a second kind of token — so it inherits the nonce burn, the PoP and the human approval rather than needing its own.Do not implement sealing. This step used to describe how to make a code SEAL — storing
sealCommitat mint, serving it frompair/resolve, relayingpair.reveal. That is REMOVED, not corrected: the profile is retired and NOT IMPLEMENTED (SPEC/retired/sealing.md), and a Hub that builds it implements something no other peer completes. The frames stay registered in wire 1.0 so golden vectors keep passing; that is a parsing guarantee, not an invitation. A deployment that needs content withheld from the operator uses the Companion'sphone-onlytranscript mode — seeSPEC/companion-provider.md§5, including the tool path it does not cover. -
Companion leg — verify token + fresh device-key PoP on connect; attach or resume (resume = key + PoP again); emit
pair.linked/session.resumedto the Surface. -
Turns — one in-flight per session; barge-in aborts with
turn.error{TURN_ABORTED}; stream your Provider's output asturn.delta/turn.citation; finish withturn.done{contextRev}. Mirrorturn.inputto the other peer. Relayturn.canceltoo (0.5.0): forward it to the other peer, close the turn for BOTH withturn.error{TURN_ABORTED}, and drop a cancel for any turn that is not the open one silently — an error back would let a probing peer discover live turn ids. A Hub that drops this frame turns every stop button into a stop-this-device-only button while the other peer keeps generating, and paying. -
Tools + confirms — store
tools.advertiseas hints; re-authorize per call; gatewrite: truebehindui.requestbroadcast to both peers, first answer wins, EXPIRY IS DENY; dispatchaction.requestto the surface and feedaction.resultback into the turn. ROUTE BOTH HALVES (0.9.0): a Hub that dispatches a call and drops its result has absorbed the call rather than relayed it, and the visitor watches their phone time out on an answer the page produced in a second. The executor addresses its result to the SESSION — it never sees the peer that asked — so hold the call byactionIduntil the result arrives, hand it to the peer that ASKED (either side may ask), and drop a result for a call you are not holding open: it is unsolicited, a second answer to a closed call, or from the peer that asked rather than the one that was asked. -
Lifecycle discipline — heartbeats both ways, 60 s companion grace, surface drop closes immediately, TTLs 30 min idle / 4 h absolute, token refresh (
session.refresh→session.refreshed) never extends session TTLs, and session close resolves pending confirms to deny.
Four things that are optional, and wrong if you half-implement them
None of these are needed for a conformant Hub. Each exists because a deployment asked for it, and each has a failure mode where the feature appears to work while the property it was added for is gone.
Gates (ui.request.mode: 'gate', Core §10.1). A decision that must not
answer itself: a signature, a valuation scope, a payment release. Auto-denying
one is as wrong as auto-allowing it — both invent an answer the accountable
person never gave. So a gate arms NO deadline, and three rules keep that from
weakening §10: it carries no actionId (a gate that gated a call in flight
would reintroduce exactly the unbounded pending write expiry exists to prevent),
its expiresAt bounds the VIEW rather than the decision, and a closing session
must not deny it — the session ending is not the human answering. Re-issue the
view when a device re-pairs; the decision underneath is untouched, and answering
either copy settles it once. Persist them. A gate that evaporates because
your process restarted is a decision the deployment cannot rely on;
ConfirmRegistry.openGates() exists to be written somewhere durable. If you do
not offer gates, treat mode: 'gate' as 'expire' — a degradation the caller
can detect from the resolution it gets.
Delivering resolutions (§10.2). A confirm is satisfiable from ANY peer, so
the peer that ASKED is frequently not the one that answered. Deliver the
resolution to the Surface even when the turn that requested it has ended — a
screen that renders approvals is the party that has to record them. Include
record, a stable id for the resolution: a JOIN, not a payload. What the
decision MEANT — the evidence on screen, the reviewer, the document version —
stays in your own system under that id, because the §14 trail is content-free by
construction and an evidence blob here would end that.
action.result.reason. Where you know the difference, say it: denied is a
person saying no, expired is nobody answering, unauthorized is your policy
refusing before any human was asked. A caller told only that a call failed
cannot tell a decision from an absence, and re-asking someone who already
refused is its own failure.
A principal (§7) and long sessions (§5.4). Pairing binds a DEVICE, which
is right for a kiosk and not enough where a Surface must record that a named
person approved a write. A claim MAY carry a principal — and it is an
assertion by an unauthenticated caller, so verify it yourself or drop it.
Carrying it through unverified turns your Hub into a laundering service that
converts a payload field into transport-established identity, which is worse
than no attribution, because the Surface will print it. Put the verified subject
on the token you mint, not in a side table: it then survives a reconnect and a
refresh, and reading it back is you reading your own signature.
For longSession, resist the request as it usually arrives — "make the TTL
longer". Four hours exists because session.refresh renews a token WITHOUT
re-proving possession, so raising it does nothing but lengthen a stolen resume
key's life. Move the bound instead: four hours becomes the life of an epoch, and
crossing one costs a fresh device-key PoP. The cheapest correct implementation
is to drop the companion socket and require a reconnect, because your connect
path already runs the proof. Close a peer that cannot re-prove; keep the sid,
which is the whole point.
If you only need the Embed widget, you need almost none of the above
The list above is the full Hub. A site that just wants a chat widget answering
about its own pages can implement the Embed minimal profile instead — one
POST returning SSE, no WebSocket, no pairing, no companion, no tool routing —
and @schwaizer/ocp-hub/embed is that profile already written:
import { createTurnHandler, createWidgetHandler, createMemoryQuotaStore } from '@schwaizer/ocp-hub/embed';
Note what that trade costs: with no pairing there is no Companion, so there is
no phone answering for this screen and no answerMode: 'companion'. The
minimal profile is a Hub that answers, and it will be billed accordingly.
You supply an adapter that answers from your backend; it owns the invariants
that are easy to miss — one terminal frame per turn, sid, contextRev,
barge-in, pre-stream refusals as real HTTP statuses, and the widget document's
frame-ancestors. See SPEC/embed.md for what the profile does and does not
carry, and that package's README for what it does not yet do.
Say what your deployment is
Two Hubs with identical wire behaviour can have opposite privacy properties, and
a visitor cannot tell them apart by watching frames. So publish a deployment
block in .well-known/ocp.json (SPEC/companion.md §1.1):
wellKnownDocument({
origin,
publicJwk: keys.publicJwk,
deployment: {
answerMode: 'companion', // or 'hub' — this one is not optional to get right
operator: 'Example AG',
dataResidency: 'CH',
retention: { transcripts: 'none', audit: 'P400D' },
},
});
wellKnownDocument parses what you pass, so a malformed declaration fails your
boot rather than a phone's parser. Declaring nothing is conformant; declaring
something false is not — and the field a deployment is most tempted to get wrong
is the one a visitor most needs, because hub means the operator reads the
question.
Write the trail down
Core §14 says pair start/claim/approve/deny/revoke, companion link/takeover, tool invocation and confirm resolution MUST be auditable. That is a requirement about what your deployment can later be asked, so it is worth wiring early: it is much harder to reconstruct who approved which device than to have recorded it.
@schwaizer/ocp-hub ships the shape so every Hub's trail reads the same:
import { createAuditLog, LogSink, JsonlFileSink } from '@schwaizer/ocp-hub';
// Already shipping logs somewhere? Then the trail goes with them.
const audit = createAuditLog({ sink: new LogSink(app.log) });
// Or write files, rotated by UTC day and swept past a retention bound.
const audit = createAuditLog({
sink: new JsonlFileSink({ dir: '/var/lib/ocp/audit', retentionDays: 400 }),
});
The event union has no field a question, an answer or a tool argument would
fit in. That is deliberate and it is the property that lets the trail outlive
the conversation: there is no turn event at all, and a confirm records
allow/deny rather than which option was chosen, because an option id is content
by another name. A trail and a transcript have opposite retention pressures —
the DPIA template (docs/dpia-template.md) asks you to justify keeping the
second, and this one is built so the first never contains it.
400 days is the default because EU AI Act Art. 26 sets six months as a FLOOR for
a deployer and FINMA documentation expectations argue for longer; whatever you
choose, retention.audit in your .well-known declaration should say the same
number your sink actually enforces.
Where the events go
Ten of the eleven are one line beside the decision they record:
| Emit | Where |
|---|---|
pair.start | the pair/start route, once a token is minted |
pair.claim | a claim arrives — carries device name/platform, attestation, and via: 'qr' | 'code' |
pair.approve / pair.deny | the human decision resolves, and every path that refuses one |
pair.revoke | a Surface revokes, and grace expiry |
companion.link | a companion attaches, including a resume |
tool.invoke | inside your authorize seam — record decision: 'allowed' | 'denied', both |
confirm.resolve | the confirm registry settles, with by: 'surface' | 'companion' | 'expiry' |
session.open | session creation |
session.close is the one that is not a call site. Not every close comes from
your code — the session service closes idle and expired sessions from its own
timer — so a Hub that emits on the closes it can see records only the sessions
that ended politely. Override the one door instead:
class AuditedSessions extends OcpSessionService<Principal> {
override close(session: OcpSession<Principal>, reason: OcpCloseReason): void {
const open = !session.closed;
super.close(session, reason);
// The was-open check keeps a repeated close from minting a second event.
if (open) audit.emit({ type: 'session.close', sid: session.sid, reason });
}
}
That makes the trail a bracket rather than a sample: every session that opened has the line that says how it ended, including the ones nobody watched.
examples/hub-server and examples/hub-answerer both do all of the above, and
the answerer additionally demonstrates the file sink — OCP_AUDIT_DIR and
OCP_AUDIT_RETENTION_DAYS — if you would rather read a working one than a
table. docs/compliance-mapping.md maps each event to the duty it serves.
Hardening checklist (Core §14)
Rate-limit pair/claim per IP · uniform PAIR_DENIED on every pairing
failure path · takeover of a live companion needs a SURFACE-ONLY confirm ·
audit pair/link/takeover/tool/confirm events · attestation hook if your
deployment requires verified devices.
If you implement the spoken code, three of those are not optional and one is
new. Share the CLAIM limiter rather than giving the code its own — two buckets
double an attacker's budget for the same door. Return the identical
PAIR_DENIED whether the code resolved or the claim failed, or the endpoint
becomes an oracle that separates "no such code" from "wrong claim". Do NOT burn
the code on resolution: the nonce is the single-use part, and burning here lets
anyone who can reach the endpoint invalidate a code they cannot use, by guessing
it once. And refuse a sealPubKey on that route entirely — a sealed pairing the
Hub mediates is the thing the camera-only rule exists to prevent.
Verify all of it against someone else's implementation with
npx ocp-conform --exec "<your adapter>", which runs the golden vectors over
stdin/stdout and knows no schema of its own.
These map 1:1 onto the integration suites of a full WSS Hub — which the
/embed export is NOT: it implements the Embed minimal profile only, where
pairing, companions and tool routing do not exist to be hardened.