Introduction
aviso is ECMWF’s notification system for data-driven workflows. It runs as a client and a server: aviso-server tracks streams of events and pushes them out in close to real time; clients connect, subscribe to the streams they care about, and react when a dataset they were waiting for has landed.
This site documents the client side. This repo holds three clients: a command-line tool, a Rust library, and a Python package. Pick the one that fits how you work.
From the command line
The aviso binary publishes notifications, listens for new ones, and replays
history. It runs on Linux and macOS.
aviso listen --event mars --identifiers '{"class":"od"}'
Start with the CLI overview.
From Python
The pyaviso package wraps the Rust core through PyO3, so you get a real Python
API: typed value objects, structured exceptions, for-iteration over a watch
stream, and the same trigger surface the CLI uses.
import os
import pyaviso
client = pyaviso.AvisoClient(base_url=os.environ["AVISO_BASE_URL"], auth=pyaviso.Env())
for notification in client.listen("mars", filter={"class": "od"}):
print(notification.sequence, notification.payload)
Start with the Python overview.
From a Rust program
The Rust library powers the CLI and is on crates.io. Use it when you want notifications inside a Rust binary or service.
use aviso::AvisoClient;
let client = AvisoClient::builder()
.base_url("https://aviso.example")
.build()?;
Start with the library guide.
New here?
- What aviso does and how the pieces fit together.
- Install the CLI or the library.
- Quickstart: your first publish and your first listener.
Reference
- CLI flags: every flag, every subcommand.
- Listener YAML: the file format the CLI reads.
- State file: what aviso writes to disk to remember where it left off.
- Rust API: types, traits, and functions.
What aviso is
aviso is ECMWF’s notification system for data-driven workflows. It runs as a client-server pair: aviso-server is the source of truth for streams of events; the client connects, asks for the events you care about, and tells you when they arrive. This page is about the client.
The pieces
flowchart LR
server["aviso-server<br/>(someone else runs this)"]
client["aviso<br/>(you run this on your box)"]
triggers["triggers run here:<br/>echo, log, webhook,<br/>command, teams, post"]
client -->|HTTP| server
server -->|SSE stream| client
client --> triggers
The server is the source of truth. Your aviso client subscribes, processes incoming notifications through any triggers you have configured, and remembers where it left off so it can resume cleanly after a restart.
What an event looks like
The server speaks in events. Every event has:
- a type that names what kind of thing happened (for example
mars), - a set of identifiers that describe which one (
class=od,stream=oper, a date, a step, and so on), - an optional JSON payload with whatever the publisher attached (often the location of a file),
- a sequence number that strictly increases per event type.
You ask aviso to listen for a type and the identifiers you care about. The server streams every matching new event to you over a long-lived HTTP connection.
Four ways to use it
| You want to | Use | Read next |
|---|---|---|
| Run aviso in a terminal or a script | The aviso CLI | CLI overview |
| Call aviso from Python | The native pyaviso package (it bundles the aviso CLI too) | Python overview |
| Use aviso in a C++ program | The C++ bindings | C++ overview |
| Embed aviso in a Rust program | The aviso Rust library | Library guide |
The CLI and the library are the same code. Pick the surface that matches the program you are writing.
What aviso takes care of for you
- Reconnects. The server intentionally closes a listener every so often. aviso reconnects without losing your place.
- Resume. When the process restarts, aviso picks up from the last notification it fully processed. Normal restarts avoid skipping events.
- At-least-once delivery. Each notification reaches your triggers at least once. Design your triggers to be idempotent.
- Backpressure. If your code is slow to handle a notification, aviso slows down its read from the network rather than buffering forever in memory.
- Heartbeat watchdog. If the network goes quiet for too long, aviso assumes the connection is dead and reconnects.
Where to go next
- New here and want to try it in 5 minutes? Quickstart.
- Want to set up a long-running listener? Listen for notifications.
- Want to understand the model in more depth? Concepts.
Install
Most users need exactly one command:
pip install pyaviso
aviso --version
The Python package carries both surfaces: the importable pyaviso library
and the aviso command-line tool. The wheel installs the CLI as a console
script that runs the Rust core in-process, so there is no Rust toolchain to
set up and no separate binary to download.
| You want to | Install |
|---|---|
| Call aviso from Python | pip install pyaviso |
Run the aviso command-line tool | pip install pyaviso (the CLI is bundled), or cargo install aviso-cli for a Rust-native binary |
| Use the Rust library in your own crate | cargo add aviso |
Command line without Python
If you do not use Python, cargo builds the same CLI from source:
cargo install aviso-cli
aviso --version
cargo install puts the binary in ~/.cargo/bin/aviso, which is on your PATH
if you installed Rust through rustup. Both install paths
give you the same aviso command and behaviour.
The CLI install page covers the from-source path and how to verify your install.
Rust library, in your Cargo.toml
[dependencies]
aviso = "2.0"
tokio = { version = "1.53", features = ["macros", "rt-multi-thread"] }
serde_json = "1.0"
aviso is async and runs on tokio. The full library guide is in the developers section.
What you need on the server side
Whichever surface you pick, you will need:
- The base URL of an aviso-server you can talk to (your team or ECMWF gives you this).
- Credentials, when the server requires them (usually a bearer token).
See authentication providers for the five ways to attach credentials.
Quickstart
The fastest path from nothing to a working notification. Pick the surface you have, follow the four steps, and you are done.
1. Get the binary
pip install pyaviso
aviso --version
The Python package bundles the aviso command-line tool, so pip is all you
need. If you prefer a Rust-native install, cargo install aviso-cli gives you
the same command. The full install guide is at Install.
2. Point at a server
Tell aviso where the server is and how to authenticate. The simplest way is environment variables:
export AVISO_BASE_URL=https://aviso.example
export AVISO_TOKEN=your-bearer-token
Or pass them on the command line each time, with --base-url and --token.
If you would rather keep them in a file, see CLI configuration.
3. Discover notification types
First, see which notification types the server offers:
aviso schema list
For a server configured with just the example mars type, the terminal output
is:
1 schema(s) registered (status: success)
- mars
This lists registered notification types, not stored notifications or data files. Pick a type from your server’s list and inspect its schema to see which identifiers you can filter on:
aviso schema get mars
Example output from a server with a minimal mars schema:
{
"event_type": "mars",
"schema": {
"identifier": {
"class": {
"description": "MARS class.",
"required": true,
"type": "EnumHandler",
"values": [
"od",
"rd"
]
}
},
"payload": {
"required": false
}
},
"status": "success"
}
Here, class is required in the listener’s filter. EnumHandler means its
value must come from the listed values, od or rd. We will choose od below.
The payload is optional for notifications of this type.
This JSON describes the schema already registered on the server; it is command output, not a configuration file to install. Your server may offer other types or require more identifiers. Use its schema to choose the event type and supply all required identifiers in the examples below.
4. Listen for something
For the example schema above, listen for mars notifications with class=od:
aviso listen --event mars --identifiers '{"class":"od"}'
Press Ctrl+C to stop.
Every matching notification prints to your terminal as JSON. When you redirect
the output to a file or pipe it into another tool, the format changes to one
compact JSON object per line so you can chain it with jq:
aviso listen --event mars --identifiers '{"class":"od"}' | jq -r '.payload'
That is it. You have a working listener.
What just happened
- aviso connected to the server and opened a long-lived stream.
- It asked for
marsevents withclass=od. - It echoed each match to your terminal.
- It remembered the last notification it printed in
~/.config/aviso/state.json, so the next run starts after that point.
Re-running the same command resumes from where you left off. A notification can
still be redelivered after a crash or failed checkpoint, so production triggers
should be safe to run more than once. To start fresh next time, add
--no-state-store.
Calling aviso from Python
The same listener as step 4, through the native Python API. The
pip install pyaviso from step 1 already gave you the pyaviso package, and
pyaviso.Env() reads the same environment variables you exported in step 2:
"""Listen for mars notifications and print each one as it arrives."""
import os
import pyaviso
client = pyaviso.AvisoClient(base_url=os.environ["AVISO_BASE_URL"], auth=pyaviso.Env())
for notification in client.listen("mars", filter={"class": "od"}):
print(f"seq={notification.sequence} payload={notification.payload}")
Notifications arrive as typed objects with sequence, identifier, and
payload fields, so there is no JSON to re-parse. Publishing, resuming across
restarts, async, and error handling are covered in the
Python section, starting with its
quickstart.
Calling aviso from a Rust program
use std::collections::BTreeMap;
use aviso::{
watch::{Trigger, WatchRequest},
AvisoClient,
};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let client = AvisoClient::builder()
.base_url("https://aviso.example")
.build()?;
let mut filter = BTreeMap::new();
filter.insert("class".to_string(), serde_json::json!("od"));
let req = WatchRequest::watch("mars")
.with_filter(filter)
.with_triggers(vec![Trigger::echo()]);
let mut stream = client.watch(req)?;
while let Some(notification) = stream.recv().await {
let n = notification?;
println!("seq {}: {}", n.sequence, n.payload);
}
Ok(())
}
The filter must include any identifier the event type’s schema marks
required: true (run aviso schema get <TYPE> to see which).
The full library walkthrough is in the library guide.
Next
- Publish a notification (your first
aviso notify). - Listen with a YAML file: named listeners, multiple triggers, the configuration you keep around.
- Concepts: the five ideas you need to get fluent.
Concepts in five minutes
The five ideas you need to get fluent with aviso. Each links to a deeper page if you want more.
Notifications
A notification is one event from the server. It has an event type, a set of identifiers, an optional JSON payload, and a sequence number that strictly increases.
You ask the server “let me know when something of type mars arrives matching
these identifiers”, and the server streams the matching events to you.
Read Notifications for the wire format and the fields.
Streams
When you listen, aviso opens a long-lived HTTP connection (an SSE stream) to the server. The server pushes events to you as they happen. The connection deliberately closes after a configured maximum duration; aviso reconnects automatically. Reconnects are normal, not an error.
Read Streams for the reconnect rules and the heartbeat watchdog.
Filters
A filter is the set of identifiers you want notifications for.
{"class":"od","stream":"oper"} says “only events where the identifier map
matches both of these”. The server enforces filters; aviso just passes them
along.
Some identifier fields are required by the server’s schema, others are optional. Omitted optional fields act as wildcards.
Read Filters for the matching rules and the spatial filter shapes.
Resume and state
When aviso processes a notification end-to-end (including running every required trigger), it writes the sequence number to a small JSON file. On restart, aviso reads that file and resumes from the next sequence. You will not miss events and you will not skip ahead.
The file lives at ~/.config/aviso/state.json by default. You can change it,
disable it for a one-off run, or delete it when you want to start fresh.
Read Resume and state for the durability rules and the rewind workflow.
Triggers
A trigger is what aviso does with each notification. Six built-in kinds are available:
echoprints the notification.logappends it to a file.commandruns a shell command (Unix only).webhookmakes an HTTP request to a URL of your choice.teamsposts to a Microsoft Teams channel.postforwards the original event envelope to another service.
You can attach as many triggers as you want to a listener. They run in order, and the sequence number only advances when every required trigger succeeds.
Read the Triggers overview for the dispatcher contract and pick a kind to dive into.
Where to go next
- Quickstart if you want to try it now.
- CLI overview if you are setting up a listener.
- Library guide if you are embedding aviso in your own Rust code.
CLI overview
aviso is a single binary that talks to an aviso-server. Use it to publish
notifications, listen for new ones, replay history, and look around (schemas,
configuration).
Use it when you want to:
- Subscribe to a stream and pipe the output to
jq, a log file, or any line-oriented tool. - Run a long-lived listener that ships notifications to a webhook, a Teams channel, a log file, or a custom shell command.
- Drop a notification into a stream from a script.
- Inspect what schemas the server publishes, or wipe a stream during testing.
Subcommands at a glance
| Command | What it does |
|---|---|
aviso notify | Publish one notification. |
aviso listen | Open a long-lived stream and run the configured triggers. |
aviso replay | Re-read history from a cursor and run the configured triggers. |
aviso schema | List the event types the server knows about, or fetch one schema. |
aviso admin | Destructive operations (wipe a stream, delete one notification). |
aviso config dump | Print the resolved configuration with sources. |
aviso completions | Print shell completion scripts. |
When to use the CLI vs the library
| Use the CLI when | Use the library when |
|---|---|
| You want a binary in your terminal, a cron job, or a systemd unit. | You are writing a Rust program and want notifications inside it. |
| You want to pipe notifications into another tool. | You want full control over how each notification is handled, in code. |
| You want a quick one-liner against a stream. | You want to share an authentication or connection pool across many subscriptions. |
| You want triggers configured in YAML. | You want triggers and listeners constructed programmatically. |
Both surfaces are built on the same code, so behaviour is identical at the wire level.
What comes next
- Install the binary.
- Quickstart: the smallest useful run, end to end.
- Publish and listen: the two commands you will use most.
Install the CLI
The fastest way to get aviso is pip. The Python package bundles the CLI, so
one install works even if you never write a line of Python.
From PyPI
pip install pyaviso
aviso --version
The wheel installs a console script that runs the Rust CLI in-process through the extension, so you do not need a Rust toolchain. See the Python install page for wheel coverage and details.
From crates.io
If you have a Rust toolchain and prefer a native binary:
cargo install aviso-cli
aviso --version
cargo install builds from source and puts the binary in ~/.cargo/bin/aviso.
That directory is on your PATH when you installed Rust through
rustup. Both install paths give you the same aviso
command and behaviour.
From a git checkout
For an unreleased version, or when contributing:
git clone https://github.com/ecmwf/aviso-client.git
cd aviso-client
cargo install --path crates/aviso-cli
Verify
aviso --version
aviso --help
You should see the subcommands listed.
Where the binary lives
The binary is called aviso, not aviso-cli. The crate name carries the -cli
suffix to leave the unprefixed aviso for the library, but the installed
executable is plain aviso so it reads cleanly on the command line.
# Linux, macOS
which aviso
# pip install: <venv-or-user-base>/bin/aviso
# cargo install: /home/you/.cargo/bin/aviso
Shell completions
aviso completions <shell> prints a completion script for bash, zsh, fish or
elvish. Save it where your shell looks for completions, then open a new shell.
The right place depends on the shell and, for zsh, on how it is set up, so
check the notes below if aviso <Tab> does nothing.
Run the commands with the same aviso you use day to day. If it lives in a
virtual environment, activate it first; the script is generated by the
binary, so it matches that version’s subcommands and flags.
Bash
mkdir -p ~/.local/share/bash-completion/completions
aviso completions bash > ~/.local/share/bash-completion/completions/aviso
This needs the bash-completion package, which most distributions install and
load from /etc/bash.bashrc or /etc/profile.d. It picks up files in that
directory by command name the first time you press Tab after the command. If
complete -p aviso in a new shell says “no completion specification”,
bash-completion is not loaded; install it, or source the file yourself from
~/.bashrc:
source ~/.local/share/bash-completion/completions/aviso
Zsh
Zsh only searches the directories in $fpath, and the file must be named
_aviso. Which directory that is depends on your setup:
# Plain zsh: create the directory and add it to fpath before compinit in
# ~/.zshrc, then save the script there.
mkdir -p ~/.zsh/completions
aviso completions zsh > ~/.zsh/completions/_aviso
# in ~/.zshrc, above `compinit`:
# fpath=(~/.zsh/completions $fpath)
# oh-my-zsh: its custom completions directory is already in fpath.
mkdir -p ~/.oh-my-zsh/custom/completions
aviso completions zsh > ~/.oh-my-zsh/custom/completions/_aviso
Zsh caches the list of completion functions in ~/.zcompdump. After adding a
new file, delete that cache and start a new shell:
rm -f ~/.zcompdump*
exec zsh
To check it worked, whence -w _aviso in the new shell prints
_aviso: function. If it prints nothing, the directory holding the file is
not in $fpath; print -l $fpath lists the ones zsh searches.
Fish
aviso completions fish > ~/.config/fish/completions/aviso.fish
Fish loads the file on the next Tab; no restart is needed.
Elvish
aviso completions elvish > ~/.config/elvish/lib/aviso.elv
The script registers the completer when it is evaluated, so add
eval (slurp < ~/.config/elvish/lib/aviso.elv) to ~/.config/elvish/rc.elv.
Upgrading
- pip:
pip install --upgrade pyaviso. - cargo:
cargo install aviso-cliagain. Cargo replaces the binary in place; add--forceto rebuild from scratch.
Uninstalling
pip uninstall pyaviso # pip install
cargo uninstall aviso-cli # cargo install
A cargo-installed binary can also be deleted from ~/.cargo/bin/aviso by hand.
Configuration and state files in ~/.config/aviso/ are left alone; remove them
if you want a clean slate:
rm -rf ~/.config/aviso
What next
- Quickstart: your first run.
- Configuration: config files, environment variables, TLS settings.
CLI quickstart
Consumers use Aviso to receive notifications from data providers. Start by discovering event types, then listen for new notifications or replay past ones. Providers publish notifications to announce data; the optional publishing section below is for them. You do not need to publish anything to listen.
First install the CLI and point at a server.
Set these to the URL and credentials supplied by your server operator:
export AVISO_BASE_URL=https://aviso.example
export AVISO_TOKEN=your-bearer-token
See what the server knows
aviso schema list
You get one event type per line. This lists notification schemas, not available datasets or access rights. You need permission to receive notifications.
These examples assume your operator has configured a small mars event type
with the schema below. Check your server’s schema and adapt the event name and
filters if it differs:
aviso schema get mars
Example response (JSON, with spacing compacted):
{
"event_type": "mars",
"schema": {
"identifier": {
"class": {
"required": true,
"type": "EnumHandler",
"values": ["od", "rd"]
},
"step": {
"range": null,
"required": false,
"type": "IntHandler"
}
},
"payload": {"required": false}
},
"status": "success"
}
The schema tells you which labels, called identifiers, describe a
notification and can be used as filters. Here, class is required in filters
and must be od or rd (EnumHandler means a choice from a list). In this
example, od means operational data. step is a whole number (IntHandler)
used for forecast hours; you can omit it from filters. range: null sets no
extra range limit. The server checks these values. The optional payload
carries extra information, such as a file location, rather than labels to
filter on.
Listen for live notifications
Select mars notifications whose class is od. Leaving out the optional
step filter selects all forecast steps:
aviso listen --event mars --identifiers '{"class":"od"}'
On a first run, you will see matching new notifications as providers publish them. Silence can simply mean none have arrived. Press Ctrl+C to stop. Later runs resume from saved progress and may first deliver missed notifications.
If you have jq installed, show just the payload:
aviso listen --event mars --identifiers '{"class":"od"}' | jq -r '.payload'
When piped or redirected, output is one JSON object per line (NDJSON).
Replay history
To run through past notifications from a cursor (a sequence id or a date):
aviso replay --event mars --identifiers '{"class":"od"}' --from 2026-05-01
aviso replay --event mars --identifiers '{"class":"od"}' --from 1000
Replay reads retained history up to the boundary captured when the run starts.
Notifications published after that boundary are not included.
The date refers to notification publication time. Replace 1000 with a sequence
id from your stream. Read more at
Replay history.
Catch up, then keep listening
To read past notifications and then keep listening:
aviso listen --event mars --identifiers '{"class":"od"}' --from 2026-05-01
This replays retained matches, then waits for new ones. See all
Configuration: --from formats.
Unlike replay, it saves progress. Run the same listener without --from to
resume from where it stopped.
Run a production listener with a YAML file
To save your filters and log notifications, create my-listeners.yaml in your
current directory:
listeners:
- name: mars-od
event: mars
identifiers:
class: od
triggers:
- type: log
path: mars-od.log
aviso listen my-listeners.yaml
Matching notifications are appended to mars-od.log. Press Ctrl+C to stop. For
more triggers and multiple listeners, see
Listen with a YAML file.
Publish a notification
Optional, for providers with permission to publish. Using the schema above, announce an operational forecast at step 12:
aviso notify 'event=mars,class=od,step:=12,data={"location":"file:///data/forecast.grib"}'
Keep the comma-separated parameters inside single shell quotes. event=mars
names the event type; class and step are its identifiers. class=od sends
text. step:=12 uses := to send a JSON number, rather than the text "12"
sent by step=12. Both are accepted by this schema’s integer validator, but
:= makes the number explicit in the request. Keep event= for the event name.
data= already parses JSON and supplies the payload; it does not need :=.
The location here is an example file reference. Publishing sends a notification,
not the file, and does not grant consumers access to it. An active matching
listener receives the notification, including this payload.
For shell variables, use repeated --identifier arguments to keep each value
in its own quoted argument. This works with notify, listen, and replay; see
repeated identifiers for scripts.
For nested arrays and spatial identifiers, see Publish and listen.
See what configuration is in effect
aviso config dump --redact
This shows resolved settings and their sources. --redact masks tokens and
passwords for sharing in an issue.
What next
- Publish and listen: the daily-use guide.
- Configuration: config files, environment variables, TLS.
- Troubleshooting: the common things that go wrong.
Publish and listen
For providers: use
aviso notifyto announce data or events.For users: use
aviso listento receive matching notifications. Go straight to Listen; you do not need to publish anything first.
Schema assumptions
The following mars examples assume your server operator has installed this
small schema, also used in the CLI quickstart. This is a
server YAML configuration section, not a client listener file:
notification_schema:
mars:
topic:
base: mars
key_order: [class, step]
identifier:
class:
type: EnumHandler
values: [od, rd]
required: true
step:
type: IntHandler
required: false
payload:
required: false
Providers must include both class and step. In listener filters, class is
required and step is optional: required: false applies to filters, not to
publishing. The payload is optional and can carry a data location.
Use the connection and authentication settings from
Configuration. Inspect your server with
aviso schema get mars and adapt the examples if its schema differs. This
command reads a schema; only the server operator configures one. See the
server schema guide.
The spatial and weather examples below state their own schema assumptions.
Publish
This section is for providers with permission to publish to the event type.
aviso notify sends one notification to the server; it does not upload the
data the notification describes.
aviso notify 'event=mars,class=od,step:=12,data={"location":"s3://bucket/path"}'
The single argument is a comma-separated list:
event=<TYPE>is required. It names the event type.data=<JSON>supplies the notification’s payload. It is optional for thismarsschema; other schemas may require it.- Every other
key=valuepair lands in the identifier map. - Use
key:=JSONwhen an identifier must be a JSON scalar rather than a string.
Bare scalar values are sent as strings: step=12 sends "12", while step:=12
sends the JSON number 12. Both are accepted by this schema’s integer
validator. Keep event= for the event name; data= already parses JSON.
The := form also accepts other JSON values, but the server’s schema must
allow the field and its value. If a string itself starts with [ or {, wrap
the value in double quotes inside the outer shell quotes to keep it as text.
Repeated identifiers for scripts
Use one --identifier argument per field to pass shell variables without
building a comma-separated parameter list or interpolating JSON:
MARS_CLASS=od
STEP=12
aviso notify 'event=mars,data={"location":"s3://bucket/path"}' \
--identifier "class=$MARS_CLASS" --identifier "step:=$STEP"
event= and the optional data= stay in the required positional argument.
Repeated identifiers supplement its identifier map. Duplicate keys, including
keys already in that map, are errors. --identifier event=... and
--identifier data=... are rejected; use the positional parameters for them.
For all three commands (notify, listen, and replay):
--identifier key=valuesends everything after the first=as an exact string. Commas, whitespace, literal quotes, backslashes, and later=or:=sequences are preserved. JSON-looking strings are still strings.--identifier key:=JSONparses an explicit JSON value, including numbers, booleans, null, arrays, objects, and JSON strings.STEPabove must contain valid JSON numeric text:12works, but012does not.- Keys are trimmed and must not be empty. A colon immediately before the first
=selects JSON syntax, so that form cannot express a string key ending in a colon. Other key syntax and supported values are checked by the server. - Missing
=, duplicate keys (even across the two forms), or invalid explicit JSON cause exit code 2 before a request is sent.
Double-quote the whole "class=$MARS_CLASS" argument. The shell expands the
variable once; the CLI does not expand variables or evaluate shell code.
Spaces, commas, quotes, literal dollar signs, and command-substitution text
inside a variable stay data in that single argument. Do not use eval.
The mars schema still restricts class to od or rd; arbitrary text needs
a schema field that accepts it.
For structured values, put complete valid JSON in a variable and pass it as one quoted argument. Do not build JSON by inserting arbitrary strings between JSON quotes. This filter uses the weather schema:
SEVERITY_FILTER='{"gte":5}'
aviso listen --event weather --identifier date=20260913 \
--identifier "severity:=$SEVERITY_FILTER" \
--identifier 'anomaly:={"between":[40,50]}' \
--identifier 'region:={"in":["north","south"]}' \
--from 0 --no-state-store
With the five weather records below, this selects B and C.
Free-form string schema
For a literal-text example, the operator can install this synthetic server
schema. The mars schema above does not have a label field.
notification_schema:
text_example:
topic:
base: text_example
key_order: [label]
identifier:
label:
type: StringHandler
required: true
payload:
required: false
LABEL='commas,"quotes",$HOME,$(false),back\slash=a:=b'
aviso notify event=text_example --identifier "label=$LABEL"
aviso replay --event text_example --identifier "label=$LABEL" --from 0
The quotes inside LABEL are literal characters. Neither $HOME nor
$(false) inside its value is evaluated. The CLI sends the string as supplied;
the server may canonicalise identifiers according to its handler rules. This
example has no spaces because label forms part of the routing subject, and
NATS subjects cannot contain whitespace.
Spatial schema assumptions
The observations examples assume the operator has installed the server’s
point-cloud schema.
It declares date (DateHandler, format %Y%m%d) and point_cloud
(PointCloudHandler, at most 10,000 points), both required, plus a required
payload. Providers send date, point_cloud, and data. Subscribers send
date and a closed polygon instead of point_cloud.
Identifier values beginning with [ or { are parsed as JSON. This sends a
point cloud as an array rather than a quoted JSON string:
aviso notify 'event=observations,point_cloud=[[46,8],[47,9]],date=20260601,data={"source":"stations"}'
A point has shape [latitude,longitude]. A polygon and a point cloud both use
[[latitude,longitude],...]. Polygons need at least four pairs, with the first
pair repeated last. Clouds do not need a closing repeat.
The outer single quotes protect the argument from the shell. Do not add double
quotes around the array.
Alternative coordinate format
The same observations listener can use a comma-separated polygon string
instead of an array. Inside the JSON filter, keep the string in double quotes:
aviso listen --event observations \
--identifiers '{"date":"20260601","polygon":"46,8,46,9,47,9,47,8,46,8"}'
This filter matches the cloud above because its points lie on the polygon’s
boundary. The HTTP API also accepts point strings such as "46,8" in watch and
replay filters where the schema supports them. Point clouds have no string
format. Prefer arrays for spatial values; CloudEvent spatial identifiers are
always arrays.
Identifier fields the server requires
Publishing requires every identifier field declared by the schema, even fields
marked required: false for filters. If you omit one, the server rejects the
notification with a helpful error.
To see what fields a schema asks for:
aviso schema get mars
What you see on success
When aviso notify succeeds, it prints the server’s response. In your terminal
you get a human-readable line; piped to a file or another command, you get one
line of compact JSON.
Listen
aviso listen opens a long-lived connection to the server and prints (or
trigger-handles) each matching notification as it arrives.
There are two ways to set up a listener: a YAML file (for anything you care about), and inline flags (for quick exploration).
Listen with inline flags
aviso listen --event mars --identifiers '{"class":"od"}'
This runs one listener with a single echo trigger. Press Ctrl+C to stop.
Alternatively, use repeated identifiers with the same mars schema:
MARS_CLASS=od
STEP=12
aviso listen --event mars \
--identifier "class=$MARS_CLASS" --identifier "step:=$STEP"
This selects class=od, step=12 and keeps listening for live notifications.
Choose either --identifiers or repeated --identifier; combining them is an
error. Either source requires --event, and --event requires one source.
Omit all inline flags to use YAML or configured listeners. Inline mode takes
precedence over those listeners, with one default echo trigger.
--identifiers takes a JSON object literal. Values may have any JSON shape:
aviso listen --event observations \
--identifiers '{"date":"20260601","polygon":[[46,8],[46,9],[47,9],[47,8],[46,8]]}'
For observations, use the spatial schema assumptions
above.
Providers send point_cloud; subscribers send a closed polygon and the
required date. A cloud matches when any point is inside or on the boundary.
For an empty identifier map (every notification of this event type), pass
'{}'. The server may still require certain fields to be present, depending on
the schema.
The inline mode runs with one default trigger: echo. For any other trigger,
use a YAML file.
Listen with a YAML file
Write the listeners you want once, and run them on demand or under systemd:
# my-listeners.yaml
listeners:
- name: mars-od
event: mars
identifiers:
class: od
triggers:
- type: log
path: /var/log/aviso/mars-od.log
- type: webhook
url: "{{ env.WEBHOOK_URL }}"
headers:
Authorization: "Bearer {{ env.WEBHOOK_TOKEN }}"
- name: mars-rd
event: mars
identifiers:
class: rd
triggers:
- type: command
command: "./on-mars.sh {{ notification.identifier.step }}"
Run them:
aviso listen my-listeners.yaml
The CLI spawns one task per listener and runs them concurrently. Each listener resumes independently from the state file.
A full reference for the file format is at Listener YAML.
Triggers, briefly
A trigger is the action aviso takes for each matching notification. Six kinds are built in:
echoprints the notification.logappends it to a file.commandruns a shell command (Unix only).webhookmakes an HTTP request to a URL of your choice.teamsposts to a Microsoft Teams channel.postforwards the original event envelope.
You can attach as many triggers as you want to a listener. They run in declaration order.
What aviso listen prints
On a TTY, the echo trigger prints a multi-line pretty JSON block per
notification with a one-line header. When the output is piped to a file or
another command, it switches to one compact JSON object per line, so
aviso listen | jq and aviso listen >> notifications.ndjson both work.
Other triggers write to their own destinations (a file, a webhook, a shell command); the CLI itself stays quiet on stdout for those.
Stopping
Ctrl+C drains in-flight work and exits. A second Ctrl+C within five seconds exits immediately (return code 130).
Resuming
When you stop and restart aviso listen against the same server and
identifiers, it resumes from the last notification it fully processed. The
cursor lives in ~/.config/aviso/state.json by default. For a one-off run that
does not write to the file, pass --no-state-store.
To start from an explicit point (a sequence id or a date) just for this run:
aviso listen my-listeners.yaml --from 2026-05-01
aviso listen my-listeners.yaml --from 1000
The full rules for --from (pure-digit input is always a sequence id, dashes
mean a date, and so on) live in
Configuration.
Listening for several event types at once
Put multiple listeners in the same YAML file (or in separate files; the CLI
accepts a list). For example, save the mars and observations filters above
in listener files named mars.yaml and observations.yaml:
aviso listen mars.yaml observations.yaml
Each listener has its own request, its own resume cursor, and its own triggers. A failure in one does not stop the others. Listeners share connections to the server, up to 64 on each.
Worked example: weather constraints
This synthetic example uses no external data services. You need a test server
whose operator has installed the following schema in its server configuration.
This is server YAML, not a client listener file. It declares the complete
weather event type; it is not a schema shipped on every server.
The operator adds this block to the server configuration; see the
schema guide.
The client can inspect a schema, but aviso schema get does not install one.
notification_schema:
weather:
topic:
base: weather
key_order: [date, severity, anomaly, region]
identifier:
date:
type: DateHandler
required: true
canonical_format: '%Y%m%d'
severity:
type: IntHandler
required: false
range: [0, 10]
anomaly:
type: FloatHandler
required: false
range: [-100, 100]
region:
type: EnumHandler
required: false
values: [north, south, west]
Set AVISO_BASE_URL in both terminals to your test server’s address, using the
connection and authentication settings from Configuration.
For example, http://127.0.0.1:8000 is only a placeholder for a locally running
server; it is not a hosted service. Inspect the installed schema before running
the example:
aviso schema get weather
All four fields are required when publishing. Only date is required in a
filter; omitting an optional filter field accepts all its values. Each seed
below has a distinct combination of routing identifiers, so the records do not
replace one another on a backend that retains only the latest record per
subject. Use a fresh test stream to get exactly the results shown.
Start the listener first
In the first terminal, run:
aviso listen --event weather \
--identifiers '{"date":"20260913","severity":{"gte":5},"anomaly":{"between":[40,50]},"region":{"in":["north","south"]}}' \
--from 0 --no-state-store
Leave it running, then publish in the second terminal. --from 0 reads retained
records after sequence zero and continues with live notifications, without
reading or writing a saved cursor. On this fresh test stream, seeds published
before or during connection setup are still included; there is no readiness
message to wait for. This cannot recover records removed by retention.
The filter means severity at least 5, anomaly from 40 through 50 inclusive, and region either north or south. See Filters for the operator rules.
Publish five records
In the second terminal, run these commands in order:
aviso notify 'event=weather,date=20260913,severity:=3,anomaly:=39.5,region=north,data={"id":"A"}'
aviso notify 'event=weather,date=20260913,severity:=5,anomaly:=40,region=NORTH,data={"id":"B"}'
aviso notify 'event=weather,date=20260913,severity:=6,anomaly:=42.5,region=south,data={"id":"C"}'
aviso notify 'event=weather,date=20260913,severity:=7,anomaly:=50,region=west,data={"id":"D"}'
aviso notify 'event=weather,date=20260913,severity:=8,anomaly:=50.5,region=south,data={"id":"E"}'
severity:=5 sends a JSON number; severity=5 sends a string. These commands
use concrete numbers, not constraint objects. data={"id":"B"} is the payload
label used to recognise a record, not a filter or the server’s notification ID.
The single quotes protect each complete argument from the shell.
| Record | Severity | Anomaly | Region | Selected? |
|---|---|---|---|---|
| A | 3 | 39.5 | north | No: severity and anomaly too low |
| B | 5 | 40 | NORTH | Yes: lower boundaries included, case normalised |
| C | 6 | 42.5 | south | Yes |
| D | 7 | 50 | west | No: region excluded |
| E | 8 | 50.5 | south | No: anomaly too high |
The listener prints two notifications, with payload.id values B then
C. Press Ctrl+C to stop it. To read the same retained records again, use
replay with this filter. To keep the filter
in a file, use the YAML equivalent.
What next
- Replay history: re-read past notifications.
- Configuration: config files, environment variables, TLS.
- Triggers overview: pick the right trigger.
- Troubleshooting: the usual snags.
Replay history
aviso replay re-reads past notifications and runs them through your triggers,
just like aviso listen does for live ones. The difference: replay always
starts from a cursor you supply, ends at a fixed history boundary or at an end
point you supply with --until, and never touches the state file.
Use it when:
- You want to backfill a new downstream system with what already happened.
- You missed a window of notifications and want to re-process them.
- You are debugging a listener and need to feed it past traffic for repeatable runs.
A first replay
These examples assume your server operator has installed the same small mars
schema used in Publish and listen.
This is server YAML, not a client listener file:
notification_schema:
mars:
topic:
base: mars
key_order: [class, step]
identifier:
class:
type: EnumHandler
values: [od, rd]
required: true
step:
type: IntHandler
required: false
payload:
required: false
In filters, class is required and step is optional. The integer step has
no configured range, and the payload is optional. Use the connection and
authentication settings from Configuration, then inspect
the installed schema:
aviso schema get mars
This command only reads the schema; the operator configures it. Adapt the filter if your server’s schema differs. Replay reads notifications already published by providers. You do not need to publish your own; for test history, see the publish example.
aviso replay --event mars --identifiers '{"class":"od"}' --from 2026-05-01
This selects stored mars notifications with class=od, with no restriction
on step. The date selects publication time from midnight UTC on 1 May 2026,
not a date in the notification’s metadata. Replay prints matching retained
notifications and stops at the history boundary captured when the run starts.
It does not wait for new notifications.
Retention limits what is available, and a server replay cap can stop a run before catch-up completes. See the server’s historical replay limits. An empty matching history prints no notifications.
--from takes either a sequence id or a date. The rules are the same as for
aviso listen --from; full list at
Configuration: --from formats.
You can also save this client listener as my-listeners.yaml. Its echo
trigger prints each matching notification:
listeners:
- name: mars-od
event: mars
identifiers:
class: od
triggers:
- type: echo
aviso replay --from 0 my-listeners.yaml
--from 0 reads retained history after sequence zero. To resume from a known
notification, replace 0 with its actual sequence id; only later sequences are
selected. If a file defines several listeners, select one by name:
aviso replay --from 0 --listener mars-od my-listeners.yaml
Stop at an end point
--until ends the replay at an end point instead of at the history boundary.
It takes the same forms as --from: a sequence id is the last one delivered,
inclusive, and a date ends with the last notification published at or before
that time.
aviso replay --event mars --identifiers '{"class":"od"}' \
--from 2026-05-01 --until 2026-05-02
This prints the matching notifications published from midnight UTC on 1 May 2026
up to and including midnight UTC on 2 May, and stops. A date without a time
means midnight UTC. The start is exclusive and the end inclusive for sequence
ids: --from 10 --until 20 prints the matching notifications with sequences 11
to 20. The banner shows both ends. A replay also stops at the history boundary,
so an end point in the future ends there.
A sequence --until that is not greater than a sequence --from is refused
before anything is sent, with exit code 2, since the replay would print
nothing. --until needs aviso-server 0.13.0 or
later; an older server rejects the request with HTTP 400.
Repeated identifiers
For shell scripts, pass each identifier as a separate argument:
MARS_CLASS=od
STEP=12
aviso replay --event mars \
--identifier "class=$MARS_CLASS" --identifier "step:=$STEP" --from 0
This reads retained mars records with class=od and step=12. key=value
preserves the exact string; key:=JSON parses a typed value. See
scripting rules for quoting and
validation. Choose repeated --identifier or one --identifiers JSON object;
the two sources conflict. Either requires --event, which requires a source.
Inline arguments take precedence over YAML and --listener, with a default
echo trigger.
Replay does not write the state file
The state file (~/.config/aviso/state.json) is for aviso listen only. Replay
never reads or writes it.
The reason: a replay run shares the same resume key as the equivalent listen run, and letting replay update the cursor could push the listen cursor past notifications the listen has not actually processed. To keep the at-least-once delivery guarantee, replay stays stateless by design.
The trade-off: if you interrupt a replay, the next replay needs an explicit
--from to resume. There is no “resume my replay” mode.
--identifiers accepts structured JSON values just like aviso listen. Quote
the whole object for the shell, but leave arrays unquoted inside it.
The following assumes the operator has installed the server’s
point-cloud schema,
also used in
Publish and listen.
Its date is required (DateHandler, format %Y%m%d). Providers publish the
required point_cloud array and payload; replay filters supply date and a
closed polygon array instead of point_cloud. Coordinates are
[latitude,longitude], with the first pair repeated last to close the polygon.
aviso replay --event observations \
--identifiers '{"date":"20260601","polygon":[[46,8],[46,9],[47,9],[47,8],[46,8]]}' \
--from 2026-05-01
A cloud matches when any point lies inside or on the polygon’s boundary.
Replay the weather example
After publishing the five records in the weather tutorial, reuse exactly the same identifier filter:
aviso replay --event weather \
--identifiers '{"date":"20260913","severity":{"gte":5},"anomaly":{"between":[40,50]},"region":{"in":["north","south"]}}' \
--from 0
The same filter can be supplied as repeated arguments instead:
aviso replay --event weather --identifier date=20260913 \
--identifier 'severity:={"gte":5}' \
--identifier 'anomaly:={"between":[40,50]}' \
--identifier 'region:={"in":["north","south"]}' --from 0
On the fresh test stream either form prints payload.id values B and C, then
exits. Do not combine the two identifier sources in one command.
--from 0 reads retained history after sequence zero; it cannot recover records
that the backend no longer retains. The wire filter is the same object used by
listen, with JSON numbers inside the constraint objects. The
operator rules are shared by both
commands.
Replay vs listen
| Behavior | Listen | Replay |
|---|---|---|
| Starts from | The state file, or --from if you set it | --from (required) |
| Ends at | Does not end on its own | The history boundary, or --until if you set it |
| Writes the state file? | Yes, unless disabled | No |
| After catching up | Stays open for new notifications | Stops at its end: the history boundary or --until |
| Reconnect on routine server close? | Yes | Yes |
When you want both
To read retained history and continue with new notifications, use listen with
--from and the same schema and filter:
aviso listen --event mars --identifiers '{"class":"od"}' --from 2026-05-01
Press Ctrl+C to stop. Listen records progress in its state file. Running replay
and then starting a separate listener without --from or saved state can leave
a gap: that listener starts at the server’s current tip, after replay’s fixed
boundary. Seeding a state file separately is not an exact-handover guarantee.
What next
- Publish and listen: the live equivalent.
- State file: what listen writes and how to edit it.
Operations
The supporting commands: schema inspection, destructive admin, configuration introspection, shell completions.
Schemas
The server publishes a schema per event type, describing which identifier fields exist, which are required, and what types they hold. Two commands let you look at them.
List event types
aviso schema list
On a terminal you get a header line and a bullet list of event types. When piped
(or with --json), each entry comes out as a JSON object, one per line:
aviso schema list | jq -r .event_type
Get one schema
aviso schema get mars
You get the schema as pretty JSON. Use it to discover which identifiers the server insists on when publishing or filtering.
To fetch every schema in one command:
aviso schema list | jq -r .event_type | xargs -I{} aviso schema get {}
aviso does not validate notifications against schemas locally. The server is the single source of truth; these commands are for human discovery.
Admin
Three destructive commands sit under aviso admin. They all require a --yes
flag on the command line; the flag cannot be set in the configuration file, by
design.
Wipe one event-type stream
aviso admin wipe-stream mars --yes
Deletes every notification of type mars on the server. Useful in test
environments. Operator-level credentials required.
Wipe everything
aviso admin wipe-all --yes
Deletes every notification of every type. Useful when you want a completely clean slate.
Delete a single notification
aviso admin delete 'mars@42' --yes
The argument is the notification id (the <event_type>@<sequence> form the
server emits).
Configuration introspection
aviso config dump --redact
Prints the resolved configuration to stdout with a comment on each line saying
where the value came from (flag, env, file, or default). --redact masks tokens
and passwords.
The dump is what aviso would use right now, given your current flags, environment, and config file. It is the fastest way to debug “why is aviso not picking up my setting”.
For JSON output (so you can jq it):
aviso config dump --redact --json
The auth block is summarised rather than printed per-field. In the YAML
form, provider is <set>, <set; redacted> with --redact, or <unset>.
In the JSON form the same fact is the boolean provider_set, alongside a
redacted boolean. source names where the credential came from (flag,
environment, config file, or credentials file); an absent source is
<unset> in YAML and null in JSON. Use source when the credential in use
is not the one you expected. The listeners block is summarised by name, event,
identifier count, and trigger count.
Shell completions
aviso completions <shell> prints a completion script for bash, zsh, fish or
elvish. Where to save it, and what to do when Tab does nothing afterwards, is
covered in Install: shell completions.
Exit codes
| Code | Meaning |
|---|---|
0 | Success. A clean Ctrl+C with no prior failure also returns 0. |
1 | Runtime error. Server returned 4xx/5xx, network failure, or a listener task errored out. |
2 | Usage error. Missing required flag, invalid argument, destructive admin command without --yes, no listeners resolved, unparseable --from value. |
130 | Second Ctrl+C within five seconds. Hard exit; no drain. |
What next
- Configuration: where settings come from and how to override them.
- Troubleshooting: the common things that go wrong.
Configuration
How aviso decides what server to talk to, how to authenticate, what TLS settings to use, and where to keep state.
Tell aviso where the server is
The minimum aviso needs is a server URL and (usually) credentials. You have three ways to supply each one. They are checked in this order:
- Command-line flag (
--base-url,--token, …). - Environment variable (
AVISO_BASE_URL,AVISO_TOKEN, …). - Config file (default
~/.config/aviso/config.yaml).
A flag beats an env var beats a config file. Layering is per-field: passing
--base-url does not blank out a file-set auth.bearer_token.
To see what aviso actually resolved, run aviso config dump --redact.
The base URL must use http or https and point to the Aviso service,
including any reverse-proxy path prefix. A website or login page is not a
stream endpoint.
Listener startup timeout
aviso listen prints Connecting first. It prints Listening for each
listener only after the server confirms an Aviso stream. No matching
notification is needed: a confirmed stream can be healthy and idle.
--startup-timeout <DURATION> limits the initial connection and retries to 30
seconds by default. This gives transient failures time to recover while keeping
a failed startup bounded. To change that budget for configured listeners:
aviso listen --startup-timeout 60s
Use --startup-timeout 0s to disable the initial budget. This listen-only flag
has no YAML key or environment variable. It stops applying after the first
confirmed handshake and does not restart on reconnect. Each connection attempt
still has a separate ten-second deadline for response headers and the Aviso
opening event. Missing it stops the listener only before the first confirmed
handshake; after that, the reconnect is retried. Heartbeats and unrelated SSE
events do not confirm startup.
Retry status goes to stderr at INFO level, with a listener name, cause, and delay. Repeated retries are coalesced to at most one message every five seconds. Notifications keep their configured trigger output. Ctrl+C while connecting stops the listener normally. A failed listener makes the eventual command exit with status 1; other listeners keep running.
The configuration file
# ~/.config/aviso/config.yaml
base_url: "https://aviso.example"
auth:
bearer_token: "your-token-here"
# Or use basic auth instead:
# basic:
# username: "alice"
# password: "secret"
# Optional, with sensible defaults if omitted:
heartbeat_interval: 30s
state_file: "/path/to/state.json"
tls:
ca_bundle:
- "/path/to/internal-ca.pem"
danger_accept_invalid_certs: false
listeners:
- name: mars-od
event: mars
identifiers:
class: od
triggers:
- type: log
path: /var/log/aviso/mars-od.log
To point at a config file in another location, use --config <PATH> or set
AVISO_CLIENT_CONFIG_FILE.
The optional YAML timeout is the total time allowed for an ordinary request,
such as notify, schema or an admin command, response body included. It is
unset by default. It does not apply to listeners, whose stream is meant to stay
open; use --startup-timeout to bound how long a listener may take to start.
Environment variables
| Variable | What it sets |
|---|---|
AVISO_BASE_URL | The server URL. |
AVISO_TOKEN | A bearer token. |
AVISO_USERNAME / AVISO_PASSWORD | Basic auth credentials. |
AVISO_CLIENT_CONFIG_FILE | Path to the config file. |
AVISO_CREDENTIALS_FILE | Path to the credentials file. |
AVISO_STATE_FILE | Path to the state file. |
AVISO_LOG | Logging filter. When set, overrides -v/-vv. Format: a tracing_subscriber EnvFilter directive. |
NO_COLOR | When set (any value), suppresses ANSI colors in the --color auto mode. Per the no-color.org convention. |
Authentication
aviso supports anonymous access (no Authorization header), HTTP Basic, and
Bearer. It looks for a credential in four places and stops at the first one
that has it:
| Order | Source |
|---|---|
| 1 | The --token flag, or --username with --password. |
| 2 | The AVISO_TOKEN env var, or AVISO_USERNAME with AVISO_PASSWORD. |
| 3 | auth.bearer_token, or auth.basic.{username,password}, in the config file. |
| 4 | The credentials file described below. |
The flags are the least safe of the four. A command line is visible to every
local user in the process list and is kept in the shell history, so
--token and --password belong to one-off tests, not to scripts or service
definitions. aviso logs one WARN line, cli.auth.on_command_line, when a
credential arrives this way. Use the environment variables or the credentials
file instead.
Once an earlier source supplies a credential, the auth: block of the config
file and the credentials file are not interpreted, so a stale entry in either
cannot fail a command that was not going to use it. The config file itself is
still parsed for its other settings, and a YAML error there is reported
regardless. With nothing in any of the four, aviso connects anonymously.
The winning source decides the provider: the environment builds an Env, the
credentials file a ConfigFile, and a flag or the config-file auth: block a
Bearer or a Basic depending on which credential you set. Those names
appear in logs and in the library API; see
Authentication providers for what each one
does.
The credentials file
When nothing else supplies a credential, aviso reads
~/.config/aviso/credentials.yaml. Set AVISO_CREDENTIALS_FILE to use a
different path. The file holds one credential and nothing else:
# ~/.config/aviso/credentials.yaml
bearer:
token: your-bearer-token
Or, for Basic authentication:
basic:
username: your-username
password: your-password
This file is meant for tools that fetch a token and write it out. It is read
last, so a credential you set with a flag, in the environment, or in the auth:
block of the config file is used instead. A missing file is fine. A file that
exists but cannot be read is an error, so a typo is reported rather than
ignored.
The credentials file is also the only source that is reread after a 401. A
tool that refreshes the token in place therefore reaches a running
aviso listen without a restart.
A credential from the environment or either file is not sent to a plain
http:// address unless it is loopback. Use an https address, or pass the
credential on the command line with --token, or --username and
--password, to say you mean it. This applies when a command makes a request;
config dump still reports the source it would have refused. A credential
you named that goes to a non-loopback http:// address is sent, with one
WARN line, client.auth.plaintext, the same way --danger-accept-invalid-certs
is announced.
To see which source is in use:
aviso config dump | grep -A2 '^auth:'
On a 401, aviso retries once
If the server returns 401 Unauthorized, aviso asks the auth provider to
refresh and retries the request once. For static credentials (bearer, basic) the
refresh is a no-op; for providers backed by an OAuth or OIDC cache, the refresh
rotates the token. A second 401 in the same attempt cycle is surfaced as an
error.
The state file
Each successful listener run records its cursor in ~/.config/aviso/state.json.
On restart, the listener resumes from the next sequence so it does not skip
ahead. A notification can still be redelivered after a crash or failed
checkpoint.
Change the location:
state_file: "/var/lib/aviso/state.json"
or:
aviso --state-file /var/lib/aviso/state.json listen ...
Disable persistence for one run:
aviso listen --no-state-store ...
The file is for aviso listen only. aviso replay does not touch it.
For the file format, edit safety, and how to genuinely reset a cursor, see State file.
--from value formats
Both aviso replay --from <VALUE> (required) and aviso listen --from <VALUE>
(optional, overrides the listener’s defaults) accept these forms, tried in
order:
- Pure digits → sequence id.
42,1234567. YYYY-MM-DD→ midnight UTC.2026-05-01."YYYY-MM-DD HH:MM"(quotes required) → UTC."2026-05-01 14:30"."YYYY-MM-DD HH:MM:SS"(quotes required) → UTC.YYYY-MM-DDTHH:MM:SS(T separator) → UTC.YYYY-MM-DDTHH:MM:SSZ(T separator with Z) → UTC.YYYY-MM-DDTHH:MM:SS.ffffffZ(with microseconds and Z) → UTC.
The pure-digit case is always a sequence id, never a date. 20260601 is
sequence id 20260601, not 1 June 2026. To pass a date, use the dashed form
(2026-06-01).
aviso replay --until <VALUE> accepts the same forms. There, a sequence id is
the last one delivered, inclusive; see
Stop at an end point.
How --from interacts with the state file
On aviso listen, when you pass --from <VALUE> and the state file already
has a cursor for the listener:
- aviso uses your
--fromfor the initial seek. - As the rewind delivers notifications, the state file ignores updates whose
sequence is at or below what is already on disk. Your
--fromcannot regress the cursor. - Once the run advances past the previous high-water mark, normal updates resume.
- Restarting without
--fromhonours the state file again.
Use --from as a one-shot rewind. Leaving it in a systemd unit means redelivery
from that point on every restart.
Logging verbosity
| Effect | |
|---|---|
| Default | aviso crate at INFO, third-party crates at WARN. |
-v | aviso crate at DEBUG. |
-vv | aviso crate at TRACE. |
AVISO_LOG=<directive> | Overrides everything else. Operator policy wins. |
Useful recipes for the env var:
AVISO_LOG=warn,aviso=debug # aviso details, everything else quiet
AVISO_LOG=h2=debug,hyper=debug,aviso=debug # also see HTTP transport
Color
--color auto|always|never. The default is never. The auto mode emits color
when the target stream is a TTY and NO_COLOR is unset. always overrides
NO_COLOR.
Color only ever applies to human-readable output. JSON output (piped or redirected) is always plain.
TLS
aviso talks HTTPS by default and uses the system trust store. Two flags adjust validation when that is not enough.
Trust an internal CA
When your aviso-server is fronted by a TLS endpoint whose certificate is signed by an internal certificate authority (a corporate root, a self-hosted ACME, a private cluster), point aviso at the CA file:
aviso --base-url https://aviso.internal.example.org --ca-bundle ~/.config/aviso/internal-ca.pem schema list
Or in the config file:
tls:
ca_bundle:
- internal-ca.pem
A relative path here is resolved against the config file’s own directory, so
this entry means ~/.config/aviso/internal-ca.pem wherever you run aviso
from. A relative --ca-bundle on the command line is resolved against the
working directory, as you would expect of a flag.
The --ca-bundle flag is repeatable, so you can pass intermediate and root
certificates separately (or put them all in one PEM file). The system trust
store stays in effect; --ca-bundle only adds.
To fetch the CA’s certificate from a running server (for inspection or for use here):
openssl s_client -connect aviso.internal.example.org:443 -showcerts < /dev/null 2>/dev/null \
| sed -n '/-----BEGIN CERTIFICATE-----/,/-----END CERTIFICATE-----/p' \
> internal-ca.pem
openssl x509 -in internal-ca.pem -noout -subject -issuer
Bypass TLS validation (insecure)
For short-lived development against a self-signed certificate when shipping the CA file is impractical:
aviso --base-url https://localhost:8443 --danger-accept-invalid-certs schema list
aviso logs a WARN at every startup when this is set, so log scrapers can flag
misuse. Do not use this in production.
The recommended order: try the system trust store first, fall back to
--ca-bundle, use --danger-accept-invalid-certs only as a last resort during
development.
What next
- Listener YAML reference: the file format the CLI reads.
- State file reference: annotated example, edit safety.
- Authentication providers: when to use which one.
Troubleshooting
Choose the symptom that matches what you see. Open a panel for checks and links to the relevant reference.
Server URL or connection errors
If aviso says base_url is required, supply the server URL through
--base-url, AVISO_BASE_URL, or base_url in your config file. Flags override
environment variables, which override the file. See
Configuration.
For connection failures, check the URL and whether the server is reachable from your machine. Confirm the hostname, port, and HTTP or HTTPS scheme with your server operator. For certificate errors, see TLS errors.
If Connecting never becomes Listening, the server has not confirmed an Aviso
stream. HTTP 200 alone is not enough. A missing or incorrect
Content-Type: text/event-stream, an unexpected opening event, or no opening
confirmation within ten seconds stops the listener with a protocol error.
Check for a website URL, a login redirect, or a proxy routing the request to the
wrong service. Rejected HTTP 200 bodies are not printed.
If the error reads no response from the server within 10s, the server
accepted the connection but did not answer at all. The server may be slow or
overloaded, or a proxy in front of it may limit how many requests one
connection carries at once. The client opens at most 64 listeners per
connection; a proxy that allows fewer makes the listeners beyond its limit
wait until this deadline. Ask the server operator about the proxy’s
concurrent-stream limit.
This error stops a listener only before it shows Listening. Once listening,
a reconnect that gets no answer is retried, and each retry is reported with the
cause no response within the opening deadline. If that cause keeps repeating,
the server is still unreachable, or a proxy limit is keeping this listener out.
For HTTP failures, listen omits unrecognized response bodies, including JSON
objects from proxies. Recognized Aviso error codes with string messages or
details retain only those fields, a request ID, and the configured event types
when relevant. Other fields are omitted. URL-like whitespace-delimited tokens
in retained text and listener hints are replaced with [URL omitted], including
their userinfo, path, query, and fragment. This does not detect arbitrary
secrets in ordinary message text. Library callers can still inspect the raw
error body.
Retrying listener connection means a recoverable failure, such as a connection
refusal, HTTP 429, or HTTP 5xx. The default initial budget is 30 seconds across
retries. See
Listener startup timeout
to change or disable that budget. Once Listening appears, startup has
finished; an idle stream with heartbeats does not need to deliver data to stay
healthy.
Authentication fails with 401 or 403
A 401 means credentials are missing, invalid, or expired. Check the credentials selected by your flags, environment variables, and config file. A 403 can mean your credentials were accepted but lack permission for the operation or event type. Ask your server operator to check access. See Authentication.
A listener that worked before suddenly returns 401 after I rotated the token
aviso retries once after a 401 by asking the auth provider to refresh. For
static credentials (--token, AVISO_TOKEN), refresh is a no-op: a second 401
surfaces as an error.
The fix: update the token in your env var or config file, then restart aviso.
Token or password values appearing in logs
They should not be. aviso marks the Authorization header as sensitive and the
Debug impl on every auth provider redacts the secret. If you see a credential
in a log line, please file a bug.
The listener is running, but no events arrive
Check that the event type and identifier filters match the notifications you expect. A listener without a saved cursor or a starting point waits for new notifications; it does not read all existing history. A saved cursor resumes after the last committed notification. See Resume and state.
aviso listen runs forever, but I am pointing at a finite test stream
Expected. aviso listen is the live mode: it reconnects on close and waits for
more events. To re-read history once and stop, use
aviso replay --from <cursor>.
--from 20260601 is treated as a sequence id, not a date
Pure-digit input is always a sequence id. To pass a date, use the dashed form:
aviso listen --from 2026-06-01
The full set of accepted --from formats is in
Configuration: --from value formats.
The server rejects the request or identifier filters
Read the server’s error message for the field it rejected. For listen and
replay, identifiers marked required: true in the server’s schema must be
supplied. Omitting a required: false identifier makes it a wildcard. Supplied
values must still satisfy the schema’s type and constraints.
Publishing is different: every identifier in the schema must be supplied, even
those marked required: false. See
Publish and listen and the
Listener YAML reference.
The event type is unknown or the schema is unexpected
Event types and identifier rules come from the server you connect to. Check the server URL and the spelling of your event type against that server’s schema. An example event type is not guaranteed to exist on your server. See Schema discovery for how to inspect the available types and their identifiers.
Listener configuration, saved state, or duplicates after restart
“no listeners to run”
error: no listeners to run
You ran aviso listen without any listener configuration. Fix one of these:
- Pass a YAML file on the command line:
aviso listen my-listeners.yaml. - Add a
listeners:block to your config file (~/.config/aviso/config.yaml). - Use the inline form:
aviso listen --event mars --identifiers '{"class":"od"}'.
“state file format version 2 does not match supported version 1”
The file uses format version 2, but this binary supports version 1. Two recovery paths:
- Install an aviso whose format matches the file.
- Stop all clients using the state file, then delete it and start fresh:
rm ~/.config/aviso/state.json ~/.config/aviso/state.json.lock. You will start from “now” (or from--fromif you pass one).
The full recovery story is at State file.
Multiple notifications delivered after a restart
Expected, by design. aviso guarantees at-least-once delivery: when a notification finishes processing (every required trigger ran successfully), the cursor advances. If aviso crashes between running a trigger and saving the cursor, the next run will redeliver that notification.
Triggers should be designed to be idempotent. For example,
kubectl annotate ... --overwrite is idempotent; mail -s subject ... is not.
TLS certificate errors or insecure-mode warnings
TLS errors when connecting
error: client error (Connect): invalid peer certificate: ...
Your aviso-server is behind a certificate aviso does not trust. Two paths:
- The right path: get the CA’s PEM file from your server operator, then pass
--ca-bundle <PATH>or settls.ca_bundlein the config file. See Configuration: trust an internal CA. - Last resort, dev only:
--danger-accept-invalid-certs. aviso logs aWARNat startup whenever this is set.
A WARN about insecure TLS keeps firing
... WARN cli.tls.insecure_mode: TLS certificate validation disabled by --danger-accept-invalid-certs ...
You have --danger-accept-invalid-certs set (or
tls.danger_accept_invalid_certs: true in the config file). The warning is
intentional: log scrapers can flag the situation in production. Remove the
insecure setting and use --ca-bundle to trust your CA instead.
A command trigger times out or leaves processes behind
A command trigger times out and leaves background processes behind
The dispatcher’s SIGKILL reaches the /bin/sh -c ... child but not pipelines,
backgrounded jobs, or grandchildren.
- For a single binary, use
exec:command: "exec ./my-binary". The shell replaces itself with the binary, so the kill reaches it directly. - A shell
trapcannot catchSIGKILL. Pipelines and background jobs need process-tree cleanup outside the dispatcher’s timeout handling.
A webhook keeps retrying or a log file cannot be opened
A webhook trigger keeps retrying despite a 4xx
It should not. 4xx responses are terminal by default (the receiver is rejecting
the request; retrying with the same body will not change the answer). Check
fail_fast in your YAML: if you set fail_fast: false, every failure becomes
retryable.
A log trigger fails with “No such file or directory”
The log trigger does not create directories. Make sure the parent exists:
mkdir -p /var/log/aviso
An admin command requires confirmation
“admin wipe-all requires –yes”
error: aviso admin wipe-all requires --yes
The destructive admin commands need an explicit --yes flag on the command
line:
aviso admin wipe-all --yes
The flag cannot be set in the config file (a config-file --yes would defeat
the safety).
How to file a useful issue
Every server response carries an X-Request-ID header. The same UUID appears in
aviso’s tracing events as request_id. Quote it when reporting an issue against
aviso-server or aviso-client.
If you can, attach the output of aviso config dump --redact (which masks
tokens and passwords). It is the fastest way for someone else to see what aviso
thinks it is configured with.
Python
Use pyaviso to receive Aviso notifications in your Python scripts. For
example, your script can wait until a dataset is ready, then start processing
it.
For users: listen for notifications or replay past ones. You do not need to publish anything yourself.
For notification providers: publish notifications to tell users that data or an event is available. Publishing requires permission on the server.
What you can do
- See what is available: discover the server’s notification types and their filter fields.
- Listen for updates: receive notifications matching the data you care about.
- Read past notifications: replay available history.
- React to a notification: run your Python code, write a log, or call another service.
Providers can also publish notifications from Python. A notification can include a file location or other useful information; publishing it does not transfer the file.
Start here
- Install pyaviso.
- Follow the Python quickstart to connect to a server, inspect its schema and start listening.
You will need your Aviso server’s address and, if required, credentials. The server’s schemas determine which notification types and filters you can use.
When you need more
- Listening: filters and reading notifications.
- Publishing: sending notifications as a provider.
- State and resume: remembering progress between runs.
- Triggers: running actions for matching notifications.
- Authentication and Troubleshooting: connection and access help.
- API reference: details of the available methods.
If your application already uses Python’s asyncio, see Async.
Otherwise, start with the regular client used in the quickstart.
Install
Use Python 3.10 or newer. Install into the Python environment where you will run your scripts or notebook kernel.
From PyPI
pip install pyaviso
Wheels ship for Linux (manylinux, x86_64 and aarch64) and macOS (universal2). No Rust toolchain is needed; the compiled extension comes inside the wheel.
Verifying the install
python -c "import pyaviso; print(pyaviso.__version__)"
It prints the installed package version. This checks the import, not a connection to an Aviso server.
The bundled aviso command
Installing pyaviso also puts the aviso command-line tool on your PATH, so a
single pip install gives you both the importable library and the CLI:
aviso --version
It is the same aviso command-line tool documented in the
CLI section. The wheel installs it as a console script
that runs the Rust CLI in-process through the extension (rather than shipping a
separate compiled binary), so you do not need a cargo install. Pick whichever
install path you prefer: pip install pyaviso and cargo install aviso-cli
give you the same aviso command and behaviour.
From source
To build a checkout locally, including when contributing:
git clone https://github.com/ecmwf/aviso-client.git
cd aviso-client
uv venv
uv sync --locked --group dev
uv run maturin develop --release --locked
The --group dev flag pulls in maturin, ruff, ty, pytest, and the other
dev tools alongside the runtime dependencies. --locked on both commands keeps
the install reproducible from the committed uv.lock and Cargo.lock. The
second command builds the Rust extension into the local virtualenv so
import pyaviso works. The first build pulls the workspace’s Rust dependencies
and compiles them; subsequent builds are incremental.
For a source build you need:
- Rust toolchain (
rustup). uv(install instructions).- A C compiler and linker on the path. macOS uses the Apple toolchain; on Linux
the system
gccorclangis fine.
Python version
pyaviso targets Python 3.10 and newer. The extension is built with
abi3-py310, so a single wheel works across every supported Python version on
the same platform.
What next
- Quickstart shows how to discover schemas, listen for notifications, and replay history.
- Overview is the page-by-page guide to what the package gives you.
Quickstart
Use Python to receive notifications from data providers. Start by checking what your server offers, then listen for new notifications or replay past ones. You do not need to publish anything to listen.
Set the environment
You need Python 3.10 or newer and pyaviso installed in your Python environment. Ask your server operator for a server URL and credentials with permission to receive notifications.
Set these in your terminal, replacing the example values with yours:
export AVISO_BASE_URL=https://aviso.example.org
export AVISO_TOKEN=your-bearer-token
The scripts create the client with no arguments; it reads the address and the
credential from these variables. If you have a username and password instead,
unset AVISO_TOKEN and set AVISO_USERNAME and AVISO_PASSWORD. A token
takes precedence when both are set. With neither set, the client looks in
~/.config/aviso/config.yaml and ~/.config/aviso/credentials.yaml, and
requests go out anonymous only if those have no credential either. The first
script prints what the client resolved, so you can check it picked up what
you meant. See Configuration for
where settings come from, and Authentication for naming a
credential in code.
What is on your server
The server operator configures event types, each with a schema describing
its notifications. The examples below use the same small mars schema as the
CLI quickstart. Your server
may have different event types or a different mars schema.
Save this as discover.py and run python discover.py in the terminal where
you set the environment variables. It lists event types, then prints the schema
for mars. If mars is absent, use a listed name and run again.
import json
import pyaviso
client = pyaviso.AvisoClient()
print(client.config)
print(client.schema().event_types)
print(json.dumps(client.schema_for("mars").as_dict(), indent=2, sort_keys=True))
On a server configured with only this example schema, the output starts with the resolved configuration (your paths and address will differ), then:
ResolvedConfig(
base_url='https://aviso.example.org/' (environment AVISO_BASE_URL),
auth="bearer" (environment AVISO_TOKEN or AVISO_USERNAME),
timeout=None (default),
heartbeat_interval=None (default),
ca_bundle=[] (default),
danger_accept_invalid_certs=False (default),
)
['mars']
{
"event_type": "mars",
"schema": {
"identifier": {
"class": {
"required": true,
"type": "EnumHandler",
"values": [
"od",
"rd"
]
},
"step": {
"range": null,
"required": false,
"type": "IntHandler"
}
},
"payload": {
"required": false
}
},
"status": "success"
}
The list names configured schemas, not available datasets or access rights.
as_dict() makes the schema response printable as JSON.
Filters
Identifiers are labels you can filter on. Here, class must be od or rd
(EnumHandler means a choice from a list). In this example, od means
operational data. step is a whole number (IntHandler) for forecast hours;
range: null adds no range limit.
required: true means a field must appear in a filter. You can leave out step
to receive all steps. Publishing is different: a provider must supply every
identifier field, including step. The optional payload carries extra
information, such as a file location, rather than labels to filter on.
Use your server’s schema to adapt the event name and filters below. You do not need to configure a server schema to receive notifications from an existing service.
Listen for notifications
Save this as listen.py and run python listen.py in the same terminal. It
selects mars notifications whose class is od, at any forecast step:
import pyaviso
client = pyaviso.AvisoClient()
for notification in client.listen("mars", filter={"class": "od"}):
print(notification.payload)
The script waits for matching new notifications and prints each payload. Silence can simply mean none have arrived. For a notification carrying a file location, you would see:
{'location': 'file:///data/forecast.grib'}
Notifications with class=rd do not match. Press Ctrl+C to stop. This script
does not save progress: restarting it waits for new notifications again.
Replay past notifications
To read retained history, replace the for loop in listen.py with this loop,
keeping the imports and client initialization above it. Run python listen.py
again:
for notification in client.listen(
"mars", filter={"class": "od"}, start_from=0, mode="replay_only"
):
print(notification.payload)
start_from=0 starts from the beginning of retained history.
mode="replay_only" makes the script end when it catches up, rather than wait
for new notifications.
It prints matching payloads in sequence order. No output means no retained
notifications match. Running it again reads the same retained history; it does
not save a position.
Resume across restarts
For a listener that remembers its position, see State and resume. For more filters and replay options, see Listening.
What about async?
Use the regular client above for a simple script. If your application already
uses asyncio, see Async for complete examples.
Publish a notification (optional, for providers)
You need permission to publish. With the schema above, save this as publish.py
and run python publish.py to announce an operational forecast at step 12:
import pyaviso
client = pyaviso.AvisoClient()
response = client.notify(
event_type="mars",
identifier={"class": "od", "step": 12},
payload={"location": "file:///data/forecast.grib"},
)
print(response.status)
It prints success when the server accepts the notification. Both class and
step are supplied, even though step is optional in filters. The location is
an example reference: publishing sends the notification, not the file, and does
not grant access to the file.
An active matching listener receives this payload. To try it yourself, leave
the live version of listen.py running and publish from another terminal with
the same environment setup. A publish sent before the listener connects can be
read with the replay loop. See Publishing for more options.
Configuration
A client needs a server address and, usually, a credential. Both can be
passed in code. On a machine where the aviso command is already configured,
they are defined once, in the environment or in ~/.config/aviso/config.yaml,
and scripts need not repeat them. Every constructor argument is therefore
optional, and any argument that is omitted is looked up:
import pyaviso
client = pyaviso.AvisoClient()
print(client.schema().event_types)
This page describes where each setting is read from, in which order, and how to inspect the configuration a client resolved.
Order of precedence
For each setting, the client uses the first source that provides a value:
| Setting | 1. Code | 2. Environment | 3. Config file | 4. Otherwise |
|---|---|---|---|---|
base_url | base_url= | AVISO_BASE_URL | base_url: | ConfigError |
auth | auth= | AVISO_TOKEN, or AVISO_USERNAME with AVISO_PASSWORD | auth: block, then the credentials file | anonymous |
timeout | timeout= | timeout: | no timeout | |
heartbeat_interval | heartbeat_interval= | heartbeat_interval: | 30 seconds | |
| TLS | danger_accept_invalid_certs= | tls: block | validate certificates |
The config file is ~/.config/aviso/config.yaml, or the file named by
AVISO_CLIENT_CONFIG_FILE. The credentials file is
~/.config/aviso/credentials.yaml, or AVISO_CREDENTIALS_FILE. A missing
file sets nothing; a file that exists but cannot be read raises
pyaviso.ConfigError. Sections intended for the aviso command, such as
listeners, are ignored.
The aviso command uses the same order, with its command-line options taking
precedence, so a script and the command on the same machine use the same
server and the same credential. Two consequences follow:
- The environment can change where a script connects. An
AVISO_BASE_URLset in the shell determines whereAvisoClient()connects. A script that must always use one server should passbase_url=. - A discovered credential is protected. A credential read from the
environment or a file is not sent to a plain
http://address unless the host is loopback; the client raisespyaviso.AuthErrorinstead. Because the credential was not named in code, a mistyped host would otherwise receive it unencrypted. Passingauth=explicitly removes this restriction. See Authentication for details.
AvisoClient.from_file(path) is the same lookup with a named config file in
place of the default one; that path must exist. AsyncAvisoClient takes the
same arguments and follows the same order.
Inspecting the resolved configuration
When a connection fails, the first question is which server and which credential the client is using. Every client reports this:
import pyaviso
client = pyaviso.AvisoClient()
print(client.config)
ResolvedConfig(
base_url='https://aviso.example.org/' (environment AVISO_BASE_URL),
auth="bearer" (credentials file /home/me/.config/aviso/credentials.yaml),
timeout=30.0 (config file /home/me/.config/aviso/config.yaml),
heartbeat_interval=None (default),
ca_bundle=[] (default),
danger_accept_invalid_certs=False (default),
)
Each line shows a setting, its value and its source. The report contains no
secrets: the credential is described by its kind and source, never by its
value, and any user:password@ is removed from the address. The report can
therefore be included as it is in a support request or a log.
The fields are also available as objects:
url = client.config.base_url
if url is not None:
print(url.value, "from", url.source)
if client.config.auth is None:
print("no credential found: requests are anonymous")
source is one of code, environment <NAME>, config file <path>,
credentials file <path> or default. auth is None when no source had a
credential; auth=pyaviso.Anonymous() is instead reported as kind
anonymous from code, since that was an explicit choice.
client.config.as_dict() returns the same information as plain values, for
structured logging.
Inspecting the configuration without a client
pyaviso.resolve_config() produces the same report without creating a
client. It takes the constructor arguments that take part in the lookup
(base_url, auth, timeout, heartbeat_interval and
danger_accept_invalid_certs; not user_agent, state_store or
flush_cursor_on_exit, which are never looked up) and works even when a
client could not be built:
import pyaviso
config = pyaviso.resolve_config()
if config.base_url is None:
print("no server address in code, AVISO_BASE_URL or the config file")
elif config.auth is not None and config.auth.refused:
print("credential found but not sent:", config.auth.refused)
else:
print(config)
The second case is a common source of confusion: a credential is configured,
yet AvisoClient() raises AuthError. The report shows the credential and
the reason the client refuses to use it, for example:
auth="bearer" (config file /home/me/.config/aviso/config.yaml; refused: http://aviso.internal.example.org is plain http on a host that is not loopback, so the client will not be built. Use https, or name the credential in code to send it anyway.),
resolve_config() raises pyaviso.ConfigError when the config file or the
credentials file exists but cannot be read, and pyaviso.AuthError when a
credential source is present but unusable, such as AVISO_USERNAME with no
AVISO_PASSWORD; that is the same point at which AvisoClient() would
fail. All other conditions are reported in the returned object.
Setting values in code
Any value passed to the constructor is used as given and reported with the
source code:
client = pyaviso.AvisoClient(
base_url="https://aviso.example.org",
auth=pyaviso.Bearer(token),
timeout=10,
)
Here the address and credential are fixed regardless of the machine’s
configuration, while heartbeat_interval and the TLS settings are still read
from the file if it sets them. To send no credential even when one is
configured on the machine, pass auth=pyaviso.Anonymous().
The aviso command
aviso config dump prints the same kind of report for the command, including
the command-line flags the library does not have. See
CLI configuration.
Publishing
Providers publish notifications to announce data or events. You need credentials with permission to publish to the event type. Publishing sends a notification, not the file it describes, and does not grant access to that file. Users who only want to receive notifications can go to Listening.
Discover what each event type requires
The server operator owns the schemas. The client can inspect them, but cannot
install one. These examples use the same small mars schema as the
quickstart. Your server may differ.
Start with pyaviso installed in your Python environment. Set
AVISO_BASE_URL and either AVISO_TOKEN or AVISO_USERNAME/AVISO_PASSWORD,
as in Set the environment.
pyaviso.Env() requires credentials and prefers the token when both are set.
For an anonymous server, pass auth=pyaviso.Anonymous() instead.
Save this as discover.py and run python discover.py. It lists event types
and prints the complete schema response for mars. If mars is absent, use a
listed name and run again:
import json
import os
import pyaviso
client = pyaviso.AvisoClient(
base_url=os.environ["AVISO_BASE_URL"], auth=pyaviso.Env()
)
print(client.schema().event_types)
print(json.dumps(client.schema_for("mars").as_dict(), indent=2, sort_keys=True))
With only this example schema installed, output looks like this:
['mars']
{
"event_type": "mars",
"schema": {
"identifier": {
"class": {
"required": true,
"type": "EnumHandler",
"values": [
"od",
"rd"
]
},
"step": {
"range": null,
"required": false,
"type": "IntHandler"
}
},
"payload": {
"required": false
}
},
"status": "success"
}
class must be od or rd; EnumHandler means a choice from a list. step
is a whole number (IntHandler), with no range limit here. Publishing needs
every identifier field, including step. The identifier’s required: false
means it may be omitted from a listener filter, not from a notification. The
payload is optional for this schema. Listing a schema does not prove you have
permission to publish to it.
A complete publish script
Save this as publish.py and run python publish.py in the same terminal. It
announces an operational forecast (class=od) at step 12:
import os
import pyaviso
client = pyaviso.AvisoClient(
base_url=os.environ["AVISO_BASE_URL"], auth=pyaviso.Env()
)
response = client.notify(
event_type="mars",
identifier={"class": "od", "step": 12},
payload={"location": "file:///data/forecast.grib"},
)
print(response.status)
On acceptance it prints:
success
notify takes keyword arguments: event_type names the schema, identifier
supplies its labels, and payload carries extra information. Pass Python
numbers and lists directly; 12 becomes a JSON number automatically. The
Python keyword is payload, not data.
The server normalizes scalar identifiers in received notifications: this
step comes back as the string "12". Payload numbers keep their JSON types.
The returned NotifyResponse has three string properties: status,
request_id, and processed_at. The request ID is for tracing the HTTP request
in server logs, not a notification ID or replay position. processed_at is the
server’s timestamp. Success means the server accepted the notification, not
that a user has received it or downloaded the file.
Publishing many notifications at once
To announce several datasets, put their notifications in a list and pass it to
notify_many. Here we announce forecasts for steps 24 and 48. In publish.py,
replace the response = client.notify(...) call and its print with this block,
keeping the imports and client initialization:
notifications = [
{
"event_type": "mars",
"identifier": {"class": "od", "step": 24},
"payload": {"location": "file:///data/forecast-24.grib"},
},
{
"event_type": "mars",
"identifier": {"class": "od", "step": 48},
"payload": {"location": "file:///data/forecast-48.grib"},
},
]
results = client.notify_many(notifications)
for result in results:
if result.response is not None:
print("Notification accepted")
else:
print("Notification failed:", result.error)
Each notification has its own result. Check for failures even if others succeeded. Results are returned in the same order as the input list.
Each input is a dict using the same keys as notify. The schema still decides
which fields must be supplied. Results are in input order; index starts at
zero. ok is a boolean. On success, response is a NotifyResponse and
error is None; on failure, response is None and error holds the
exception.
The batch is not atomic: valid notifications can be stored even if another
fails. Check the outcome before retrying. Malformed batch input, such as an
item missing event_type, raises ValueError before sending any requests.
A non-dict item raises TypeError.
The optional concurrency argument limits how many notifications are sent at
the same time. Omitting it or passing 0 allows up to 16 requests at a time.
The batch uses different step values from the single notification. On a
backend that keeps only the latest notification per subject, publishing the
same routing identifiers again replaces the retained record. Replay reads
retained history, not every publish ever sent.
Error paths
For the mars schema above, leaving out step is an error even though it is
optional in filters. This intentionally invalid example keeps the imports and
client initialization from publish.py:
try:
client.notify(event_type="mars", identifier={"class": "od"})
except pyaviso.HttpError as error:
print(f"server rejected: status={error.status} request_id={error.request_id}")
print(error.body)
The server rejects the missing identifier with HTTP 400. Use its error body to find the field to fix. An unknown event type, an unsupported identifier, or a value outside the schema’s choices can also be rejected by the server. The client does not decide which identifier names your server allows.
pyaviso.HttpErrormeans a non-2xx HTTP response.statusis an integer,bodyis a string, andrequest_idis a string orNonewhen unavailable.pyaviso.AuthErrormeans the auth source could not produce credentials.pyaviso.Env()can raise it during setup, beforenotifyis called.pyaviso.TransportErrorcovers connection or response-transfer failures. A failure does not prove the server received nothing.
Publishes are not automatically retried after transport failures: the server may already have stored the notification. Check before resending to avoid duplicates. After HTTP 401, a client with an auth provider attempts credential refresh and retries once. See Authentication for credential setup.
Payload shape
The mars payload is optional. To publish without one, keep the imports and
client initialization from publish.py and use:
response = client.notify(
event_type="mars", identifier={"class": "rd", "step": 60}
)
print(response.status)
payload accepts JSON-compatible Python values, including nested dicts and
lists, strings, numbers, and booleans. None means no payload in notify.
The server schema determines whether a payload is required. Put extra details
in the payload, rather than inventing identifier fields. Identifiers must
match their handlers; arbitrary objects are not valid values for the mars
enum and integer fields.
Identifier shapes
Spatial examples need a different schema. The following publish assumes the
operator has installed the public server
observations point-cloud schema.
It requires date (DateHandler, format %Y%m%d), point_cloud
(PointCloudHandler, at most 10,000 points), and a payload. Inspect it with
client.schema_for("observations") before using this example.
Keep the imports and client initialization from publish.py:
response = client.notify(
event_type="observations",
identifier={
"date": "20260601",
"point_cloud": [[46.0, 8.0], [47.0, 9.0]],
},
payload={"source": "stations"},
)
print(response.status)
A point is [latitude, longitude]. Point clouds are lists of those pairs and
accept arrays only. Pass Python lists, not json.dumps(...) strings. Clouds
do not need a closing repeat; duplicate points are allowed and keep their
order.
For this schema, subscribers filter with date and polygon, not
point_cloud. A polygon is a list of at least four coordinate pairs, with the
first pair repeated last. Any cloud point inside or on the boundary matches.
See spatial filters.
The reserved point field is a watch/replay filter for polygon streams, not a
provider identifier. The
alternative coordinate format
applies to points and polygons, not clouds.
Admin operations
Operators can use wipe_stream(event_type) or wipe_all() to clear retained
notifications, and delete_notification(notification_id) to remove one by ID.
These are destructive operations requiring the appropriate permissions. A
publish response’s request_id is not the notification ID to delete. See
the API reference for the method signatures.
Async equivalent
If your application uses asyncio, the async client takes the same arguments:
import asyncio
import os
import pyaviso
async def main() -> None:
client = pyaviso.AsyncAvisoClient(
base_url=os.environ["AVISO_BASE_URL"], auth=pyaviso.Env()
)
response = await client.notify(
event_type="mars",
identifier={"class": "rd", "step": 72},
payload={"location": "file:///data/forecast-72.grib"},
)
print(response.status)
asyncio.run(main())
This uses the same mars schema and prints success on acceptance. See
Async for concurrent publishing and listening.
Listening
Receive notifications from data providers and use their labels to select what you need. You do not need to publish anything to listen.
Start with pyaviso installed. Set AVISO_BASE_URL and
credentials with permission to receive notifications, as in
Set the environment. The examples use
pyaviso.Env(): set AVISO_TOKEN, or unset it and set both AVISO_USERNAME
and AVISO_PASSWORD. For an anonymous server, pass
auth=pyaviso.Anonymous() instead; Env() requires credentials.
A complete listener
These examples use the same small mars schema as the
quickstart and
Publishing. It has a required class filter (od or rd) and
an optional whole-number step filter. If your server differs, use
schema discovery below to choose its event type
and fields.
Save this as listen.py:
import os
import pyaviso
client = pyaviso.AvisoClient(
base_url=os.environ["AVISO_BASE_URL"], auth=pyaviso.Env()
)
try:
with client.listen("mars", filter={"class": "od"}) as notifications:
for notification in notifications:
print(notification)
except KeyboardInterrupt:
print("Stopped listening")
print(notification) displays the original server message as indented JSON.
Run python listen.py in the terminal where you set the environment. It waits
for new mars notifications with class=od, at any step. Silence is normal
when nothing matches. For a local trial, start this listener first, then run
the publish script in another terminal
with the same environment. Press Ctrl+C to stop.
That publish produces a notification like this (your sequence, timestamp and server URLs will differ):
{
"data": {
"identifier": {
"class": "od",
"step": "12"
},
"payload": {
"location": "file:///data/forecast.grib"
}
},
"datacontenttype": "application/json",
"dataschema": "https://aviso.example/schema/mars",
"id": "mars@1",
"source": "https://aviso.example",
"specversion": "1.0",
"time": "2026-09-15T15:15:15.799578652Z",
"type": "int.ecmwf.aviso.mars"
}
notification.identifier holds the notification’s labels. The server normalizes
scalar labels, so the published integer step=12 arrives as the string "12".
notification.payload holds extra information, such as a file location;
receiving it does not download the file. It is None when absent.
notification.sequence
is a position used for replay, not a promise of strictly increasing delivery
order or a count of your matches.
notification.cloudevent contains the original server message shown above.
notification.as_dict() returns a convenience dictionary with event_type,
sequence, identifier, payload, and cloudevent. That outer dictionary is
created by the client; it is not the server’s wire format.
This script does not save a cursor to disk. Starting it again starts fresh at the live edge, rather than reading notifications published while it was off.
Check your server’s schema
Save this as discover.py and run python discover.py. It lists event types
and prints the schema response for mars. If mars is absent, replace it with
a listed name and run again. Discovery reads the operator’s configuration; it
does not install schemas or prove your access rights.
import json
import os
import pyaviso
client = pyaviso.AvisoClient(
base_url=os.environ["AVISO_BASE_URL"], auth=pyaviso.Env()
)
print(client.schema().event_types)
print(json.dumps(client.schema_for("mars").as_dict(), indent=2, sort_keys=True))
With only the small mars schema installed, the output is:
['mars']
{
"event_type": "mars",
"schema": {
"identifier": {
"class": {
"required": true,
"type": "EnumHandler",
"values": [
"od",
"rd"
]
},
"step": {
"range": null,
"required": false,
"type": "IntHandler"
}
},
"payload": {
"required": false
}
},
"status": "success"
}
EnumHandler means a choice from a list; IntHandler means a whole number.
Here step has no range limit. An identifier’s required: true means it must
appear in a listener filter. required: false lets you omit that filter field.
Providers still supply every identifier field, including step. The
payload is optional for this schema.
Filtering
filter= selects identifier labels, not payload contents. Start with the
required class, as in listen.py. To receive only step 12, replace its
try/except block with this, keeping the imports and client initialization:
with client.listen("mars", filter={"class": "od", "step": 12}) as notifications:
for notification in notifications:
print(notification.identifier)
Both fields must match. An rd notification or an od notification at step
24 is excluded. Use Python numbers and lists directly, rather than JSON-encoded
strings. The examples below also keep the imports and client from listen.py
unless they show their own setup. Live examples wait until you press Ctrl+C;
without the try/except, Python also prints a KeyboardInterrupt traceback.
Start from a specific position
Add start_from to read retained notifications before continuing with new ones.
An integer starts after that sequence; start_from=0 requests retained
history after sequence zero. A UTC date string such as
start_from="2026-06-01T00:00:00Z" selects publication time, not a date field
in the notification’s labels. To filter those labels, put date in filter= if
the schema supports it.
The choice depends on the Python value’s type, not its size or appearance:
start_from=20260915is an integer: start after sequence 20260915, even though the number looks like a date.start_from="2026-09-15T00:00:00Z"is a string: start from that publication time.
Do not quote sequence numbers. start_from="20260915" is sent as a time
string, not a sequence number. For time-based starts, use a full UTC timestamp
like the one above so your intent is clear.
Replay only
start_from=0 starts from the beginning of retained history.
mode="replay_only" finishes after that history instead of waiting for new
notifications:
with client.listen(
"mars",
filter={"class": "od"},
start_from=0,
mode="replay_only",
) as notifications:
for notification in notifications:
print(notification)
Each notification is printed as indented JSON, as in the listener above.
An empty matching history prints nothing. Replay ends at the history boundary
captured at the start, when the server signals replay_completed. It does not
wait for new publications. Retention may have removed older records. If the
server truncates replay at its cap, the client raises pyaviso.HistoryGapError;
a failed run is not proof of full catch-up. See
historical replay limits.
Stop at an end point
until ends the replay at an end point instead of at the last stored
notification. An integer is the last sequence to deliver, inclusive; a UTC
date string ends with the last notification published at or before that time.
until implies mode="replay_only":
with client.listen(
"mars",
filter={"class": "od"},
start_from="2026-06-01T00:00:00Z",
until="2026-06-02T00:00:00Z",
) as notifications:
for notification in notifications:
print(notification.sequence, notification.identifier)
The script prints the matching notifications published from midnight UTC on 1
June 2026 up to and including midnight UTC on 2 June, then exits. Both dates are
inclusive. An integer start is exclusive and an integer end inclusive:
start_from=10, until=20 delivers the matching notifications with sequences 11
to 20. A replay also stops at the last notification stored when it starts, so an
end point in the future ends there. Like start_from, a date refers to
publication time, and the value’s type selects its meaning.
An integer until that is not greater than an integer start_from raises
pyaviso.ConfigError, since the replay would deliver nothing. If the connection
drops during the replay, the client resumes up to the same end point. An end
point needs aviso-server 0.13.0 or later; an older server rejects the request
with pyaviso.HttpError status 400.
Errors can arise when opening or iterating the listener. For example, a rejected
filter can raise pyaviso.HttpError; inspect its status and body.
Connection losses and retryable server failures normally trigger reconnection.
A quiet listener is not itself an error. See
error types and
the API reference.
Numeric and enum constraints
After installing the schema and publishing the five seeds in the
weather tutorial, point
AVISO_BASE_URL at that test server. This replay prints B and C, then ends:
with client.listen(
"weather",
filter={
"date": "20260913",
"severity": {"gte": 5},
"anomaly": {"between": [40, 50]},
"region": {"in": ["north", "south"]},
},
start_from=0,
mode="replay_only",
) as notifications:
for notification in notifications:
print(notification.payload["id"])
gte includes 5; between includes both endpoints; in accepts either listed
region. These dicts are identifier predicates, not payload queries. See the
shared constraint rules.
Spatial filters
Spatial examples need different schemas. The server’s public
test_polygon example
requires a polygon filter. Its date (DateHandler, %Y%m%d) and time
(TimeHandler) are optional in subscriber filters. Providers supply all three
identifiers and a required payload. Inspect it with
client.schema_for("test_polygon") before using this replacement listener:
with client.listen(
"test_polygon",
filter={"polygon": [[0, 0], [1, 0], [1, 1], [0, 0]], "date": "20260601"},
) as notifications:
for notification in notifications:
print(notification.identifier)
Coordinates are [latitude, longitude]. A polygon needs at least four pairs,
with the first pair repeated last. Pass nested Python lists, not an encoded
string. Spatial identifiers arrive as arrays rather than normalized strings.
The public
observations schema
works differently: providers send a required date, a point_cloud array, and
a payload. Subscribers filter with date and a closed polygon, not
point_cloud. Any cloud point inside or on the polygon boundary matches. See
Publishing: identifier shapes for the paired
provider example.
Resume across restarts
To save a resume position, replace the client initialization in listen.py
with this block. Keep its imports and try/except listener:
from pathlib import Path
state_path = Path.home() / ".config" / "aviso" / "state.json"
state_path.parent.mkdir(parents=True, exist_ok=True)
client = pyaviso.AvisoClient(
base_url=os.environ["AVISO_BASE_URL"],
auth=pyaviso.Env(),
state_store=pyaviso.JsonFileStore(state_path),
)
Use a local filesystem. With no saved cursor, the first run starts at the live edge. Later runs with the same server, schema and filter resume after the saved position. The client commits a pending sequence before sending the next notification to the iterator’s buffer. This is not an acknowledgement that your loop finished its work. Make repeated handling safe; at-least-once redelivery depends on retained history and a usable saved cursor, and does not guarantee completion of application work. See State and resume for resume keys and storage details.
Clean shutdown with with
Exiting with calls the synchronous iterator’s close(), cancelling and
waiting for its background task to finish. This also happens after a loop body
raises or breaks. A break alone does not close an iterator you still hold;
leave the context or call close() explicitly.
flush_cursor_on_exit defaults to False, leaving the last pending sequence
uncommitted when no later notification arrives. With a state store, setting
flush_cursor_on_exit=True on the client attempts to save that pending cursor
during shutdown. Context exit waits for the attempt, but a storage failure can
still prevent it. Flushing does not certify that your application processed
every buffered notification. Without a saved cursor, a fresh run starts live.
Reusing a watch request
For reusable listener settings, use WatchRequest and pass it as request=.
It cannot be combined with the event/filter/start/until/mode/trigger arguments
used above. See Builder pattern for complete examples and
Triggers for actions attached to a listener.
Multiple listeners
To receive notifications for several filters or event types through one loop, see Multiple listeners.
Async equivalent
For an application using asyncio, use AsyncAvisoClient, async with, and
async for. Async context exit awaits aclose(), rather than the synchronous
close(). With the default asyncio.run() signal handler, Ctrl+C cancels the
main task first, allowing context cleanup; KeyboardInterrupt is then raised
outside asyncio.run(). See Async for complete listener examples.
Multiple listeners
client.listen_many() opens several listeners and delivers their
notifications through a single loop. Each listener has a name, an event type
and, optionally, a filter; listeners may use different event types. Each item
the loop yields is a pair of the listener name and the notification:
for name, notification in notifications:
...
The same method is available on AvisoClient and AsyncAvisoClient. It does
not require threads or an asyncio event loop of the caller’s own.
When to use it
When the notifications of interest differ only in the value of one
identifier key, a single listener is sufficient. The in operator accepts
several values, and the loop can distinguish them:
with client.listen("mars", filter={"class": {"in": ["od", "rd"]}}) as notifications:
for notification in notifications:
if notification.identifier["class"] == "od":
...
Use listen_many when the listeners need different identifier keys,
different event types, different triggers or different start positions.
Example
The example uses the
quickstart environment and its mars
schema: class is a required choice of od or rd, and step is an
optional integer.
Save the following as listen_many.py and run python listen_many.py:
import pyaviso
client = pyaviso.AvisoClient()
listeners = {
"operational": {"event_type": "mars", "filter": {"class": "od"}},
"research": {"event_type": "mars", "filter": {"class": "rd", "step": 0}},
}
try:
with client.listen_many(listeners) as notifications:
for name, notification in notifications:
print(name, notification.identifier)
except KeyboardInterrupt:
print("Stopped listening")
The dictionary maps each listener name to the keyword arguments listen()
accepts: event_type, filter, start_from, until, mode and triggers.
The script waits for new notifications. With three notifications published from
another terminal (class=od at step 12, class=rd at step 6, and class=rd at
step 0), the output is:
operational {'class': 'od', 'step': '12'}
research {'class': 'rd', 'step': '0'}
The notification with class=rd at step 6 matches neither listener. Press
Ctrl+C to stop. Leaving the with block closes every listener.
Listeners for different event types
Each listener has its own event type. On a server that also defines an
alerts event type, with a region of north or south:
listeners = {
"forecasts": {"event_type": "mars", "filter": {"class": "od"}},
"alerts": {"event_type": "alerts", "filter": {"region": "north"}},
}
with client.listen_many(listeners) as notifications:
for name, notification in notifications:
print(name, notification.event_type, notification.identifier)
forecasts mars {'class': 'od', 'step': '12'}
alerts alerts {'region': 'north'}
Each event type defines its own filter keys, and the credential in use must grant read access to every event type listed.
Function triggers per listener
To process every notification with application code, without writing the
loop, give each listener a Trigger.function and call run():
from functools import partial
import pyaviso
from pyaviso import Trigger
def save(notification, folder):
print("save", notification.identifier, "to", folder)
client = pyaviso.AvisoClient()
listeners = {
"operational": {
"event_type": "mars",
"filter": {"class": "od"},
"triggers": [Trigger.function(partial(save, folder="/data/od"))],
},
"research": {
"event_type": "mars",
"filter": {"class": "rd"},
"triggers": [Trigger.function(partial(save, folder="/data/rd"))],
},
}
try:
client.listen_many(listeners).run()
except KeyboardInterrupt:
print("Stopped listening")
With the same three notifications published, the output is:
save {'class': 'od', 'step': '12'} to /data/od
save {'class': 'rd', 'step': '6'} to /data/rd
save {'class': 'rd', 'step': '0'} to /data/rd
The function receives the notification as its only argument. Bind any
further arguments with functools.partial, as above, or with a lambda.
run() processes notifications until every listener has ended or the
process is interrupted, then closes all listeners.
Functions run in the calling thread, one notification at a time, in arrival
order. They therefore need no synchronisation and may call blocking code. A
slow function delays every listener; no notification is lost, because the
client reads from the server more slowly. See
Triggers for retries, required and the
interaction with built-in triggers, which always run first.
Shared options
start_from, until and mode passed to listen_many apply to every
listener that does not set its own. The following replays the retained history
and then returns:
listeners = {
"operational": {"event_type": "mars", "filter": {"class": "od"}},
"alerts": {"event_type": "alerts", "filter": {"region": "north"}},
}
with client.listen_many(listeners, start_from=0, mode="replay_only") as notifications:
for name, notification in notifications:
print(name, notification.sequence, notification.identifier)
print("replay finished")
After the preceding examples, one possible output is:
alerts 1 {'region': 'north'}
operational 1 {'class': 'od', 'step': '12'}
operational 4 {'class': 'od', 'step': '12'}
operational 5 {'class': 'od', 'step': '12'}
replay finished
Sequence numbers are per event type: mars sequences 1, 4 and 5 are the
od notifications published for the preceding examples, and alerts
sequence 1 is the north alert. The loop ends when every listener has
ended; a listener that ends earlier stops contributing.
A listener may also be given as a WatchRequest
instead of a dictionary. A WatchRequest carries its own start position, so
shared options do not apply to it.
Error handling
A listener can fail, for example because of a misspelt filter key, a missing
read permission, or a replay that exceeds the server’s limit. A
Trigger.function can raise an exception. The on_error argument defines
the response:
on_error | A listener fails | A required function raises |
|---|---|---|
"raise" (default) | Every listener is closed and the error is raised. | Same as for a listener. |
"continue" | That listener stops; the others continue. | That notification is skipped; the listener continues. |
| A function | It is called with (name, error); the others continue unless it raises. | Same as for a listener. |
Functions are required by default. A function created with
required=False does not reach on_error: its failure is logged, and the
notification is still delivered.
With "raise", the error keeps its type, its message begins with the
listener name, and the listener attribute holds the name:
try:
client.listen_many(listeners).run()
except pyaviso.HttpError as error:
print(error.listener, error.status)
With "continue", each failure is logged as a warning and recorded in
errors. In the following example the second listener misspells class, so
the server rejects it for omitting a required key:
listeners = {
"operational": {"event_type": "mars", "filter": {"class": "od"}},
"typo": {"event_type": "mars", "filter": {"clas": "rd"}},
}
try:
with client.listen_many(listeners, on_error="continue") as notifications:
for name, notification in notifications:
print(name, notification.identifier)
except KeyboardInterrupt:
print("Stopped listening")
for failure in notifications.errors:
print(failure.listener, failure.kind.value, type(failure.error).__name__)
listener 'typo': http 400 (...): {..."Required field 'class' missing for watch operation"...} (listener stopped; the other listeners continue)
operational {'class': 'od', 'step': '12'}
Stopped listening
typo listener HttpError
failure.kind is a pyaviso.ListenFailureKind: LISTENER when a listener
stopped and TRIGGER when a function raised. Being a str enum, it also
compares equal to "listener" and "trigger". The warnings are logged on the
pyaviso.listen logger, so they can be routed or silenced with the standard
logging configuration.
When on_error is a function, it is responsible for reporting, and no warning
is logged:
def report(name, error):
print(f"{name} failed: {error}")
if isinstance(error, pyaviso.AuthError):
raise error # stop every listener
client.listen_many(listeners, on_error=report).run()
With "continue" or a function, if every listener fails, the loop raises
pyaviso.AvisoError, whose failures attribute lists each failure. A script
therefore cannot finish silently when every listener has failed. After the
loop has stopped, for any reason, further iteration ends immediately.
Shared start_from, until and mode values are checked even when every
listener sets its own.
Arguments are validated before any listener opens. An unknown key such as
filtre, a missing event_type or an invalid on_error raises immediately,
and no listener is left running.
Asynchronous client
On AsyncAvisoClient, the same method returns an asynchronous iterator.
Functions may be defined with async def; they are awaited:
import asyncio
import pyaviso
from pyaviso import Trigger
async def save(notification):
await asyncio.sleep(0.1) # stands in for an asynchronous upload or write
print("saved", notification.identifier)
async def main():
client = pyaviso.AsyncAvisoClient()
listeners = {
"operational": {
"event_type": "mars",
"filter": {"class": "od"},
"triggers": [Trigger.function(save)],
},
"alerts": {"event_type": "alerts", "filter": {"region": "north"}},
}
async with client.listen_many(listeners) as notifications:
async for name, notification in notifications:
print(name, notification.identifier)
try:
asyncio.run(main())
except KeyboardInterrupt:
print("Stopped listening")
saved {'class': 'od', 'step': '12'}
operational {'class': 'od', 'step': '12'}
alerts {'region': 'north'}
await notifications.run() replaces the loop. An on_error function may
also be defined with async def.
Behaviour
- Order. Within one listener, notifications are delivered in the same
order as with
listen(). Across listeners the order is not defined: listeners are read in turn, so a busy listener cannot delay a quiet one. - Saved positions. With a
state_store, each listener keeps its own position, the same positionlisten()would use for that event type and filter. The listener name is not part of it, so renaming a listener keeps its position. Two listeners with the same event type and filter share a position, and the client logs a warning when this occurs. - Resources. A listener is not a thread. All listeners run on the client’s existing background threads and share its connections.
Triggers
A trigger runs an action automatically for each matching notification, such as
appending to a file or sending an HTTP request. Add triggers to
client.listen() to run them before Python receives the notification in your
loop. To run your own Python analysis in the loop, you do not need a trigger.
Triggers run in your client process. They do not keep listening or start new actions after your Python program exits.
A complete listener with triggers
Use the installation and environment setup from the
quickstart, including AVISO_BASE_URL
and credentials for pyaviso.Env(). For an anonymous server, pass
auth=pyaviso.Anonymous() instead.
This uses the quickstart’s mars schema: class is a required choice of od
or rd; step is an integer, optional in filters. Leaving it out selects all
steps. Providers must supply both identifier fields when publishing. The
optional payload can carry a file location. Check
your server’s schema if it differs.
Save this as listen.py and run python listen.py in that terminal:
import os
import pyaviso
from pyaviso import Trigger
client = pyaviso.AvisoClient(
base_url=os.environ["AVISO_BASE_URL"], auth=pyaviso.Env()
)
with client.listen(
"mars",
filter={"class": "od"},
triggers=[Trigger.log('mars.log')],
) as notifications:
for notification in notifications:
print(notification)
The script waits for new mars notifications with class=od. For each one, it
appends a JSON line to mars.log in your current directory, then prints the
notification. A notification with class=rd causes neither action. Press Ctrl+C
to stop; the with block closes the listener.
Run this from a directory you can write to. If the log file cannot be written, the listener stops with an error by default.
The two outputs contain different views of the same notification.
print(notification) shows the original CloudEvent as indented JSON, including
its specversion, type and data. The log contains a smaller JSON object on
one line, with fields such as event_type, sequence, identifier and
payload. A payload such as {"location": "file:///data/forecast.grib"} is a
reference to a file; the log trigger does not copy that file.
When to use which
| You want to | Use | Details |
|---|---|---|
| Print notifications automatically | Trigger.echo() | Echo guide |
| Append notifications to a file | Trigger.log() | Log guide |
| Run a shell command | Trigger.command() | Command guide |
| Send an HTTP request with your chosen body | Trigger.webhook() | Webhook guide |
| Send a card to a Teams workflow webhook | Trigger.teams() | Teams guide |
| Forward the original CloudEvent | Trigger.post() | Post guide |
| Run your own Python function | Trigger.function() | Below |
The six kinds
Each fragment below replaces the triggers=[...], line in listen.py. Keep the
imports, client setup and loop. Run python listen.py again after each change.
For HTTP examples, set the named environment variable to a receiver URL you
control before running the script.
Echo
Print each matching notification, using the same smaller view as the log trigger:
triggers=[Trigger.echo()],
In a terminal, echo prints a heading and indented JSON. When redirected or piped, it prints one compact JSON line. The loop still prints the CloudEvent afterwards, so you see both views.
Log
To keep a log and also echo each notification, put both actions in the list:
triggers=[Trigger.log("mars.log"), Trigger.echo()],
Actions run in list order before the loop receives that notification. The log file opens on the first matching notification and appends to existing content.
Command (Unix only)
Append each forecast step to steps.txt in your current directory:
triggers=[
Trigger.command('printf "%s\\n" "$AVISO_IDENTIFIER_STEP" >> steps.txt')
],
This runs through /bin/sh -c. The client automatically supplies
AVISO_IDENTIFIER_CLASS and AVISO_IDENTIFIER_STEP for this schema, plus
AVISO_EVENT_TYPE, AVISO_SEQUENCE, AVISO_PAYLOAD_JSON and
AVISO_NOTIFICATION_JSON. Quote shell variable expansions as above. Prefer
these variables to inserting notification text into shell source with templates.
Command stdout is captured, so write to a file when you want to keep the output.
On non-Unix builds, the constructor raises pyaviso.ConfigError.
Webhook
Set FORECAST_WEBHOOK_URL to your HTTP receiver’s URL. Send it a JSON body with
the forecast step:
triggers=[
Trigger.webhook(
os.environ["FORECAST_WEBHOOK_URL"],
body_template='{"step": {{ notification.identifier.step }}}',
)
],
The default method is POST. Here the template writes step as a JSON number.
Without body_template, the body is the smaller notification view used by log
and echo. URL, header values and body templates can read notification fields
and environment variables. See the
template guide for syntax and escaping rules.
Teams
Set FORECAST_TEAMS_URL to your Teams workflow webhook URL:
triggers=[Trigger.teams(os.environ["FORECAST_TEAMS_URL"])],
The client builds an Adaptive Card from the notification and sends it by HTTP POST. Configure the receiving workflow as described in the Teams guide.
Post
Set FORECAST_COLLECTOR_URL to your CloudEvent receiver’s URL:
triggers=[Trigger.post(os.environ["FORECAST_COLLECTOR_URL"])],
This sends the original server CloudEvent as JSON by HTTP POST. It preserves the
envelope fields shown by print(notification), including CloudEvent extensions.
Use it when the receiver needs those fields rather than the smaller default
webhook body.
Function
Trigger.function(func) calls a Python function of yours with each
notification. It is the way to run your own code without writing the loop:
run() reads the stream to the end and closes it.
import pyaviso
from pyaviso import Trigger
def save(notification):
print("save", notification.identifier)
client = pyaviso.AvisoClient()
try:
client.listen(
"mars",
filter={"class": "od"},
triggers=[Trigger.log("mars.log"), Trigger.function(save)],
).run()
except KeyboardInterrupt:
print("Stopped listening")
When an od notification is published, a line is appended to mars.log and
the script prints:
save {'class': 'od', 'step': '12'}
A function trigger differs from the six built-in kinds in where it runs. The
built-in kinds run inside the library, before the notification reaches
Python. A function runs in the application, in the thread that reads the
notification (or on its event loop with AsyncAvisoClient), when the
notification reaches it. Consequently:
- The built-in triggers for a notification always run before its functions, regardless of their order in the list.
- Functions run one notification at a time, in arrival order. They need no
synchronisation and may call blocking code. A slow function delays the
listener; no notification is lost, because the client reads from the server
more slowly. On
AsyncAvisoClient, a blocking call in a function blocks the event loop, and with it every listener; useasync defand awaitable calls there. - With
AsyncAvisoClient, a function may be defined withasync def; it is awaited.AvisoClient.listen()rejects such a function with aTypeErrorwhen called, because nothing would await it. - The notification has already been received when a function runs. Unlike a failed built-in trigger, a failed function therefore never holds back the saved position, whether or not the loop continues: the notification may already be recorded as received, and is then not delivered again after a restart. Record completed work separately when every notification must be processed.
The function receives the notification as its only argument. Bind further
arguments with functools.partial(save, folder="/data") or a lambda.
Several functions run in the order listed.
To use a different function for each of several listeners, see Multiple listeners.
Tunables
By default, every trigger is required and has no retries. If a required action
fails, listening stops with pyaviso.TriggerError; that notification does not
reach the Python loop. Earlier actions are not undone. Set required=False for
an action whose failure should emit a warning and let later actions and the
Python loop continue.
For example, replace the trigger list with this optional webhook, allowing two extra attempts and a five-second timeout per request:
triggers=[
Trigger.webhook(
os.environ["FORECAST_WEBHOOK_URL"],
retries=2,
required=False,
timeout=5.0,
)
],
retries=0means one attempt.retries=2allows up to three attempts.fail_fast=Trueis the default for command and HTTP triggers. A non-zero command exit, an HTTP 4xx response, an invalid request or a template error fails immediately, bypassing retries. HTTP 5xx responses, connection errors and timeouts can retry. Setfail_fast=Falseto retry those immediate failures too, within the configured retry count.- HTTP triggers (
webhook,teams,post) default to a 30-second timeout per request. Commands have no timeout by default; usetimeoutto set one in seconds. Echo and log do not accept a timeout keyword. Their.timeout()setter is ignored, as is.fail_fast(). Trigger.functiontakesretries,requiredandlabel. A function that raises counts as a failure: it is called again up toretriestimes, then a required one raisesTriggerError(withtrigger_kind == "function"and the original exception as its__cause__) and the functions after it are not called for that notification; an optional one is logged and skipped.timeoutandfail_fastdo not apply to functions and raiseValueError: Python code cannot be interrupted safely.- A plain function that returns an awaitable is a programming error, not a
failure of one notification: it raises
TypeError, whateverrequiredandon_errorsay.
The builder pattern covers chainable setters and reusing triggers. See error handling for catching client errors.
With AsyncAvisoClient
Pass the same triggers=[...] list to AsyncAvisoClient.listen(). Actions
still run before the notification reaches your async for loop, and
Trigger.function may be given an async def function, which is awaited. Use
await iterator.run() in place of .run(). See Async for a
complete listener.
Builder pattern
Start with the keyword arguments on Listening. Use a
WatchRequest when you want to name a set of listener settings and reuse it.
A builder is simply a series of calls that returns those settings. There is no
final .build() call.
These examples use the
quickstart’s small mars schema:
class is a required filter with choices od and rd; the whole-number step
filter is optional. Providers supply both fields when publishing. The payload
is optional. Check your server’s schema if it differs.
Build a watch request
This runs without a server. Save it as build_request.py and run
python build_request.py:
import pyaviso
request = pyaviso.WatchRequest.watch("mars").with_filter({"class": "od"})
print(request.event_type, request.mode)
Output:
mars watch
The request describes a listener; constructing it does not connect to a server.
Pass it to client.listen(request=request), as below.
A complete example
Use the installation and
environment setup
from the quickstart, including AVISO_BASE_URL and credentials for
pyaviso.Env(). For an anonymous server, pass auth=pyaviso.Anonymous()
instead. Save this as listen_builder.py and run
python listen_builder.py:
import os
import pyaviso
client = pyaviso.AvisoClient(
base_url=os.environ["AVISO_BASE_URL"], auth=pyaviso.Env()
)
request = pyaviso.WatchRequest.watch("mars").with_filter({"class": "od"})
try:
with client.listen(request=request) as notifications:
for notification in notifications:
print(notification)
except KeyboardInterrupt:
print("Stopped listening")
It waits for new operational forecasts at any step and prints each original CloudEvent as indented JSON, as shown in Listening. Press Ctrl+C to stop. You do not need publishing permission to listen. For a local trial with provider credentials, run the publish script in another terminal.
request= cannot be combined with event_type, filter, start_from,
until, mode or triggers. Put those settings on the request instead.
Replay history and exit
In listen_builder.py, replace the request = ... line with this block. Keep
the imports, client initialization and loop:
request = pyaviso.WatchRequest.replay_only("mars", 0).with_filter({"class": "od"})
Run the script again. It reads matching retained history, prints each notification and exits at the server’s replay boundary. Empty history prints nothing. It does not publish anything or wait for new notifications.
An integer start position is exclusive: 0 means everything retained after
sequence zero, not every notification ever published. To read after sequence
1024 and then keep listening, use WatchRequest.watch_from("mars", 1024).
Both start-position factories also accept a UTC timestamp string such as
"2026-06-01T00:00:00Z". replay_only also takes an end point:
WatchRequest.replay_only("mars", 0, until=100) ends with sequence 100. See
start positions
and State and resume.
Build a trigger
You can add triggers to run actions before a notification reaches your loop. Their factories accept keyword arguments. Chainable setters let you adjust an existing trigger. This standalone example needs no server:
import pyaviso
trigger = pyaviso.Trigger.echo().retries(2).required(False)
request = (
pyaviso.WatchRequest.watch("mars")
.with_filter({"class": "od"})
.with_triggers([trigger])
)
print(request.event_type, request.mode)
It prints mars watch. Passing this request to a listener adds an optional
echo action with up to two extra attempts. Echo prints a smaller notification
view; print(notification) in your loop prints the original CloudEvent.
The setters are .retries(n), .required(on), .timeout(seconds),
.fail_fast(on) and .label(name). Timeout and fail-fast settings affect
command and HTTP triggers; label affects only echo. See
Triggers for failure behavior and
the API reference for factory defaults.
Setters return a new value
Keep the result of a setter: it returns a new value and does not change the original. This standalone example builds two independent filters:
import pyaviso
base = pyaviso.WatchRequest.watch("mars")
operational = base.with_filter({"class": "od"})
research = base.with_filter({"class": "rd"})
print(operational.event_type, research.event_type)
It prints mars mars. A call such as base.with_filter({"class": "od"}) whose
result you discard leaves base unchanged.
When to use the builder
Use keywords for a single listen call. Use a request for settings you want to
reuse or derive from a common base. Trigger and WatchRequest work with both
clients; they do not belong to a particular client instance. See
Async if your application already uses asyncio.
Authentication
Ask your server operator for its URL and credentials. Users need permission to receive notifications; providers need permission to publish. An auth provider tells the client where to get credentials. It does not grant permissions.
Let the client find your credentials
If you create a client without an auth argument, it looks for a credential
in three places and uses the first one it finds:
- The environment:
AVISO_TOKEN, orAVISO_USERNAMEwithAVISO_PASSWORD. - The
auth:block of~/.config/aviso/config.yaml. ~/.config/aviso/credentials.yaml.
import pyaviso
client = pyaviso.AvisoClient()
print(client.schema().event_types)
print(client.config.auth)
The address is found the same way, from AVISO_BASE_URL or the config file;
Configuration has the full order and how to see what
was chosen. client.config.auth names the credential’s kind and source
without its value.
This suits a script or notebook whose credentials were set up beforehand by something else. Nothing in the code names a credential, so the same file runs for a colleague whose token lives somewhere different.
The two file paths can be moved with AVISO_CLIENT_CONFIG_FILE and
AVISO_CREDENTIALS_FILE.
Finding nothing leaves the client anonymous. Finding a file that cannot be read
raises pyaviso.ConfigError, and a half-set environment (a username with no
password) raises pyaviso.AuthError, so a mistake is reported rather than
quietly ignored.
A credential found this way is not sent to a plain http:// address unless it
is loopback, and the client raises pyaviso.AuthError instead. You did not
name the credential in the code, so a mistyped host would otherwise send it in
the clear. Naming it yourself lifts the restriction:
client = pyaviso.AvisoClient(
base_url="http://aviso.internal.example.org",
auth=pyaviso.Bearer(os.environ["AVISO_TOKEN"]),
)
To send no credential even though one is present on the machine, pass the
Anonymous marker:
client = pyaviso.AvisoClient(
base_url=os.environ["AVISO_BASE_URL"], auth=pyaviso.Anonymous()
)
Read a particular config file
AvisoClient() already reads ~/.config/aviso/config.yaml. To read a
different file instead, name it; that path must exist:
client = pyaviso.AvisoClient.from_file("/etc/aviso/production.yaml")
Keyword arguments replace what the file said, and auth=pyaviso.Anonymous()
drops a credential the search found. A file that cannot be read, or that has a
mistyped key inside a section the client reads, raises pyaviso.ConfigError.
Relative paths under tls.ca_bundle are resolved against the file’s own
directory, not the working directory.
The plaintext rule above still applies: a credential the file supplied is
refused for a plain http:// address that is not loopback, whether that
address came from the file or from a base_url argument. Pass auth yourself
to lift it.
AsyncAvisoClient.from_file takes the same arguments.
The rest of this page covers naming a source yourself, which is what you want when one script must use particular credentials.
Environment
Start with pyaviso installed and the
quickstart environment. Set
AVISO_BASE_URL and AVISO_TOKEN, or unset the token and set both
AVISO_USERNAME and AVISO_PASSWORD.
Save this as check_auth.py and run python check_auth.py:
import os
import pyaviso
client = pyaviso.AvisoClient(
base_url=os.environ["AVISO_BASE_URL"], auth=pyaviso.Env()
)
print(client.schema().event_types)
It prints the server’s configured event types, for example ['mars'] for the
quickstart schema. Schema discovery
does not prove you can listen or publish: those operations have their own
permission checks, and discovery may be public.
Env() reads credentials when constructed:
- A non-empty
AVISO_TOKENtakes precedence over username and password. - Otherwise it uses a non-empty
AVISO_USERNAMEwithAVISO_PASSWORDset. The password may be an empty string if your service permits that. - Without either combination, construction raises
pyaviso.AuthError.
Env() has no anonymous fallback of its own. For an anonymous server, pass
auth=pyaviso.Anonymous(); omitting auth starts the search described above.
Updating environment variables does not change an existing Env provider;
create it again or restart the script.
Bearer token
To select a token explicitly, replace the client initialization in
check_auth.py with this block. Keep the imports and schema call:
client = pyaviso.AvisoClient(
base_url=os.environ["AVISO_BASE_URL"],
auth=pyaviso.Bearer(os.environ["AVISO_TOKEN"]),
)
This sends the token in the HTTP Authorization header. Bearer redacts it in
repr(). Keep credentials out of source files and do not print them.
Basic auth
With AVISO_USERNAME and AVISO_PASSWORD set, use this replacement instead:
client = pyaviso.AvisoClient(
base_url=os.environ["AVISO_BASE_URL"],
auth=pyaviso.Basic(os.environ["AVISO_USERNAME"], os.environ["AVISO_PASSWORD"]),
)
This explicitly selects username/password authentication even if AVISO_TOKEN
is also set. Basic authentication encodes credentials; HTTPS protects them in
transit. Basic redacts its password in repr().
Config file
ConfigFile reads an auth-only YAML file, not the CLI’s general
configuration file. Create ~/.config/aviso/auth.yaml with exactly one of the
following shapes, replacing the example values with your credentials:
bearer:
token: your-bearer-token
Or, for Basic authentication:
basic:
username: your-username
password: your-password
Restrict file access to the account running your script. Keep the URL in the
client initialization, not this file. Unknown fields, both sections, neither
section, an unreadable file or malformed YAML raise pyaviso.ConfigError at
construction.
Use this replacement client initialization in check_auth.py:
client = pyaviso.AvisoClient(
base_url=os.environ["AVISO_BASE_URL"],
auth=pyaviso.ConfigFile("~/.config/aviso/auth.yaml"),
)
The path accepts a string or Path; ~ is expanded. The file is read at
construction and reread when the client requests a credential refresh after
HTTP 401. A file change alone does not immediately replace the cached value.
Refresh on 401
With an auth provider, the client attempts refresh after a 401 and retries the
request once if refresh succeeds. A second 401 is still an error: HttpError
for a one-shot request, or AuthError when watch authentication remains
rejected.
Bearer,BasicandEnvkeep their original credentials; refresh does not change them.ConfigFilerereads its source file. An invalid replacement file raisesAuthErrorduring refresh. A credential found at~/.config/aviso/credentials.yamlis read this way, so a token rewritten in place is picked up without restarting the script.Chainrefreshes the first member currently able to produce a header.
For static credentials, fix the source and construct a new provider/client or restart the script. Inspect HTTP errors to distinguish a server rejection from a local credential setup failure.
Chain
This is an advanced option for already-constructed providers. Chain asks each
member for an authorization header in order and uses the first successful
result. If all fail, it propagates the last provider error. An empty chain
raises AuthError when a request needs credentials.
It is not a list of credentials to try against a server. A 401 does not switch
to the next member. Env() and ConfigFile(...) are constructed before being
passed to Chain; a missing environment or invalid file raises immediately,
before the chain can provide fallback. Choose and validate your available
credential source during setup rather than relying on a chain to skip those
construction errors.
With AsyncAvisoClient
The same providers work with the async client. Credential setup remains
synchronous; HTTP methods are awaited. See Async if your
application already uses asyncio.
State and resume
A state store saves a cursor, the sequence position used to resume a listener. This helps a restarted script read retained notifications published while it was stopped. It does not record whether your Python analysis finished.
A saved cursor is not an acknowledgement of completed work. The client can save progress while notifications are still waiting in the iterator’s buffer. A crash can therefore leave application work unfinished even for a saved sequence. Make repeated processing safe and track completed work separately when it matters. The cursor alone gives neither lossless processing nor exactly-once execution.
A complete resuming listener
Use the quickstart environment, including
AVISO_BASE_URL and credentials for pyaviso.Env(). For an anonymous server,
pass auth=pyaviso.Anonymous() instead.
This uses the small mars schema:
class is a required choice of od or rd; step is a whole number optional
in filters. Omitting it receives all steps. Providers supply both identifiers;
the payload is optional. You only need receiving permission for this script.
Save this as listen_saved.py and run python listen_saved.py:
import os
from pathlib import Path
import pyaviso
state_path = Path.home() / ".config" / "aviso" / "state.json"
state_path.parent.mkdir(parents=True, exist_ok=True)
client = pyaviso.AvisoClient(
base_url=os.environ["AVISO_BASE_URL"],
auth=pyaviso.Env(),
state_store=pyaviso.JsonFileStore(state_path),
)
try:
with client.listen("mars", filter={"class": "od"}) as notifications:
for notification in notifications:
print(notification)
except KeyboardInterrupt:
print("Stopped listening")
It prints each original CloudEvent as indented JSON. Press Ctrl+C to stop. With no saved cursor, the first run waits for new notifications. Later runs with the same settings resume after the saved sequence, if one exists. The last printed notification may be delivered again. If nothing was saved, restarting begins at the live edge, so do not assume a one-notification first run has saved progress.
For a local trial, start this listener, then run the provider publish script in another terminal. Publish several different steps, stop the listener, publish another step, then restart it. What remains available depends on server retention.
Where state lives
- With
state_store=None(the default), there is no saved state across runs. MemoryStore()holds checkpoints in memory for clients using that store instance. They disappear when the process exits.JsonFileStore(path)writes checkpoints to a local JSON file, using an atomic rename and a sidecar lockfile for cooperating writers.
The example creates the parent directory first. Choose a local path your user
can write to, including permission to create the lockfile and replace the state
file. Relative paths are relative to the working directory. ~ is expanded.
Keep the state file if you want to resume; deleting it discards that position.
Local filesystems only
Use local storage rather than NFS or CIFS for JsonFileStore. File locking does
not make several listeners a work-sharing queue: they can receive the same
notifications. The Python API accepts the two built-in stores, not arbitrary
custom Python store objects.
How it works
The Python client derives a resume key from the server URL, event type and filter. Changing one of these can select a different key with no saved cursor. A server schema change alone does not change the key.
For each notification, the background listener:
- Saves the previous pending sequence, if any.
- Runs this notification’s triggers.
- Sends it to the iterator’s buffer.
- Records its sequence as pending if it advances the current position.
These steps do not wait for your Python loop to finish processing the previous notification. A required trigger failure prevents the failing notification from becoming pending, but the previous position may already have been saved. Checkpoints do not move backwards within a watch session; out-of-order notifications can still reach triggers and your loop.
Redelivery depends on retained history and a usable cursor. A sequence is a resume boundary, not a separate acknowledgement for every event. See Listening for retention and replay limits.
Choose a starting position
An explicit start_from takes precedence over saved state. An integer is
exclusive: start_from=1024 reads after sequence 1024; start_from=0 requests
all retained history after zero. A UTC timestamp such as
start_from="2026-06-01T00:00:00Z" selects publication time, not identifier
labels. Subsequent checkpoints use sequences.
Keep sequence numbers unquoted. See Start from a specific position for how Python distinguishes sequence numbers from times.
With start_from=None, a matching saved cursor is used if available; otherwise
listening starts live. To deliberately start fresh without affecting existing
state, construct a client without that state store. Do not delete your state
file just to investigate a problem.
Flush on exit
Keep the default flush_cursor_on_exit=False for a processing script unless you
have a reason to advance the final pending position during shutdown. With the
default, the last pending sequence is not saved just because the iterator
closes. It may be replayed on restart, provided a usable starting position and
retained record exist. This does not protect all unfinished buffered work:
earlier checkpoints can already be ahead of your processing.
Setting flush_cursor_on_exit=True on the client attempts to save the last
pending cursor on shutdown. This can reduce final-notification repeats for a
display-only listener. It can also skip unfinished application work on restart,
including after your loop raises an exception. A failed storage write can
prevent the flush. It is not a successful-work acknowledgement.
The iterator’s with block calls close(), which cancels and waits for the
background listener, including any exit-flush attempt. A break alone does not
close an iterator you still hold outside a context manager.
With AsyncAvisoClient
Use the same store and constructor options. Wrap the returned iterator in
async with; it awaits aclose() on exit. See the complete
async listener. The checkpoint and
unfinished-work limits above apply equally to async code.
Error handling
Read the exception message first. A missing credential needs a setup change; a rejected filter needs a schema check. Repeating the same request will not necessarily fix either problem.
Client errors inherit from pyaviso.AvisoError. Python input errors can also
raise TypeError or ValueError, and a missing environment variable accessed
through os.environ[...] raises KeyError. AvisoError does not catch these
or errors in your own analysis.
Catching everything
Use the quickstart setup, including
AVISO_BASE_URL and credentials for pyaviso.Env(). For an anonymous server,
pass auth=pyaviso.Anonymous() instead.
This listener uses the
small mars schema.
It has a required class choice (od or rd) and optional whole-number step
filter.
Providers must supply both identifiers; the payload is optional. Listening
requires receiving permission, not publishing permission.
Save this as listen_errors.py and run python listen_errors.py:
import os
import pyaviso
try:
client = pyaviso.AvisoClient(
base_url=os.environ["AVISO_BASE_URL"], auth=pyaviso.Env()
)
with client.listen("mars", filter={"class": "od"}) as notifications:
for notification in notifications:
print(notification)
except pyaviso.AvisoError as error:
print("Listening failed:", error)
raise
except KeyboardInterrupt:
print("Stopped listening")
It prints matching notifications as indented CloudEvent JSON until you press
Ctrl+C. Setup is inside try because Env() can fail before listening starts.
The with block closes the iterator even if your loop raises an exception.
The error branch reports the failure and reraises it, so a failed job does not
look like a successful run.
HttpError exposes the server’s response
HttpError carries the integer status, string body and optional
request_id. Keep the request ID when asking your operator to find the request
in server logs. The response body explains what the server rejected.
For providers, here is an intentionally invalid publish. Save it as
bad_publish.py and run python bad_publish.py with publishing credentials and
the same environment setup:
import json
import os
import pyaviso
client = pyaviso.AvisoClient(
base_url=os.environ["AVISO_BASE_URL"], auth=pyaviso.Env()
)
try:
client.notify(event_type="mars", identifier={"class": "od"})
except pyaviso.HttpError as error:
print("status:", error.status)
print(json.dumps(json.loads(error.body), indent=2, sort_keys=True))
Captured output with the small mars schema (your request ID will differ):
status: 400
{
"code": "INVALID_NOTIFICATION_REQUEST",
"details": "Required field 'step' missing for notify operation",
"error": "Invalid Notification Request",
"message": "Required field 'step' missing for notify operation",
"request_id": "daf5f6d6-6f40-42db-a1e5-8b22df279b07"
}
The server rejects this with HTTP 400 because step is missing.
It is optional in listener filters, but required when publishing. This example
decodes the Aviso server’s JSON error body; a proxy or another server can return
plain text, so general error handlers should not assume every body is JSON.
When errors propagate
- Setup can fail when constructing a provider, client, request or state store.
- HTTP methods such as
notifyandschemaraise when the request fails.notify_manyreturns per-notification results; inspect every result. Invalid batch input can raise before any request is sent. - Listening can fail when opening the iterator or during iteration. After a terminal stream error is delivered, subsequent iteration is exhausted.
- A required trigger failure stops the listener before that notification reaches your loop. Earlier trigger effects are not undone.
Connection losses and retryable server responses normally cause listeners to
reconnect. One-shot publishes are not automatically retried after transport
failure: the server may already have stored the notification. An auth provider
is asked to refresh after HTTP 401, followed by one retry if refresh succeeds.
If authentication is still rejected while opening a watch, the listener raises
AuthError. A one-shot HTTP request instead exposes the final 401 as
HttpError.
See Authentication.
HistoryGapError carries a reason
A history gap ends the iterator. Inspect error.reason:
| Reason | Meaning | Useful fields |
|---|---|---|
replay_limit_reached | The server capped the requested replay | max_allowed |
sequence_jump | The protocol reported an unexpected sequence boundary | expected, observed |
Do not treat a failed replay as complete. Check server retention and replay
limits with the operator, then choose a starting point appropriate to your
work. Starting live skips historical work. Also, start_from=None uses an
existing saved cursor when one is configured; it does not override it. See
choosing a starting position.
TriggerError carries a kind and a sub-kind
trigger_kind identifies the action: echo, log, command, webhook,
teams, post, function or unknown. error_kind describes the failure:
| Error kind | Useful fields |
|---|---|
command | exit_code, stderr_tail |
webhook | status, body_tail |
timeout | timeout_seconds |
template | context, field, template_kind |
io | path for a log trigger; read the exception message |
webhook_build | reason |
raised | A Trigger.function raised; the original exception is __cause__ |
encode, unknown | Read the exception message |
Fields that do not apply are None. Required triggers stop the watch when their
retry policy is exhausted or fail-fast applies. Optional triggers warn and let
processing continue. Only make an action optional when continuing without it is
acceptable. See trigger retry settings.
The failing notification does not advance the pending cursor. An earlier notification may already have been checkpointed. Restarting does not guarantee recovery of unfinished work; see State and resume.
Catching is not the same as recovering
Before retrying a publish, determine whether it may already have succeeded. For a malformed stream event or protocol error, reconnecting to the same input may reproduce the failure. Preserve the error and ask the operator to investigate instead of silently moving past it.
Closing a listener cancels and waits for its background task. It does not certify that your analysis finished. Ctrl+C handling while waiting for input does not set a deadline for interrupting Python work or waiting for cleanup.
Hierarchy
All of these inherit directly from AvisoError:
| Exception | What to check |
|---|---|
AuthError | Credential source or watch authentication rejected after refresh |
ConfigError | Client settings, auth file or request options |
HttpError | Server status and response body |
TransportError | Connection or response-transfer failure |
DecodeError | Unexpected response format |
MalformedEventError | Invalid CloudEvent identity |
HistoryGapError | Replay limit or sequence boundary |
StreamProtocolError | Fatal streaming protocol condition |
StateStoreError | Local state path, permissions or contents |
TriggerError | Failed required action |
With AsyncAvisoClient
The same exceptions can arise from an awaited method or async for iteration.
Use async with on the iterator to await aclose() on exit. With
asyncio.run(), catch KeyboardInterrupt outside the run call, as in the
async listener. A TaskGroup can wrap
task failures in an exception group.
Async
Use AvisoClient for an ordinary script or notebook. This page is for
applications already using asyncio, Python’s way of letting tasks share a
thread while they wait for input or network responses.
Use AvisoClient by default
You do not need async to receive notifications. The
regular listener is the simplest starting
point. Use AsyncAvisoClient when your application already has an event loop,
or you need several listeners to wait concurrently in one thread.
A complete async listener
Use the quickstart environment: install
pyaviso, set AVISO_BASE_URL and provide credentials for pyaviso.Env().
For an anonymous server, pass auth=pyaviso.Anonymous() instead.
The examples use the
small mars schema.
class is a required od/rd filter; step is an optional whole-number
filter. Providers supply both fields and may omit the payload. You only need
receiving permission for the listener.
Save this as listen_async.py and run python listen_async.py:
import asyncio
import os
import pyaviso
async def main() -> None:
client = pyaviso.AsyncAvisoClient(
base_url=os.environ["AVISO_BASE_URL"], auth=pyaviso.Env()
)
async with client.listen("mars", filter={"class": "od"}) as notifications:
async for notification in notifications:
print(notification)
try:
asyncio.run(main())
except KeyboardInterrupt:
print("Stopped listening")
It prints each matching new notification as indented CloudEvent JSON. Silence means no matching notification has arrived. For a local trial, leave it running and run the provider script in another terminal. Press Ctrl+C to stop.
listen() returns an async iterator directly; do not await the listen() call.
async for waits for each notification. async with awaits the iterator’s
aclose() when the block exits, including on an exception or a break.
On Python 3.11 and newer, the default asyncio.run() signal handler makes the
first Ctrl+C cancel the main task so its context managers can clean up. On
Python 3.10, asyncio.run() cancels remaining tasks during its final cleanup.
The script works on both: KeyboardInterrupt is caught outside asyncio.run().
Do not swallow asyncio.CancelledError inside your tasks. Cleanup duration
depends on work in progress; it is not a fixed deadline.
In a notebook or framework that already runs an event loop, await main() from
that environment instead of nesting asyncio.run().
When async helps
To replay operational and research notifications concurrently, replace main
in listen_async.py with this definition. This example uses Python 3.11 or
newer for TaskGroup:
async def main() -> None:
client = pyaviso.AsyncAvisoClient(
base_url=os.environ["AVISO_BASE_URL"], auth=pyaviso.Env()
)
async def replay(data_class: str) -> None:
async with client.listen(
"mars",
filter={"class": data_class},
start_from=0,
mode="replay_only",
) as notifications:
async for notification in notifications:
print(notification)
async with asyncio.TaskGroup() as tasks:
tasks.create_task(replay("od"))
tasks.create_task(replay("rd"))
It prints matching retained notifications and ends after both replays finish. Empty history prints nothing. Output from the two listeners can interleave. The task group waits for its tasks; if one fails, it cancels and waits for the others and raises an exception group. Each listener still closes its iterator. See replay limits.
Concurrent publishing (providers)
Providers with publishing permission can use await client.notify_many(...)
for a batch. Save this as publish_async.py and run
python publish_async.py with the same environment:
import asyncio
import os
import pyaviso
async def main() -> None:
client = pyaviso.AsyncAvisoClient(
base_url=os.environ["AVISO_BASE_URL"], auth=pyaviso.Env()
)
notifications = [
{"event_type": "mars", "identifier": {"class": "od", "step": 24}},
{"event_type": "mars", "identifier": {"class": "rd", "step": 48}},
]
results = await client.notify_many(notifications, concurrency=2)
for result in results:
if result.response is not None:
print("Notification accepted")
else:
print("Notification failed:", result.error)
asyncio.run(main())
For valid input and permitted access, it prints Notification accepted twice.
Both identifiers are supplied; this schema’s payload is optional. The batch is
not atomic: some requests can succeed while others fail. Results stay in input
order. See
batch publishing
before retrying failures.
Multiple listeners
AsyncAvisoClient.listen_many() delivers the notifications of several
listeners through one async for loop; see
Multiple listeners.
What stays the same
The clients use the same constructor options, filters, value types and auth providers. State-store behavior and its unfinished-work limits also apply to async listeners.
| Task | Sync | Async |
|---|---|---|
| Publish one | client.notify(...) | await client.notify(...) |
| Publish a batch | client.notify_many(...) | await client.notify_many(...) |
| Discover schemas | client.schema() | await client.schema() |
| Inspect one schema | client.schema_for(...) | await client.schema_for(...) |
| Iterate | for notification in iterator | async for notification in iterator |
| Close a listener | iterator.close() | await iterator.aclose() |
HTTP and schema methods, including admin methods, return awaitables on the
async client. listen() is the exception: it returns the iterator directly.
The async client itself is not an async context manager; use async with on
its listener. Both clients use the same error types.
Mixing the two is a mistake
Synchronous calls block the thread running your event loop, preventing other
tasks from making progress. Use AsyncAvisoClient inside async code. If you
must call existing synchronous code, asyncio.to_thread() can move it to a
worker thread. Async also does not make CPU-heavy analysis nonblocking: move
that work out of the event-loop thread.
API reference
Use the quickstart for your first script. This reference is
for looking up signatures and return values. Import the public pyaviso module.
For motivation and when-to-use-this guidance on individual surfaces, see the
narrative pages. The async client is documented on the Async page;
HTTP methods return awaitables on the async client. listen() returns an async
iterator directly, without await.
Clients
class pyaviso.AvisoClient(
*,
base_url: str | None = None,
auth: AuthProvider | Anonymous | None = None,
timeout: float | None = None,
user_agent: str | None = None,
state_store: StateStore | None = None,
heartbeat_interval: float | None = None,
danger_accept_invalid_certs: bool | None = None,
flush_cursor_on_exit: bool | None = None,
)
Every argument is optional. What is not given is taken from the environment, then the aviso config file, then the default; see Configuration for the order.
Discover schemas
schema() -> SchemaCatalogschema_for(event_type) -> SchemaResponse
Receive notifications
listen(event_type=None, *, filter=None, start_from=None, until=None, mode=None, triggers=None, request=None) -> NotificationIterator | FunctionTriggerIterator(withoutmode, the listener stays live, unlessuntilis given, which ends it likemode="replay_only";triggersis aSequence[Trigger]; the returned iterator is also a context manager viawithand supportsiterator.close()for explicit teardown)
start_from is int | str | None. Integers are exclusive sequence positions;
0 requests retained history after zero. A UTC string such as
"2026-06-01T00:00:00Z" selects publication time. None uses saved state if
available, otherwise the live edge. An explicit start overrides saved state.
The value’s type selects the meaning, so sequence numbers must be unquoted;
see Start from a specific position.
Use mode="replay_only" with a start position to end at the replay boundary.
See State and resume for checkpoint limits.
until is int | str | None: the end point of a replay. An integer is the last
sequence to deliver, inclusive; a UTC string ends with the last notification
published at or before that time. until implies mode="replay_only" and needs
start_from. See Stop at an end point.
listen_many(listeners, *, start_from=None, until=None, mode=None, on_error=None) -> MultiNotificationIterator
listeners maps a name to a dict of listen() keywords (event_type,
filter, start_from, until, mode, triggers) or to a WatchRequest.
The start_from, until and mode given here apply to dict entries that set
none. The
loop yields (name, notification). on_error is "raise" (or None, the
default), "continue" or a function taking (name, error); see
Error handling. Arguments are checked
before any listener opens, the shared start_from, until and mode
included. A
mistake in the structure of listeners (not a mapping, an empty or
non-string name, an unknown key, a missing event_type) or an invalid
on_error raises TypeError or ValueError. An invalid listen() argument
inside an entry raises what listen() would. When the mistake belongs to one
entry, the message names that listener.
Publish notifications (providers)
notify(*, event_type, identifier=None, payload=None) -> NotifyResponsenotify_many(notifications, *, concurrency=0) -> list[NotifyResult]
notify_many takes a sequence of notification dicts and returns results in
input order. concurrency=0 limits it to 16 in-flight requests. A batch is not
atomic; inspect each result. See Publishing.
identifier is a mapping from strings to JSON-compatible Python values. The
same applies to each notification passed to notify_many. Structured values
such as point-cloud lists are sent as JSON arrays. Cyclic containers are not
valid JSON and raise TypeError. Identifier input deeper than 100 nested
containers raises ValueError before conversion.
Supply every identifier field defined by the server when publishing, even
fields optional in filters. payload=None omits the payload; the schema decides
whether a payload is required.
Admin operations (operators)
wipe_stream(stream_name) -> Nonewipe_all() -> Nonedelete_notification(notification_id) -> None
These remove retained data and require appropriate permission. A publish response’s request ID is not a notification ID.
What the client resolved
config -> ResolvedConfig: every setting with its value and source, the credential by kind and source only. Safe to log.pyaviso.resolve_config(*, base_url=None, auth=None, timeout=None, heartbeat_interval=None, danger_accept_invalid_certs=None) -> ResolvedConfig: the same report for those arguments, without building a client.
ResolvedConfig has base_url (SourcedValue | None), timeout,
heartbeat_interval, ca_bundle, danger_accept_invalid_certs (each a
SourcedValue with .value and .source), auth (ResolvedAuth | None
with .kind, .source and .refused), config_file, credentials_file,
and as_dict().
See Configuration.
Constructor options and lifecycle
base_url includes the HTTP or HTTPS scheme; when None it comes from
AVISO_BASE_URL or the config file, and ConfigError is raised if neither
has one. auth=None searches the environment and the credential files; see
Authentication. Pass auth=pyaviso.Anonymous() to send no
credentials. state_store=None does not persist checkpoints.
flush_cursor_on_exit=False leaves the final pending cursor unflushed;
enabling it does not acknowledge completed work.
See shutdown behavior.
timeout and heartbeat_interval are seconds. When None, each comes from
the config file if it is set there; otherwise timeout imposes no request
timeout and heartbeat_interval uses 30 seconds as the expected server
heartbeat interval (it does not set the server’s cadence). timeout bounds
ordinary requests such as notify and schema; it does not apply to
listen, whose stream is meant to stay open.
user_agent=None uses aviso/<crate-version>.
danger_accept_invalid_certs=None keeps certificate validation enabled unless
the config file’s tls: block turns it off.
Use with client.listen(...) to close a synchronous listener. The synchronous
client’s own __enter__ / __exit__ do not close its listeners. The async
client is not an async context manager; use async with on its iterator.
pyaviso.AsyncAvisoClient is the same shape. notify / schema /
notify_many / schema_for / wipe_* / delete_notification return
awaitables; listen returns an AsyncNotificationIterator.
Value types
pyaviso.Notification(event_type, sequence, identifier, payload, cloudevent=None)
Properties: event_type, sequence, identifier, payload, cloudevent.
Method: as_dict(). Unhashable (the payload may be a dict).
str(notification) formats the original cloudevent as indented JSON. For a
manually constructed notification without cloudevent, it formats event_type,
sequence, identifier, and payload instead. as_dict() returns the client
convenience fields, including cloudevent when present. repr(notification)
remains a compact debugging summary.
Identifier values retain the JSON shape emitted by the server.
pyaviso.NotifyResponse(status, request_id, processed_at)
Properties: status, request_id, processed_at. Method: as_dict().
pyaviso.NotifyResult (returned by notify_many)
Properties: zero-based index, boolean ok, response (NotifyResponse on
success, otherwise None) and error (exception on failure, otherwise None).
pyaviso.SchemaCatalog
Properties: status, event_types, total_schemas, schema. Method:
as_dict(). The schema property is a dict keyed by event type, whose values
match the per-event-type schema below.
pyaviso.SchemaResponse
Properties: status, event_type, schema. Method: as_dict(). The schema
is a dict with two keys: payload (a {"required": bool} shape) and
identifier (a dict keyed by identifier field name, each value naming the
validator type, the required flag (which gates whether a filter or watch call
must include the field, not whether a notify call must), and any type-specific
metadata).
Watch shape
pyaviso.WatchRequest
Constructors:
WatchRequest.watch(event_type)WatchRequest.watch_from(event_type, start_from)WatchRequest.replay_only(event_type, start_from, *, until=None)
Builders: .with_filter(dict), .with_triggers(list). Properties:
event_type, mode.
pyaviso.WatchMode is a str + Enum with members WATCH = "watch" and
REPLAY_ONLY = "replay_only".
pyaviso.NotificationIterator and pyaviso.AsyncNotificationIterator: returned
by listen. The sync one implements __iter__ / __next__ / run / close;
the async one implements __aiter__ / __anext__ / run / aclose. Both are
iterator context managers: with closes the sync iterator; async with awaits
async closure. A loop break alone does not close an iterator you still hold.
run() (awaited on the async one) reads the stream to its end, running its
triggers, then closes it. When the triggers include Trigger.function,
listen returns a pyaviso.FunctionTriggerIterator (or
pyaviso.AsyncFunctionTriggerIterator) with the same methods.
pyaviso.MultiNotificationIterator and
pyaviso.AsyncMultiNotificationIterator: returned by listen_many, with the
same methods as above, yielding (name, notification), plus errors: a list
of pyaviso.ListenFailure, each with listener (the name), kind (a
pyaviso.ListenFailureKind, LISTENER or TRIGGER) and error. A listener
or function failure raised from the loop carries error.listener. When every
listener has failed, the loop raises AvisoError with a failures list.
Every AvisoError has both attributes; they are None when not set.
Triggers
pyaviso.Trigger with class-method constructors:
Trigger.echo(*, retries=0, required=True, label=None)Trigger.log(path, *, retries=0, required=True)Trigger.command(cmd, *, env=None, working_dir=None, retries=0, required=True, timeout=None, fail_fast=True)(Unix only)Trigger.webhook(url, *, method=None, headers=None, body_template=None, retries=0, required=True, timeout=30.0, fail_fast=True)Trigger.teams(url, *, retries=0, required=True, timeout=30.0, fail_fast=True)Trigger.post(url, *, retries=0, required=True, timeout=30.0, fail_fast=True)Trigger.function(func, *, retries=0, required=True, label=None): callsfunc(notification)in the reading thread or event loop; see Function
Chainable setters: .retries(n), .required(on), .timeout(seconds),
.fail_fast(on), .label(name).
Setters return new values. Webhook method=None means POST. Timeout and
fail-fast setters affect only command and HTTP triggers, and raise
ValueError on a function trigger; label affects echo and names a function
trigger in its errors. Echo and log output the smaller notification view,
while post forwards the original CloudEvent. See Triggers.
pyaviso.HttpMethod is a str + Enum with members POST, GET, PUT,
PATCH, DELETE.
Auth providers
pyaviso.Bearer(token), pyaviso.Basic(username, password=""),
pyaviso.Env(), pyaviso.ConfigFile(path), pyaviso.Chain(*providers).
Type alias: pyaviso.AuthProvider = Bearer | Basic | Env | ConfigFile | Chain.
State stores
pyaviso.MemoryStore(), pyaviso.JsonFileStore(path).
Type alias: pyaviso.StateStore = MemoryStore | JsonFileStore.
Exceptions
Base: pyaviso.AvisoError.
Subclasses:
pyaviso.TransportErrorpyaviso.HttpError(attrs:status,body,request_id)pyaviso.AuthErrorpyaviso.DecodeErrorpyaviso.MalformedEventErrorpyaviso.HistoryGapError(attrs:reason,max_allowed,expected,observed)pyaviso.StreamProtocolError(attrs:message,request_id)pyaviso.ConfigErrorpyaviso.StateStoreErrorpyaviso.TriggerError(attrs:trigger_kind,error_kind,path,exit_code,stderr_tail,status,body_tail,reason,timeout_seconds,context,field,template_kind)
Version
pyaviso.__version__ is the Python distribution version. pyaviso.VERSION
is the Rust crate version. They are pinned to the same value via maturin’s
dynamic = ["version"].
Troubleshooting
Choose the symptom that matches what you see. Open a panel for checks and links to the relevant guide. Keep the exception message and any request ID when asking your server operator for help; do not include credentials.
Import fails: no module named pyaviso._native
The compiled extension is missing from the Python environment running your script. Check that your terminal, editor or notebook uses the environment where you installed pyaviso. See Install.
For a source checkout, run the build commands under
From source. The dev group supplies maturin, which
builds the extension. If the build fails, check the Rust and C compiler
requirements there. Reinstalling into a different environment will not fix the
one your notebook uses.
ConfigError: invalid base_url
Pass the complete URL supplied by your operator, including https:// or
http:// and any required port. A hostname alone, such as localhost, is not
a complete URL. The Python constructor requires base_url; the examples read
it from os.environ["AVISO_BASE_URL"]. A KeyError for that name means the
environment variable is missing before the client is even constructed.
See Set the environment.
Credentials fail: AuthError, HTTP 401 or 403
Env() requires credentials. Set AVISO_TOKEN, or unset it and set both
AVISO_USERNAME and AVISO_PASSWORD. A non-empty token takes precedence. For
an anonymous server, pass auth=pyaviso.Anonymous(). Omitting auth
does not mean anonymous: the client then searches for a credential.
A 401 means the server did not accept the request’s credentials. A 403 can mean the credentials lack permission for the operation or event type. Ask your operator to check receiving or publishing access as appropriate. Listing schemas does not prove those permissions.
ConfigFile expects an auth-only file with one top-level bearer or basic
section, not a general CLI config. Env caches credentials at construction;
ConfigFile rereads its file on a requested refresh after 401. See
Authentication for the exact shapes and retry behavior.
TransportError: connection or TLS failure
Check the URL, network access and server availability. The exception message can identify a DNS, TCP or certificate problem. For TLS, check the hostname and trusted certificate chain with your operator. Disabling certificate validation is not a fix for a production certificate problem.
A transport error can also occur while receiving a response. It does not prove the server stored nothing. Check before retrying a publish to avoid duplicates. See Error handling.
The listener is running, but nothing arrives
A fresh listener waits for new notifications. Silence can be normal. Confirm
the event type and filter against the
server’s schema. In the example mars
schema, class=od excludes rd; omitting step selects all steps.
Use replay-only mode to read retained history once
and exit. start_from=0 requests retained history after sequence zero.
It cannot restore expired records. You do not need to publish anything yourself.
HistoryGapError: replay stopped early
Inspect reason. replay_limit_reached means the server capped the requested
replay; max_allowed reports the cap. It does not by itself mean all missing
records expired. sequence_jump reports an unexpected protocol sequence
boundary through expected and observed.
The iterator stops rather than silently treating that replay as complete. Check
retention and replay limits with your operator before choosing a new start.
start_from=None still uses a saved cursor if available. Starting live can skip
historical work. See
State and resume.
TriggerError: a required action failed
Check trigger_kind, error_kind and the exception message. For a failed
command, exit_code and stderr_tail help identify the cause. A timeout has
error_kind="timeout" and timeout_seconds; inspect fields appropriate to that
kind rather than assuming every command error has an exit code.
Fix the command, path or receiving service. Retries can help a transient
failure, but fail-fast may stop immediately. Set required=False only if
continuing without that action is acceptable; it can leave work undone. See
trigger settings.
Ctrl+C or closing a listener takes time
Use with client.listen(...) so context exit calls close(). This cancels and
waits for the background listener. The sync iterator checks Python signals
while waiting for notifications, but that is not a 100 ms shutdown guarantee.
Your loop’s work and cleanup can take longer.
For async code, use async with on the iterator; exit awaits aclose(). Catch
KeyboardInterrupt outside asyncio.run() and let cancellation propagate
inside tasks. See the async listener.
StateStoreError or unexpected resume behavior
Create the state file’s parent directory before constructing JsonFileStore.
Choose local storage where your account can create the lockfile and replace the
state file. Check file contents and permissions if an existing store fails;
preserve the file while investigating rather than deleting your resume data.
See the
complete resuming listener.
A changed URL, event type or filter can select a different resume key. A server schema change alone does not change the key. With no saved cursor, the listener starts live. The default exit policy can leave the final notification uncommitted, so repeats are possible. Enabling exit flushing can skip unfinished buffered work on restart; it is not a work acknowledgement. See Flush on exit.
Other async tasks stop while Aviso is waiting
Synchronous methods block the event-loop thread. Use AsyncAvisoClient in async
code and await its HTTP methods. listen() returns an async iterator directly;
use async for, not await client.listen(...). See Async.
C++ binding
aviso ships a C++ binding as a stable C ABI plus a header-only C++ facade over
it. The aviso-ffi crate builds a libaviso_ffi library and generates a C
header (aviso.h); the hand-written aviso.hpp adds RAII handles,
std::string and std::optional ergonomics, and a throwing aviso::Error.
The point of the C ABI is packaging. A C++ application links the prebuilt library and compiles the facade with its own toolchain, so the binding works under C++17 and needs no Rust toolchain in the consumer’s build.
What you can do today
- Build a client from a base URL, with a Bearer token, HTTP Basic credentials, or a credential the client finds for itself.
- Publish notifications with
notify; see Publishing. - Listen to a stream with a callback handler, with filtering and replay; see Listening.
- Listen with several watches through one handler, each with its own filter; see Several watches at once.
- Attach triggers (echo, log, command, webhook, Teams, post) to a listener; see Triggers.
- Read schemas with
schemaandschema_for, and run the operator-only admin callswipe_stream,wipe_all, anddelete_notification; see Operations. - Call verbs asynchronously: each except
notify_manyhas a*_asyncform returning astd::future; see Async. - Read structured errors: every failure throws an
aviso::Errorwhosewhat()is a human-readable message and whoseerror()returns the kind, HTTP status, and request id.
Credentials
When you hold the credential in your code, name it on the builder.
bearer_auth sends a token and basic_auth sends a username and password:
aviso::Client client = aviso::ClientBuilder("https://aviso.example.org")
.bearer_auth(token)
.build();
An empty token throws from build(). A credential named this way is sent to
whatever address you gave, plain http:// included; writing it into the call
is choosing where it goes.
When the credential is supplied by the environment or by a file instead, call
discover_auth and let the client find it:
aviso::Client client = aviso::ClientBuilder("https://aviso.example.org")
.discover_auth()
.build();
It looks in the environment, then the auth: block of
~/.config/aviso/config.yaml, then ~/.config/aviso/credentials.yaml, and
stops at the first one that has a credential. Finding nothing leaves the
client anonymous. A source that exists but cannot be read throws from
build().
A credential found this way is not sent to a plain http:// address unless
it is loopback, and build() throws instead. Nothing in the code named the
credential, so a mistyped host would otherwise send it in the clear. Use an
https address, or pass the credential yourself with bearer_auth or
basic_auth.
Starting from the config file
If the machine already has ~/.config/aviso/config.yaml set up for the
aviso command, from_file starts the builder from it: server address,
timeouts and TLS settings from the file, plus a credential found the same way
discover_auth finds one.
aviso::Client client = aviso::ClientBuilder::from_file().build();
Setters called afterwards replace what the file said, and base_url lets you
supply an address the file did not have:
aviso::Client client = aviso::ClientBuilder::from_file()
.base_url("https://other.example.org")
.bearer_auth(token)
.build();
A missing default file sets nothing. from_file(path) reads a specific file,
which must exist. Either way, a file that cannot be read throws from
build(), and so does a found credential paired with a plain http://
address that is not loopback, whether the address came from the file or from
a later base_url call. Naming the credential with bearer_auth or
basic_auth lifts that.
Starting from the environment
from_environment behaves like from_file, except that the address may also
come from AVISO_BASE_URL, which takes precedence over the file. The aviso
command and the Python binding use the same order, so a program built this
way needs no arguments on a machine configured for either:
aviso::Client client = aviso::ClientBuilder::from_environment().build();
If neither the environment nor the file provides an address, build() throws
a configuration error that names both sources.
Inspecting the resolved configuration
When a connection fails, the first question is which server and which
credential the client is using. describe() reports this for any builder,
without building a client:
aviso::ClientBuilder builder = aviso::ClientBuilder::from_environment();
std::cout << builder.describe();
base_url https://aviso.example.org/ (environment AVISO_BASE_URL)
auth bearer (credentials file /home/me/.config/aviso/credentials.yaml)
timeout 30s (config file /home/me/.config/aviso/config.yaml)
heartbeat_interval none (default)
ca_bundle none (default)
danger_accept_invalid_certs false (default)
Each line shows a setting, its value and, in parentheses, its source. The
report contains no secrets: the credential is described by kind and source,
never by value, and any user:password@ is removed from the address. It can
therefore be included as it is in a log or a support request. A discovered
credential that build() would refuse is shown with the reason on its auth
line. A builder created with ClientBuilder(url) that has no named credential
and has not called discover_auth reports auth as anonymous.
describe() throws aviso::Error for an error the builder already holds,
such as a null address, or for a config file that exists but cannot be read.
A first call
#include "aviso.hpp"
#include <iostream>
int main() {
try {
aviso::Client client = aviso::ClientBuilder("http://localhost:8000")
.basic_auth("user", "pass")
.build();
std::cout << client.schema() << '\n';
} catch (const aviso::Error& error) {
std::cerr << "aviso error: " << error.what() << '\n';
return 1;
}
}
How calls behave
Every call blocks until the server responds and throws aviso::Error on any
failure, so wrap them in try / catch. JSON-shaped data crosses as strings:
notify takes its payload as a JSON string and the calls return their responses
as JSON strings, which you parse with whatever JSON library your application
already uses.
These calls must not run inside a listener or async callback (a runtime thread);
doing so throws an aviso::Error with kind AvisoErrorKind_InvalidUsage rather
than deadlocking. See Listening for the callback surface.
Some objects are used up by the call that consumes them: a ClientBuilder by
build(), a WatchRequest by the watch or WatchSet it is given to, a
Trigger by add_trigger, and a WatchSet by watch_many. Moving from a
Client, Watch or any of these leaves the source empty as well. Calling a
method on a used-up or moved-from object throws an aviso::Error with kind
AvisoErrorKind_InvalidUsage, whose message names the object.
Reading an error
error.what() is a message for humans. error.error() is the part to branch
on: an ErrorInfo with a kind, the http_status when the server answered,
and the server’s request_id to quote when asking for help.
The kind tells you what to do next. AvisoErrorKind_Transport means the
server or network was unreachable and a retry may work. AvisoErrorKind_Http
means the server answered and said no; look at http_status and the message.
A credential the server rejects on a request arrives this way, as a 401 or
403. AvisoErrorKind_Auth covers the other credential problems: none was
available, one would have travelled in the clear to a non-loopback http
address, or a watch was refused even after refreshing the credential. None of
these is worth a retry. AvisoErrorKind_InvalidInput means the calling
code passed something the binding could see was wrong before any request went
out, such as identifier JSON that is not an object.
} catch (const aviso::Error& error) {
const aviso::ErrorInfo& info = error.error();
if (info.kind == AvisoErrorKind_Transport) {
// retry later
} else if (info.kind == AvisoErrorKind_Http && info.http_status == 401) {
// the server rejected the credential; do not retry
} else if (info.kind == AvisoErrorKind_Http && info.http_status == 404) {
// the event type is not on this server
}
}
examples/cpp/resilience/03_error_handling.cpp
provokes seven different errors on purpose and shows a branch for each.
Building against the library
Eighteen worked, tested examples live in
examples/cpp,
grouped by purpose: basics/, resilience/, triggers/ and async/. Each
one links the library and compiles the facade through CMake, the way your own
program would, and CI runs every one against a real server. For local
development, build the library from the workspace first:
cargo build -p aviso-ffi
cmake -S examples/cpp -B build/cpp
cmake --build build/cpp
./build/cpp/01_schema # nothing configured: says so, exits 0
AVISO_BASE_URL=http://localhost:8000 ./build/cpp/01_schema
The examples connect through from_environment(), so they need no further
setup on a machine where the aviso command is configured; 01_schema
prints the resolved configuration before its first request. The
two CMake cache variables AVISO_FFI_INCLUDE_DIR and AVISO_FFI_LIB_DIR
default to the in-tree locations; point them at a prebuilt drop to build
against shipped artifacts with no Rust toolchain. The
examples README
lists every file with what it shows.
Installing with cargo-c
For a standard system install rather than an in-tree build,
cargo-c drops the library, a pkg-config
.pc, and the headers in the usual layout:
cargo install cargo-c # once
cargo cinstall -p aviso-ffi --release --prefix=/usr/local --libdir=/usr/local/lib
A consumer then discovers it through pkg-config, with no in-tree paths and no Rust toolchain:
find_package(PkgConfig REQUIRED)
pkg_check_modules(AVISO_FFI REQUIRED IMPORTED_TARGET aviso_ffi)
target_link_libraries(my_app PRIVATE PkgConfig::AVISO_FFI)
The install places the headers under include/aviso_ffi/, and the .pc puts
that directory on the include path, so #include "aviso.hpp" works unchanged.
The C header and the facade
aviso.h is generated from the Rust surface by cbindgen and is the source of
truth for the ABI; aviso.hpp is hand-written over it and is the recommended
surface for C++ callers. C callers can use aviso.h directly: every fallible
call returns an owning AvisoOutcome that you inspect, take a value out of, and
free.
Publishing
notify publishes one notification and returns the server’s response as a JSON
string. Its map overload is convenient for string-only identifiers. The payload
is an optional JSON string. Like every call it throws aviso::Error on failure
(see Overview).
std::map<std::string, std::string> identifier = {{"date", "20260101"},
{"time", "0000"}};
std::string response = client.notify("test_event", identifier);
// With a payload:
std::string with_payload =
client.notify("test_event", identifier, std::string(R"({"value": 42})"));
Use notify_json when an identifier contains arrays, objects, or other JSON
values. Spatial coordinates use latitude first. These spatial examples use the
server’s public
observations schema,
which requires a date, a point cloud, and a payload:
const std::string identifier = R"({
"date": "20260601",
"point_cloud": [[46, 8], [47, 9]]
})";
std::string response = client.notify_json(
"observations", identifier, std::string(R"({"source":"stations"})"));
A polygon has the same [[lat, lon], ...] shape as a point cloud, but needs at
least four pairs with the first pair repeated last. Clouds need no closing
repeat; duplicate points are valid and their order is preserved. Subscribers
filter clouds with polygon, not point_cloud. The built-in point is only a
watch/replay filter for polygon streams, not a provider identifier. Do not quote
arrays inside the JSON object. The notify_json_async method provides the same
path for asynchronous calls.
To see which identifier fields a stream expects, read its schema first with
schema_for; see Operations.
Publishing many at once
notify_many sends a whole batch concurrently and returns a JSON array with one
entry per input, in order. Over a single HTTP/2 connection a batch that would
take many sequential round-trips finishes in roughly one. You pass the batch as
a JSON array string of {event_type, identifier?, payload?} objects, built with
whatever JSON you like, and an optional max_concurrency (0 selects a default).
const std::string notifications = R"([
{"event_type": "observations", "identifier": {"date": "20260601", "point_cloud": [[46, 8], [47, 9]]}, "payload": {"source": "stations-a"}},
{"event_type": "observations", "identifier": {"date": "20260602", "point_cloud": [[48, 10], [49, 11]]}, "payload": {"source": "stations-b"}}
])";
std::string results = client.notify_many(notifications);
The batch is not atomic. A per-item failure comes back as an "error" entry in
the array rather than throwing, so one bad notification does not sink the rest;
only a malformed array (not valid JSON, or an item missing event_type) throws.
Each entry is {"index", "status", "response"} on success or
{"index", "status", "error"} on failure, where the error carries kind,
http_status, message, and request_id.
A complete example
examples/cpp/basics/02_publish.cpp
publishes one notification with a string identifier.
examples/cpp/basics/06_publish_polygon.cpp
sends a spatial identifier and a required payload with notify_json.
examples/cpp/basics/04_publish_many.cpp
publishes a batch with notify_many, with one bad item so you can see a
per-item failure next to two successes. CI compiles and runs all three on every
change, so they never drift from the binding.
Listening
A listener streams notifications to your code as they arrive. In C++ you start
one with client.watch, passing a WatchRequest that says what to listen to
and a handler that says what to do with each notification. The library calls
your handler on a background thread until the listener ends, whether because
you stopped it or because it failed.
The handler
Subclass aviso::NotificationHandler and override two methods.
on_notification runs once per notification and returns whether to keep
going. on_end runs once, after the last notification, and tells you whether
the listener stopped because you asked or because it failed.
class Handler : public aviso::NotificationHandler {
public:
bool on_notification(const aviso::Notification& n) override {
std::cout << n.event_type() << " #" << n.sequence() << " "
<< n.identifier_json() << " " << n.payload_json() << '\n';
return true; // keep going; return false to stop
}
void on_end(const std::optional<aviso::ErrorInfo>& error) override {
if (error) {
std::cerr << "watch failed: " << error->message << '\n';
}
}
};
Do not leave on_end empty. It is the only place a failed watch is reported.
An exception thrown out of on_notification also ends up there: it stops the
watch, and on_end receives an AvisoErrorKind_Internal error whose message
names the exception.
wait() returns normally whether the watch ended because your handler said so
or because the connection was refused, so a handler that ignores on_end
turns every failure into a quiet exit. The examples keep a small base class in
common.hpp that stores the error, and a finish() helper that turns it into
the exit code.
The callbacks run on a runtime thread, so the handler must be thread-safe.
They must not make blocking aviso calls: a blocking call from inside a callback
throws an aviso::Error of kind AvisoErrorKind_InvalidUsage, and the async
verbs are the way to make requests from there (see Async). The
Notification you receive is a borrowed view, valid only for that call. Its
accessors return owned std::strings, so copy out anything you want to keep.
Starting and stopping
Build a WatchRequest, then call client.watch. It returns a Watch whose
destructor stops the listener and waits for it, so you cannot leave one
running by accident. The handler you pass must outlive the Watch.
client.watch consumes the request: calling a setter on it afterwards, or
passing it to another watch, throws an aviso::Error of kind
AvisoErrorKind_InvalidUsage. Build a new request for each watch.
Handler handler;
aviso::WatchRequest request("test_event");
aviso::Watch watch = client.watch(request, handler);
// Block until listening ends: the handler returned false, the stream ended,
// or it failed. Or just let `watch` go out of scope to stop and wait.
watch.wait();
watch.stop() asks for a graceful stop. It returns at once and is safe from
any thread, including from inside the handler. After a stop(), on_end runs
with no error, so a stopped watch looks the same as one whose handler returned
false.
watch.wait() blocks until on_end has returned, so do not call it from
inside a callback.
A signal handler may not call stop() itself, since almost nothing is allowed
inside one. The pattern that works is to set a flag in the signal handler and
have a small thread call stop() when the flag turns.
examples/cpp/basics/03_listen.cpp
is this in full: it prints each notification and stops itself after three.
examples/cpp/resilience/04_stop_from_outside.cpp
runs until Ctrl+C or a timer, and stops the way a real listener would.
Several watches at once
To listen to several things through one handler, give each WatchRequest a
name in a WatchSet and call client.watch_many. The handler derives from
aviso::MultiNotificationHandler. It works like the single-watch handler, and
on_notification also receives the name of the watch:
class Printer : public aviso::MultiNotificationHandler {
public:
bool on_notification(const std::string& name,
const aviso::Notification& n) override {
std::cout << name << ": " << n.identifier_json() << '\n';
return true;
}
void on_end(const std::optional<aviso::ErrorInfo>& error) override {
if (error) {
std::cerr << "listening failed: " << error->message << '\n';
}
}
};
aviso::WatchRequest operational("mars");
operational.filter_json(R"({"class": "od"})");
aviso::WatchRequest research("mars");
research.filter_json(R"({"class": "rd", "step": 0})");
aviso::WatchSet watches;
watches.add("operational", operational).add("research", research);
Printer printer;
aviso::Watch watch = client.watch_many(watches, printer);
watch.wait();
Each line shows which watch delivered the notification:
operational: {"class":"od","step":"6"}
research: {"class":"rd","step":"0"}
operational: {"class":"od","step":"12"}
- Each request keeps its own event type, filter, start position and triggers.
- The watches are read in turn, so a busy watch cannot starve a quiet one.
- The callbacks never run at the same time, so the handler needs no lock for its own state.
- The returned
Watchworks as for a single watch:stop(),wait()and the destructor act on every watch in the set. watch_manyconsumes theWatchSet, andaddconsumes eachWatchRequest. Using either again throws anaviso::Errorof kindAvisoErrorKind_InvalidUsage.
When one watch fails
By default, one failing watch stops them all, and on_end receives its error.
The message begins with the name of the watch:
listening failed: watch 'research': http 400: ... Unknown field 'stepp' ...
To drop only the failing watch and keep the others, override on_error, which
also receives the name, and return true:
bool on_error(const std::string& name,
const aviso::ErrorInfo& error) override {
std::cerr << name << " dropped: " << error.message << '\n';
return true;
}
on_end still receives an error if every watch fails, so the program cannot
end quietly with nothing running. An exception thrown from on_notification
or on_error stops every watch, and on_end receives an
AvisoErrorKind_Internal error whose message names the exception.
examples/cpp/basics/07_watch_many.cpp
is a complete program: it runs two watches and stops once each has delivered
three notifications.
Resuming where you left off
Every notification carries a sequence number. If your process stops, the last sequence you handled is all you need to carry on without a gap: ask for everything after it, then stay live. The server replays what you missed first and switches to live delivery on the same connection.
aviso::WatchRequest request("test_event");
request.watch_from_sequence(last_handled); // replay after this, then live
Where you keep last_handled is up to you. A file is enough for a single
process; record it in on_notification after handling, not before, so a
crash mid-handler replays that notification rather than skipping it. That
gives you at-least-once delivery, which is what you want for anything that
matters.
watch_from_date does the same with a timestamp instead of a sequence.
examples/cpp/resilience/01_resume_from_sequence.cpp
keeps the position in a file. Run it, stop it, publish a few notifications,
run it again: the missed ones arrive first.
Reading history only
Sometimes you want a batch, not a feed. replay_from_sequence and
replay_from_date deliver what the server has kept and then end the watch by
themselves, without waiting for anything new. The handler can return true
throughout; wait() still returns when history runs out.
aviso::WatchRequest request("test_event");
request.replay_from_date("2026-01-01T00:00:00Z"); // history, then end
A server caps how much one replay may return. When you hit the cap the watch
ends with an error of kind AvisoErrorKind_HistoryGap whose message names the
limit, and the notifications you did get are complete and in order up to the
last one. Ask again with replay_from_sequence from that last sequence to get
the rest, and repeat until the watch ends without an error.
examples/cpp/resilience/02_replay_only.cpp
does exactly that: it reads everything since a date in as many batches as the
cap requires, then exits with a count.
Stopping at an end point
replay_until_sequence and replay_until_date end the replay at an end point
instead of at the last stored notification. A sequence is the last one
delivered, inclusive; a date ends with the last notification published at or
before that time. Combine either with a replay_from_* setter, in any order:
aviso::WatchRequest request("test_event");
request.replay_from_date("2026-01-01T00:00:00Z")
.replay_until_date("2026-01-02T00:00:00Z"); // one day, then end
The start is exclusive and the end inclusive for sequences:
replay_from_sequence(10) with replay_until_sequence(20) delivers the
matching notifications with sequences 11 to 20. An end point in the future ends
at the last stored notification. If the connection drops, the watch resumes up
to the same end point.
An end point without a replay_from_* setter makes on_end report
AvisoErrorKind_InvalidInput when the watch starts, since a live watch has no
end. A sequence end that is not after a sequence start is reported as
AvisoErrorKind_Config. An end point needs aviso-server 0.13.0 or later; an
older server rejects the request with an AvisoErrorKind_Http error, status
400.
Filtering
filter_json narrows the stream to notifications whose identifier matches a
JSON object: an exact value per key, or a server-defined rule object.
aviso::WatchRequest("test_event").filter_json(R"({"date":"20260101"})");
Fields you leave out match anything. The filter is applied by the server, so
notifications that do not match never reach your process.
examples/cpp/basics/05_filter.cpp
listens for one date and lets everything else go past.
Numeric and enum constraints
With the schema and seeds from the
weather tutorial, use this
request with the Handler above and a client built as in
A first call, using your test server’s address and
credentials. Replay delivers B and C, then ends; inspect their labels in
n.payload_json().
aviso::WatchRequest request("weather");
request.replay_from_sequence(0).filter_json(R"({
"date": "20260913",
"severity": {"gte": 5},
"anomaly": {"between": [40, 50]},
"region": {"in": ["north", "south"]}
})");
Handler handler;
auto watch = client.watch(request, handler);
watch.wait();
For live delivery, omit replay_from_sequence(0) and start before publishing.
The raw string contains JSON objects with numeric operands, not quoted JSON
objects. See Filters for supported
operators and schema requirements.
Triggers
A trigger is a per-notification side effect attached to a listener. When it
processes a notification, its triggers fire before the notification reaches your
handler. Build a trigger with a factory, tune it with the chainable setters, and
attach it to a WatchRequest with add_trigger.
Kinds
Trigger::echo()writes each notification as one line of JSON to standard output. The one to try first, because it shows you exactly what every other trigger receives.Trigger::log(path)appends each notification as JSON to a file, across runs. A durable record with no file handling of your own.Trigger::command(cmd)runs/bin/sh -c <cmd>per notification (Unix only). The notification arrives as environment variables:AVISO_EVENT_TYPE,AVISO_SEQUENCE, oneAVISO_IDENTIFIER_<FIELD>per identifier field, andAVISO_NOTIFICATION_JSONfor the whole thing. The command’s own stdout is captured and dropped, so write to a file if you want to see output.Trigger::webhook(url)sends an HTTP request per notification, with a settable method, headers, and a body template that can name notification fields, such as{{ notification.identifier.date }}.Trigger::teams(url)posts an Adaptive Card to a Teams Workflows webhook.Trigger::post(url)HTTP POSTs the rawCloudEventenvelope.
Triggers fire whether or not your handler does anything with the notification. A handler that only counts, paired with a trigger that does the real work, is a perfectly good listener.
Attaching
add_trigger consumes the trigger. A factory result attaches directly:
aviso::WatchRequest request("test_event");
request.add_trigger(aviso::Trigger::log("/var/log/aviso/notifications.log"));
aviso::Watch watch = client.watch(request, handler);
A trigger is move-only, so to tune one with the setters, build it as a named value and move it in:
aviso::Trigger hook = aviso::Trigger::webhook("https://example.com/hook");
hook.method(aviso::HttpMethod::Post)
.header("Authorization", "Bearer " + token)
.retries(3)
.timeout_secs(10);
request.add_trigger(std::move(hook));
After add_trigger, hook is used up. Calling a setter on it, or attaching
it again, throws an aviso::Error of kind AvisoErrorKind_InvalidUsage.
Reliability
Every trigger has retries (default 0), required (default true), a
timeout_secs, and fail_fast (default true). Set label to name the trigger
in error messages when you attach more than one.
required is the setting that decides what a failure means. A required
trigger that still fails after its retries ends the listener: on_end
receives an aviso::ErrorInfo of kind AvisoErrorKind_Trigger, with
trigger_kind naming the trigger and error_kind naming the failure. An
optional trigger (required(false)) that fails is skipped and the listener
carries on. Nothing tells your code about it: there is no callback and no log
you can read from C++, and the notification reaches your handler as if the
trigger had worked. If you need to know when a trigger fails, make it required,
or have the trigger leave its own trace. Use required for the side effect the
listener exists to perform, and optional for the ones that are nice to have.
Triggers run in the order you added them, for every notification, before your handler sees it.
Complete examples
Each file in
triggers/
covers one kind, and the last one is a composition:
01_echo.cppand02_log.cppare the two simplest, with a handler that only counts.03_command.cppruns a shell command that appends theAVISO_*variables to a file, then prints the file.04_webhook.cppPOSTs a templated body to a URL. It opens a tiny receiver on a loopback port so it runs without one, and prints what arrived.05_multiple.cppattaches a required log, an optional echo, and an optional command that always fails, so you can see the failure leave the listener running. Change it torequired(true)and the listener ends on the first notification instead.
Every one stops itself after three notifications; publish from another terminal to drive them.
Operations
The schema lookups and the admin calls. Each one either succeeds or throws
aviso::Error (see Overview).
Schemas
schema returns the full catalogue (GET /api/v1/schema); schema_for
returns one stream’s schema (GET /api/v1/schema/{event_type}). Both return
JSON strings.
std::string catalogue = client.schema();
std::string one = client.schema_for("test_event");
Admin
The admin calls are operator-only and return nothing on success. wipe_stream
removes every notification for one stream, wipe_all removes them for every
stream, and delete_notification removes a single notification by its
<event_type>@<sequence> id.
client.wipe_stream("test_event");
client.delete_notification("test_event@42");
client.wipe_all();
Async
Every blocking verb except notify_many has an async form that returns a
std::future and runs on a background thread, so several calls can be in
flight at once. The async forms
are also safe to call from inside a listener or async callback, where a blocking
verb would throw AvisoErrorKind_InvalidUsage.
The future holds the same value the blocking verb returns: a JSON string for
notify_async, schema_async, and schema_for_async, and nothing
(std::future<void>) for wipe_stream_async, wipe_all_async, and
delete_notification_async. Calling .get() blocks for the result and rethrows
any aviso::Error.
std::future<std::string> published =
client.notify_async("test_event", identifier);
std::future<std::string> catalogue = client.schema_async();
std::cout << published.get() << '\n'; // rethrows aviso::Error on failure
std::cout << catalogue.get() << '\n';
When to reach for them
For publishing a batch, notify_many already sends concurrently and reports
per-item failures without throwing; see Publishing. It has no
async form because it does not need one. The async verbs earn their keep when
the requests are not all publishes, or when you have work to do between
starting them and collecting. Inside a callback they are the only option,
since a blocking verb throws there.
The pattern for many requests is: start them all, keep the futures, then
get() each one. A failed request throws from its own get() and the others
are unaffected, so wrap each get() in a try if one failure should not stop
you collecting the rest.
Complete examples
examples/cpp/async/01_basic.cpp
puts two requests in flight at once and times them.
examples/cpp/async/02_fan_out.cpp
starts ten publishes and a schema lookup, then collects them all in about one
round trip.
Triggers
A trigger is a per-notification side-effect attached to a listener. When a notification matches the listener’s filter, every configured trigger runs in declaration order. The core has six built-in trigger kinds:
| Kind | What it does | Use when |
|---|---|---|
| echo | Prints JSON | Inspect or pipe |
| log | Appends NDJSON | Save to a file |
| command | Runs a shell | Custom scripts |
| webhook | Sends HTTP | REST endpoints |
| teams | Posts a card | Teams channels |
| post | Posts a CloudEvent | Forward events |
- Echo writes pretty JSON on a terminal and NDJSON in a pipe, for tools
such as
jqor file ingestion. - Log keeps a local file for audit trails or later batch processing.
- Command runs
/bin/sh -c <rendered>withAVISO_*environment variables set. Use it for shell scripts or CLI integration. Unix only. - Webhook lets you choose the URL, headers, method, and body of the HTTP request.
- Teams builds a Microsoft Teams Adaptive Card and sends it by HTTP POST through Workflows or Power Automate.
- Post forwards the server’s CloudEvent by HTTP POST. Use it for pyaviso migration or generic CloudEvent receivers. See Body shape for how values and JSON formatting are preserved.
All six triggers share the same dispatch contract: retry budget,
required-vs-optional, timeout, and fail-fast policy. The
template engine is shared by command, webhook,
teams, and post.
Configuring triggers in listener YAML
A listener block carries a triggers: array. Each entry must have a type:
field; the other fields depend on the trigger kind.
listeners:
- name: my-listener
event: mars
identifiers:
class: od
triggers:
- type: echo
- type: webhook
url: "https://hooks.example.com/notify"
headers:
Authorization: "Bearer {{ env.HOOK_TOKEN }}"
When the listener receives a matching notification, both triggers run sequentially: echo prints to stdout, then webhook POSTs the notification.
Shared trigger options
retries and required are accepted by every trigger kind. timeout and
fail_fast are accepted only by command, webhook, teams, and post; the
YAML loader rejects them on echo and log (those triggers do not have a
meaningful timeout or fail-fast concept).
| Field | Type | Default |
|---|---|---|
retries | integer | 0 |
required | boolean | true |
timeout | duration | See below |
fail_fast | boolean | true |
Field meanings:
retries: additional attempts after the first failure. Total attempts =retries + 1. Backoff between attempts uses the supervisor’s standard exponential schedule with full jitter.required: whentrue, a final failure terminates the listener withClientError::TriggerFailed. Whenfalse, the failure is logged atWARNand the listener continues.timeout: per-trigger wall clock. Parsed as a humantime string (30s,2m,1h30m,500ms). Defaults to30sfor webhook, teams, and post. Absent by default for command.fail_fast: whentrue, deterministic failures bypass the retry budget. Whenfalse, every failure is retryable up to theretriesbudget.
A deterministic failure is one that produces the same outcome on every retry with the same notification and environment:
- For
command: non-zero exit code, template render error. - For
webhook,teams, andpost: 4xx HTTP status, template render error, invalid request setup.
Transient failures (5xx responses, transport errors, timeouts, I/O errors) use
the retry budget regardless of fail_fast because they can succeed on a retry.
Order, atomicity, and failure semantics
Triggers run sequentially in declaration order. aviso does not run triggers for the same notification in parallel.
During retry waits and between triggers, aviso honours Ctrl+C and exits cleanly. It does not interrupt a trigger halfway through its current attempt.
A required: true trigger that fails terminates that listener after exhausting
retries. Other listeners in the same aviso listen invocation continue running.
A required: false trigger that fails logs a warning and the listener
continues.
At-least-once delivery and trigger ordering
The supervisor advances the resume cursor (last_committed_sequence in
the state file) only after all required triggers
for a notification succeed. This means:
- If a notification has 3 triggers and only the first succeeds when the listener crashes, the cursor does NOT advance. On restart, the listener redelivers the notification, and ALL THREE triggers run again. Operators must design triggers to be idempotent.
- Optional triggers (
required: false) do not block cursor advancement. A failed optional trigger does not cause redelivery. - Triggers run BEFORE the notification is checkpointed to the state file. The trigger’s side-effects are durable-before-cursor-advance.
Picking the right trigger
| If you want to | Use |
|---|---|
| See notifications in your terminal during interactive testing | echo |
| Tail notifications into a local file | log |
| Run an arbitrary shell command per notification | command |
| POST to any HTTP endpoint with full control | webhook |
| Send to a Microsoft Teams channel | teams |
| Send an email per notification | command or webhook, see Sending email |
| Forward the unmodified CloudEvent that aviso-server emitted | post |
| Forward to multiple of the above | Combine - listeners accept multiple triggers |
Echo trigger
Writes the notification to standard output. Format adapts to whether stdout is a terminal or a pipe, so it works for both humans and tools.
Quick start without a YAML file
aviso listen --event <TYPE> --identifiers <JSON> runs a single ad-hoc listener
with a default echo trigger, no YAML required:
aviso listen --event mars --identifiers '{"class":"od"}'
aviso listen --event mars --identifiers '{"class":"od"}' | jq -r '.payload'
See
Publish and listen: listen with inline flags
for flag pairing, precedence, state store behaviour, and --from.
YAML
triggers:
- type: echo
retries: 0 # optional, default 0
required: true # optional, default true
No other configurable fields. timeout and fail_fast are not accepted because
echo has no network call or child process to time out.
TTY output (interactive)
new notification (listener: my-listener, trigger: echo):
{
"event_type": "mars",
"sequence": 42,
"identifier": {
"class": "od",
"date": "20260601",
...
},
"payload": { ... }
}
The leader line uses muted color when color is enabled. The JSON body stays
plain text so it can be copied into jq and similar tools.
The (listener: <name>, ...) segment appears when the listener carries a name.
Multi-listener configurations listening to the same event_type can interleave
deliveries on shared stdout; the leader names the listener so operators can
attribute each line. The label comes from three places, in priority order:
- An explicit
name:field on the listener in its YAML (name: mars-od→(listener: mars-od, trigger: echo)). - The fixed string
ad-hocwhen the listener was built via inline mode (aviso listen --event ... --identifiers ...). The startup banner readsListening for ad-hoc [mars] (class=od). Press Ctrl+C to stop.and every echo leader carries(listener: ad-hoc, trigger: echo). - Nothing (the bare leader
new notification (trigger: echo):) when the listener has noname:in its YAML.
Pipe output (machine consumers)
When stdout is not a TTY (piped, redirected to file, captured by a downstream tool), the echo trigger emits one line of compact NDJSON per notification:
{"event_type":"mars","sequence":42,"identifier":{"class":"od",...},"payload":{...}}
No leader line, no ANSI escapes, no whitespace, exactly one notification per
line. This is the contract aviso listen | jq and
aviso listen >> notifications.ndjson rely on.
Pipe-mode JSON has the same fields as the TTY body, only without whitespace.
Color control
The CLI’s global --color flag selects from three modes (interacts with the
NO_COLOR environment variable per its convention):
| Mode | Behavior |
|---|---|
--color never (default) | No color anywhere; no ANSI escapes emitted |
--color auto | Color enabled on TTY, disabled in pipe; respects NO_COLOR=1 |
--color always | Color enabled; pipe-mode body stays plain NDJSON |
Note: --color always does not force the human format when stdout is piped.
Pipe mode always emits compact NDJSON because downstream tools expect clean
JSON.
Failure modes
Echo can fail only when stdout cannot be written, for example when a downstream pipe closes or a redirected file is on a full disk.
Retry settings rarely help echo failures. A closed pipe or full disk usually needs an operator fix before the same write can succeed.
When to use
- Interactive testing:
aviso listenin a terminal, eyeballing notifications. - Pipelines:
aviso listen | jq '.payload'to extract a field across notifications. - Bulk capture for analysis:
aviso listen > notifications.ndjson.
When NOT to use
Log trigger
Appends each notification as one line of compact NDJSON to a user-specified
file. The shape matches what the echo trigger emits in pipe mode,
so a log file is interchangeable with aviso listen > notifications.ndjson
output.
YAML
triggers:
- type: log
path: /var/log/aviso/mars.log # required
retries: 0 # optional, default 0
required: true # optional, default true
Behavior
- The file is created on the first notification if it does not exist.
- The parent directory must exist. The log trigger does not create directories.
- The path is not template-rendered; it is a static string from the YAML.
- aviso keeps the file open while the listener runs.
- aviso flushes after each line so tailers see notifications promptly. It does
not force every write to disk with
fsync. - No rotation. Use
logrotateor your log platform if the file can grow for a long time.
Each line is one compact JSON notification followed by a newline. If several processes append to the same file, aviso does not coordinate between them. Very large notification lines can interleave. Use one log file per listener, or send output to a log aggregator, if that matters.
Failure modes
All these failures surface as TriggerError::Io. Opening errors occur at the
first dispatch; disk and file errors can occur while writing.
| Error | Message | Action |
|---|---|---|
| Missing parent directory | No such file or directory | Create the parent: mkdir -p $(dirname /path/to/log) |
| Path not writable | Permission denied | chown / chmod the parent + file |
| Disk full | No space left on device | Free space |
| Broken pipe / file vanished | I/O error | ls -ld $(dirname /path/to/log) to verify the directory still exists |
Check that the aviso user can write to the path. A full disk usually points to a wider problem; logs may only be the symptom.
The CLI surfaces these via a specific operator-facing hint:
Error in listener my-listener: trigger log(/var/log/aviso/mars.log) failed: io: No such file or directory (os error 2)
Hint: log trigger could not open `/var/log/aviso/mars.log`: No such file or directory (os error 2). Common causes: the parent directory does not exist (the log trigger does NOT create directories), or the path is not writable by the aviso user. Verify the parent exists and the user can write to it: `ls -ld $(dirname '/var/log/aviso/mars.log')`
Other listeners continue.
At-least-once and idempotency
The log trigger advances the resume cursor only after a write succeeds. A listener crash mid-write (rare) leaves the cursor un-advanced; on restart the listener redelivers and the same notification’s NDJSON line is appended a second time.
Downstream log processors should be ready for this. Filter on
event_type@sequence uniqueness if exact-once output matters.
When to use
- Local persistence: every notification on disk for later analysis.
- Audit trails: append-only file with file-level permissions enforcing read-only access for audit consumers.
- Batch processing pipelines: read the file with any NDJSON-aware tool (
jq -s, Pandasread_json(lines=True), etc.).
When NOT to use
- High-volume sustained writes: the trigger does not rotate; the file grows
unboundedly. Use
logrotate. - Multi-host aggregation: the trigger writes to a single local path. Use
webhookorpostto forward to a central log collector. - Strict-once semantics: log appends are at-least-once. Use a deduplicating
downstream consumer keyed on
event_type@sequence.
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 write | The 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:
| Variable | Value |
|---|---|
AVISO_EVENT_TYPE | The event type (e.g. mars) |
AVISO_SEQUENCE | The sequence number (decimal string) |
AVISO_IDENTIFIER_<KEY> | One per identifier field |
AVISO_PAYLOAD_JSON | The payload as compact JSON |
AVISO_NOTIFICATION_JSON | The 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 shellexecs 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"-tailsurvives even aftergrepis 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.
| Error | Fail-fast on | Fail-fast off |
|---|---|---|
Non-zero exit code (TriggerError::Command) | terminal | retryable |
Template render error (TriggerError::Template) | terminal | retryable |
Timeout (TriggerError::Timeout) | retryable | retryable |
I/O error spawning the child (TriggerError::Io) | retryable | retryable |
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
webhookfor 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_failedevent when template rendering fails); anyone with access to the DEBUG log sees the secret. The publicTriggerError::Commandcarries onlyexit_codeandstderr_tail, not the command itself, but the command’s own stderr can still leak secrets it echoed. Pass secrets viaenv:instead (env values are also redacted from the trigger’sDebugimpl and never echoed by the dispatcher).
Webhook trigger
Generic HTTP request per notification. Operators get full control of method, URL, headers, and body via the template engine. Use this trigger to forward notifications to any REST endpoint: Slack, Discord, PagerDuty, custom internal services, GitHub Actions, log aggregators, and so on.
YAML
triggers:
- type: webhook
# Required, template-rendered.
url: "https://hooks.example.com/notify"
# Optional; POST is the default.
method: POST
# Optional headers and body.
headers:
Authorization: "Bearer {{ env.WEBHOOK_TOKEN }}"
X-Source: "aviso-listener"
body_template: |
{
"seq": {{ notification.sequence }},
"payload": {{ notification.payload }}
}
# Optional; defaults to 30s.
timeout: 30s
# Optional; defaults to 0.
retries: 2
# Optional; both default to true.
required: true
fail_fast: true
Method, URL, headers
method: one ofGET,POST,PUT,PATCH,DELETE(uppercase required). DefaultPOST.url: template-rendered at dispatch time. Operators commonly use{{ env.WEBHOOK_URL }}to keep the URL out of the YAML. A{{ notification.* }}value in the URL is percent-encoded, so it cannot add a path segment or a query parameter, and it is refused in the scheme or authority, where it would be the host. Notification values belong in the path, query or fragment.headers: header NAMES are taken literally; header VALUES are template-rendered. The YAMLheadersblock is a map so each header name appears once; multi-value headers (e.g. multipleSet-Cookie) are not representable via the YAML config. Operators needing repeated header names should use the lib’sTrigger::webhook(...).header(name, value)programmatic builder, which is repeatable.
The dispatcher auto-injects Content-Type: application/json when the operator
does not supply one. If you set your own Content-Type, the dispatcher does NOT
override it.
Body template
When body_template is absent, the body defaults to the notification
serialised as compact JSON (matching the echo trigger’s pipe-mode
shape). This abbreviated example is wrapped for readability; the actual body
is compact JSON:
{
"event_type": "mars",
"sequence": 42,
"identifier": {...},
"payload": {...}
}
When body_template is set, that string is template-rendered at dispatch.
Two patterns are common:
The following fragments belong inside a webhook trigger. The selected-fields
example expects class and date in the notification’s identifier; the
metadata example embeds its payload as JSON.
Forward selected fields:
body_template: |
{
"who": "{{ notification.identifier.class }}",
"what": "{{ notification.event_type }}",
"when": "{{ notification.identifier.date }}"
}
Wrap with metadata:
body_template: |
{
"alert": {
"title": "aviso {{ notification.event_type }} #{{ notification.sequence }}",
"details": {{ notification.payload }},
"source": "aviso-listener"
}
}
When embedding JSON-typed notification fields (identifier, payload) inside a
JSON string literal, see
Template engine: value rendering
for the escaping rules - short version, embed them OUTSIDE a string field (as in
the example above), not inside "text": "...".
Retry classifier
The fail_fast setting controls which failures can be retried. It is enabled
by default (true); set it to false to disable it.
| Outcome | Fail-fast on | Fail-fast off |
|---|---|---|
| 2xx response | success | success |
| 4xx response | terminal (no retry) | retryable |
| 5xx response | retryable | retryable |
| Transport error | retryable | retryable |
| Timeout | retryable | retryable |
| Request build error | terminal | retryable |
| Template render error | terminal | retryable |
Transport errors include DNS, TCP, TLS, and mid-stream interrupts. A request build error means the HTTP client refused the rendered request, for example an invalid URL or bad header. With fail-fast off, every failure is retryable, even deterministic request-build or template errors that will fail identically for the same notification. The dispatcher still honours the retry budget.
Terminal failures bypass the retries budget. Retryable failures are retried up
to retries + 1 total attempts with the supervisor’s exponential backoff (250
ms base, doubling, 30 s cap, full jitter).
Response body capture
The response body is captured into a 4 KiB ring buffer via streaming chunks. Operators see the tail of the response on a failure error. This is essential for debugging 4xx responses from services that include validation details in the body.
Example failure surface for a 4xx, wrapped for readability (not exact output line breaks):
Error in listener my-listener: trigger webhook failed: webhook:
status=400 body_tail={"error":"missing_required_field","field":"alert.title"}
Hint: webhook returned 4xx (400 Bad Request) which is TERMINAL
(no retries) per the dispatcher contract: 4xx means the receiver
rejected the request, retrying with the same notification will
fail identically. Check the webhook URL, headers, and body_template;
the receiver's response body is included above and may name the
specific field that failed validation.
Other listeners continue.
The 4 KiB cap is per-response, not per-listener-session. A server streaming a multi-gigabyte body in the timeout window does not cause memory bloat: the ring buffer caps at 4 KiB regardless of total body size.
Security: secret-bearing surfaces
The URL, header values, and body template can carry secrets (bearer tokens,
signed payloads, embedded API keys). The dispatcher’s Debug impl for the
trigger config redacts all three. Compiled templates use the markers below;
failed compilation uses <bad-url-template-redacted> or
<bad-body-template-redacted>. Headers appear only as a count. An absent body
template appears as <default-notification-json>.
Formatting example, expanded and wrapped for readability:
WebhookConfig {
url_template: <compiled-url-template-redacted>,
method: Post,
header_count: 2,
body_template: <compiled-body-template-redacted>
}
This redaction applies to the trigger config’s Debug representation, including
templates that failed to compile. It is not a guarantee for all error or log
content: template diagnostics can include expressions at DEBUG level, and a
receiver’s response body is included in failure errors.
TLS
The webhook reuses the supervisor’s shared reqwest::Client. Any TLS
configuration (--ca-bundle, --danger-accept-invalid-certs) inherits
automatically.
Examples
Slack incoming webhook
triggers:
- type: webhook
url: "{{ env.SLACK_WEBHOOK_URL }}"
body_template: >-
{"text": "aviso {{ notification.event_type }}
sequence {{ notification.sequence }}"}
Discord webhook
triggers:
- type: webhook
url: "{{ env.DISCORD_WEBHOOK_URL }}"
body_template: >-
{"content": "aviso {{ notification.event_type }}
sequence {{ notification.sequence }}"}
Internal alerting service
triggers:
- type: webhook
url: "https://alerts.example.org/api/v1/incidents"
method: POST
headers:
Authorization: "Bearer {{ env.ALERT_TOKEN }}"
X-Source: "aviso-mars"
body_template: |
{
"title": "Mars notification #{{ notification.sequence }}",
"severity": "info",
"tags": ["aviso", "mars", "{{ notification.identifier.class }}"]
}
retries: 3
timeout: 10s
GitHub Actions repository dispatch
triggers:
- type: webhook
url: "https://api.github.com/repos/example/repo/dispatches"
method: POST
headers:
Authorization: "Bearer {{ env.GITHUB_TOKEN }}"
Accept: "application/vnd.github+json"
body_template: |
{
"event_type": "aviso",
"client_payload": {
"sequence": {{ notification.sequence }},
"event": "{{ notification.event_type }}"
}
}
When to use
- Any HTTP endpoint without a kind-specific shortcut.
- Custom body shapes that don’t match
teamsorpost. - Multi-step workflows where each step is a separate webhook.
When NOT to use
Teams trigger
Auto-builds a Microsoft Teams Adaptive Card from the notification and POSTs it to a Teams Workflows endpoint. Shortcut over the webhook trigger; saves operators ~30 lines of Adaptive Card boilerplate per listener.
YAML
triggers:
- type: teams
url: "{{ env.TEAMS_WEBHOOK_URL }}" # required
title_template: "Custom title {{ notification.event_type }}" # optional
retries: 2 # optional, default 0
required: true # optional, default true
timeout: 30s # optional, default 30s
fail_fast: true # optional, default true
Setting up the Teams webhook
Microsoft is deprecating the legacy “Incoming Webhook” connector; the current recommendation is Workflows (Power Automate).
- Open the Teams channel where notifications should arrive.
- Click the
⋯(More options) next to the channel name. - Workflows → search “Post to a channel when a webhook request is received”.
- Connect to your Teams and select the channel.
- Click Add workflow. Teams shows the HTTPS URL once; save it carefully.
The URL looks like:
https://prod-XX.YYY.logic.azure.com:443/workflows/.../triggers/manual/paths/invoke?api-version=1&sp=...&sv=1.0&sig=...
The sig= query parameter is a SAS token. Treat the entire URL as a secret:
anyone with it can post to your channel.
Card body
The Adaptive Card aviso builds for each notification has three sections:
- Title TextBlock - rendered from
title_template. Default:aviso {{ notification.event_type }} #{{ notification.sequence }}. - Identifier FactSet - one row per identifier field plus
EventandSequencerows (auto-generated from the notification’s runtime data). - Payload section - when the payload is an object, another FactSet with one
row per payload key; when scalar/array, a monospace TextBlock with the JSON;
when
null, the section is omitted.
Illustrative card content for a mars event with payload
{"seed": "x", "count": 5}. Teams controls the card’s final appearance:
aviso mars #42
Event: mars
Sequence: 42
class: od
date: 20260601
domain: g
...
Payload
seed: x
count: 5
The card is built programmatically at dispatch time using the notification’s runtime data, so it correctly handles any identifier field set or payload shape. Operators don’t need to write the Adaptive Card JSON themselves.
Title template
The title_template is rendered via the template engine
at dispatch:
# Default
title_template: "aviso {{ notification.event_type }} #{{ notification.sequence }}"
# Custom with identifier fields
title_template: "ECMWF {{ notification.event_type }} step={{ notification.identifier.step }}"
# With env-var prefix for environment tagging
title_template: "[{{ env.DEPLOYMENT_TIER }}] aviso {{ notification.event_type }}"
JSON-special characters in the title (", \) are correctly escaped before
embedding in the card; operators don’t need to escape them manually.
Securing the URL
Webhook URLs are secrets. Two safe patterns:
Env var (recommended):
triggers:
- type: teams
url: "{{ env.TEAMS_WEBHOOK_URL }}"
Then:
export TEAMS_WEBHOOK_URL='https://prod-XX.../workflow'
aviso listen my-listeners.yaml
Gitignored separate listener file: keep the listener YAML with the URL
outside the repo, or in a .gitignore’d directory; the CLI accepts the path as
a positional argument.
How it relates to other triggers
Internally, the Teams trigger is sugar over webhook. The
dispatch flow:
- Render the title via the template engine.
- Build the Adaptive Card body programmatically (using the notification’s identifier and payload).
- Synthesise a webhook config with the body pre-rendered, method
POST,Content-Type: application/json. - Delegate to the webhook dispatcher.
Operators wanting full control of the card shape (extra sections, custom colors,
action buttons) should use the webhook trigger directly with a
hand-written body_template. The Teams trigger is the shortcut for the common
case; the webhook trigger is the way to express anything else.
Failure modes
Inherits all of webhook’s error semantics. Common Teams-specific issues:
| Symptom | Cause | Fix |
|---|---|---|
| HTTP 400 | Body shape mismatch | Check the schema |
| HTTP 401/403 | Expired token or deleted workflow | Regenerate the URL |
| HTTP 202, no card | Downstream flow error | Check run history |
| Plain-text card | Wrong output schema | Use Adaptive Card |
- HTTP 400: a customised Workflow may expect a different body schema. Use
the default Adaptive Card shape or switch to
webhookwith a hand-written body. - HTTP 401/403: regenerate the URL in Teams and update the environment variable. The URL’s SAS token may have expired, or the workflow was deleted.
- HTTP 202 with no card: the Workflow accepted the request, but a later step failed. Open its run history in Power Automate to inspect the error.
- Plain-text card: check that the receiver Workflow’s response schema uses Adaptive Card output.
When to use
- Microsoft Teams channels via Workflows / Power Automate.
- Minimal YAML config (URL + optional title).
- Don’t want to hand-write Adaptive Card JSON.
When NOT to use
- You need custom Adaptive Card features (action buttons, custom colors,
multiple FactSets, images, conditional sections): use
webhookwith a hand-writtenbody_template. - The legacy “Incoming Webhook” connector instead of Workflows: the body shape
is different (legacy uses
MessageCard, Workflows uses Adaptive Card). For legacy, usewebhookwith theMessageCardbody. Microsoft is deprecating legacy connectors anyway. - Non-Teams receivers: use
webhook.
Post trigger
HTTP POST per notification with the CloudEvent envelope from aviso-server as the
request body. Custom headers are supported. Use this when migrating from
pyaviso’s post trigger or sending to any receiver that expects CloudEvents.
YAML
triggers:
- type: post
url: "https://receiver.example/aviso" # required
headers: # optional
Authorization: "Bearer {{ env.RECEIVER_TOKEN }}"
X-Source: "aviso-listener"
retries: 2 # optional, default 0
required: true # optional, default true
timeout: 30s # optional, default 30s
fail_fast: true # optional, default true
No body_template field: the body is always the CloudEvent envelope. For
arbitrary body shapes, use the webhook trigger directly. No
method field: always POST.
Body shape
When the notification comes from the live watch stream, the body is the CloudEvent envelope aviso-server sent, including its server-side fields:
{
"specversion": "1.0",
"type": "int.ecmwf.aviso.mars",
"source": "https://aviso.example.org",
"id": "mars@42",
"time": "2026-05-23T22:28:52.384985528Z",
"datacontenttype": "application/json",
"dataschema": "https://aviso.example.org/schema/mars",
"data": {
"identifier": {
"class": "od",
"date": "20260601",
...
},
"payload": { ... }
}
}
Fields worth knowing:
typeis per-event-type (int.ecmwf.aviso.mars,int.ecmwf.aviso.dissemination, etc.). Downstream routing logic that branches ontypeworks correctly.timeis the server’s emission timestamp with nanosecond precision. Receivers can use it for ordering and latency measurement.sourceis the aviso-server URL. Receivers can identify which server (production vs staging) emitted the event.dataschemais a URL pointing at the schema definition; receivers can fetch it for validation.
aviso preserves these server-provided values instead of rebuilding the
CloudEvent locally. Whitespace and object-key order may differ because aviso
parses and writes the JSON again; field values stay the same. Use the
webhook trigger with a hand-written body_template if
byte-for-byte output matters.
Fallback body
A live aviso listen run forwards the server’s CloudEvent envelope.
Notifications built directly in library tests or custom code may not carry that
envelope; in that case the post trigger falls back to a minimal CloudEvent body.
Headers
| Header | Default | When |
|---|---|---|
Content-Type | application/cloudevents+json | Auto-injected when the operator does not set their own |
| Operator-supplied headers | template-rendered at dispatch | As declared in YAML |
The auto-injected Content-Type matches the CloudEvents structured-mode content
type. Official CloudEvents SDKs can parse it directly.
If the operator sets Content-Type explicitly, that wins:
triggers:
- type: post
url: "https://receiver.example/aviso"
headers:
Content-Type: "application/json" # explicit, overrides the default
Migrating from pyaviso
pyaviso’s post trigger forwards the notification as a POST body. The aviso
(Rust) equivalent matches the same wire contract:
| pyaviso config | aviso YAML |
|---|---|
type: post | type: post |
url: ... | url: "..." |
headers: {...} | headers: {...} |
| AWS-specific options (S3, SNS) | not supported |
For AWS-specific options, use a custom receiver or the webhook
trigger with a hand-written body.
The dispatched body shape matches pyaviso’s (CloudEvent envelope with
specversion, type, source, id, time, data). Downstream receivers
built for pyaviso work without changes.
Examples
Generic CloudEvent receiver
triggers:
- type: post
url: "https://events.example.com/ingest"
headers:
X-Service-Auth: "{{ env.INGEST_SERVICE_TOKEN }}"
Knative Eventing
triggers:
- type: post
url: "http://broker-ingress.knative-eventing.svc.cluster.local/default/aviso"
# Content-Type defaults to application/cloudevents+json which Knative consumes natively
Internal CloudEvent-aware queue
triggers:
- type: post
url: "https://events.example.org/queue/aviso"
headers:
Authorization: "Bearer {{ env.QUEUE_TOKEN }}"
X-Routing-Key: "aviso.{{ notification.event_type }}" # custom routing on the receiver side
retries: 5
timeout: 10s
Failure modes
Inherits all of webhook’s error semantics:
- 4xx terminal under
fail_fast: true(default) - 5xx retryable
- Transport errors retryable
- Timeout retryable
- Template render errors terminal (only
urland header values are templated; the body is not)
The webhook hint dispatcher fires for post trigger failures too, with the same operator-facing wording.
When to use
- pyaviso migration: matching wire shape.
- Generic CloudEvent receivers (Knative Eventing, ArgoEvents, CloudEvent-aware message brokers).
- Receivers that want the server’s actual
type/source/time/dataschema(not a reconstruction).
When NOT to use
Template engine
The shared template engine that command, webhook, teams, and post
triggers use to substitute notification fields and environment variables into
their templated inputs.
Syntax
Two expression forms inside {{ ... }}:
{{ notification.<dotted.path> }} -- substitutes a notification field
{{ env.<NAME> }} -- substitutes a process environment variable
Literal {{ is escaped as \{{. Everything outside {{ ... }} is passed
through verbatim.
Notification paths
The notification namespace walks the notification’s serialised JSON tree.
Empty path means the whole notification:
| Expression | Resolves to |
|---|---|
{{ notification }} | The whole notification as compact JSON: {"event_type":"mars","sequence":42,...} |
{{ notification.event_type }} | The event_type string, unquoted: mars |
{{ notification.sequence }} | The sequence as a numeric-string: 42 |
{{ notification.identifier }} | The identifier object as compact JSON: {"class":"od","date":"20260601",...} |
{{ notification.identifier.class }} | A specific identifier value, unquoted: od |
{{ notification.payload }} | The payload as compact JSON: {"seed":"x"} (or null) |
{{ notification.payload.seed }} | A specific payload field, unquoted: x |
Paths can go arbitrarily deep into nested objects. Array indexing is not supported (the engine walks object keys only).
Env paths
url: "{{ env.WEBHOOK_URL }}"
body_template: '{"token": "{{ env.SECRET }}"}'
{{ env.<NAME> }} reads std::env::var(NAME). Two failure modes:
- Variable not set:
TemplateErrorKind::EnvNotSet. Operator mustexport NAME=valuebefore running. - Variable set but not UTF-8:
TemplateErrorKind::EnvNotUnicode. Rare; usually means a misconfigured deployment.
Both surface as TriggerError::Template at first dispatch (the constructor is
infallible; errors are deferred to render time).
Value rendering rules
How each JSON value type renders into the template’s output:
| JSON type | Rendered as | Example |
|---|---|---|
| String | The string contents, unquoted | od (NOT "od") |
| Number | Decimal representation, unquoted | 42, 3.14, -7 |
| Boolean | true or false | true |
| Null | Literal four-character string null | null |
| Object | Compact JSON, including the surrounding braces | {"class":"od","date":"20260601"} |
| Array | Compact JSON | ["a","b","c"] |
These are the values as rendered for a JSON body or a header. When the text is a shell command or a URL, each notification value is also neutralised for that use; see Where the text goes.
The string-unquoted rule is the load-bearing one for safe embedding in JSON bodies. Compare:
"value": "{{ notification.identifier.class }}"
↓ renders ↓
"value": "od" ← valid JSON; the surrounding quotes ARE the JSON string delimiters
vs. trying to embed an object value inside a JSON string:
"value": "{{ notification.identifier }}"
↓ renders ↓
"value": "{"class":"od","date":"20260601"}" ← INVALID JSON; inner quotes break the string
For object/array values, embed them outside a JSON string field:
"identifier": {{ notification.identifier }}
↓ renders ↓
"identifier": {"class":"od","date":"20260601"} ← valid JSON; object is a JSON value, not a string
Where the text goes
Notification values are written by the publisher, not by you. Each place a
rendered template is used has its own idea of which characters are special, so
the engine neutralises every {{ notification.* }} value for that place:
| Rendered text is | Notification values are |
|---|---|
a command: string | quoted for the shell context they land in: wrapped in single quotes when bare, ' escaped inside single quotes, and backslash, $, backtick and " escaped inside double quotes. The shell reads the value as one literal argument. A placeholder after a here-document, arithmetic expansion or backticks is refused with ValueAfterUnsupportedShellSyntax; see the Command trigger page. |
a webhook url: | percent-encoded (letters, digits, -, _ and ~ kept; . is encoded too so .. cannot fold the path), so a value cannot add or remove a path segment or add a query parameter. A notification placeholder in the scheme or authority, where the value would be the host, is refused with ValueInUrlAuthority. Put the host in the template or in an {{ env.* }} value and notification values in the path, query or fragment. |
| a header value or a body | inserted as written. You supply the encoding around the value, for example the quotes of a JSON string. |
{{ env.* }} values are your own and are inserted as written everywhere.
Common patterns
Pass-through scalar identifier values
body_template: '{"who": "{{ notification.identifier.class }}", "what": "{{ notification.event_type }}"}'
Renders to: {"who": "od", "what": "mars"}
Pass-through whole identifier as nested object
body_template: '{"id": {{ notification.identifier }}, "seq": {{ notification.sequence }}}'
Renders to: {"id": {"class":"od","date":"20260601",...}, "seq": 42}
Pass-through whole notification
body_template: '{{ notification }}'
Renders to:
{"event_type":"mars","sequence":42,"identifier":{...},"payload":{...}}
This is the default body when body_template: is omitted on the
webhook trigger. The echo trigger emits the same
shape in pipe mode.
Secret-bearing URL
url: "{{ env.WEBHOOK_URL }}"
The URL never appears in YAML, in logs, or in error messages. Set the env var in your deployment:
export WEBHOOK_URL='https://hooks.example.com/notify?token=xxx'
Conditional content via env tagging
title_template: "[{{ env.DEPLOYMENT_TIER }}] aviso {{ notification.event_type }}"
{{ env.DEPLOYMENT_TIER }} can be prod, staging, dev, etc., setting
different prefixes per deployment.
Error categories
Template errors fall into one of seven TemplateErrorKind values, surfaced via
TriggerError::Template { context, field, kind }:
| Kind | When | Example template |
|---|---|---|
Missing | A {{ notification.<path> }} resolved to nothing | {{ notification.identifier.nonexistent }} when the notification has no nonexistent field |
EnvNotSet | A {{ env.<NAME> }} variable is not in the process environment | {{ env.UNDEFINED_VAR }} when UNDEFINED_VAR is not exported |
EnvNotUnicode | A {{ env.<NAME> }} variable’s value is not valid UTF-8 | (rare; usually a misconfigured deployment) |
BadSyntax | Template parse failure: unclosed {{, empty path segment, unknown namespace | {{ unclosed, {{ notification..empty }}, {{ unknown.foo }} |
ValueInUrlAuthority | A {{ notification.<path> }} in the scheme or authority of a webhook URL | https://{{ notification.payload.host }}/hook |
ValueAfterUnsupportedShellSyntax | A {{ notification.<path> }} in a command after a here-document, $(( )), backticks or case, directly after a $, inside ${ }, after a redirection operator, as the command name, or as an argument to eval, trap, . or sh -c; field names the reason | cat <<EOF ... EOF; run {{ notification.sequence }} |
NotificationEncode | Notification could not be serialised to JSON | Practically unreachable |
NotificationEncode occurs when resolving a path. It is practically
unreachable for well-typed notifications. The variant points the diagnosis at
the notification rather than a missing-path template bug.
All seven are terminal under fail_fast: true (the default): retrying with
the same notification and environment will produce the same template error.
The context carried on TriggerError::Template is the safe static label
("webhook url", "command", "teams title", etc.) of where the failure
occurred. field carries the JSON path / env-var name / parse-failure category
(whichever applies to the kind). Neither echoes the raw template, which may
carry secrets.
What the template engine does NOT support
- Conditionals - no
{% if %}/{% else %}. Use Rust code outside aviso if you need branching. - Loops - no
{% for %}over object keys or array elements. Templates render specific paths; the teams and post triggers iterate identifier fields at dispatch time via Rust code (not via the template engine). - Filters / pipes - no
{{ value | uppercase }}or{{ value | json }}. Escaping for the shell and for URLs is applied by the engine according to where the text goes, so no filter is needed for that. Operators wanting other transformations should do them in the receiver. - Macros / includes - templates are flat strings; no recursion.
This is intentional: a more featureful template engine adds attack surface and
complexity for marginal value. Operators wanting full programmability should use
the command trigger (which runs arbitrary shell code) or
process the notification downstream.
Trigger examples
Recipes that combine the built-in triggers for common tasks. Each one is configured like any other trigger: in the CLI listener YAML, or through the Rust, Python, and C++ trigger builders.
- Sending email - deliver notifications by email through the command or webhook trigger.
Sending email
aviso has no built-in email trigger. Email is one more per-notification side effect, sent through the generic triggers: you point a trigger at your own SMTP relay or mail endpoint and supply the credentials, and aviso runs the generic client (it ships no managed-service email client).
Two paths, depending on what you have:
- The command trigger driving a local SMTP client (
msmtp,sendmail,mailx). Unix only, but the most direct SMTP route. - The webhook trigger posting to your mail gateway’s HTTP API. Cross-platform.
Either way, email is not idempotent: at-least-once delivery means a
redelivered notification (for example after a crash before the cursor is
checkpointed) sends a second message. required: false keeps an email failure
from terminating the listener or forcing a retry-on-restart, but it does not
prevent duplicate sends. If duplicate emails are unacceptable, deduplicate on
event_type@sequence. See Idempotency.
With the command trigger (SMTP)
msmtp is a small self-contained SMTP client. Put the relay host, port, TLS,
and credentials in its own config (~/.msmtprc), so no secret lives in the
listener YAML, then template the message from the notification:
listeners:
- name: ops-email
event: mars
triggers:
- type: command
command: >
printf 'From: %s\nTo: %s\nSubject: aviso %s #%s\n\n%s\n'
"$MAIL_FROM" "$MAIL_TO" "$AVISO_EVENT_TYPE" "$AVISO_SEQUENCE" "$AVISO_NOTIFICATION_JSON"
| msmtp --from="$MAIL_FROM" "$MAIL_TO"
env:
MAIL_FROM: aviso@example.com
MAIL_TO: ops@example.com
required: false # email is non-idempotent; do not force redelivery
timeout: 30s
retries: 2 # transient SMTP failures are retried
The notification fields arrive as the AVISO_* environment variables the
command trigger injects: AVISO_EVENT_TYPE, AVISO_SEQUENCE,
AVISO_NOTIFICATION_JSON, and so on. See the
command trigger reference
for the full list. Keep credentials out of the YAML: msmtp reads them from its
own config, or
export them in the environment running aviso and reference them as $VAR in the
command (the child inherits the environment). If your host already has a
configured MTA, printf ... | sendmail -t or mailx works the same way.
With the webhook trigger (HTTP)
If you reach mail through an HTTP endpoint (a self-hosted gateway, an internal
relay’s REST API), use the webhook trigger with a body_template matching the
endpoint and the auth header from the environment:
triggers:
- type: webhook
url: "https://mail-gateway.example.org/send"
headers:
Authorization: "Bearer {{ env.MAIL_TOKEN }}"
Content-Type: "application/json"
body_template: >
{"to": "ops@example.com",
"subject": "aviso {{ notification.event_type }} #{{ notification.sequence }}",
"text": "aviso {{ notification.event_type }} #{{ notification.sequence }} fired"}
required: false
This is cross-platform and goes through the same
template engine as the other HTTP triggers. That engine
substitutes values verbatim and does not quote or JSON-escape them, so keep
substitutions in quoted, scalar positions (event_type, sequence) as above.
Avoid dropping a raw {{ notification.payload }} into the body: a string or
number payload renders unquoted and produces invalid JSON, while an object or
array payload stays valid JSON but changes the field’s type and likely violates
the receiver’s schema. If the body needs the full payload, send it through the
command trigger using AVISO_PAYLOAD_JSON / AVISO_NOTIFICATION_JSON instead.
From code
These are ordinary triggers, so the same two recipes work from the library
builders, not just the CLI YAML: Trigger::command(...) and
Trigger::webhook(...) in Rust and the C++ binding, and
the equivalent trigger arguments in the Python package. The CLI YAML above is
just the declarative form of those builders.
Notifications
A notification tells you that something happened, such as a dataset becoming available. A data provider publishes it to the server. You receive matching notifications with a listener, then use their details in your analysis or in triggers, actions that aviso runs for you.
The notification usually describes the data rather than containing the dataset itself. A file location in a notification does not download the file for you.
The four fields you care about
| Field | What it is |
|---|---|
event_type | Kind of event, such as mars. |
sequence | Number used to resume listening. |
identifier | Named values describing the event. |
payload | Extra information from the provider. |
Reading a notification
This example uses the
small quickstart schema.
It has a required class filter and an optional step filter. Data providers
supply both identifiers when publishing. Here is the original server message
printed by Python’s print(notification); the id and publication time vary:
{
"data": {
"identifier": {
"class": "od",
"step": "12"
},
"payload": {
"location": "file:///data/forecast.grib"
}
},
"datacontenttype": "application/json",
"dataschema": "https://aviso.example/schema/mars",
"id": "mars@1",
"source": "https://aviso.example",
"specversion": "1.0",
"time": "2026-09-15T15:15:15.799578652Z",
"type": "int.ecmwf.aviso.mars"
}
This format is called a CloudEvent. The client exposes event_type as mars
and sequence as 1, taken from the message’s id. The data object holds
the identifiers and payload. The server represents scalar identifiers such as
step as strings. source identifies the service, and time is publication
time, not a forecast date.
The CLI’s default echo output uses the four client fields in the table above, not the original CloudEvent. When piped to another command, it writes one JSON object per line. See Python listening for a complete script and how to access individual fields.
Identifiers
Identifiers describe what a notification concerns. Different values distinguish different data, but the same values can appear in more than one notification.
Which fields belong in the map depends on the event type’s schema. The small
mars example has class and step. Your service may define more forecast
fields, or use a different event type. Check the schema before choosing a
filter.
The server determines which identifiers are valid; aviso just passes them along.
Values may have any JSON shape. For spatial identifiers a point is [lat, lon],
while polygons and point clouds are [[lat, lon], ...]. That shape is preserved
when a notification is published, delivered, printed, or passed to a trigger.
To see the schema for a type:
aviso schema get mars
Filtering
When you listen, you pass aviso an identifier map that acts as a filter. The server returns only notifications whose identifier matches every field you set:
aviso listen --event mars --identifiers '{"class":"od","step":12}'
You get notifications with class=od and step=12. Identifiers you do
not mention act as wildcards (subject to the schema’s required: true fields,
which the server still demands).
The full filter rules, including spatial filters, are at Filters.
Sequence numbers and ordering
The server assigns increasing sequence numbers within each event type. They are 64-bit whole numbers. A filtered listener can skip numbers because other notifications do not match its filter.
Do not assume every delivery arrives in increasing order. Repeated or older notifications can still reach your code and triggers. Saved resume positions only move forwards. See Resume and state for the limits of recovery after a crash.
The sequence is also part of the notification id, such as mars@42. Server
operators use that id for administration.
Payloads
The payload is whatever the publisher put there. aviso does not interpret it, validate it, or modify it. Common patterns:
- A
locationURL pointing at where a freshly written dataset lives. - A small JSON object with the publisher’s own metadata.
null, when the event itself is all there is to know.
In a trigger, you reach the payload as notification.payload:
triggers:
- type: webhook
url: "https://hooks.example/notify"
body_template: '{"event": "{{ notification.event_type }}", "payload": {{ notification.payload }}}'
What next
- Streams: how aviso talks to the server.
- Filters: the matching rules.
- Triggers overview: what aviso does with each notification.
Filters
A filter selects the notifications you want to receive. For example, you might want only one forecast class or only observations inside an area. Every condition you give must match; this is what combining conditions with AND means.
First check the event’s schema, the server’s rules for its identifier fields,
with aviso schema get <TYPE>. Include every field required for listening.
Your server operator supplies the schema. As a listener, you do not need
permission to publish data or change it.
Filters use supported identifier fields, not values inside the payload. Spatial
filters can use different field names: polygon can select point clouds, and
point can select polygons. See Spatial filters.
A scalar filter
A scalar is a single value, such as od, rather than a list or range. These
examples use the
small quickstart schema.
It requires class in filters and lets you omit step to receive all steps.
listeners:
- name: mars-od
event: mars
identifiers:
class: od
step: 12
triggers:
- type: echo
This listener gets notifications where class=od and step=12. Fields
you do not mention act as wildcards (subject to the server’s required: true
rule, below). The echo trigger prints each match. Save this as a listener
configuration and pass its path with aviso listen --config <PATH>.
Inline equivalent:
aviso listen --event mars --identifiers '{"class":"od","step":12}'
Required vs optional identifiers
The schema for each event type declares which identifier fields are
required: true and which are required: false. The flag means different
things on the listen side and on the notify side.
- When you listen,
required: truefields must appear in the filter.required: falsefields can be omitted; an omitted field acts as a wildcard so the server returns every value. - When you publish, every identifier in the schema is required, regardless
of the flag. Omitting any of them returns
400with aRequired field '<name>' missing for notify operationmessage. Therequired: falseflag only lets listeners omit a field. Providers still supply it when publishing.
To see which fields are required for an event type:
aviso schema get mars
Constraint filters
Constraints let you select a range or a choice of identifier values. They work in both listen and replay. Data providers publish actual values, not conditions. Use the event’s schema to choose valid fields and types; the server operator controls that schema, not the client.
The weather tutorial provides the schema and five synthetic records. This filter selects records B and C:
{
"date": "20260913",
"severity": {"gte": 5},
"anomaly": {"between": [40, 50]},
"region": {"in": ["north", "south"]}
}
Read it as: on this date, severity is at least 5 and anomaly is from 40
through 50 and region is either north or south. Fields combine with AND; the
choices inside one in combine with OR. Keep the required date even when
other fields use constraints.
Operators
| Operator | Meaning | Example field value |
|---|---|---|
eq | Equal to | {"eq": 6} |
in | Equal to any listed value | {"in": [3, 7]} |
gt | Greater than | {"gt": 6} |
gte | Greater than or equal to | {"gte": 6} |
lt | Less than | {"lt": 6} |
lte | Less than or equal to | {"lte": 6} |
between | Within an inclusive range | {"between": [5, 7]} |
The schema calls each field’s validation rules a handler. IntHandler is for
whole numbers, FloatHandler for numbers that can include decimals, and
EnumHandler for a fixed list of choices.
IntHandlerandFloatHandlersupport all seven operators.EnumHandlersupports onlyeqandin, with strings from itsvalueslist. Matching ignores case, but does not trim whitespace:"NORTH"matches"north";" north "is not the same value.- Use exactly one lowercase operator per object. To bound a range, use
between, not an object containing bothgteandlte. inneeds a nonempty list.betweenneeds exactly two endpoints in ascending order; equal endpoints are allowed. Both endpoints are included.- Values inside a constraint must fit the limits in the field’s schema.
IntHandlerneeds JSON whole numbers, not5.0, quoted strings, or booleans (true/false values).FloatHandleraccepts finite JSON numbers, including whole numbers and decimals, but not infinity or NaN (“not a number”). For either numeric handler, write{"gte": 5}, not{"gte": "5"}.EnumHandlerneeds strings from its list of choices. - Keep objects as objects.
"{\"gte\":5}"is a string, not a constraint. Other handlers, such as dates and plain strings, do not accept these objects.
For numeric and enum fields, a single value selects one exact match. The server
can convert a quoted number used on its own to a numeric value. Inside a
numeric constraint, however, quoted numbers are rejected. Float eq and in
require numbers to match exactly; they do not allow a small difference. Use
between to accept numbers within a range.
The empty filter
An empty filter asks for all values. This is only accepted when the schema has
no required filter fields. For a mars schema with required class, the
following command demonstrates a rejected request:
aviso listen --event mars --identifiers '{}'
With the example schema, the server rejects this request because class is
missing. Include class to receive all steps for that class. Other schemas may
have different required fields; check yours before using an empty filter.
Spatial filters
Use these examples with the spatial schemas in Publish and listen. A point is one location, a polygon is a closed boundary around an area, and a point cloud is a collection of locations.
Spatial identifiers use latitude-longitude arrays. A point is [lat, lon]. A
polygon needs at least four pairs, with the first pair repeated last. A point
cloud is also a list of points, but does not describe a ring and does not need a
closing repeat. Duplicate cloud points are valid and their order is preserved.
identifiers:
polygon:
- [46, 8]
- [46, 9]
- [47, 9]
- [47, 8]
- [46, 8]
You get notifications whose geometry overlaps the polygon.
On the command line, the same value comes through with quoting because of the embedded commas:
aviso listen --event test_polygon \
--identifiers '{"polygon":[[46,8],[46,9],[47,9],[47,8],[46,8]]}'
Providers publish point clouds using the same nested-array shape:
{"point_cloud":[[46,8],[47,9],[46.5,8.5]]}
Subscribers use polygon, not point_cloud, to select clouds with any point
inside or on the polygon boundary. See
Publish and listen for the complete request and
schema. Subscribers to polygon events can use polygon for overlap or point
for containment. Run aviso schema get <TYPE> to see the published identifier
fields and their types.
When the filter does not match
If you set a field to a value the server’s schema does not allow, the server rejects the request with a clear error. aviso surfaces the message verbatim. The server also rejects unknown filter fields. Use the schema’s supported fields and the spatial filter names described above.
If you set a field to a value that is allowed but matches nothing right now, the listener waits. New matching notifications will arrive when they happen.
How the filter affects resume
The key in the state file is calculated from the server URL, event type and filter. The client puts filter values in a consistent form before calculating it. Different filters can have different saved positions, called cursors.
A listener with class=od resumes independently from one with class=rd.
If you change the filter, the next run looks for a saved position for that
filter, even if the listener name stays the same. An explicit --from takes
precedence. With no explicit start and no matching saved position, the listener
starts from “now”.
What next
- Notifications: the shape of what arrives.
- Resume and state: how the filter affects the cursor.
- CLI publish and listen: the commands that use filters.
Streams and reconnects
When you run aviso listen, aviso opens a long-lived HTTP connection to the
server and receives notifications as they arrive. This connection is called a
stream. The server sends messages over it using Server-Sent Events (SSE).
The listener first reads any requested history that the server still stores, then waits for new matches. Without a starting position or saved state, it waits for new notifications. Replay-only requests stop after stored history.
aviso reconnects automatically after routine interruptions. Backoff means waiting before another attempt, so it does not repeatedly contact a busy server. The details below help explain pauses and errors.
Confirming a stream
The client accepts only HTTP 200 with the text/event-stream media type. Media
type matching ignores case and accepts parameters such as charset=utf-8.
It then waits for an Aviso opening control: connection_established on a
live-only watch, or replay_started when reading history. A saved resume cursor
also makes the request historical. Every reconnect validates its own opening.
Headers and confirmation share a ten-second deadline per connection. Heartbeats and unknown SSE events cannot extend that deadline or mark the listener ready. Until the listener has been confirmed once, a missed deadline stops it with a protocol error, so a wrong address or a proxy that holds the request fails fast. After that, a reconnect that misses the deadline is retried with backoff like any other lost connection: the server may be slow or briefly unreachable, or a proxy that limits concurrent streams may have given the slot to another listener. The CLI and Python logging report each retry with its cause. For non-200 responses, a diagnostic body that exceeds the deadline is omitted; the HTTP status still determines whether to retry or stop. Retry backoff resets only after confirmation. Once confirmed, the normal heartbeat watchdog applies; time spent handling notifications or waiting for a slow consumer does not count as network silence.
The CLI also applies an initial 30-second budget across retries, configurable
with aviso listen --startup-timeout. Library consumers have no initial retry
budget unless they explicitly set one. A healthy idle stream outlives either
startup deadline.
The connection lives a while, then closes
aviso-server intentionally closes each watch connection after a configured
maximum duration (by default, one hour). The server signals this politely with a
connection-closing event whose reason is max_duration_reached.
aviso does not treat this as an error. It reconnects immediately, with no backoff, and resumes from the next sequence. From your point of view, the listener just keeps running.
The one exception is a connection that closes within a second of opening. Repeated short sessions turn the immediate reconnect into a growing backoff, so a server that keeps closing the watch the moment it opens is not hammered with connection attempts. A session of ordinary length resets this.
When aviso reconnects, and what kind of backoff
| What happened | What aviso does |
|---|---|
| Routine connection time limit | Reconnect immediately. |
| Server shutdown | Wait, then reconnect. |
| Connection drops | Retry with increasing delays. |
| Busy server (429 or 503) | Use the server’s retry delay. |
| Rejected credentials (401) | Refresh credentials and retry once. |
| Other client error (4xx) | Stop the listener. |
For a dropped connection, the delay limit starts at 250 milliseconds and doubles
up to 30 seconds. The actual delay is random within that limit so listeners do
not all reconnect together. A server’s Retry-After delay is capped at five
minutes. A second 401 in the same attempt cycle stops the listener.
Heartbeats
The server sends a small heartbeat event periodically (every 30 seconds by
default). If aviso does not see any event (heartbeat, notification, or control)
for longer than max(3 × heartbeat_interval, heartbeat_interval + 30 s), it
declares the connection silently dead and reconnects.
This check can detect a stalled network connection even when no explicit error arrives, for example after a laptop wakes up on a different WiFi network.
Reconnect-as-normal
You normally leave the same listener running through routine reconnects. The background component managing the connection, called the supervisor, retries recoverable failures and reports errors it cannot recover from.
Resume after a reconnect
A reconnect requests the notifications after the highest sequence already delivered to your code, so it does not deliver that notification again. The listener keeps this position in memory even without a state file. Saved state lets a later run resume too, from the saved position described in Resume and state.
Recovery depends on history still being available on the server. A saved position does not prove your application finished processing every notification or guarantee that every queued notification reaches your code after a crash.
For the full rules of how cursors advance, see Resume and state.
When the supervisor gives up
Some errors are terminal. The supervisor closes the stream and stops the listener:
- A 4xx other than 401 or 429 from the server (the request is rejected; retrying will not help).
- A second 401 in the same cycle after a refresh attempt (the credential really is rejected).
- A required trigger that fails after all retries (the work cannot complete).
- A history gap that the server has declared (the notification you need has been pruned).
- A malformed event from the server (a CloudEvents id that does not parse, or a notification for an event type other than the one the listener asked for).
- A stream that exceeds what the parser will hold for one line or one event (16 MiB and 32 MiB; the server’s store passes on at most a few MiB, so a real notification never reaches them).
The CLI reports the failed listener’s error. Other listeners in the same
aviso listen command keep running. After all listeners stop, the command exits
with code 1 if any listener failed.
What next
- Resume and state: how aviso remembers where it was.
- Filters: what the server uses to decide which events to send you.
- Troubleshooting: the common failure modes.
Resume and state
A cursor records how far a listener has reached, using a notification’s sequence number. Saving it lets a later run request notifications after that position. The server must still have the history you need.
The CLI saves state in ~/.config/aviso/state.json by default. Python clients
have no state store by default. To save Python progress across runs, configure a
file store.
A saved cursor does not confirm that your analysis finished. Notifications can still be waiting in the client’s queue when it saves progress. A crash can therefore leave unfinished work behind the saved position. Track completed work separately when you need to recover it, and make repeated processing safe.
When the cursor advances
For each notification, the background listener:
- Saves the previous pending sequence, if there is one and a store is set.
- Runs this notification’s triggers. Required triggers must succeed.
- Places the notification in the queue for your code to read.
- Marks its sequence as pending if it advances the current position.
These steps do not wait for your code to finish its work. If a required trigger
fails, this notification does not become pending, but the previous position
may already have been saved. The final pending position is saved on exit only
if flush_cursor_on_exit is enabled. Python defaults to False.
Saved positions only move forwards. Older notifications can still be delivered without moving the saved position backwards.
The saved position can be one notification behind the last one delivered, so a later run may start with that notification again; it does not when the final position was saved on exit. A reconnect within the same run never asks for it again: it resumes after the highest sequence delivered, a notification that has already reached the queue and run its triggers.
What at-least-once means for your triggers
A trigger can run more than once if the process stops after the action but before its sequence is saved. This repeat behaviour is often called at-least-once delivery. It is not a guarantee that every notification reaches your application after a crash: recovery also needs a usable starting position and retained history.
Where possible, design an action so repeating it has the same result as doing it once. This is called being idempotent.
| Same result when repeated | Additional effect when repeated |
|---|---|
| Replace a file with the same contents. | Append another log entry. |
| Update a record using its unique id. | Insert another database row. |
| Record that an email was already sent. | Send another email. |
Use the pair event_type@sequence to recognise notifications you have already
handled. Recording this and performing the action must be coordinated if a
crash between the two would cause a problem.
Optional triggers are different
A trigger with required: false in YAML is still attempted. If it fails, aviso
logs a warning and allows progress to continue. Its failure does not by itself
cause redelivery.
Use optional triggers for actions whose failure should not stop listening. Use required triggers when a failed action should stop that listener.
Where the file lives
For the CLI, the default paths are:
~/.config/aviso/state.json
~/.config/aviso/state.json.lock
The lockfile coordinates access from cooperating processes. The JSON state file is written when progress is saved; merely starting a listener does not mean it has saved a position.
Change the location:
# ~/.config/aviso/config.yaml
state_file: "/var/lib/aviso/state.json"
The equivalent CLI option is --state-file, followed by the local file path.
The CLI creates the parent directory for its configured state file. Library
callers using JsonFileStore::open directly must create the parent directory
first.
The state file must be on a local filesystem. NFS and CIFS are not supported because the cross-process advisory lock does not work reliably on them.
Running aviso without a state file
For one-off exploration where you do not want to save progress, add
--no-state-store. This example assumes the mars schema accepts class: od
and requires no other filter fields; check with aviso schema get mars first:
aviso listen --no-state-store --event mars --identifiers '{"class":"od"}'
aviso uses an in-memory store for the lifetime of the command. When the process
exits, the cursor is lost; the next run starts from “now” (or from your
--from, if you pass one).
Replay does not touch the state file
aviso replay is stateless by design. It never reads or writes the state file.
The reason: replay would compute the same hash key as the equivalent listen, and
letting it advance the cursor could move the listen past notifications listen
has not processed.
The trade-off: if you interrupt a replay, the next replay needs an explicit
--from.
This rule describes the CLI command. Python replay-only listening still uses the client’s configured state store, if any; it is not automatically stateless. For an independent inspection, use a Python client without a state store. See Python replay-only listening for the call.
The state file format, very briefly
This shortened example shows the saved fields. A real key has 64 hexadecimal
characters; 46fe3e30... stands for the full key here.
{
"version": 1,
"key_format_version": 1,
"checkpoints": {
"46fe3e30...": {
"last_committed_sequence": 72,
"last_event_id": "mars@72"
}
}
}
versionandkey_format_versionare integer format versions.checkpointscontains saved positions. Each key is calculated from the server URL, event type and filter. Different filters can have separate positions.last_committed_sequenceis the cursor.last_event_idis a human-readable form for the file’s reader; aviso does not consult it on restart.
See the state file reference for every field and how to change saved state safely.
Rewinding the cursor
A rewind reads from an earlier position. A sequence start is exclusive:
--from 41 in the CLI or start_from=41 in Python reads strictly after
sequence 41, not including 41. You receive only matching notifications that the
server still stores.
Choose whether you want a one-time read or a new saved position:
- For a one-time read, use CLI replay or a Python client without a state store. You can also give a listener an earlier explicit start. It may repeat notifications, but its saved position will not move backwards.
- To replace saved positions, stop aviso before removing its state file. Start again with your chosen starting position. Once progress has been saved, remove the explicit start so later runs use the saved position.
- Do not edit a running listener’s state file to move its position backwards.
Deleting a state file removes every saved position in that file, not just the listener you are investigating. Prefer replay for a one-time inspection.
An explicit start takes precedence over saved state. If you leave it in a recurring command or script, every restart requests that same starting point.
What next
- State file reference: annotated example, edit safety, recovery from a format mismatch.
- Streams: when the cursor matters (every reconnect).
- Filters: how the hash key is derived (filter content matters).
Authentication providers
Authentication tells the server who you are. Ask your service operator for the server address and the credentials to use: a token, or a username and password. Some servers allow you to receive public notifications without credentials.
Receiving notifications needs read permission. Publishing needs write permission. Managing schemas and deleting notifications are operator tasks. Valid credentials do not necessarily grant all of these permissions.
An authentication provider supplies credentials to the client. This use of “provider” is different from a data provider, who publishes notifications. For setup, follow CLI authentication or Python authentication.
Which one the CLI picks for you
The CLI checks credentials in this order:
- Command-line flags:
--token, or--usernamewith--password. - Environment variables:
AVISO_TOKEN, orAVISO_USERNAMEwithAVISO_PASSWORD. - The config file:
auth.bearer_token, orauth.basicwith bothusernameandpassword. - The credentials file:
~/.config/aviso/credentials.yaml, or the path inAVISO_CREDENTIALS_FILE. - If none are set, an anonymous connection with no credentials.
Choose one authentication method at a time. Conflicting credential flags are rejected. Incomplete environment credentials cause an error rather than silently falling back to the file. When a token and a complete username/password pair are both in the environment, the token takes precedence. The file rejects a configuration containing both methods.
Python clients check the same sources, minus the flags, when you create them
without an auth argument. Pass a provider to choose one yourself, or
pyaviso.Anonymous() to send no credentials at all.
aviso config dump reports the source in use under auth:, which is the
quickest way to find out why a credential you expected is not the one being
sent.
A credential that was found rather than named is not sent to a plain http://
address unless it is loopback. Supplying the credential yourself, with
--token or an explicit provider, removes that restriction, because then the
choice of address is deliberate. The client still logs one warning,
client.auth.plaintext, when it builds, so the choice is visible in the logs.
What happens on a 401
401 Unauthorized means the server rejected the credentials. aviso asks the
selected authentication provider to refresh them, then retries once. A second
401 in the same attempt cycle stops the request with an authentication error.
BearerandBasichold fixed credentials. Refresh does not change them.Envreads the process’s environment once, when the provider is created. Refresh does not change those credentials. Create a new provider and client after updating the environment, or restart the listener with the new values.ConfigFilere-reads its credentials file, so a replaced credential can be picked up on refresh.Chainchecks its members again in order and refreshes the first one that can currently supply a header.
The CLI turns credentials from its main config file into a fixed Bearer or
Basic provider. Editing that file does not give it ConfigFile refresh
behaviour. Restart the CLI to use the changed main configuration.
The credentials file is read through ConfigFile, so it is the one source
that a running listener picks up again after a 401. A tool that rewrites the
token in place does not need you to restart anything.
403 Forbidden usually means the account is not allowed to perform the
requested operation. Ask the service operator about the needed permission.
The five providers at a glance
These program names appear in logs and library documentation. Bearer sends a token; Basic sends a username and password encoded in an HTTP header. Encoding is not encryption, so use your service’s HTTPS address for credentials.
| Provider | Credential source |
|---|---|
Bearer | A token supplied in code. |
Basic | A username and password supplied in code. |
Env | The process’s environment variables. |
ConfigFile | A separate credentials file. |
Chain | An ordered list of providers. |
All five mark the Authorization request header as sensitive so the HTTP
libraries can hide its value in logs.
Python adds Anonymous, which is a marker rather than a provider. It carries
no credential and cannot go into a Chain. Use it when credentials exist on
the machine but must not be sent to the server you are addressing.
When you need ConfigFile
This is a library option for credentials kept separately from the main client
configuration. A Rust caller uses ConfigFile::from_path. The CLI does not
select this provider for its main config file.
The separate credentials file contains one of these shapes, with your own credentials in place of the example values:
bearer:
token: "opaque-or-jwt-token"
or:
basic:
username: "alice"
password: "wonderland"
Use exactly one block. The parser rejects both, neither, or unknown keys. These are credentials-file examples, not main CLI configuration files.
When you need Chain
Library callers can use Chain to try several credential sources in order.
The first member that successfully supplies a request header wins. This is a
fallback between sources, not an attempt to log in with every credential until
the server accepts one. On a 401, the chain checks its members again and
refreshes the first one that can currently supply a header. If a custom
provider’s availability has changed, this can be a different member from the
one that supplied the rejected header.
The CLI already checks flags, environment and configuration in order, so you do not need to construct a chain for ordinary command-line use. See the library guide for programmatic setup.
Writing a custom provider
If your login service is not covered by the built-in providers, an application developer can add support for it. See the Rust API reference for the developer interface.
What next
- CLI configuration: authentication: set credentials for commands.
- Python authentication: choose credentials in Python.
- Library guide: configure Rust applications.
Glossary
Use this page to look up words in the guides. The links explain each idea in more detail.
Authentication provider
Supplies credentials, such as a token, to the client. This is different from a data provider who publishes notifications. See Authentication providers.
Checkpoint and cursor
A cursor is the sequence position used to resume listening. A checkpoint saves that position. It does not confirm that your analysis finished processing the notification. See Resume and state.
Data provider
A person or service that publishes notifications about available data. Receiving notifications does not require permission to publish them. See Publishing.
Event type
A named kind of event, such as mars. Each type has a schema describing its
identifier fields. See Notifications.
Filter
Conditions selecting which notifications you receive. Every condition must match. See Filters.
Identifier
A named value describing the event, such as a forecast’s class or step. The schema defines the available fields. See Notifications.
Listener
Receives matching notifications for an event type and filter. In the CLI, you can give a listener a name and attach triggers in a YAML file, or specify its event and filter on the command line. See Publish and listen.
Notification
A message telling you something happened. The server sends it as a CloudEvent, a JSON message with a standard set of fields. The client also exposes convenient fields for the event type, sequence, identifiers and payload. See Notifications.
Payload
Extra information supplied by the data provider, such as a file location. It
may be null. Filters select identifiers, not payload fields. See
Notifications.
Replay and retained history
Replay reads earlier notifications still stored by the server. Retention is how long the server keeps them. Deleted or expired history cannot be replayed. See Replay history.
Resume
Continue listening after a recorded sequence position. Recovery needs retained history and a usable position. Notifications may repeat, and saved progress does not guarantee completed application work. See Resume and state.
Schema
The server’s rules for identifier names, their values and which fields a filter
must include. Data providers supply every declared identifier when publishing.
Inspect the rules with aviso schema get <TYPE>. See
Schemas.
Sequence number
A 64-bit whole number assigned within an event type. Stored sequences increase, but deliveries can repeat or arrive out of order. See Notifications.
State file
A local file of saved resume positions. The CLI defaults to
~/.config/aviso/state.json; Python has no state store unless you configure
one.
See Resume and state.
Stream
The connection over which a listener receives notifications. It uses Server-Sent Events (SSE), which lets the server send messages as they become available. See Streams.
Trigger
An action aviso runs for a matching notification, such as writing to a log or calling a webhook. A webhook sends an HTTP request to another service. See Triggers overview.
WatchRequest
The Rust request type describing what a listener should receive. Python’s
listen method exposes the main choices as arguments. See the
Rust API and
Python listening guide.
CLI flags reference
Every flag for every subcommand. This is the long-form reference; for narrative usage, start at CLI overview. Refresh this page whenever the Clap command surface changes.
To see the same information from the binary itself:
aviso --help
aviso <SUBCOMMAND> --help
Global flags
Available on every subcommand.
| Flag | Description |
|---|---|
-c, --config <PATH> | Path to the YAML config file. Default ~/.config/aviso/config.yaml. Env override: AVISO_CLIENT_CONFIG_FILE. |
--state-file <PATH> | Path to the state file. Default ~/.config/aviso/state.json. Env override: AVISO_STATE_FILE. |
--base-url <URL> | Override the server URL. Env override: AVISO_BASE_URL. |
--token <TOKEN> | Bearer auth token. Mutually exclusive with --username/--password. Env override: AVISO_TOKEN. |
--username <USERNAME> | Basic auth username. Requires --password. Mutually exclusive with --token. Env override: AVISO_USERNAME. |
--password <PASSWORD> | Basic auth password. Requires --username. Mutually exclusive with --token. Env override: AVISO_PASSWORD. |
--ca-bundle <PATH> | PEM-encoded CA certificate to trust in addition to the system roots. Repeatable. See Configuration: trust an internal CA. |
--danger-accept-invalid-certs | Disable TLS validation. Insecure; logs a WARN at startup. |
--json | Force JSON output. Overrides the TTY-aware default. |
--color <auto|always|never> | Color output mode. Default never. |
-v, --verbose | Increase verbosity. Repeatable: -v for DEBUG, -vv for TRACE. Overridden by AVISO_LOG when set. |
-h, --help | Print help. |
-V, --version | Print version. |
aviso notify <PARAMETERS>
Publish one notification to /api/v1/notification.
| Argument | Description |
|---|---|
<PARAMETERS> | Comma-separated parameter list. event=<TYPE> is required; data=<JSON> is the optional payload; every other pair enters the identifier map. Use key:=JSON for any JSON identifier value, including numbers, booleans, and null. The key=value form keeps bare scalars as strings but auto-parses arrays and objects. Double-quote strings containing commas. |
--identifier <KEY=VALUE> | Repeatable supplement to positional identifiers. key=value preserves exact strings without comma splitting, quote stripping, or JSON detection; key:=JSON parses any JSON value. Duplicate keys are errors. event and data must use positional parameters. |
See repeated identifiers for scripts for shell quoting, key trimming, and typed values.
Returns exit code 0 on success, 1 on a server error or network failure, 2 on missing parameters.
aviso listen [LISTENER_FILES]...
Run one or more listeners against /api/v1/watch.
| Argument / Flag | Description |
|---|---|
[LISTENER_FILES]... | Listener YAML files. Each file’s listeners: list is concatenated in argv order. Positional files replace (do not merge with) the global config’s listeners: block for this invocation. |
--no-state-store | Use an in-memory store for this invocation. Ignores any configured state_file. |
--startup-timeout <DURATION> | Initial budget across retries until the first Aviso handshake. Default 30s; 0s disables it. Does not limit a confirmed stream or later reconnects. Each connection still has a ten-second opening deadline, which stops the listener only before the first handshake; after that, a reconnect that misses it is retried. |
--from <VALUE> | Cursor override applied uniformly to every resolved listener. Overrides per-YAML from_id/from_date. See Configuration: --from value formats. |
--event <TYPE> | Inline ad-hoc listener: event type to listen for, without a YAML file. Requires an identifier source. Takes precedence over positional YAML files. |
--identifiers <JSON> | Inline ad-hoc listener: identifiers filter as a JSON object whose values may have any JSON shape. Requires --event. The inline listener runs with a single echo trigger. |
--identifier <KEY=VALUE> | Repeatable exact string (key=value) or typed JSON (key:=JSON). Requires --event; conflicts with --identifiers. |
Returns 0 on a clean Ctrl+C, 1 if any listener task errored, 2 on no listeners resolved.
aviso replay --from <VALUE> [--until <VALUE>] [LISTENER_FILES]...
Replay historical notifications from a cursor.
| Argument / Flag | Description |
|---|---|
[LISTENER_FILES]... | Listener YAML files. Same resolution as aviso listen. |
--listener <NAME> | Pick one listener by name from the resolved set. Required when more than one listener resolves. |
--event <TYPE> | Inline ad-hoc replay: event type, without a YAML file. Requires an identifier source. |
--identifiers <JSON> | Inline ad-hoc replay: identifiers filter as a JSON object whose values may have any JSON shape. Requires --event. |
--identifier <KEY=VALUE> | Repeatable exact string (key=value) or typed JSON (key:=JSON). Requires --event; conflicts with --identifiers. |
--from <VALUE> | Required. Sequence id or date to start replay from. |
--until <VALUE> | Sequence id or date to end replay at, inclusive. Same forms as --from. Needs aviso-server 0.13.0 or later. |
Replay never touches the state file. Returns 0 on completion, 1 on error.
aviso schema list
List event types the server knows about.
No subcommand-specific flags. On a TTY the output is a header line and a bullet
list of event types; piped or with --json, the output is NDJSON (one JSON
object per line).
aviso schema get <EVENT_TYPE>
Fetch one schema as pretty JSON.
| Argument | Description |
|---|---|
<EVENT_TYPE> | Event type name. |
aviso admin wipe-stream <EVENT_TYPE> --yes
Delete every notification of one event type.
| Argument / Flag | Description |
|---|---|
<EVENT_TYPE> | Event type whose notifications to delete. |
--yes | Required. Confirms the destructive operation. |
aviso admin wipe-all --yes
Delete every notification across every stream.
| Flag | Description |
|---|---|
--yes | Required. Confirms the destructive operation. |
aviso admin delete <NOTIFICATION_ID> --yes
Delete one notification.
| Argument / Flag | Description |
|---|---|
<NOTIFICATION_ID> | The notification id in the <event_type>@<sequence> form. |
--yes | Required. Confirms the destructive operation. |
aviso config dump
Print the resolved configuration to stdout.
| Flag | Description |
|---|---|
--redact | Mask tokens and passwords in the output. |
--json | Force JSON output. The source-attribution comments become source: fields. |
aviso completions <SHELL>
Print a shell completion script to stdout.
| Argument | Description |
|---|---|
<SHELL> | One of bash, zsh, fish, elvish. |
Environment variables
| Variable | Effect |
|---|---|
AVISO_LOG | A tracing_subscriber EnvFilter directive. When set, overrides -v/-vv. Useful recipes: AVISO_LOG=warn,aviso=debug, AVISO_LOG=h2=debug,hyper=debug,aviso=debug. |
NO_COLOR | When set, suppresses ANSI colors in the --color auto mode. Per no-color.org. |
AVISO_CLIENT_CONFIG_FILE | Config file path. Lower priority than --config. |
AVISO_STATE_FILE | State file path. Lower priority than --state-file. |
AVISO_BASE_URL | Server URL. Lower priority than --base-url. |
AVISO_TOKEN | Bearer token. Lower priority than --token. |
AVISO_USERNAME / AVISO_PASSWORD | Basic auth credentials. Lower priority than the flags. |
AVISO_CREDENTIALS_FILE | Credentials file path. Default ~/.config/aviso/credentials.yaml. Read only when no flag, environment variable, or config-file auth: block supplies a credential. |
Exit codes
| Code | Meaning |
|---|---|
0 | Success. A clean Ctrl+C with no prior listener failure also returns 0. |
1 | Runtime error: server returned 4xx/5xx, network failure, file I/O failure, or one or more aviso listen listener tasks errored or panicked. |
2 | Usage error: missing required flag, invalid argument value, destructive admin command without --yes, no listeners resolved for aviso listen, unparseable --from value. |
130 | Second Ctrl+C within 5 seconds (128 + SIGINT). Hard exit; no drain. |
Signal handling
The first Ctrl+C triggers a graceful drain: aviso closes the watch connection, flushes in-flight commits, then exits 0 (or 1 if a listener errored earlier). A second Ctrl+C within five seconds calls the OS exit directly with code 130, bypassing the drain.
Listener YAML reference
The file format aviso listen (and aviso replay with positional files) reads.
A listener file has one top-level key, listeners:, with a list of listener
definitions. The same shape is accepted inside the main config file
(~/.config/aviso/config.yaml).
Minimal example
listeners:
- name: mars-od
event: mars
identifiers:
class: od
triggers:
- type: echo
All fields
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
name | string | no | unnamed | Label for logs and the echo trigger’s leader line. |
event | string | yes | Event type to subscribe to. Must match a type the server publishes. | |
identifiers | map | no | {} | Filter using supported identifier fields. Spatial filters can use different names from the schema: polygon for point clouds or point for polygons. See Spatial filters. Conditions combine with AND. Empty map allowed only when the schema has no required filter fields. |
triggers | list | no | [] | Triggers to run for each matching notification. Empty list is accepted, but a useful listener normally has at least one trigger. |
from_id | integer | no | unset | Default starting sequence for the first connection. Overridden by --from. |
from_date | string | no | unset | Default starting ISO-8601 datetime. The value is sent verbatim to the server, so it must be in a form the server accepts: YYYY-MM-DDTHH:MM:SSZ, YYYY-MM-DDTHH:MM:SS.ffffffZ, or YYYY-MM-DD HH:MM:SS+HH:MM. A bare YYYY-MM-DD is not accepted here (the CLI’s --from flag does that normalisation, but the YAML field does not). Mutually exclusive with from_id. |
Identifier values may have any JSON-compatible YAML shape. Spatial values use latitude first. This listener filters point-cloud notifications with a polygon:
listeners:
- name: alpine-observations
event: observations
identifiers:
date: "20260601"
polygon:
- [46, 8]
- [46, 9]
- [47, 9]
- [47, 8]
- [46, 8]
triggers:
- type: echo
A point is [latitude, longitude]. Polygons need at least four pairs, with the
first pair repeated last. Providers send point_cloud; subscribers send
polygon. This uses the observations schema from
Publish and listen.
Constraint mappings
Use YAML mappings for constraints, not strings containing JSON. With the schema
and seed records from the
weather tutorial, put this
in weather-listeners.yaml:
listeners:
- name: selected-weather
event: weather
identifiers:
date: "20260913"
severity:
gte: 5
anomaly:
between: [40, 50]
region:
in: [north, south]
triggers:
- type: echo
Quote the date so it stays a string. Leave numeric operands unquoted. This selects B and C; the central operator reference explains the allowed mappings.
Run the file directly, replaying the retained seeds before listening live:
aviso listen weather-listeners.yaml --from 0 --no-state-store
aviso replay weather-listeners.yaml --from 0
The listen command runs until Ctrl+C; run replay separately. Alternatively,
place the same listeners: block in your client config, weather-config.yaml,
alongside your connection settings. Select its listener by name for replay:
aviso --config weather-config.yaml listen --from 0 --no-state-store
aviso --config weather-config.yaml replay --listener selected-weather --from 0
Both config commands use the same mapping; they do not read the positional
listener file. AVISO_BASE_URL can supply the test server address for either
form.
Trigger fields
Every trigger entry has a type: field plus per-kind fields. Shared options:
| Field | Type | Default | Applies to | Description |
|---|---|---|---|---|
retries | integer | 0 | all | Additional attempts after the first failure. Total attempts = retries + 1. |
required | boolean | true | all | When true, a final failure stops the listener. When false, the failure logs a WARN and the listener keeps running. |
timeout | duration | 30s for HTTP triggers; absent for command | command, webhook, teams, post | Per-attempt wall clock. humantime syntax: 30s, 2m, 1h30m, 500ms. |
fail_fast | boolean | true | command, webhook, teams, post | When true, deterministic failures (4xx, template errors, non-zero command exit) bypass the retry budget. When false, every failure is retryable. |
echo
- type: echo
No required fields. Writes the notification to stdout (pretty JSON on a TTY, NDJSON otherwise).
log
- type: log
path: /var/log/aviso/mars.log
| Field | Required | Description |
|---|---|---|
path | yes | Path to a file. Resolves relative to the working directory of the aviso process when not absolute. The parent directory must exist; the trigger does not create directories. |
Appends one line of compact JSON per notification.
command
- type: command
command: "./on-event.sh {{ notification.event_type }} {{ notification.sequence }}"
env:
DATA_DIR: /var/lib/aviso/data
working_dir: /var/lib/aviso
timeout: 30s
| Field | Required | Description |
|---|---|---|
command | yes | Shell command to run. Goes through the template engine; notification values are quoted so the shell reads each as one literal argument. |
env | no | Extra environment variables, applied on top of the dispatcher-injected AVISO_* set. Values are not template-rendered. |
working_dir | no | Directory to spawn the shell in. |
Unix only.
webhook
- type: webhook
url: "https://hooks.example/notify"
method: POST
headers:
Authorization: "Bearer {{ env.WEBHOOK_TOKEN }}"
body_template: '{"event": "{{ notification.event_type }}", "seq": {{ notification.sequence }}}'
| Field | Required | Default | Description |
|---|---|---|---|
url | yes | Template-rendered URL. | |
method | no | POST | One of GET, POST, PUT, PATCH, DELETE. Uppercase. |
headers | no | Map of header name → template-rendered value. | |
body_template | no | the compact JSON of the notification | Template-rendered body. |
teams
- type: teams
url: "{{ env.TEAMS_WEBHOOK_URL }}"
title_template: "Custom {{ notification.event_type }}"
| Field | Required | Default | Description |
|---|---|---|---|
url | yes | Teams Workflows webhook URL. | |
title_template | no | aviso {{ notification.event_type }} #{{ notification.sequence }} | Template-rendered title. |
Builds the Adaptive Card body automatically from the notification.
post
- type: post
url: "https://receiver.example/aviso"
headers:
Authorization: "Bearer {{ env.RECEIVER_TOKEN }}"
| Field | Required | Default | Description |
|---|---|---|---|
url | yes | Receiver URL. | |
headers | no | Map of header name → template-rendered value. |
Body is always the server’s original CloudEvent envelope. No body_template, no
method (always POST).
Templates
The template engine for trigger fields is documented at
Triggers: template engine. Two namespaces
inside {{ ... }}:
{{ notification.<dotted.path> }}: a field of the notification.{{ env.<NAME> }}: a process environment variable.
Resolution and precedence
When you run aviso listen:
- Positional YAML files replace the global config’s
listeners:for this invocation. They do not merge with it. - With multiple positional files, the listener lists are concatenated in argv order.
- With no positional files, the global config’s
listeners:block is used. - With no listeners anywhere,
aviso listenexits with code 2.
--event with either --identifiers or repeated --identifier (inline mode)
takes precedence over positional YAML files when both are present.
What next
- Trigger overview: pick the right trigger.
- CLI publish and listen: running listeners.
State file reference
The on-disk record of where each listener left off. By default at
~/.config/aviso/state.json. A sibling lockfile lives next to it at
~/.config/aviso/state.json.lock.
If you have never run aviso listen, neither file exists yet. Both are created
lazily on the first successful checkpoint commit. aviso replay does not touch
either file.
For the conceptual model, read Resume and state first.
A worked example
After listening for mars events with class=od and processing one
notification, the file looks like:
{
"version": 1,
"key_format_version": 1,
"checkpoints": {
"46fe3e30937478ae30527c74cb2f03a5e5419228a11051cdaccefe7d044b21c3": {
"last_committed_sequence": 72,
"last_event_id": "mars@72"
}
}
}
The next time aviso runs against the same server, event type, and filter, it reads this checkpoint and asks the server for sequence 73 onwards.
What each field is for
version
The format version of the JSON document itself (which top-level fields exist, what types they hold).
The check is exact match. A file with version: 2 is rejected by a client
that knows only version: 1. If you upgrade aviso and the version bumps, the
old file becomes unreadable until you either install a matching aviso, or delete
the file and accept that the next run starts from “now”.
key_format_version
The version of the recipe used to derive the hex keys in checkpoints. Also
exact match. A mismatch means the keys on disk were hashed differently and
cannot be matched to anything the live client computes.
checkpoints
A map keyed by 64-character hex resume keys. Each value is one checkpoint object.
| Subfield | Type | Meaning |
|---|---|---|
last_committed_sequence | unsigned 64-bit integer | The cursor. The sequence of the last fully-processed notification. |
last_event_id | string or null | The notification’s id in the <event_type>@<sequence> form, for humans reading the file. aviso does not consult it on restart. |
What “committed” actually means
last_committed_sequence advances only after every required trigger for the
notification succeeded and aviso wrote the new value to disk. If anything in
that chain fails, the cursor does not advance, and the notification will be
redelivered on the next run.
So when you see last_committed_sequence: 72, that means mars@72 was fully
processed end-to-end. The cursor is a high-water mark of completed work, not a
“received” counter.
Can I edit or delete this file?
Delete the whole file: safe, with consequences
rm ~/.config/aviso/state.json ~/.config/aviso/state.json.lock
Do this only when no aviso listen is running. The next run starts with no
cursor:
- If your YAML or command-line sets
--from, the run resumes from there. - Otherwise the run starts from “now” (the server’s current tip).
You will miss the notifications between the deleted cursor and “now” unless you explicitly replay them.
Delete just the lockfile: unsafe while a client is running
rm ~/.config/aviso/state.json.lock
The cross-process lock is attached to the lockfile’s inode. Deleting the lockfile while a client holds the lock lets a sibling process acquire “the” lock on a fresh inode, which breaks mutual exclusion. Only delete it when no aviso process is using the file.
Hand-edit the file: mostly read-only by design
The store guards last_committed_sequence against going backwards. Two
consequences:
- Lowering a sequence is silently ignored. If you edit
72down to42and start aviso, the next checkpoint write compares the on-disk value against the in-memory candidate and keeps the higher. Your edit is overwritten on the next commit. - Raising a sequence skips notifications. If you edit
72up to100, the next reconnect asks the server for sequence 101 onwards, and notifications 73 through 100 are never delivered to this listener. aviso has no way to tell whether that was intentional, so it trusts you.
To genuinely reset a cursor, delete the file (with aviso stopped) and start with
--from:
rm ~/.config/aviso/state.json
aviso listen my-listeners.yaml --from 2026-05-23T00:00:00Z
cat while aviso is running: safe
Reading the file from outside aviso is harmless. The atomic-write protocol guarantees you see either the previous complete file or the new complete file, never a half-written tear.
How --from interacts with the state file
When you pass --from <VALUE> and a cursor already exists in the file:
- aviso uses your
--fromfor the initial seek. - As the rewind delivers notifications, the state file ignores updates whose
sequence is at or below the existing high-water mark. Your
--fromcannot push the cursor backwards. - Once the run advances past the previous high-water mark, normal updates resume.
- Restarting without
--fromhonours the stored cursor again.
Use --from as a one-shot rewind. Leaving it in a systemd unit means redelivery
from that point on every restart.
Recovery from a format-version mismatch
state file format version 2 does not match supported version 1
or:
state file uses key_format_version 2; this client uses 1
Three options:
- Match the client to the file. Install an aviso whose
versionandkey_format_versionmatch what is on disk. This preserves cursors. - Migrate the file manually. Only viable if you know the layout differences between the two versions. There is no automated tool.
- Accept the loss. Stop any running clients, delete both files, restart.
Cursors are lost; the next run begins from “now” or from
--from.
Pin the aviso binary version next to your state file in production so the mismatch only surfaces during a controlled upgrade.
Where the file must live
A local filesystem. The cross-process advisory lock uses POSIX semantics that are not reliable on NFS or CIFS.
When using JsonFileStore directly, the parent directory must already exist.
The CLI creates the parent directory for its configured state file before
opening the store.
What is in the hex key
The 64-character hex keys are the SHA-256 of a deterministic, length-prefixed byte sequence built from:
- The format version.
- The normalised server base URL (lowercase scheme and host, default ports stripped, userinfo removed).
- The event type.
- The filter, canonicalised so
{"a":1,"b":2}and{"b":2,"a":1}hash the same way. - An optional schema fingerprint.
Two listeners with different filters get different keys. Two listeners on different servers also get different keys.
The hex is a hash, not a literal. Filter contents do not appear on disk in plain text.
What next
- Resume and state: the concept.
- CLI configuration: how to change where the file lives.
- Troubleshooting: when things go wrong.
Rust API reference
The full Rust API reference is generated by rustdoc and hosted at https://docs.rs/aviso. It lives close to the source so it stays in lock-step with the released code.
For an overview and worked examples, read the library guide in this book.
Direct links
avisocrate rootAvisoClient: the main entry point.auth: theAuthProvidertrait and the five built-in providers.watch: theWatchRequestbuilder,NotificationStream, andTriggerbuilders.state: theStateStoretrait,MemoryStore, andJsonFileStore.
docs.rs builds the documentation from the source on every release, so the version you see there matches the version on crates.io.
Stream readiness and startup
AvisoClient::watch returns a stream immediately. Creating the stream does not
mean the server accepted the subscription. Call subscribe_ready() on the
NotificationStream to observe first-handshake confirmation without consuming a
notification. Its current value may already be true when you subscribe.
Inspect that value or use wait_for(|ready| *ready) to await confirmation.
The value stays true after confirmation, even during later reconnects.
If it closes while false, read the stream for a possible terminal error.
Cancellation can end startup without an error.
WatchRequest::with_startup_timeout(Some(duration)) sets an optional initial
budget across retries. None or zero disables that budget, which is the library
default. It does not limit stream lifetime. Independently, each connection must
provide SSE headers and its Aviso opening event within ten seconds. Invalid
responses yield ClientError::StreamProtocol, and so does an expired opening
deadline before the first handshake. Once the watch has been confirmed, a
reconnect that misses the deadline is retried with backoff.
Unsupported base URL schemes fail at build time with ClientError::Config.
Building docs locally
If you have a checkout of the repository:
cargo doc --workspace --no-deps --open
This builds the API docs for the workspace and opens them in your browser.
Developers
This section is for people working on aviso itself, or embedding the aviso
Rust library in their own program. If you just want to use the CLI, start at
CLI overview instead.
What lives here
- Architecture: how the crates fit together and where each responsibility lives.
- Library guide: how to use the
avisoRust crate from your own code. - Contributing: how to make a change to this repository: tests, gates, the local workflow.
The CLI and the future Python package are both consumers of the aviso core
library. The core library does not depend on either of them. The
Architecture page draws the relationship.
Architecture
A view of the shared Rust client and its language bindings.
Crate dependencies
The client has five product crates. The workspace also includes aviso-e2e,
an unpublished package for end-to-end tests. Arrows below point from a crate
to its dependency; the server connection is a network call.
flowchart TB
subgraph consumers["consumers"]
direction LR
cli["aviso-cli<br/>CLI library and binary"]
py["aviso-py<br/>PyO3 cdylib<br/>(Python bindings)"]
ffi["aviso-ffi<br/>C ABI and C++ facade"]
end
core["aviso<br/>Core Rust client"]
parser["finesse<br/>Synchronous SSE parser"]
server["aviso-server<br/>(separate repo)"]
cli --> core
py --> core
py -- bundled CLI --> cli
ffi --> core
core --> parser
core -- HTTP + SSE --> server
The CLI and language bindings share the core library. The Python extension
also depends on the CLI library so the Python distribution can bundle the
aviso command. The core library does not depend on the CLI or any binding
crate.
The crates in more detail
aviso (core library)
The shared client behavior lives here. Adapters handle language-specific interfaces and application setup.
- HTTP:
reqwestwith rustls for TLS. - SSE: an in-tree parser (
finesse) implementing the WHATWG parsing algorithm. The reconnect loop is owned, not delegated; off-the-shelf SSE crates assume the WHATWGLast-Event-IDmechanism, but aviso-server’s resume contract isfrom_id/from_datein the POST body, which requires a re-POST on every reconnect. - Reconnect supervisor: a single task per
watch()call, driving a small state machine. - State store: a trait with two built-in implementations, an in-memory store and a JSON file store with crash-safe atomic writes.
- AuthProvider: an async trait with five built-in providers.
- Trigger dispatcher: a crate-private enum with a public builder API.
aviso-cli
The CLI library resolves layered config (flag > env > file > default),
builds an AvisoClient, attaches a JsonFileStore, parses listener YAML,
and dispatches subcommands. A small binary exposes it as aviso.
The standalone binary and the Python-bundled command call the same
aviso_cli::run entry point.
The CLI’s responsibilities are mostly about composition and I/O surfaces. It does not implement any of the SSE, reconnect, or trigger logic; that is all in the library.
aviso-py
The PyO3 extension crate. Built as a cdylib for the Python wheel and as an
rlib so the workspace cargo test sees its types. Exposes a synchronous
AvisoClient and an asynchronous AsyncAvisoClient over the same channel the
Rust core uses, plus typed value classes, the trigger builder, auth providers,
state stores, and the exception hierarchy.
The distribution and public Python package are named pyaviso. The compiled
extension is pyaviso._native; pyaviso/__init__.py re-exports its classes
and defines Python-side enums and type aliases.
The installed aviso command and python -m pyaviso both enter through
pyaviso.__main__:main. A native bridge releases the GIL and calls the CLI
library in-process, rather than starting a separate executable.
aviso-ffi
The C adapter exposes a stable ABI over the core library and builds static
and shared libraries. Its committed aviso.h header is generated with
cbindgen.
The hand-written aviso.hpp header provides a C++17 facade over that ABI.
It wraps C handles with RAII and translates errors into C++ exceptions.
It supports blocking calls, asynchronous calls returning std::future, and
callback-based listeners. The facade is header-only, not a separate Rust crate.
finesse
The SSE parser, kept separate so the protocol can evolve without churning the rest of the library. It owns no transport, no async runtime, and no aviso semantics: the caller drives the parser by feeding bytes in and draining typed frames out.
The parser holds bytes until a line terminator or a blank line arrives, so it
bounds how much it will hold: 16 MiB for one line and 32 MiB of data: for
one event by default. A notification arrives as one line, and the server’s
store passes on at most a few MiB, so a large polygon is never refused. A
server
that exceeds a bound ends the watch with a stream protocol error rather than
being kept in memory. Each byte is examined once, so a long line costs time
proportional to its length.
Data flow at runtime
When you call client.watch(WatchRequest::watch("mars")):
- The listening surface constructs a
WatchRequest, derives a resume key, and spawns a supervisor task. - The supervisor reads the cursor from the state store (if one is configured).
- The supervisor sends a
POST /api/v1/watchto the server with the filter and the cursor. - The server replies with an SSE stream. The supervisor reads chunks, feeds
them into the
finesseparser, and turns parsed frames intoNotificationvalues. - For each notification, the supervisor:
- runs every configured trigger,
- persists the previous notification’s sequence to the state store (next-send commit),
- sends the current notification on a bounded channel to the consumer.
- On a
connection-closingevent, a transport error, a heartbeat timeout, or a 5xx, the supervisor reconnects using the right backoff for the cause. - On a terminal error (4xx other than 401, second 401 after refresh, required trigger failure exhausted retries, history gap), the supervisor closes the stream with the error and exits.
Dropping the NotificationStream cancels the supervisor cooperatively through a
oneshot channel; the supervisor exits within one event-loop tick.
Startup confirmation
The connection runner validates HTTP 200 and the SSE media type before decoding
the body. An opening gate consumes frames until the expected Aviso control
arrives: connection_established for live-only or replay_started for a
resolved historical cursor. Headers and opening share a ten-second deadline.
Before the first handshake an expired deadline is fatal; on a reconnect of a
confirmed watch it is a lost connection, retried with backoff.
Only then does it transition to Connected, reset backoff, and publish
readiness.
NotificationStream::subscribe_ready() exposes a watch receiver whose value
stays true after the first handshake. The CLI uses it for its Listening
status; it does not infer readiness from log text or wait for the first
notification. Retry telemetry uses tracing in the listener span, with causes
and delays coalesced over five seconds. Status stays on stderr.
WatchRequest::with_startup_timeout optionally bounds the supervisor until the
first handshake, including cursor lookup, authentication, and retry sleeps.
The initial budget is removed once readiness becomes true. It never wraps
notification dispatch, persistent checkpoint writes, or consumer backpressure.
The CLI sets this budget to 30 seconds unless overridden; bindings inherit the
core’s unlimited initial retry policy and per-connection protocol validation.
Cancellation and shutdown
aviso is cooperative everywhere. There are three cancellation paths:
- Per-stream: dropping the
NotificationStreamdrops a oneshot sender; the supervisor’sselect!notices and exits. - Parent-cascade: dropping the last
AvisoClientclone trips atokio::sync::watchflip that every child supervisor observes. The stream then ends without an error. The Python and C bindings therefore keep a clone for each open stream, so a stream outlives the client object it came from. - Ctrl+C in the CLI: a signal handler triggers a graceful drain via the same per-stream mechanism for every active listener.
The supervisor’s select! is biased so cancellation cannot be starved by a
fast stream. Every supervisor await (auth header, HTTP send, chunk read, channel
send) is wrapped in the cancel arm.
Why a single supervisor per listener
A bounded channel (capacity 128 by default; 1 when a state store is configured)
sits between the supervisor and the consumer. The channel applies TCP
backpressure end to end: when the consumer falls behind, the supervisor’s send
blocks, which makes it stop reading bytes from the wire, which throttles the
server.
When a state store is configured, the channel capacity drops to 1 so the supervisor’s commit-of-previous-notification is forced to happen before the consumer can pull the next one. The user-facing contract is “pulling item N+1 implies item N is durable”, and capacity 1 is what makes that true.
Watch connections
A watch keeps one HTTP request open for as long as it runs. Over HTTP/2, all requests from one HTTP client to a server share a single TCP connection, and servers and proxies limit how many requests one connection may carry at once (nginx allows 128 by default, HAProxy 100). A watch past that limit is not refused: its request waits for a free stream, which never comes while the other watches stay open.
The client therefore keeps two kinds of HTTP client. Ordinary requests
(notify, schema, admin calls) use one, with the request timeout the caller
configured. Watches use a small set of others, each carrying at most 64
watches. When every client in the set is full, the next watch gets a new
client and therefore a new connection; when a watch ends its slot is freed,
and clients left idle at the end of the set are released. The watch clients
have no request timeout, because a watch’s response is meant to stay open;
the ten-second opening deadline and the heartbeat watchdog bound a stalled
watch instead. Clones of an AvisoClient share both.
A proxy that allows fewer than 64 streams per connection makes the watches past its limit fail the opening deadline with “no response from the server”.
The state-store contract
The store is strictly monotonic: a put whose sequence is at or below the
existing on-disk value is silently a no-op. This is what guarantees the cursor
never moves backwards, even across concurrent writers or after operator errors
editing the file.
For multiple processes, the file store uses an advisory flock on a sidecar
lockfile. The lockfile is separate from the data file because the atomic-rename
pattern would otherwise invalidate the lock between operations.
A failed put leaves both memory and disk unchanged. Aside: put and delete
on the file store are not cancellation-safe; drive them to completion. The CLI
does this.
What is intentionally not here
- Client-side schema validation. The server is the single source of truth.
aviso does not depend on
aviso-validatorsand does not pre-validate notifications. - Auto-retry on
notify. APOSTthat fails after the request body has been sent might have been processed; a blind retry would risk a duplicate. When a server-side idempotency-key contract becomes available the policy will be revisited. - A metrics surface. The core emits structured
tracingevents. Consumers that need Prometheus or OpenTelemetry metrics build a thin adapter on top.
Where to go next
- Library guide: how to use the library from your own code.
- Contributing: tests, gates, the workflow.
Rust library guide
How to use the aviso crate from your own Rust code.
Adding aviso to your project
[dependencies]
aviso = "2.0"
tokio = { version = "1.45", features = ["macros", "rt-multi-thread"] }
serde_json = "1.0"
aviso is async and runs on tokio.
Building a client
use aviso::AvisoClient;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let client = AvisoClient::builder()
.base_url("https://aviso.example")
.build()?;
println!("client base url = {}", client.display_base_url());
Ok(())
}
base_url() returns the URL exactly as configured, which may include a
user:password@ part. For anything a person reads or a log keeps, use
display_base_url(), which strips that part; the client’s Debug output does
the same.
The client is Clone. Cloned handles share the same HTTP connections and the
same authentication provider, so you can hand copies to multiple tasks without
paying for extra sockets. Watches use connections of their own, at most 64
watches on each; see
Watch connections.
The builder normalises the base URL: a trailing slash is added if missing; a
path prefix (https://gw.example/aviso) is preserved so the client works behind
a reverse proxy. Endpoint paths are joined relatively, never absolutely; the
absolute form would strip the proxy prefix.
Authentication
use std::sync::Arc;
use aviso::{AvisoClient, auth::Bearer};
let auth = Arc::new(Bearer::new("opaque-or-jwt-token")?);
let client = AvisoClient::builder()
.base_url("https://aviso.example")
.auth(auth)
.build()?;
Five built-in providers are available: Basic, Bearer, Env, ConfigFile,
and Chain. The page on
authentication providers covers when to use
each one. The full API is at
aviso::auth.
Letting the library find the credential
When the credential is supplied by the environment or by a file rather than
by your code, ask the library to find it. discover_for_url checks the
environment, then the auth: block of ~/.config/aviso/config.yaml, then
~/.config/aviso/credentials.yaml, and stops at the first one that has a
credential:
use aviso::AvisoClient;
use aviso::auth::{DiscoveryPaths, discover_for_url};
let base_url = "https://aviso.example";
let mut builder = AvisoClient::builder().base_url(base_url);
if let Some(found) = discover_for_url(base_url, &DiscoveryPaths::from_env())? {
builder = builder.auth(found.into_provider());
}
let client = builder.build()?;
The same code is a doctest on discover_for_url, compiled by
cargo test --doc. Like every Rust block in this book, this copy itself is not
compiled, so if the two ever differ, the doctest is the one to trust.
Ok(None) means nothing was found and the client stays anonymous. A source
that exists but cannot be used is an error, so a typo in a credentials file is
reported rather than skipped. found.source() says where the credential came
from, which is worth logging when more than one place could have supplied it.
discover_for_url refuses a credential it found if base_url is plain
http:// and not loopback, because nothing in your code named that
credential, and a mistyped host would otherwise send it in the clear. discover
and discover_with perform the same search without that check; use them only
when you apply your own rule about where a credential may go. Passing a
provider to .auth() yourself, as in the first example, never goes through
this check: writing the credential into the call is choosing where it goes.
Starting from the config file
When the machine already has ~/.config/aviso/config.yaml set up for the
aviso command, start the builder from it. The file supplies base_url,
timeout, heartbeat_interval and tls; the credential search described
above supplies the credential:
use aviso::AvisoClient;
let client = AvisoClient::builder_from_file()?
.timeout(std::time::Duration::from_secs(10))
.build()?;
Setters called afterwards replace what the file said. A missing default file
sets nothing, so build() fails for want of a base_url exactly as it would
for an empty builder. AvisoClientBuilder::from_file_at(path) reads a specific
file, which must exist. ClientSettings exposes the parsed values on their own
for callers that want to inspect them before building.
The same code is the doctest on AvisoClientBuilder::from_file, compiled by
cargo test --doc; this copy is not.
Reading the address from the environment too
from_file reads the address from the file only. Where a script should also
honour AVISO_BASE_URL, as the aviso command and the Python binding do,
build from the environment instead, passing whatever the code itself fixes:
use aviso::resolve::CodeInputs;
use aviso::AvisoClientBuilder;
let client = AvisoClientBuilder::from_environment(&CodeInputs {
timeout: Some(std::time::Duration::from_secs(10)),
..CodeInputs::default()
})?
.build()?;
For each setting the first source with a value wins: the CodeInputs, then
the environment (AVISO_BASE_URL; AVISO_TOKEN or AVISO_USERNAME with
AVISO_PASSWORD), then the config file, then the credentials file. Setters
called on the returned builder still replace what was found.
To report what was chosen without building, call aviso::resolve::resolve
with the same CodeInputs and DiscoveryPaths::from_env(). The
ResolvedSettings in its result carries every setting as a Sourced<T>
(value plus Source), the credential as a ResolvedAuth (kind, source, and
the reason if it would be refused), and never a secret, so it can go into a
log as it is. Source implements Display (code, environment NAME,
config file PATH, credentials file PATH, default).
For custom providers (OAuth, OIDC, AWS SigV4, …), implement the AuthProvider
trait. Always call HeaderValue::set_sensitive(true) on the value you return;
that is what makes downstream loggers redact it.
Publishing a notification
use std::collections::BTreeMap;
use std::sync::Arc;
use aviso::{AvisoClient, NotificationRequest, auth::Bearer};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let client = AvisoClient::builder()
.base_url("https://aviso.example")
.auth(Arc::new(Bearer::new("opaque-jwt")?))
.build()?;
let mut identifier = BTreeMap::new();
identifier.insert("date".into(), serde_json::json!("20260601"));
identifier.insert(
"point_cloud".into(),
serde_json::json!([[46.0, 8.0], [47.0, 9.0]]),
);
let request = NotificationRequest::new("observations")
.with_identifier(identifier)
.with_payload(serde_json::json!({ "location": "s3://bucket/path" }));
let response = client.notify(&request).await?;
println!(
"published: request_id={}, processed_at={}",
response.request_id, response.processed_at
);
Ok(())
}
NotificationRequest::identifier is a
BTreeMap<String, serde_json::Value>. Points use [lat, lon]; polygons and
point clouds use [[lat, lon], ...]. Received Notification values use the
same map type, so structured identifiers retain their array shape.
This spatial example uses the server’s public
observations schema.
Polygons need at least four pairs with the first repeated last. Clouds need no
closing repeat; duplicate points are valid and their order is preserved.
Subscribers filter clouds with polygon, not point_cloud. The built-in
point is only a watch/replay filter for polygon streams.
with_identifier accepts BTreeMap<String, serde_json::Value>. For a
string-only map, use with_string_identifier; it converts each entry to a JSON
string. The public identifier fields themselves are JSON-valued. Code that
reads Notification.identifier must therefore handle serde_json::Value
rather than assuming every received value is a String.
On a 401, the client calls AuthProvider::refresh and retries once. A second
401 is surfaced as ClientError::Http.
notify does not auto-retry on a transport error after the request body has
been sent (the server may have already processed it; a blind retry would risk a
duplicate).
Listening for notifications
Listen with watch() for a stream or watch_with_handler() for callbacks.
Both take a WatchRequest and use the same supervisor underneath.
Stream surface
use std::collections::BTreeMap;
use aviso::{watch::WatchRequest, AvisoClient};
let client = AvisoClient::builder().base_url("https://aviso.example").build()?;
let mut filter = BTreeMap::new();
filter.insert("class".to_string(), serde_json::json!("od"));
let mut stream = client.watch(WatchRequest::watch("mars").with_filter(filter))?;
while let Some(item) = stream.recv().await {
let notification = item?;
println!("seq {}: {}", notification.sequence, notification.payload);
}
The stream is an async Stream<Item = Result<Notification, ClientError>>. Drop
it to cancel.
The filter must include every identifier the event type’s schema marks
required: true. Omitting one returns
400 Required field '<name>' missing for watch operation. Run
aviso schema get <TYPE> to see which fields are required.
Several watches through one stream
watch_many opens one watch per named request and merges them. Each item
carries the name, so one loop serves watches with different filters or event
types:
use aviso::watch::{ErrorPolicy, WatchRequest};
use futures_util::StreamExt;
let od = WatchRequest::watch("mars")
.with_filter([("class".to_string(), serde_json::json!("od"))].into());
let north = WatchRequest::watch("alerts")
.with_filter([("region".to_string(), serde_json::json!("north"))].into());
let mut stream =
client.watch_many([("operational", od), ("alerts", north)], ErrorPolicy::Continue)?;
while let Some(item) = stream.next().await {
match item {
Ok((name, notification)) => println!("{name}: seq {}", notification.sequence),
Err(failure) => eprintln!("{failure}"),
}
}
Watches are read in turn, so a busy one cannot starve a quiet one, and the
stream ends when every watch has ended. A failing watch yields one
EntryError with its name and error. ErrorPolicy::Stop then ends the
others; ErrorPolicy::Continue drops only that one. Every request is checked
before any watch opens, and each keeps the resume position watch() would
give it. close().await closes them all. check_watch_request performs the
same check on one request without opening anything, for callers that build
requests from their own input and want to report a mistake first.
Numeric and enum constraints
Use JSON objects in the filter map. With the client above connected to the
test server from the
weather tutorial, this
replays the seeded records B and C and then ends:
use std::collections::BTreeMap;
use aviso::watch::WatchRequest;
use serde_json::json;
let filter = BTreeMap::from([
("date".into(), json!("20260913")),
("severity".into(), json!({"gte": 5})),
("anomaly".into(), json!({"between": [40, 50]})),
("region".into(), json!({"in": ["north", "south"]})),
]);
let request = WatchRequest::replay_only(
"weather",
aviso::watch::ResumeStart::AfterSequence(0),
)
.with_filter(filter);
let mut stream = client.watch(request)?;
while let Some(item) = stream.recv().await {
println!("{}", item?.payload["id"]);
}
For live delivery, use WatchRequest::watch("weather") and start before
publishing. All bindings use the same
constraint rules; the map selects
identifiers, not payload contents.
Callback surface
use std::collections::BTreeMap;
let mut filter = BTreeMap::new();
filter.insert("class".to_string(), serde_json::json!("od"));
client
.watch_with_handler(
WatchRequest::watch("mars").with_filter(filter),
|notification| async move {
println!("got sequence {}", notification.sequence);
Ok(())
},
)
.await?;
Same supervisor, same behaviour. Pick the shape that fits your code.
Building a watch request
Four constructors prevent invalid combinations:
use std::collections::BTreeMap;
use serde_json::json;
use aviso::watch::{ReplayEnd, ResumeStart, WatchRequest};
// Live: stream new notifications as they arrive.
let live = WatchRequest::watch("mars");
// Historical then live: replay after sequence 41, then keep going.
let historical = WatchRequest::watch_from("mars", ResumeStart::AfterSequence(41));
// Replay only: replay from a date, then close cleanly.
let replay = WatchRequest::replay_only("mars", ResumeStart::Date("2026-01-01T00:00:00Z".into()));
// Replay a range: sequences 11 to 20, then close cleanly.
let range = WatchRequest::replay_range(
"mars",
ResumeStart::AfterSequence(10),
ReplayEnd::Sequence(20),
);
println!("{live:?}\n{historical:?}\n{replay:?}\n{range:?}");
// Add a filter. Values are JSON, so spatial filters fit too.
let mut filter = BTreeMap::new();
filter.insert("date".to_string(), json!("20260601"));
filter.insert("polygon".to_string(),
json!([[46.0, 8.0], [46.0, 9.0], [47.0, 9.0], [46.0, 8.0]]));
let req = WatchRequest::watch("observations").with_filter(filter);
ResumeStart::AfterSequence(n) reads as “I already have everything up to n;
give me n+1 onward”. The supervisor sends from_id = (n + 1).to_string() on the
wire.
ReplayEnd is the end point of a replay, and it is inclusive:
ReplayEnd::Sequence(n) delivers sequence n and then ends, and
ReplayEnd::Date ends with the last notification published at or before that
time. It goes on the wire as to_id or to_date, which aviso-server accepts
from 0.13.0. A sequence end that is not after a sequence start is refused with
ClientError::Config before anything is sent. The server reports the end it
resolved in replay_started; the supervisor sends that sequence on every
reconnect, so a date end cannot move, and ends the stream without reconnecting
once the notification at the end has been delivered.
Triggers from the library
use std::time::Duration;
use aviso::watch::{HttpMethod, Trigger, WatchRequest};
let req = WatchRequest::watch("mars")
.with_triggers(vec![
Trigger::echo(),
Trigger::log("/var/log/aviso/mars.log"),
Trigger::command("./on-event.sh {{ notification.event_type }}")
.timeout(Duration::from_secs(30))
.retries(2),
Trigger::webhook("https://hooks.example/notify")
.method(HttpMethod::Post)
.header("Authorization", "Bearer {{ env.HOOK_TOKEN }}")
.body_template(r#"{"seq": {{ notification.sequence }}}"#),
]);
let mut stream = client.watch(req)?;
Each trigger has the same four tunables: retries, required, timeout,
fail_fast. For YAML equivalents and the trigger contracts, see
Triggers overview.
State store: surviving restarts
use std::sync::Arc;
use aviso::state::{JsonFileStore, StateStore};
use std::path::PathBuf;
let path = PathBuf::from("/var/lib/aviso/state.json");
let store: Arc<dyn StateStore> = Arc::new(JsonFileStore::open(&path).await?);
let client = AvisoClient::builder()
.base_url("https://aviso.example")
.state_store(store)
.build()?;
When a store is configured:
- When listening starts, if your
WatchRequesthas no explicit resume position, the supervisor reads the stored checkpoint and resumes from there. - After each successful notification dispatch, the supervisor commits the previous notification’s sequence before letting the consumer pull the next.
- The user-facing contract is “pulling item N+1 implies item N is durable”.
MemoryStore is the in-process equivalent: useful for tests and short-lived
processes.
For the on-disk format and edit safety, see State file.
Reading the schema
let catalog = client.schema().await?;
for name in &catalog.event_types {
println!("{name}");
}
let one = client.schema_for("mars").await?;
println!("identifier rules: {:?}", one.schema.identifier);
aviso does not validate notifications against schemas. These methods are for discovery.
Errors
ClientError variants you will see most:
| Variant | When |
|---|---|
Transport | DNS, connect, TLS, or partial body before any response. |
Http { status, body, request_id } | Any non-success status. The request_id is the server’s correlation id; quote it when filing issues. |
Decode | The body did not deserialise as expected (server contract drift). |
Auth | The auth provider failed to produce a header, or refresh itself failed. |
TriggerFailed { kind, source } | A required trigger failed after all retries. |
StateStore | A configured StateStore::put or get failed. Terminal: continuing would silently violate at-least-once delivery. |
HistoryGap { reason } | The supervisor detected a non-consecutive sequence or a server-emitted replay-limit signal. Terminal for the same reason. |
MalformedEvent | A CloudEvents id did not parse as <event_type>@<u64>. Terminal to avoid livelocking. |
The resilience layer absorbs transient errors internally (transport hiccups, 429/503, heartbeat timeouts) and reconnects with the right backoff. Those will not surface as errors to your code.
Building a custom state-store
Implement the StateStore trait:
use aviso::state::{Checkpoint, ResumeKey, StateStore, StoreError};
#[async_trait::async_trait]
impl StateStore for MyStore {
async fn get(&self, key: &ResumeKey) -> Result<Option<Checkpoint>, StoreError> {
// your read
}
async fn put(&self, key: &ResumeKey, cp: Checkpoint) -> Result<(), StoreError> {
// your write. Must reject any cp whose last_committed_sequence <= the
// currently-stored value, to keep at-least-once delivery sound.
}
async fn delete(&self, key: &ResumeKey) -> Result<(), StoreError> {
// your delete
}
}
The contract is linearisable: a successful put is committed-before-visible. A
failed put leaves all state unchanged. Strict monotonicity (no cursor moves
backwards) is mandatory.
Where to go next
- Rust API reference: every type and method.
- Architecture: why the API is shaped this way.
- Triggers overview: the trigger dispatcher contract.
Contributing
How to work on this repository. The canonical document is
CONTRIBUTING.md
at the repository root; this page is a quick orientation for people landing in
the book.
What you need installed
- Rust 1.88 or newer through rustup. The MSRV is also
declared in the workspace’s root
Cargo.toml. mdbookfor the documentation:cargo install mdbook.mdbook-mermaidfor diagram rendering in the book:cargo install mdbook-mermaid. Without it,mdbook build docsleaves mermaid blocks as raw code instead of rendered diagrams.cargo-denyfor the license and vulnerability gate:cargo install cargo-deny.- Docker for the end-to-end tests’ compose validation. Needed to run the full CI gate set locally; pure cargo workflows do not need it.
The Python toolchain (uv, ruff, ty, pytest) is only needed when you
touch the future Python extension or the pure-Python helpers; see
CONTRIBUTING.md for the details.
The local workflow
git clone https://github.com/ecmwf/aviso-client.git
cd aviso-client
# Make a change. Run the gates locally before you push:
cargo fmt --all
cargo clippy --locked --workspace --all-targets -- -D warnings
cargo test --locked --workspace --all-targets
cargo test --locked --workspace --doc
mdbook test docs
mdbook build docs
cargo deny check
git diff --exit-code Cargo.lock
docker compose -f tests/e2e/docker-compose.yml config --quiet
A pre-commit hook in .githooks runs the fast subset (format, clippy, unit
tests). Enable it with:
git config core.hooksPath .githooks
A pre-push hook runs the slower targets, including the Docker Compose syntax check.
What CI runs
The same gates listed above. The full set is in
.github/workflows/ci.yml.
Every gate is expected to be green on main.
Adding a new trigger
A common contribution. The shape:
- Add a new payload struct in
crates/aviso/src/watch/trigger/and wire it into the crate-privateTriggerKindenum. - Add a public constructor on the
Triggerbuilder (Trigger::myname(...)). - Add the dispatcher logic in the trigger module.
- Add a
MyConfigpayload struct and aTriggerConfig::MyName(MyConfig)variant for YAML. - Write unit tests for the dispatcher and round-trip serde tests for the YAML.
- Add a docs page under
docs/src/triggers/and link it fromdocs/src/triggers/overview.md.
A worked example is in the webhook trigger
(crates/aviso/src/watch/trigger/webhook.rs); it has all the pieces you will
need.
Documentation contributions
Pages live under docs/src/ in the layout described in
SUMMARY.md.
When you add or move a page, keep the SUMMARY in sync.
Keep the documentation easy to follow:
- Lead with what the user is trying to do, not with how the code is laid out.
- Examples come early, prose later.
- Avoid being overly technical when there is a plainer way to say the same thing.
Planning changes
When you propose a structural change, update the relevant planning material instead of burying long rationale in user-facing docs.
Durable planning lives in GitHub Issues and milestones.
Filing a bug
Use the issue tracker. When you can, include:
- The aviso version (
aviso --version). - The
X-Request-IDfrom the server response (visible in tracing asrequest_id). - The output of
aviso config dump --redactif the bug is configuration-shaped. - Steps to reproduce, against a public server when possible.
Licences and attributions
aviso-client is distributed under the Apache License 2.0. The NOTICE records ECMWF’s copyright and institutional notice.
The documentation includes Mermaid and other third-party assets. Their credits and permission notices accompany this book, along with the full licence texts:
The modified Mermaid initializer is distributed in source form as
mermaid-init.js. It remains under MPL-2.0. The NOTICE
identifies its upstream source and describes the modifications.