Table of Contents
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
vmust equal1. Any other value is rejected immediately. When the protocol evolves, this field gates backward-incompatible changes.typemust be a recognized message type. Unknown types are rejected.tsmust include timezone information. Naive timestamps (no offset, no Z) are rejected.agent_idmust be a non-empty string.payloadmust 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/docker → docker). 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
Zsuffix 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 > 1rejects 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
registermessage) 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
- Agent generates an EC P-256 keypair locally. The private key never leaves the machine.
- Agent builds a CSR (Certificate Signing Request) with
CN=<agent_id>, signed with the private key. - Agent POSTs the CSR and a one-time enrollment token to the dashboard.
- Dashboard validates the token, signs the CSR with the private CA, and returns the signed cert, CA cert, and HMAC key.
- 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"}.