Agent protocol
The wire protocol between the hub and its agents. Any agent that follows it works with the hub.
The hub and an agent talk over one long-lived TCP connection to the hub’s
agent port (55001 by default, -agent-port or TCP_SERVER_PORT). This page
is the contract. The reference agent lives in agent/, and the hub’s Go types
are in server/internal/tcpserver/types.go.
Transport #
- TLS 1.3 only. The hub presents a self-signed certificate created on
first start (
$DB_PATH/tls/agent-hub.crtand.key), or one supplied with-agent-tls-certand-agent-tls-key. - The agent doesn’t validate a CA chain or host name. It pins the SHA-256 fingerprint of the hub’s leaf certificate (DER), taken from the join token. The handshake signature is still verified.
- If the hub’s certificate is regenerated, the fingerprint changes and every enrolled agent refuses to connect until it’s re-enrolled.
Framing #
Every message is one frame: a 4-byte big-endian length, then that many bytes of JSON.
+-----------------------+------------------------------+
| length: u32, big end. | JSON envelope (length bytes) |
+-----------------------+------------------------------+
The length must be between 1 and 1048576 (1 MiB). Anything else is a protocol error and the connection is dropped.
{ "type": "sys_stat", "node_id": 3, "token": "…", "data": { } }
| Field | Type | Notes |
|---|---|---|
type | string | Required. The message type. |
node_id | int | Optional. Set by the hub. The hub ignores any value an agent sends and uses the authenticated node. |
token | string | Optional. Credentials, only in enroll and connected. |
data | object | Optional. The payload; its shape depends on type. |
Both sides ignore message types they don’t know.
Credentials #
| Credential | Format | Lifetime | Stored by the hub as |
|---|---|---|---|
| Join token | vpsp1.<secret>.<fingerprint hex> | One use, 1 hour | SHA-256 of <secret> |
| Node key | random, base64url | Until revoked or re-enrolled | SHA-256 |
| Agent ID | UUID | Per enrollment | plain |
The dashboard creates join tokens (Nodes → Add node, or Re-enroll agent
on a node). The agent sends only <secret> to the hub; the fingerprint is for
its pin.
Handshake #
The hub closes any connection that hasn’t authenticated within 10 seconds,
TLS handshake included. Until then it drops every message except enroll and
connected.
First run: enroll
agent → hub enroll token = <join secret> data = SystemInfo
hub → agent enrolled node_id data = { agent_id, node_key, node_id }
hub → agent sys_stat node_id (start streaming)
The agent must save agent_id, node_key, node_id and the pinned
fingerprint before it streams. A join token created for an existing node
re-enrolls it: the node keeps its ID and history, and its old key stops
working.
Later runs: connect
agent → hub connected token = "<agent_id>:<node_key>" data = SystemInfo
hub → agent sys_stat node_id (start streaming)
Rejection
hub → agent auth_failed data = { "reason": "…" }
The hub then closes the connection. Retrying with the same credentials won’t
work, so the agent should stop; the reference agent exits with code 3.
A node has at most one live connection: a second successful connected
closes the older one. Revoking a node’s key closes its connection, and its
next connected gets auth_failed.
After the handshake #
| Type | Direction | data |
|---|---|---|
sys_stat | agent → hub | SystemStat, every interval (5 s by default) |
sys_info | agent → hub | SystemInfo. Optional; the reference agent sends it only in the handshake. |
projects | agent → hub | ProjectSyncPayload |
project_settings | hub → agent | ProjectSettings, after the handshake and whenever they change |
rescan_projects | hub → agent | none. Search for projects now. |
command_run | hub → agent | CommandRun, see Run commands |
command_cancel | hub → agent | { "run_id": "…" } |
command_output | agent → hub | CommandOutput |
command_exit | agent → hub | CommandExit |
Projects #
The agent searches its project directories (default /var/www and /home)
up to 4 levels deep, skipping hidden directories, node_modules, vendor,
target and symlinked directories. It also reads each manual path from
ProjectSettings.paths directly, if the path resolves inside one of its
project directories (or the agent runs with --allow-any-project-path).
It searches when the session starts, when project_settings arrives with
different values, when rescan_projects arrives, and every scan_interval
seconds while auto_scan is true. Until it receives settings it uses
auto_scan: true, scan_interval: 900 and no manual paths.
After a search it sends projects if the result differs from what it last
sent in this session. The first search of a session and a search requested by
rescan_projects are always sent. Each message is the complete state: the
hub replaces the node’s projects and errors with it.
Run commands #
The hub asks the agent to run a command by name, never by command string.
The agent looks the name up in the project’s config.vpspilot.json and runs
what it finds there.
hub → agent command_run { run_id, project_path, command }
agent → hub command_output { run_id, seq, stream, data } (0 or more)
agent → hub command_exit { run_id, status, … } (exactly one)
Only agents whose SystemInfo.features contains "commands" support this.
Agents from v0.2 and earlier send no features; they would ignore
command_run, so the hub never sends it to them.
Before running, the agent answers with status rejected unless all of these
hold:
project_pathis a project from its latest search.- It reads
<project_path>/config.vpspilot.jsonagain and it parses. The file isn’t a symlink and isn’t writable by its group or others. commandis thenameof one of itscommands.- It can run as the file’s owner: it runs as root, or already as that user.
- The same command isn’t already running in that project, and fewer than 4 commands are running.
The command runs as the config file’s owner (user, group and the user’s
groups) with /bin/sh -c in project_path. Stdin is /dev/null, and the
environment is only HOME, USER, LOGNAME, SHELL and
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin. It gets
its own process group. A timeout (10 minutes, --command-timeout), a
command_cancel or the end of the session kills the group: SIGTERM, then
SIGKILL 5 seconds later. The hub marks runs that never got a command_exit
as lost when the node disconnects.
Output is sent as it arrives, in chunks of up to 32 KiB of UTF-8 (invalid
bytes become U+FFFD). seq counts up from 0 across both streams. After 4 MiB
the agent stops sending output, keeps reading it, and sets truncated.
status | Meaning | exit_code |
|---|---|---|
exited | Ran and exited | its exit code (128 + n if killed by signal n) |
timeout | Killed after the timeout | null |
cancelled | Killed after command_cancel | null |
rejected | Not started; error says why | null |
failed | Couldn’t be started | null |
A command_cancel for a run that has finished, or that the agent doesn’t
know, is ignored.
Payloads #
SystemInfo. hostname names the node on first enrollment; total_memory
is in bytes. agent_version and features were added in v0.3; when they’re
missing, the hub treats the agent as having no features.
{
"hostname": "web-1",
"os": "linux",
"platform": "ubuntu",
"platform_version": "24.04",
"kernel_version": "6.8.0-45-generic",
"cpus": 4,
"total_memory": 8221474816,
"agent_version": "v0.3.0",
"features": ["commands"]
}
SystemStat. cpu_usage has one percentage per core; mem_usage and
disk_usage (root filesystem) are percentages; network values are bytes per
second across non-loopback interfaces.
{
"cpu_usage": [12.5, 3.1, 7.8, 4.0],
"mem_usage": 41.2,
"disk_usage": 63.0,
"net_sent_ps": 10240,
"net_recv_ps": 52011
}
ProjectSettings. scan_interval is in seconds and the agent clamps it to
60–86400; paths are absolute directories added in the dashboard.
{ "auto_scan": true, "scan_interval": 900, "paths": ["/var/www/legacy/app"] }
ProjectSyncPayload. projects holds every valid config, with path set by
the agent; errors holds unusable configs and rejected manual paths, keyed by
directory.
{
"projects": [
{
"name": "shop",
"path": "/var/www/shop",
"tech": ["laravel"],
"commands": [{ "name": "build", "command": "npm run build" }],
"logs": ["storage/logs"],
"backups": { "env_file": ".env", "dir": ["storage/app"] }
}
],
"errors": [
{ "path": "/opt/legacy-app", "error": "outside the agent's project directories (--projects-dir)" }
]
}
CommandRun. run_id is a UUID chosen by the hub; project_path is the
project’s path from ProjectSyncPayload; command is a command name.
{ "run_id": "0b6c3c3e-6a8f-4b7e-9d0a-2f3c1e7a9b10", "project_path": "/var/www/shop", "command": "build" }
CommandOutput. stream is stdout or stderr.
{ "run_id": "0b6c3c3e-…", "seq": 0, "stream": "stdout", "data": "> shop@1.0.0 build\n" }
CommandExit. run_as is the user it ran as, empty if it never started;
error is a short reason for rejected and failed.
{
"run_id": "0b6c3c3e-…",
"status": "exited",
"exit_code": 0,
"run_as": "deploy",
"duration_ms": 41230,
"truncated": false,
"error": ""
}