11 Protocol Specification
mathew edited this page 2026-02-22 22:27:01 +00:00

Protocol Specification

Storm Pulse uses a JSON-over-WebSocket protocol for all communication between agents and the dashboard. Every message shares a common envelope structure. The agent initiates all connections -- the dashboard never reaches out to agents.


Envelope

Every message on the wire is a JSON object with these fields:

Field Type Description
v integer Protocol version. Currently 1.
type string One of the message types listed below.
id string Unique message ID (UUID v4).
ts string ISO 8601 timestamp with timezone. UTC uses Z suffix.
agent_id string Identifies the sending/receiving agent. Matches the certificate SAN and config.
payload object Message-specific data. Always a JSON object, even if empty.

Example:

{
  "v": 1,
  "type": "heartbeat",
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "ts": "2026-02-21T12:00:00Z",
  "agent_id": "vps-toronto-01",
  "payload": {}
}

Validation rules

  • v must equal 1. Any other value is rejected immediately. When the protocol evolves, this field gates backward-incompatible changes.
  • type must be a recognized message type. Unknown types are rejected.
  • ts must include timezone information. Naive timestamps (no offset, no Z) are rejected.
  • agent_id must be a non-empty string.
  • payload must be a JSON object. Arrays, strings, and other types are rejected.
  • Extra fields on the envelope are silently ignored for forward compatibility. A v1 parser won't break if a future version adds fields.
  • Missing required fields are rejected immediately. Partial envelopes don't parse.

Message types

heartbeat

Direction: Agent -> Dashboard Interval: Every 30 seconds Purpose: Confirms the WebSocket connection is alive.

Payload is always empty:

{"payload": {}}

If the dashboard receives no heartbeat for 90 seconds (3 missed intervals), it should consider the agent disconnected.


register

Direction: Agent -> Dashboard When: Sent once on each new WebSocket connection, before any other message.

Field Type Description
version string Agent software version (e.g. "0.1.0").
pulse_token string UUID from Server.pulse_token in the dashboard. Binds the connection to a specific server record.
commands object or null Command metadata dict. Keys are command names, values are metadata objects. null for backward compatibility with older agents.

Each command metadata object:

Field Type Description
group string Command group for UI sections (e.g. "deploy", "diagnostics").
description string Human-readable description for tooltips. May be empty.
template array of strings Display-safe command template. Absolute binary paths are stripped to basenames (e.g. /usr/bin/dockerdocker). Placeholders like {project_dir} are preserved.
timeout integer Maximum execution time in seconds.
requires_confirmation boolean If true, the dashboard should show a confirmation dialog before sending.
params object Parameter definitions. Keys are parameter names, values are param metadata objects. Empty {} when no parameters.

Each param metadata object:

Field Type Description
default string or null Default value. null means no static default — a runtime override is required (or the value comes from config).
pattern string Regex pattern for validation (matched with re.fullmatch).
description string Human-readable description. May be empty.
{
  "payload": {
    "version": "0.1.0",
    "pulse_token": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
    "commands": {
      "git_pull": {
        "group": "deploy",
        "description": "Pull latest changes from remote",
        "template": ["git", "-C", "{project_dir}", "pull"],
        "timeout": 60,
        "requires_confirmation": false,
        "params": {}
      },
      "docker_logs": {
        "group": "diagnostics",
        "description": "Show recent service logs",
        "template": ["docker", "compose", "--env-file", "{env_file}", "-f", "{compose_file}", "logs", "--tail", "{tail_lines}", "{docker_service_name}"],
        "timeout": 30,
        "requires_confirmation": false,
        "params": {
          "docker_service_name": {
            "default": null,
            "pattern": "[a-zA-Z0-9_-]+",
            "description": "Docker Compose service name"
          },
          "tail_lines": {
            "default": "100",
            "pattern": "[0-9]{1,5}",
            "description": "Number of log lines to show"
          }
        }
      }
    }
  }
}

The dashboard looks up the server by pulse_token and may reject the connection if the token is unknown or the agent version is too old. When commands is present, the dashboard stores the metadata on the server record so the frontend can render command buttons with groups, descriptions, confirmation prompts, and parameter input fields. The Deploy button is disabled if any sequence step is missing from the command keys.


metrics.push

Direction: Agent -> Dashboard Interval: Configurable, default 15 seconds.

