Skip to main content

Suzent Nodes Guide

This guide covers the node system in Suzent — how to connect companion devices and control them remotely.

Overview

Nodes are companion devices (phones, desktops, headless servers) that connect to the Suzent server via WebSocket and expose capabilities the agent can invoke remotely. Inspired by OpenClaw's distributed control architecture.

Key Concepts

ConceptDescription
NodeA connected device that advertises capabilities
CapabilityA named command a node can handle (e.g., camera.snap)
NodeManagerServer-side registry that tracks and dispatches to nodes
WebSocketNodeNode implementation using WebSocket + JSON-RPC protocol

Architecture

┌───────────────────┐     WebSocket       ┌───────────────────┐
│ Companion Node │◄──────────────────► │ Suzent Server │
│ (Phone/Desktop) │ JSON-RPC msgs │ (NodeManager) │
└───────────────────┘ └──────┬────────────┘
│ REST API
┌──────▼────────────┐
│ CLI / REST Agent │
│ suzent nodes / API│
└───────────────────┘

Sandbox Python Example

import os
import requests

base = os.environ["SUZENT_BASE_URL"]

# List nodes
nodes = requests.get(f"{base}/nodes", timeout=30).json().get("nodes", [])
if not nodes:
raise RuntimeError("No nodes connected")

# Resolve by display name (recommended)
target_name = "MyPhone"
target = next((n for n in nodes if n.get("display_name") == target_name), nodes[0])
node_id = target["node_id"]

# Describe
detail = requests.get(f"{base}/nodes/{node_id}", timeout=30).json()

# Invoke
resp = requests.post(
f"{base}/nodes/{node_id}/invoke",
json={"command": "camera.snap", "params": {"format": "png"}},
timeout=60,
)
resp.raise_for_status()
print(resp.json())

Using Nodes via CLI (Host Mode)

Nodes are controlled through the suzent node CLI subcommands. The agent uses these same commands via BashTool.

List Connected Nodes

suzent node list

Check Connectivity

suzent node status

Describe a Node's Capabilities

suzent node describe <node_id_or_name>

Invoke a Command

# Option 1: Key-Value Pairs (Simpler)
suzent node invoke <node> <command> key=value [key2=value2 ...]

# Option 2: JSON Params (Legacy/Complex)
suzent node invoke <node> <command> --params '{"key": "value"}'

Examples:

# Take a photo (simple)
suzent node invoke MyPhone camera.snap format=png

# Speak with arguments (inferred types)
suzent node invoke "Local PC" speaker.speak text="Hello world" prompt=cheerful

# Mixed types (int, boolean)
suzent node invoke MyNode some.command count=5 verbose=true

# JSON fallback for complex objects
suzent node invoke MyNode config.update data='{"nested": true}'

WebSocket Protocol

Nodes connect to the server at ws://<host>:<port>/ws/node and follow a JSON-RPC-style message protocol.

1. Handshake

Node → Server (on connect):

{
"type": "connect",
"display_name": "MyPhone",
"platform": "ios",
"capabilities": [
{
"name": "camera.snap",
"description": "Take a photo with the device camera",
"params_schema": {"format": "str", "quality": "float"}
}
]
}

Server → Node (acknowledgment):

{
"type": "connected",
"node_id": "a1b2c3d4-..."
}

2. Command Invocation

Server → Node:

{
"type": "invoke",
"request_id": "uuid-...",
"command": "camera.snap",
"params": {"format": "png"}
}

Node → Server:

{
"type": "result",
"request_id": "uuid-...",
"success": true,
"result": {"file": "/tmp/photo.png"}
}

3. Heartbeat

Server → Node: {"type": "ping"} Node → Server: {"type": "pong"}

Building a Node Client

To connect a device as a node, implement a WebSocket client that:

  1. Connects to ws://<suzent-host>:25314/ws/node
  2. Sends a connect message with capabilities
  3. Waits for connected acknowledgment
  4. Listens for invoke messages and responds with result messages

Python Example

import asyncio
import json
import websockets

async def run_node():
uri = "ws://localhost:25314/ws/node"
async with websockets.connect(uri) as ws:
# Handshake
await ws.send(json.dumps({
"type": "connect",
"display_name": "MyDevice",
"platform": "python",
"capabilities": [
{"name": "echo", "description": "Echo back a message"},
]
}))

resp = json.loads(await ws.recv())
print(f"Connected as {resp['node_id']}")

# Message loop
async for message in ws:
data = json.loads(message)
if data["type"] == "invoke":
# Handle the command
result = {"echo": data["params"].get("text", "")}
await ws.send(json.dumps({
"type": "result",
"request_id": data["request_id"],
"success": True,
"result": result,
}))
elif data["type"] == "ping":
await ws.send(json.dumps({"type": "pong"}))

asyncio.run(run_node())

Configuration

Node system settings in Suzent configuration:

FieldDefaultDescription
nodes_enabledtrueEnable/disable node WebSocket connections
node_auth_mode"open"Authentication mode: open, approve, or token

Modify via CLI:

suzent config get nodes_enabled
suzent config set nodes_enabled true

Pydantic Models

All protocol messages and API schemas are defined as Pydantic models in src/suzent/nodes/models.py:

  • Protocol: ConnectMessage, ConnectedResponse, InvokeMessage, ResultMessage, PingMessage, PongMessage, EventMessage, ErrorResponse
  • API: NodeInfo, NodeListResponse, InvokeRequest, InvokeResponse, CapabilitySchema

Troubleshooting

Node disconnects immediately

Check that the connect message has valid JSON with required display_name field. Review server logs for handshake errors.

Command timeout

Default timeout is 30 seconds. Ensure the node client sends a result message with the matching request_id promptly.