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" }

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.

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)

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
internal_errorUnexpected 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:

  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.

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 policy
  • Worker/HeartbeatService.cs — 30-second loop
  • Commands/CommandDispatcher.cs — routes ExecuteCommand to handlers
  • Platform/IPlatformAutomation.cs — platform abstraction

Where to go next