Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Command trigger

Spawns /bin/sh -c <rendered> per notification, with the notification’s fields exposed as AVISO_* environment variables. Useful for any operator task that fits in a shell command. Unix only (#[cfg(unix)]).

YAML

triggers:
  - type: command
    command: "echo {{ notification.event_type }}@{{ notification.sequence }} >> /tmp/seen.log"
    env:                                         # optional; values are literal (NOT templated)
      DATA_DIR: /var/lib/aviso/data
      MODE: production
    working_dir: /var/lib/aviso                  # optional
    timeout: 30s                                 # optional
    retries: 2                                   # optional, default 0
    required: true                               # optional, default true
    fail_fast: true                              # optional, default true

env: values are passed literally to the child process; they are NOT run through the template engine. Only the command: string is templated. If you need a value that depends on a notification field or another env var, render it inside the command string (e.g. command: "FOO={{ notification.event_type }} ./run.sh") or compute it inside the shell command itself (command: "DATA=$HOME/aviso ./run.sh").

Notification values are data, never code

The command string is yours. The identifier and payload values that get substituted into it are not: they were written by whoever published the notification, and on a shared server that is often someone else. So the engine quotes every {{ notification.* }} value for the place it lands in, and the shell reads it as one literal argument whatever it contains:

You writeThe shell sees for value a'b$(x)
run {{ notification.payload.v }}run 'a'\''b$(x)'
run '{{ notification.payload.v }}'run 'a'\''b$(x)'
run "{{ notification.payload.v }}"run "a'b\$(x)"

In each case run receives exactly a'b$(x). A value cannot add a second command, redirect output, or run $(...). Plain values look the way they always did: {{ notification.sequence }} gives run '42', and run sees 42. An empty value is one empty argument. A # comment is recognised, so an apostrophe in a comment does not count as an opening quote.

This holds when the placeholder is part of a command word: bare, inside single quotes, inside double quotes, or inside $( ), nested or not. Four shell constructs are read by rules the engine does not follow: here-documents (<<), arithmetic expansion ($(( ))), backticks and case statements. A {{ notification.* }} placeholder that comes after one of those in the command, directly after a $, inside a ${ } expansion, as the operand of a redirection (a quoted value there would still let the publisher pick the file), where the command name goes (likewise the program), or as an argument to a command that reparses its arguments or whose name is not plain text (see below) is refused at dispatch with a ValueAfterUnsupportedShellSyntax template error naming the reason, rather than quoted on a guess. Placeholders before it are fine, and so are {{ env.* }} values anywhere.

For the constructs the engine does not follow, reach the notification through the AVISO_* environment variables below, with the usual double quotes around them, as a data argument. The program a command runs and the file a redirection opens must stay yours: "$AVISO_IDENTIFIER_PROGRAM" as the command name or > "$AVISO_IDENTIFIER_PATH" hands that choice to the publisher just as a placeholder would.

Some commands read their arguments as shell code a second time, or run the file they name: eval, trap, . and source, and a shell started with -c (sh -c, bash -c). Nothing makes notification data safe there. A value that was quoted once, or expanded once from "$AVISO_EVENT_TYPE", is read as shell syntax the second time. A {{ notification.* }} placeholder anywhere in the arguments of such a command is refused; keep the AVISO_* variables out of them too.

{{ env.* }} values are yours, so they are inserted as written. If you want one to expand into several arguments, it still can.

The AVISO_* environment variables are the other safe route and often the simpler one: the shell does the quoting and the command string stays short.

Template rendering on the command string

The command: value runs through the template engine. Two namespaces:

  • {{ notification.<dotted.path> }} - substitutes a notification field.
  • {{ env.<NAME> }} - substitutes a process environment variable.

Example:

command: "curl -X POST https://api.example/notify -d '{{ notification.payload }}' -H 'Authorization: Bearer {{ env.API_TOKEN }}'"

The payload lands inside your single quotes, so a quote character in it is escaped and cannot end them. The token is your own environment variable and is inserted as written.

Environment variables injected by the dispatcher

Every command runs with these AVISO_* env vars set automatically:

VariableValue
AVISO_EVENT_TYPEThe event type (e.g. mars)
AVISO_SEQUENCEThe sequence number (decimal string)
AVISO_IDENTIFIER_<KEY>One per identifier field
AVISO_PAYLOAD_JSONThe payload as compact JSON
AVISO_NOTIFICATION_JSONThe whole notification as compact JSON (matches the echo trigger’s pipe-mode output)

In AVISO_IDENTIFIER_<KEY>, <KEY> is uppercased with non-alphanumerics replaced by _. For example, class becomes AVISO_IDENTIFIER_CLASS.

Operator-supplied env: keys are applied after the dispatcher-injected vars, so user keys override dispatcher keys when both are present.

The child inherits the rest of the listener’s environment, with two exceptions. AVISO_TOKEN, AVISO_USERNAME and AVISO_PASSWORD are removed: a trigger has no reason to hold the credential the listener uses to talk to the server. And any variable the command string already read through {{ env.NAME }} is removed too, since its value is already on the command line. To hand one of these to the child on purpose, set it in env:, which wins.

This means you can write commands as either:

# Style A: template engine
command: "ingest {{ notification.event_type }} {{ notification.sequence }}"

# Style B: env vars (often shorter and easier to escape)
command: "ingest \"$AVISO_EVENT_TYPE\" \"$AVISO_SEQUENCE\""

Both pass each value as one argument, whatever it contains. Style B keeps the command string shorter. Quote the variables, as here; an unquoted $AVISO_... is split on spaces and expanded as a glob by the shell.

Output capture

Stdout and stderr are captured concurrently into 4 KiB ring buffers. Stdout content is dropped (per the no-payload-logging discipline); only the captured byte count reaches DEBUG-level tracing. Stderr tail surfaces in the public TriggerError::Command variant on non-zero exit.

This means: don’t pipe large output from your command. If you need to capture stdout, redirect to a file inside the command (>> /var/log/foo).

Timeout and process tree

When timeout expires, the dispatcher sends SIGKILL to the shell child and reaps the zombie. It does NOT propagate the kill to pipelines, backgrounded jobs, or grandchildren that survive the shell. Patterns:

  • Safe: command: "exec ./my-binary" - the shell execs the binary, replacing itself in place, so the kill reaches the binary directly.
  • Risky: command: "./long-running-command &" - backgrounded job survives the kill.
  • Risky: command: "tail -f /var/log/foo | grep ERROR" - tail survives even after grep is killed.

Use exec for single-binary commands. For pipelines that need cleanup, write a wrapper script with a trap handler.

Fail-fast classification

The fail_fast setting defaults to true (on). Set it to false to turn it off.

ErrorFail-fast onFail-fast off
Non-zero exit code (TriggerError::Command)terminalretryable
Template render error (TriggerError::Template)terminalretryable
Timeout (TriggerError::Timeout)retryableretryable
I/O error spawning the child (TriggerError::Io)retryableretryable

Terminal failures bypass the retries budget and the trigger fails immediately. The lib emits a hint:

Hint: command trigger exited non-zero. Common causes: the rendered command is malformed (check `{{ notification.* }}` substitutions; the rendered command appears in DEBUG-level tracing only), the command is missing or not on PATH (check the shell's behaviour with `/bin/sh -c '<your command>'`), or the command genuinely failed (check the stderr tail above).

Idempotency

Commands run at-least-once. A required command that succeeds twice (because the listener crashed before the cursor advanced and the notification was redelivered) must produce the same observable effect both times.

Idempotent: echo X >> file.log (duplicate lines are usually fine), kubectl annotate node ... --overwrite.

Not idempotent: mail -s "X" admin@example.com (sends a second email), psql -c "INSERT ..." (creates a duplicate row).

For non-idempotent commands, either:

  • Set required: false (the cursor advances even on failure, so retry-on-restart doesn’t fire) - but then the operator misses the side-effect on real failures.
  • Make the command itself idempotent (e.g., INSERT ... ON CONFLICT DO NOTHING).
  • Wrap with a deduplicating layer keyed on event_type@sequence.

When to use

  • Glue to existing CLI tools / scripts: any command-line workflow benefits.
  • Filesystem actions: cp, mv, ln -s, anything triggered by a notification.
  • Lightweight integrations where setting up a webhook receiver is overkill.

When NOT to use

  • Cross-platform deployments: command is Unix only. Use webhook for cross-platform.
  • Long-running tasks: command is per-notification, not per-listener-session. Spawning a 60-second task per notification at 100 notifications/sec is going to break things.
  • Sensitive secrets in the command string: the rendered command appears in DEBUG-level tracing (e.g. via the client.trigger.template.render_failed event when template rendering fails); anyone with access to the DEBUG log sees the secret. The public TriggerError::Command carries only exit_code and stderr_tail, not the command itself, but the command’s own stderr can still leak secrets it echoed. Pass secrets via env: instead (env values are also redacted from the trigger’s Debug impl and never echoed by the dispatcher).