Field Type Description
cpu_percent float CPU usage percentage (0.0 - 100.0).
memory_percent float Memory usage percentage.
memory_used_mb float Used memory in megabytes.
memory_total_mb float Total memory in megabytes.
disk_percent float Root filesystem usage percentage.
disk_used_gb float Used disk space in gigabytes.
disk_total_gb float Total disk space in gigabytes.
load_avg_1m float 1-minute load average.
load_avg_5m float 5-minute load average.
uptime_seconds float System uptime in seconds.
containers array List of container status objects. May be empty.

Each container object:

Field Type Description
name string Container name.
status string Container status (e.g. "running", "exited").
image string Image name and tag.
{
  "payload": {
    "cpu_percent": 23.5,
    "memory_percent": 61.2,
    "memory_used_mb": 1245.0,
    "memory_total_mb": 2048.0,
    "disk_percent": 45.0,
    "disk_used_gb": 18.2,
    "disk_total_gb": 40.0,
    "load_avg_1m": 0.52,
    "load_avg_5m": 0.78,
    "uptime_seconds": 864000.0,
    "containers": [
      {"name": "web", "status": "running", "image": "myapp:latest"}
    ]
  }
}

command.request

Direction: Dashboard -> Agent Purpose: Execute a single whitelisted command.

Field Type Description
command string Command name from the registry (e.g. "git_pull").
params object dict[str, str] of runtime parameter overrides. Empty {} when no overrides.
hmac string HMAC-SHA256 hex digest over the canonical request string (see below).
nonce string Unique random string. Prevents replay attacks.
{
  "payload": {
    "command": "docker_logs",
    "params": {"service": "celery", "lines": "50"},
    "hmac": "a1b2c3d4e5f6...",
    "nonce": "8f3a9b7c-unique-random"
  }
}

HMAC canonical format:

v1\n{command}\n{params_canonical}\n{nonce}\n{timestamp}

Where params_canonical is the sorted key=value pairs joined by & (e.g. lines=50&service=celery). When params is empty, the canonical string contains an empty component between the separators: v1\ngit_pull\n\nnonce\ntimestamp.

The agent verifies the HMAC, checks the nonce hasn't been seen, confirms the timestamp is within the configured expiry window, validates runtime params against the command's ParamDef declarations, and only then executes the command.


command.sequence

Direction: Dashboard -> Agent Purpose: Execute an ordered sequence of commands (the "Deploy" button).

Field Type Description
sequence_id string UUID identifying this sequence. All results reference it.
commands array of strings Ordered command names to execute.
stop_on_failure boolean If true, halt the sequence on the first failed step.
hmac string HMAC-SHA256 hex digest over the full payload.
nonce string Unique random string.
{
  "payload": {
    "sequence_id": "a1b2c3d4-5678-9012-3456-789012345678",
    "commands": ["git_pull", "docker_build", "docker_down", "docker_up"],
    "stop_on_failure": true,
    "hmac": "f6e5d4c3b2a1...",
    "nonce": "seq-nonce-unique"
  }
}

The dashboard sends the command list -- there is no hardcoded default sequence. All command names are validated against the registry before any execution begins. A typo in the last step won't cause earlier steps to run and then fail.


command.result

Direction: Agent -> Dashboard When: After each command execution (individual or within a sequence).

Field Type Description
request_id string UUID of the originating command request, or a per-step UUID for sequences.
command string The command name that was executed.
group string Command group (e.g. "deploy").
success boolean Whether the command exited with code 0.
exit_code integer Process exit code. -1 for timeout, binary not found, or OS error.
stdout string Standard output from the command.
stderr string Standard error from the command.
duration_ms integer Execution time in milliseconds.
sequence_id string or null If this result is part of a sequence, the sequence UUID. Null for individual commands.
failure_reason string or null Null on success. Categorizes the failure mode when success is false.

failure_reason values:

Value Meaning
null Command succeeded (exit code 0).
"exit_code" Command ran but exited non-zero. Check exit_code and stderr for details.
"timeout" Command exceeded its timeout. stdout/stderr may contain partial output.
"not_found" The command binary doesn't exist on this system (e.g. Docker not installed).
"os_error" OS-level failure (permissions, resource limits, etc.). stderr contains the error message.
{
  "payload": {
    "request_id": "b2c3d4e5-6789-0123-4567-890123456789",
    "command": "git_pull",
    "group": "deploy",
    "success": true,
    "exit_code": 0,
    "stdout": "Already up to date.\n",
    "stderr": "",
    "duration_ms": 342,
    "sequence_id": "a1b2c3d4-5678-9012-3456-789012345678",
    "failure_reason": null
  }
}

