Node Protocol
Sophon Node <-> Gateway SignalR contract — pairing, heartbeat, command dispatch, result acknowledgment.
This page is the wire-level reference for Sophon Node's SignalR protocol. Use it when implementing a custom Node (e.g., an embedded-device Node, a server-management Node, a stripped-down Node for restricted environments).
For Node user / operator documentation see Sophon Node.
Hub URL
https://<gateway>/hubs/nodeNodes authenticate with a token in the access_token query string:
wss://gw.example.com/hubs/node?access_token=<node-token>Token format: {nodeId}.{secret}. The Gateway stores only the SHA-256 hash of secret.
Connection lifecycle
- Connect — Node opens WebSocket with token.
- Authenticate — Gateway verifies token against the stored hash. If token is valid and the Node is approved, connection proceeds. Otherwise immediate disconnect with close reason.
- Ready — Node is now in the Gateway's connected-nodes registry.
- Heartbeat loop — Node invokes
Heartbeatevery 30 seconds. - Commands — Gateway invokes
ExecuteCommandon the Node when work arrives. - Disconnect — either side can close; Gateway marks Node offline after 90 seconds of no heartbeats.
Pairing
Pairing happens over REST, not SignalR. The Node CLI polls:
POST /api/nodes/me/pair
Content-Type: application/json
{
"nodeId": "abc123",
"pairingSecret": "s_a1b2c3..."
}Response while pending approval:
{ "status": "pending", "pairingCode": "ABC-123" }Response once approved:
{
"status": "approved",
"token": "abc123.a1b2c3d4e5f6...",
"gatewayUrl": "https://gw.example.com"
}The CLI polls every 3 seconds until approved or rejected. Pairing secrets expire after 15 minutes.
Node → Gateway methods
Methods the Node invokes on the hub.
Heartbeat(dto: HeartbeatDto)
{
"nodeId": "abc123",
"status": "online",
"uptime": 3600,
"platform": "windows",
"version": "1.0.0",
"cpuPercent": 2.5,
"memoryMb": 45
}Response: none. If the Gateway wants the Node to change anything (scope refresh, revoke), it invokes RefreshScopes or RevokeAccess — doesn't piggy-back on heartbeat response.
CommandResult(dto: CommandResultDto)
Response to an ExecuteCommand dispatch. The commandId echoes the Gateway's ID so the dispatcher can correlate.
{
"commandId": "cmd_xyz789",
"ok": true,
"result": { "image": "<base64>", "sizeBytes": 12345, "format": "jpeg" },
"durationMs": 234
}Or on failure:
{
"commandId": "cmd_xyz789",
"ok": false,
"error": {
"code": "platform_unsupported",
"message": "screen.region not supported under Wayland",
"details": {}
}
}Large results are truncated at 1 MB per field — Nodes must split or stream larger responses via multiple result messages with partIndex and isLast fields.
ScopeChanged(scopes: string[])
Ack of RefreshScopes. The Node echoes back the effective scopes it now enforces locally.
Gateway → Node methods
Methods the Gateway invokes on the Node.
ExecuteCommand(dto: ExecuteCommandDto)
{
"commandId": "cmd_xyz789",
"command": "screen.capture",
"params": { "quality": 80, "maxWidth": 1280 },
"timeoutMs": 30000,
"originatedBy": {
"userId": "usr_abc",
"agentId": "sophon",
"sessionId": "sess_def"
}
}Node responds via CommandResult with the matching commandId. If the command isn't recognized or the scope is missing, Node returns ok: false with an appropriate error code.
RefreshScopes(scopes: string[])
Tells the Node its effective scopes have changed. Node applies locally and invokes ScopeChanged to ack.
["screen.capture", "input.control", "app.manage"]RevokeAccess(reason: string)
Force disconnect. Node closes the connection cleanly. Must not reconnect without re-pairing.
{ "reason": "admin_revoked" }CancelCommand(commandId: string)
Asks the Node to abandon a command that is still in flight. The Node stops the work and reports a CommandResult carrying the cancelled error code; on Windows a shell command has its whole process tree killed.
{ "commandId": "cmd_xyz789" }A command that has already finished is not an error. The Node simply has nothing to cancel.
Command IDs
- Gateway-generated UUIDs, opaque to the Node.
- Used to correlate
ExecuteCommandandCommandResult. - Nodes should not assume sequential ordering — multiple in-flight commands are possible.
Concurrent commands
Nodes may receive multiple ExecuteCommand calls concurrently. Implementations should:
- Process commands in parallel where the underlying platform allows it
- Respect platform serialization where it doesn't (e.g., mouse input commands naturally serialize)
- Track outstanding commands and apply the global 30-second default timeout per-command
If a command would exceed platform capacity, Node returns ok: false with code busy and suggested retryAfterMs.
Heartbeat semantics
- Interval: 30 seconds. Drift up to 5 s tolerated.
- Missed heartbeats: Gateway considers Node offline after 90 s (3 missed cycles). Status flips from
OnlinetoOffline; pending commands queue server-side. - On reconnect: Gateway re-dispatches any pending commands that haven't timed out.
- Ping mode: Gateway sends WebSocket pings every 15 s to keep NAT / proxy state alive; not part of the application heartbeat.
Cancelling a command
A command in flight can be cancelled over REST:
POST /api/nodes/commands/{commandId}/cancel| Response | Meaning |
|---|---|
204 No Content | Cancellation accepted and sent to the Node |
404 Not Found | Unknown command, or one that had already finished |
A 404 here is a normal outcome rather than a failure: a command that completed a moment before the request is simply nothing left to cancel. The Gateway relays the request to the Node as CancelCommand.
Fetching commands over REST
GET /api/nodes/me/commands
Authorization: Bearer <node-token>Response:
{
"commands": [
{
"commandId": "cmd_xyz789",
"command": "screen.capture",
"params": { "quality": 80 },
"timeoutMs": 30000
}
]
}Results are posted back:
POST /api/nodes/me/commands/cmd_xyz789/result
{ "ok": true, "result": { ... }, "durationMs": 234 }This endpoint exists for clients that cannot hold a hub connection. The official Node does not use it. It keeps its hub connection and reconnects with exponential backoff and jitter when the connection drops, rather than falling back to polling. Treat this as a surface available to a custom Node, not as a degraded path the shipped one takes.
Error codes
| Code | Meaning |
|---|---|
not_authorized | Scope missing or revoked |
approval_rejected | Critical command rejected by approval gate |
timeout | Command exceeded timeoutMs |
platform_unsupported | Command not available on this OS |
invalid_params | Params failed validation |
busy | Platform resources exhausted; include retryAfterMs |
cancelled | Command cancelled by Gateway or user |
node_offline | No node connected to run the command |
node_not_found | No such node for this owner |
internal_error | Unexpected Node-side failure |
node_not_found is also the answer for a node that exists but belongs to someone else. Ownership is enforced where work is queued, and a caller cannot use the error to distinguish the two cases.
For why a command was refused before it ran, see audit outcomes.
Compatibility
There is no protocol version field and no version negotiation. Compatibility is implicit and additive:
- Nodes must ignore fields they do not recognise, so the Gateway can add them without breaking older nodes.
- A Node simply never receives a server-to-client method it did not register, so new methods are safe to add.
Do not code against a protocol version string; there isn't one to read.
Implementing a custom Node
Minimum viable implementation:
- Accept a Gateway URL + token at startup.
- Establish SignalR connection with the token.
- Invoke
Heartbeatevery 30 s. - Handle at least one
ExecuteCommand(e.g.,system.info— just return{ "hostname": ... }). - Respond with
CommandResultmatching the incomingcommandId. - Handle
RefreshScopesandRevokeAccessby applying locally. - Handle
CancelCommandby abandoning the named command and reportingcancelled.
The official Node implements the full command set, but a custom Node can implement as few as one command type. Everything else simply reports platform_unsupported.
Reference implementation
The official Sophon Node implements this protocol end-to-end; its building blocks map directly onto the sections above:
- a hub client with the reconnection policy described above
- a 30-second heartbeat loop
- a command dispatcher that routes
ExecuteCommandto handlers, through the scope, rate-limit and consent gates - a platform abstraction layer behind the OS-specific implementations
Where to go next
- Sophon Node — user-facing overview
- Commands & Actions — the command types the official Node implements
- Gates, Limits & Audit — what a Node checks before executing
- SignalR — broader SignalR hub reference