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" }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.
HTTP polling fallback
If WebSockets are blocked, the Node can poll REST:
GET /api/nodes/me/commands
Authorization: Bearer <node-token>Response:
{
"commands": [
{
"commandId": "cmd_xyz789",
"command": "screen.capture",
"params": { "quality": 80 },
"timeoutMs": 30000
}
]
}Node executes, POSTs result:
POST /api/nodes/me/commands/cmd_xyz789/result
{ "ok": true, "result": { ... }, "durationMs": 234 }Polling interval: configurable, default 2 seconds. Higher latency than WebSockets but works through restrictive proxies.
Error codes (CommandResult.error.code)
| 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 |
internal_error | Unexpected Node-side failure |
Versioning
ExecuteCommandDto and CommandResultDto include a protocolVersion field. Clients and server negotiate the highest mutually-supported version during connection.
Current version: 2026-04-01.
Forward compatibility: Nodes must ignore unknown fields. Backward compatibility: Gateway supports the last two protocol versions concurrently during upgrades.
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.
The official Node (src/Sophon.Node/) is ~4000 lines of C# — most of it is the 22-method platform-automation surface. Stripping the automation down to one command is straightforward.
Reference implementation
See src/Sophon.Node/ in the Sophon repo:
Connection/GatewayConnection.cs— SignalR client with reconnection policyWorker/HeartbeatService.cs— 30-second loopCommands/CommandDispatcher.cs— routesExecuteCommandto handlersPlatform/IPlatformAutomation.cs— platform abstraction
Where to go next
- Sophon Node — Overview — user-facing overview
- Node Commands Reference — the 23 commands the official Node implements
- SignalR — broader SignalR hub reference