During a deploy sequence, each step produces its own command.result as it completes. The dashboard receives real-time progress -- it doesn't wait for the full sequence to finish.


Dashboard response types

The dashboard sends acknowledgement and error messages back to the agent. These use the standard envelope structure. The agent logs them at debug level and takes no action -- they exist so the dashboard can confirm receipt and so operators can trace the full message flow in logs.

Type Direction Purpose
register.ok Dashboard → Agent Confirms registration succeeded.
heartbeat.ack Dashboard → Agent Confirms heartbeat received.
metrics.ack Dashboard → Agent Confirms metrics stored.
command.result.ack Dashboard → Agent Confirms result received.
error Dashboard → Agent Something went wrong (invalid token, unknown agent, etc.).

register.ok

Sent after a successful register. Payload is empty or may contain dashboard-defined fields (agents ignore the payload).

{"type": "register.ok", "payload": {}}

heartbeat.ack

Sent after each heartbeat. Payload is empty.

{"type": "heartbeat.ack", "payload": {}}

metrics.ack

Sent after each metrics.push. Payload is empty.

{"type": "metrics.ack", "payload": {}}

command.result.ack

Sent after each command.result. Payload is empty.

{"type": "command.result.ack", "payload": {}}

error

Sent when the dashboard encounters a problem with a message from the agent. The payload may include a human-readable message. The agent logs this at debug level -- it cannot take corrective action.

{"type": "error", "payload": {"message": "Unknown pulse_token"}}

Serialization

  • JSON encoding uses compact separators ("," and ":") with no whitespace padding.
  • Timestamps use ISO 8601 format with Z suffix for UTC (not +00:00).
  • Non-UTC offsets are preserved as-is (e.g. +05:30).
  • dataclasses.asdict() handles payload serialization for outbound agent messages.
  • Inbound messages are parsed as raw dicts on the envelope. Consuming code parses into typed payload dataclasses after matching on type.

Versioning

The v field exists to support future protocol changes without breaking deployed agents.

Current version: 1

Rules:

  • Adding new fields to existing payloads is backward-compatible. Parsers ignore unknown fields.
  • Adding new message types requires a version bump only if old agents must understand them.
  • Changing the meaning of existing fields, removing fields, or changing the envelope structure requires incrementing v.
  • An agent that receives v > 1 rejects the message and logs a warning. It does not crash -- the connection stays open for messages it can understand.
  • The dashboard should track each agent's protocol version (from the register message) and avoid sending messages the agent can't parse.

Enrollment Protocol

Enrollment is a separate HTTPS protocol used once to bootstrap an agent's credentials before it can connect over WebSocket. It does not use the envelope structure above.

Flow

  1. Agent generates an EC P-256 keypair locally. The private key never leaves the machine.
  2. Agent builds a CSR (Certificate Signing Request) with CN=<agent_id>, signed with the private key.
  3. Agent POSTs the CSR and a one-time enrollment token to the dashboard.
  4. Dashboard validates the token, signs the CSR with the private CA, and returns the signed cert, CA cert, and HMAC key.
  5. Agent writes credentials to disk.

Request

POST /api/enroll/ over standard HTTPS (no client cert — the agent doesn't have one yet).

{
    "agent_id": "vps-toronto-01",
    "token": "one-time-enrollment-token",
    "csr_pem": "-----BEGIN CERTIFICATE REQUEST-----\n...\n-----END CERTIFICATE REQUEST-----\n"
}
Field Type Description
agent_id string Unique agent identifier. Must match the CSR's CN.
token string One-time enrollment token from the dashboard. Burned after use.
csr_pem string PEM-encoded CSR. Must contain CN=<agent_id> and a valid signature.

Response (200)

{
    "client_cert_pem": "-----BEGIN CERTIFICATE-----\n...",
    "ca_cert_pem": "-----BEGIN CERTIFICATE-----\n...",
    "hmac_key": "base64-encoded-32-byte-hmac-key"
}
Field Type Description
client_cert_pem string PEM-encoded client certificate (the signed CSR).
ca_cert_pem string PEM-encoded CA certificate for mTLS trust chain.
hmac_key string Base64-encoded HMAC-SHA256 shared secret for command authentication.

Errors

Code Meaning
400 Malformed request, invalid CSR PEM, or CSR CN does not match agent_id.
401 Invalid or expired enrollment token.
409 Agent ID already enrolled.

Error body: {"error": "human-readable message"}.