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.crt and .key), or one supplied with -agent-tls-cert and -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": { } }
FieldTypeNotes
typestringRequired. The message type.
node_idintOptional. Set by the hub. The hub ignores any value an agent sends and uses the authenticated node.
tokenstringOptional. Credentials, only in enroll and connected.
dataobjectOptional. The payload; its shape depends on type.

Both sides ignore message types they don’t know.

Credentials #

CredentialFormatLifetimeStored by the hub as
Join tokenvpsp1.<secret>.<fingerprint hex>One use, 1 hourSHA-256 of <secret>
Node keyrandom, base64urlUntil revoked or re-enrolledSHA-256
Agent IDUUIDPer enrollmentplain

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 #

TypeDirectiondata
sys_statagent → hubSystemStat, every interval (5 s by default)
sys_infoagent → hubSystemInfo. Optional; the reference agent sends it only in the handshake.
projectsagent → hubProjectSyncPayload
project_settingshub → agentProjectSettings, after the handshake and whenever they change
rescan_projectshub → agentnone. Search for projects now.
command_runhub → agentCommandRun, see Run commands
command_cancelhub → agent{ "run_id": "…" }
command_outputagent → hubCommandOutput
command_exitagent → hubCommandExit

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:

  1. project_path is a project from its latest search.
  2. It reads <project_path>/config.vpspilot.json again and it parses. The file isn’t a symlink and isn’t writable by its group or others.
  3. command is the name of one of its commands.
  4. It can run as the file’s owner: it runs as root, or already as that user.
  5. 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.

statusMeaningexit_code
exitedRan and exitedits exit code (128 + n if killed by signal n)
timeoutKilled after the timeoutnull
cancelledKilled after command_cancelnull
rejectedNot started; error says whynull
failedCouldn’t be startednull

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