Skip to main content

WebSocket protocol

A small, closed message union. Unknown types are rejected rather than routed.

This is deliberately not a reuse of the older platform's message type union, which still carries chat, workflow, and channel types this control plane does not implement.

Envelope​

interface ProtocolMessage {
messageId: string;
type: ProtocolMessageType;
payload: unknown;
timestamp: string; // ISO 8601
}

Message types​

TypeDirectionPurpose
registerActor → CPAnnounce a display name and kind — not an identity
auth_challengeCP ↔ ActorChallenger round, service: "auth"
auth_completeCP → ActorIdentity proven, connection established
auth_failedCP → ActorHandshake rejected
registration_pendingCP → ActorUnknown DID, awaiting admin approval
heartbeat / pongActor ↔ CPLiveness
capability_requestActor → CPRequest capabilities, initially or later
cert_challengeCP ↔ ActorChallenger round, service: "certificate"
cert_issuedCP → ActorCertificate delivered
cert_failedCP → ActorCertificate exchange failed
cert_status_requestAny → CPAsk whether a certificate is still good
cert_status_responseCP → AnySigned status answer
actor_configCP → ActorKind-specific configuration push
kill_switchCP → ActorAn emergency kill switch covering this Actor was armed — pushed just before the socket is closed
sensor_telemetryActor → CPSensor workload observations
errorCP → ActorProtocol error

Registration​

interface RegisterPayload {
name: string; // display label shown in the admin console
version?: string;
kind: string; // open-ended: "openclaw" | "mcp" | "sensor" | "proxy" | "harness" | "device" | …
}
No DID, and no public key

A client cannot assert who it is. The registration payload carries only a display name and a kind; the DID and public key are derived from the completed Challenger handshake and persisted by the control plane from there.

This is a security property, not a detail of the encoding. A client that could name its own DID at registration could claim to be an Actor whose key it does not hold.

kind is open-ended by design — a new kind requires no protocol change, and it decides which capability allow-list an approval is filtered against. human is not valid here; humans onboard through login.

Authentication​

The Challenger handshake, service: "auth". The server opens with an empty challenge; both sides exchange rounds until identity is proven.

interface AuthChallengePayload {
sessionId: string;
// Base64 Challenger certificate bytes, or "" for the server's opening message.
data: string;
}

The client is the initiator (pk1) and creates the challenge with protocol: "p2p", service: "auth". The server never speaks first on the socket at all — see the traps below.

Handshake state is held in memory per connection, not round-tripped through a database session row. This is a long-lived process, not a stateless function.

Outcomes:

  • Known Actor → auth_complete, plus delivery of any approved-but-undelivered grant.
  • Unknown Actor → registration_pending. The pending record is reused across reconnects, not duplicated.

Certificate issuance​

capability_request carries the requested set. It needs no signature of its own — the connection already proved identity, and the message only needs to arrive over that authenticated channel.

After an admin approves, the control plane proactively opens a second Challenger exchange with service: "certificate", mirroring auth_challenge's mechanics exactly. Completing it produces a natively dual-signed certificate.

interface CertIssuedPayload {
certificate: string;
capabilities: string[]; // plain, unsigned — see below
}
Why capabilities rides alongside rather than inside

The certificate for this exchange carries no signed metadata.

The Go Challenger implementation has a verification bug: its Step2 and Finalize reconstruct the signed payload with metadata hardcoded to empty rather than what was actually received, so any non-empty signed metadata fails Go-side re-verification — even though the TypeScript side signs and verifies it correctly.

Rather than depend on a fix landing in a pinned external dependency, cert_issued carries the granted capabilities as a plain, unsigned field alongside the certificate bytes. The certificate itself remains the authoritative, verifiable artefact; this field is a convenience for the client, not a grant.

A zero-capability approval skips the exchange entirely and is simply marked delivered — there is nothing to certify. auth_complete is always sent before that decision, so a client whose grant comes back empty is still told it is connected.

Status checks​

