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. onSealCommit and onSealReveal are RETIRED. The content sealing profile is NOT IMPLEMENTED — see SPEC/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 on PairInput and 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.input carries its turnId alone, turn.delta and turn.citation are not sent, and turn.error.message is redacted. No cryptography, no Hub-served code in the confidentiality path. Read SPEC/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:

  • card is the site's finished answer, in its own conventions. Render it.
  • speech is the same answer for a voice — no grouping characters, currency as a word. Speak that one. CHF 19'590 is correct on a screen and comes out of a TTS engine as a spelled-out digit string or a pronounced apostrophe.
  • figures is 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 why
  • suspended → auto-resume → linked again (show a reconnecting state)
  • ui.request confirms if you render them — voice "yes" can answer a browser-initiated confirm and vice versa