Build a Companion
A Companion is a phone (or any device) that pairs with a running Surface session and talks to its workspace. The headless client does everything protocol-shaped; you provide crypto, transports, and UI.
npm install @schwaizer/ocp-companion
1. Provide a DeviceKey
The pairing model binds tokens to a P-256 device key (proof of possession). The client never touches crypto directly — you inject it:
import type { DeviceKey } from '@schwaizer/ocp-companion';
const deviceKey: DeviceKey = {
publicJwk: async () => ({ kty: 'EC', crv: 'P-256', x, y }),
signCompactJws: async (payload) => /* ES256 compact JWS by the PRIVATE key */,
sha256Base64url: async (input) => /* base64url(sha256(input)) */,
};
On React Native, @noble/curves + a keystore-backed scalar works today;
hardware-backed keys (Secure Enclave / StrongBox) slot in behind the same
interface. In node, WebCrypto or jose is enough.
2. Pair
The Surface shows a QR encoding ocp://pair?d=<JWS>. Decode the deeplink,
validate the issuer against YOUR allowlist, then claim:
import { OcpCompanionClient } from '@schwaizer/ocp-companion';
const client = new OcpCompanionClient({ deviceKey });
client.onStatus = (s) => render(s); // claiming → waiting_approval → …
client.onTurnEvent = (e) => transcript(e); // input/delta/citation/done/error
const res = await client.pair({ token, iss }, { name: 'My Phone', platform: 'ios' });
pair() resolves only after a human approves on the Surface — that approval
is mandatory in the protocol, not a UI nicety. Denials are uniform.
2.1 Offer a way in that is not a camera
A QR is a visual gate. A visitor who cannot see the Surface has no way through
it, and neither does one whose Surface IS the phone — a camera cannot photograph
the screen behind it. So a Hub MAY also mint a short code, shown next to the
host, and pair() takes that instead of a token:
const res = await client.pair({ code, iss }, { name: 'My Phone', platform: 'ios' });
Everything after the claim is identical: the code resolves Hub-side to the SAME token, so the single-use nonce, the device-key PoP and the human approval all still stand. A guessed code buys what a photographed QR buys, and no more.
Two things your UI has to get right:
- Ask for the host as well as the code. A scanned deeplink carries the
issuer; eight characters do not. A companion that pairs with more than one Hub
and asks only for a code has been told what to send and not where. Validate it
against the same allowlist you apply to a scanned
iss. - Do not seal.
onSealCommitandonSealRevealare RETIRED. The content sealing profile is NOT IMPLEMENTED — seeSPEC/retired/sealing.md. This guide used to teach the commit-then-reveal code path here, with working code; that passage is removed rather than corrected, because a guide that explains how to build something is read as saying it works. The two hooks still exist onPairInputand still behave as documented — removing them would be a breaking change — but they are deprecated and nothing on the other side completes the exchange. - Withhold the conversation instead.
MirrorMode: 'phone-only'gives the Hub turn boundaries and nothing else:turn.inputcarries itsturnIdalone,turn.deltaandturn.citationare not sent, andturn.error.messageis redacted. No cryptography, no Hub-served code in the confidentiality path. ReadSPEC/companion-provider.md§5 for what it does NOT cover — the tool path crosses in clear, and that limit belongs in any copy you write.
const res = await client.pair({ code, iss }, { name: 'My Phone', platform: 'ios' });
Be honest with yourself about the standing: on the QR path a key-substituting Hub structurally cannot read the session; on the code path it is CAUGHT by the commitment and the six-digit comparison rather than prevented — one blind 10⁻⁶ guess per pairing, the same footing as every Bluetooth numeric-comparison pairing. The forced choice on the Surface is what spends that guess against a human.
2.2 Read what the deployment says it is
After approval the client fetches the Hub's .well-known/ocp.json and hands you
its deployment block if it published one (SPEC/companion.md §1.1):
answerMode, operator, dataResidency, retention.
const deployment = res.deployment; // undefined when the Hub declares nothing
Two rules about what you may do with it. Display it, do not decide on it — it is a claim by the operator, not a property the wire can verify, and a Companion that showed it as a verified fact would be repeating the mistake the retired sealing profile already taught this specification. And a Hub that declares nothing is not suspicious: absence means it has not said, which is different from having said no.
It is worth surfacing precisely because the two answering modes are indistinguishable to a visitor who is not told, and they have opposite privacy properties. The fetch is failure-blind — a Hub that serves no document, or a malformed one, pairs exactly as before.
3. Connect and talk
if (res.ok) await client.connect(); // device-key PoP → session.linked
const turnId = client.ask('what changed this week?');
// deltas/citations stream to onTurnEvent; a new ask() barge-ins the old turn
client.cancel(turnId); // visitor pressed stop — the Hub relays it (0.5.0)
cancel() is worth wiring to a real control. Since 0.5.0 the Hub relays
turn.cancel to the other peer and closes the turn for both transcripts with
turn.error{TURN_ABORTED}, so a stop pressed on the phone stops the Surface's
spend too — and the reverse arrives at your app as a { kind: 'cancelled' }
turn event, which means ABORT generation, not render an apology: the visitor
on the other screen already moved on, and every further token is spent on an
answer nobody will read.
The client owns heartbeats (10 s), silent token refresh at 2/3 TTL
(refreshNow() on app foreground), and one automatic resume per drop inside
the Hub's 60 s grace window. Deliberate close() never resumes.
3.1 When the site publishes its own numbers
A site's tool may return card, speech and figures beside its raw fields
(Core §9.1). Prefer them over anything you would derive yourself:
cardis the site's finished answer, in its own conventions. Render it.speechis the same answer for a voice — no grouping characters, currency as a word. Speak that one.CHF 19'590is correct on a screen and comes out of a TTS engine as a spelled-out digit string or a pronounced apostrophe.figuresis what you check the model against. If the number the site computed did not survive into the sentence your model generated, ship the card instead of the sentence. A model that quietly rounds, drops or replaces a figure produces something that reads exactly like a correct answer.
The division is the same one your own brain seam should already enforce: the site computed the number, the model phrases it, and no path exists by which the model's arithmetic reaches a visitor.
4. What your UI must handle
waiting_approval(tell the user to look at the Surface screen)denied— uniform; never speculate whysuspended→ auto-resume →linkedagain (show a reconnecting state)ui.requestconfirms if you render them — voice "yes" can answer a browser-initiated confirm and vice versa