Commands
Storm Pulse agents execute commands from a strict whitelist. Every command runs via subprocess.run(shell=False) with absolute binary paths and parameters sourced entirely from local config -- never from the wire. The dashboard sends the command name; the agent resolves it locally.
Built-in commands
Two commands ship with every agent:
| Name | Group | Description | Timeout | Confirmation |
|---|---|---|---|---|
git_pull |
deploy | Pull latest changes from remote | 60s | No |
docker_logs |
diagnostics | Show recent service logs | 30s | No |
The built-in docker_logs command has two overridable parameters:
| Parameter | Default | Pattern | Description |
|---|---|---|---|
docker_service_name |
(from config) | [a-zA-Z0-9_-]+ |
Docker Compose service name |
tail_lines |
100 |
[0-9]{1,5} |
Number of log lines to show |
When docker_service_name has no runtime override, the value from project.docker_service_name in the config is used automatically.
Deploy-specific commands (docker build/down/up, django migrate, etc.) are project-specific. Define them in your config — see the example config for ready-to-paste TOML.
Template placeholders
Commands use placeholders that resolve from your [project] config:
| Placeholder | Source |
|---|---|
{project_dir} |
project.project_dir |
{compose_file} |
project.compose_file |
{docker_service_name} |
project.docker_service_name |
{env_file} |
project.env_file (optional) |
When env_file is not set in the TOML, the --env-file flag and its value are automatically stripped from the resolved command. You don't need two config variants.
Disabling commands
To remove a built-in command from the registry, add it to disabled_commands in the [agent] section:
[agent]
id = "stormdevelopments.ca"
pulse_token = "..."
disabled_commands = ["docker_logs"]
The command is removed at startup. It doesn't exist in the registry, so:
- HMAC-signed requests for it return "Unknown command"
- The dashboard sees it missing from the agent's
commandslist in theregistermessage and can hide the button - A deploy sequence will fail upfront validation if it includes a disabled command
To re-enable, remove the name from the list (or remove disabled_commands entirely).
Custom commands
Add new commands in the [commands.<name>] section of your TOML config. Each command is a whitelisted shell-free subprocess call.
Required fields
| Field | Type | Description |
|---|---|---|
group |
string | Category for the dashboard UI (e.g. "maintenance", "deploy"). Must not be empty. |
command |
array of strings | The executable and arguments. First element must be an absolute path. |
timeout |
integer | Maximum execution time in seconds. Must be positive. |
Optional fields
| Field | Type | Default | Description |
|---|---|---|---|
requires_confirmation |
boolean | false |
If true, the dashboard should show a confirmation dialog before sending. |
description |
string | "" |
Human-readable description shown in the dashboard. |
Example: restart Caddy
[commands.restart_caddy]
group = "maintenance"
command = ["/usr/bin/systemctl", "restart", "caddy.service"]
timeout = 30
requires_confirmation = true
description = "Restart Caddy reverse proxy"
This adds restart_caddy to the registry alongside the built-ins. The dashboard will see it in the register message and can render a button for it.
Example: override a built-in
Custom commands override built-ins on name collision. If your git binary is in a different location:
[commands.git_pull]
group = "deploy"
command = ["/usr/local/bin/git", "-C", "{project_dir}", "pull"]
timeout = 120
This replaces the built-in git_pull with your version. Placeholders like {project_dir} work in custom commands too.
Using placeholders in custom commands
Custom commands can use the same placeholders as built-ins:
[commands.collectstatic]
group = "deploy"
command = [
"/usr/bin/docker", "compose",
"--env-file", "{env_file}",
"-f", "{compose_file}",
"exec", "{docker_service_name}",
"python", "manage.py", "collectstatic", "--noinput"
]
timeout = 60
description = "Collect Django static files"
Validation rules
command[0]must be an absolute path (starts with/). Relative paths are rejected at config load time.- All elements in
commandmust be strings. timeoutmust be a positive integer.groupmust be a non-empty string.requires_confirmationmust be a boolean if present.descriptionmust be a string if present.
Parameters
Commands can declare overridable parameters using [commands.<name>.params.<param>] sub-tables. This lets the dashboard send runtime overrides (e.g. {"service": "celery"}) instead of requiring a separate command per variation.
Declaring parameters
Each parameter is a sub-table under params with these fields:
| Field | Type | Required | Description |
|---|---|---|---|
placeholder |
string | Yes | The placeholder name. Must match the sub-table key. |
default |
string | No | Static default value. Omit to let the config provide the fallback. |
pattern |
string | Yes | Regex pattern for validation (matched with re.fullmatch). |
description |
string | No | Human-readable description. |
Example
[commands.service_logs]
group = "diagnostics"
command = ["/usr/bin/docker", "compose", "-f", "{compose_file}", "logs", "--tail", "{lines}", "{service}"]
timeout = 30
description = "Show recent logs for a specific service"
[commands.service_logs.params.service]
placeholder = "service"
default = "web"
pattern = "[a-zA-Z0-9_-]+"
description = "Docker Compose service name"
[commands.service_logs.params.lines]
placeholder = "lines"
default = "100"
pattern = "[0-9]{1,5}"
description = "Number of log lines to show"
The dashboard sends {"service": "celery", "lines": "50"} in the command.request params to override the defaults. If a param is not sent, the default is used.
Resolution order
For each declared parameter:
- If the dashboard sends a runtime override, use it (after regex validation).
- If no override and the param has a
default, use the default. - If no override and no default (
defaultkey omitted in TOML), skip -- the config-level placeholder provides the value.
Config-level placeholders ({project_dir}, {compose_file}, {env_file}) are resolved separately and cannot be overridden from the wire.
Protected placeholders
These placeholders are reserved for config-level resolution and cannot be declared as parameters in custom commands:
project_dircompose_fileenv_file
Attempting to declare a parameter with a protected placeholder name will fail at config load time.
Note: docker_service_name is not protected. Built-in commands declare it as a parameter with default=null, which lets the config provide the default while still allowing runtime overrides from the dashboard (e.g. {"docker_service_name": "celery"}).
HMAC signing
Runtime params are included in the HMAC canonical string. The canonical format for command.request is:
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). Both the dashboard and agent must produce the same canonical string for the signature to verify.
Validation
The agent validates all runtime params before execution:
- Unknown params (not declared in
ParamDef) are rejected. - Values must match the declared
patternregex (fullmatch). - All param values must be strings.
How the dashboard knows what's available
On every WebSocket connection, the agent sends a register message that includes a commands field -- a dict of all available commands with full metadata from the final registry (built-ins + custom - disabled):
{
"type": "register",
"payload": {
"version": "0.1.0",
"pulse_token": "...",
"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 stores this metadata on the server record and uses it to:
- Render command buttons grouped by
group - Show
descriptionin tooltips - Display the
templateso operators can see what runs - Show a confirmation dialog when
requires_confirmationis true - Render input fields for
paramswith defaults and validation patterns - Disable the Deploy button if any sequence step is missing from the command keys
- Return an error if someone tries to send a command the agent doesn't have
If the agent reconnects with a different set of commands (e.g. after a config change and restart), the dashboard updates automatically.
Older agents that don't send commands will have null -- the dashboard should treat this as "all built-in commands available" for backward compatibility.
Execution model
All commands run with:
subprocess.run(shell=False)-- no shell interpretation, no injection possiblecapture_output=True-- stdout and stderr are captured and sent back to the dashboard- Per-command timeout -- the process is killed if it exceeds the configured timeout
- Config-level placeholders from local config only -- the dashboard sends a command name and optional runtime params. Params are regex-validated against
ParamDefdeclarations before substitution. Protected placeholders (project_dir,compose_file,env_file) cannot be overridden from the wire
Result reporting
Every command execution produces a command.result message with:
| Field | Description |
|---|---|
success |
true if exit code is 0 |
exit_code |
The process exit code, or -1 for timeout/not_found/os_error |
stdout |
Captured standard output |
stderr |
Captured standard error |
duration_ms |
Execution time in milliseconds |
failure_reason |
null on success, or one of: exit_code, timeout, not_found, os_error |
Failure modes
failure_reason |
What happened |
|---|---|
exit_code |
Command ran but exited non-zero. Check exit_code and stderr. |
timeout |
Command exceeded its timeout. Partial output may be available. |
not_found |
Binary doesn't exist (e.g. Docker not installed). |
os_error |
OS-level failure (permissions, resource limits). |