@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=companionWS leg →session.linked; 10 s heartbeats; silent token refresh at 2/3 of the TTL (plusrefreshNow()for app foreground); one automatic resume per drop inside the Hub's grace window. - turns —
ask()/cancel(); streameddelta/citation/doneevents, 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.