@schwaizer/ocp-companion

Headless OCP Companion client — everything protocol-shaped for a phone (or any client) that pairs with an OCP Surface and becomes the microphone for a screen it is not on:

  • pair — claim a scanned QR token → wait for the mandatory on-Surface human approval → receive a device-bound companion session token.
  • connect — device-key proof-of-possession → the /ocp?role=companion WS leg → session.linked; 10 s heartbeats; silent token refresh at 2/3 of the TTL (plus refreshNow() for app foreground); one automatic resume per drop inside the Hub's grace window.
  • turnsask() / cancel(); streamed delta / citation / done events, plus the Surface transcript mirror.

Crypto and transports are injected (DeviceKey, fetch, WebSocket), so the same code runs on React Native (Hermes, no WebCrypto) and node.

import { OcpCompanionClient } from '@schwaizer/ocp-companion';

const client = new OcpCompanionClient({ deviceKey }); // P-256 in the keystore
client.onStatus = (s) => render(s);
client.onTurnEvent = (e) => appendTranscript(e);
if ((await client.pair({ token, iss }, { name, platform })).ok) await client.connect();
client.ask('what changed this week?');

Adopting the answering layer (0.6.0)

tools(), callTool(), emit(), onLinked, onCancel and the mirror policy landed here so a Companion does not have to write them. The app they came from cannot use them until this package is PUBLISHED — it resolves the registry, not the workspace, and that is deliberate: the caret range plus the lockfile is the cadence firewall between an OCP publish and a store build. Linking the repos as workspaces, or with file:/link:, removes exactly the protection that firewall provides.

So the sequence is: publish, bump the app's range, delete the app's own socket tap. Not before.

What this client does not do

It asks; it does not answer. A Companion that carries the model declares capabilities.answer, is settled onto answerMode: 'companion' by the Hub, and originates turn.delta / turn.citation / turn.done itself (SPEC/companion-provider.md). None of that is implemented here — this client sends turn.input and renders what comes back. The answering half belongs to the app that owns the model, and the obligations it inherits (streaming, exactly one terminal frame, grounding in the Surface's context, answeredBy naming the party that actually ran the model) are in that profile.

It does not do audio. Microphone, VAD, echo cancellation, routing and TTS are the platform's, and they are the reason a Companion is a native app rather than a tab. The protocol only carries the consequences: one in-flight turn, and a cancel path for the visitor who interrupts a spoken answer.

Proven against a real Hub in an end-to-end integration harness. License: Apache-2.0.