interface CertStatusRequestPayload {
certId: string;
requesterDid: string;
nonce: string;
signature: string;
}

interface CertStatusResponsePayload {
certId: string;
agentDid: string;
status: "active" | "revoked" | "superseded" | "expired";
capabilities: string[];
resourceLimits: ResourceLimits | null;
checkedAt: number;
expiresAt: number;
signature: string; // signed by the control plane
}

The requester must be an authenticated Actor — never anonymous. Every request is recorded as a status-check row and surfaces on the certificate's detail page.

The response is signed by the control plane, so it can be cached, forwarded, or presented to a third party without that party reaching the control plane.

See Trust verification.

Configuration push​

interface ActorConfigPayload {
kindConfig: unknown; // kind-specific
grantToken: string; // the Actor's certificate
ruleSetToken: string; // signed rule set, for proxy kinds
trust: { failClosed: boolean; maxStatusAgeSeconds: number };
}

One kind-agnostic message. The signature that makes a rule set enforceable is produced at push time — the stored copy is left unsigned on purpose, because a stored signature would need regenerating on every edit and a stale one is indistinguishable from a tampered one.

The trust block is already resolved for this Actor: its workspace may override either trust-policy field, and the control plane settles that before building the payload. A recipient has no vocabulary for workspaces and never learns one. Saving a policy re-pushes this message to the affected connected Actors, rather than waiting for their next reconnect.

trust.failClosed is translated from the resolved fail mode. maxStatusAgeSeconds is not a copy of the staple TTL: the enforcing kinds (proxy, harness) carry their own number, because an offline decider cannot perform the live query that a staple TTL of 0 describes. Every other kind does inherit the resolved TTL, where 0 keeps its strict meaning and unbounded must be written as a negative. See why.

Kill switch​

interface KillSwitchPayload {
scope: "global" | "workspace";
workspaceId?: string;
reason: string; // the admin's reason, verbatim — log it
armedAt: string;
}

Advisory, like every other push in this protocol. What actually stops a cooperating holder is that its certificates now answer revoked on any status check, and that its handshake is refused until the switch is disarmed. Expect the socket to close right after this message; reconnect with your normal backoff — recovery is automatic once an admin disarms.

Sensor telemetry​

Classified workload observations from a sensor Actor. The device DID is always the connection's own authenticated identity, never a client-claimed field on the envelope.

Observations upsert by device and workload fingerprint — current state, not an append-only log. Host metadata from the batch merges into the Actor's kindConfig; kind-specific fields do not get their own columns.

Integration traps​

Each of these has caused a real bug in a client. Both SDKs handle them; a hand-rolled client must too.

#FactConsequence
1The server sends nothing on socket openSend register unprompted. Waiting for a greeting hangs forever.
2register carries {name, version, kind}No DID, no public key — identity is derived from the handshake, never self-asserted.
3The client is the Challenger initiator on both exchangescreateChallenge("p2p", "auth", 0), then a fresh challenger with ("p2p", "certificate", 0). The server hard-rejects any other service string.
4The certificate round is server-initiated with data: ""The client builds its challenger in response and replies on the same sessionId.
5On reconnect, auth_complete arrives before the final auth_challengeDo not tear down the challenger on auth_complete. Once it is complete, ignore any trailing round — feeding it to update() throws.
6registration_pending is not terminalauth_complete arrives later, out of band, on admin approval. Hold the socket open.
7Granted capabilities come from cert_issued.capabilitiesNever from certificate metadata — see the warning above.
8challenger.update() throwsChecking hasFailed() alone does not catch everything.
9Two challengers live concurrentlyAuth session and certificate session, keyed by distinct sessionIds.
10auth_complete carries { did } onlyNo agent id, no capabilities.

Transport​

WebSocket is the default. PeerJS/WebRTC is supported for the human login flow and shaped for but not yet implemented for Actors.

Transport is not part of an Actor's identity, and must not be part of its authorisation either. Any transport, once identity is proven, still passes the same ledger check.