Sophon 2.0 is here
Sophon Docs
API Reference

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/node

Nodes 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

  1. Connect — Node opens WebSocket with token.
  2. 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.
  3. Ready — Node is now in the Gateway's connected-nodes registry.
  4. Heartbeat loop — Node invokes Heartbeat every 30 seconds.
  5. Commands — Gateway invokes ExecuteCommand on the Node when work arrives.
  6. 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 ExecuteCommand and CommandResult.
  • 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 Online to Offline; 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
ResponseMeaning
204 No ContentCancellation accepted and sent to the Node
404 Not FoundUnknown 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

CodeMeaning
not_authorizedScope missing or revoked
approval_rejectedCritical command rejected by approval gate
timeoutCommand exceeded timeoutMs
platform_unsupportedCommand not available on this OS
invalid_paramsParams failed validation
busyPlatform resources exhausted; include retryAfterMs
cancelledCommand cancelled by Gateway or user
node_offlineNo node connected to run the command
node_not_foundNo such node for this owner
internal_errorUnexpected 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:

  1. Accept a Gateway URL + token at startup.
  2. Establish SignalR connection with the token.
  3. Invoke Heartbeat every 30 s.
  4. Handle at least one ExecuteCommand (e.g., system.info — just return { "hostname": ... }).
  5. Respond with CommandResult matching the incoming commandId.
  6. Handle RefreshScopes and RevokeAccess by applying locally.
  7. Handle CancelCommand by abandoning the named command and reporting cancelled.

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 ExecuteCommand to handlers, through the scope, rate-limit and consent gates
  • a platform abstraction layer behind the OS-specific implementations

Where to go next