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
| Type | Direction | Purpose |
|---|---|---|
register | Actor → CP | Announce a display name and kind — not an identity |
auth_challenge | CP ↔ Actor | Challenger round, service: "auth" |
auth_complete | CP → Actor | Identity proven, connection established |
auth_failed | CP → Actor | Handshake rejected |
registration_pending | CP → Actor | Unknown DID, awaiting admin approval |
heartbeat / pong | Actor ↔ CP | Liveness |
capability_request | Actor → CP | Request capabilities, initially or later |
cert_challenge | CP ↔ Actor | Challenger round, service: "certificate" |
cert_issued | CP → Actor | Certificate delivered |
cert_failed | CP → Actor | Certificate exchange failed |
cert_status_request | Any → CP | Ask whether a certificate is still good |
cert_status_response | CP → Any | Signed status answer |
actor_config | CP → Actor | Kind-specific configuration push |
kill_switch | CP → Actor | An emergency kill switch covering this Actor was armed — pushed just before the socket is closed |
sensor_telemetry | Actor → CP | Sensor workload observations |
error | CP → Actor | Protocol 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" | …
}
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
}
capabilities rides alongside rather than insideThe 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.
| # | Fact | Consequence |
|---|---|---|
| 1 | The server sends nothing on socket open | Send register unprompted. Waiting for a greeting hangs forever. |
| 2 | register carries {name, version, kind} | No DID, no public key — identity is derived from the handshake, never self-asserted. |
| 3 | The client is the Challenger initiator on both exchanges | createChallenge("p2p", "auth", 0), then a fresh challenger with ("p2p", "certificate", 0). The server hard-rejects any other service string. |
| 4 | The certificate round is server-initiated with data: "" | The client builds its challenger in response and replies on the same sessionId. |
| 5 | On reconnect, auth_complete arrives before the final auth_challenge | Do not tear down the challenger on auth_complete. Once it is complete, ignore any trailing round — feeding it to update() throws. |
| 6 | registration_pending is not terminal | auth_complete arrives later, out of band, on admin approval. Hold the socket open. |
| 7 | Granted capabilities come from cert_issued.capabilities | Never from certificate metadata — see the warning above. |
| 8 | challenger.update() throws | Checking hasFailed() alone does not catch everything. |
| 9 | Two challengers live concurrently | Auth session and certificate session, keyed by distinct sessionIds. |
| 10 | auth_complete carries { did } only | No 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.