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.
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
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.
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