One API for natural-hazard alerts from multiple official sources, normalised to a single schema with provenance intact.
alertmux relays alerts published by official authorities. It never originates a warning, and it is not a substitute for official warnings from the issuing authority.
| Source | Coverage |
|---|---|
| WMO SWIC | 59 national alerting authorities, warnings currently in force |
| USGS | Global earthquakes ≥ M 4.5, past day (feed configurable — see docs/DATA-SOURCES.md) |
| NOAA / NWS | United States, active alerts |
| GDACS | Global disaster alerts (earthquakes, floods, cyclones, drought, wildfire) |
| NASA EONET | Satellite-observed events (wildfires, severe storms) — observations, not warnings |
| tsunami.gov (NTWC + PTWC) | US tsunami bulletins (Information Statement, Watch, Advisory, Warning) |
pip install -e ".[dev]"uvicorn alertmux.api:app --reloadGET /alerts— all current alerts. Optional?authority=ng-nimet.GET /alerts/{alert_id}/detail— one alert's raw CAP file, merged in. SWIC's list view (what/alertsserves) structurally omitsheadline,description,instruction,onsetandexpires—expiresmatters most, since without it a live warning can't be told from a lapsed one. This route fetches the authority's original CAP 1.2 file for that one alert and merges it into the record; where the CAP file states a named severity/urgency/certainty, that value wins over the list view's integer-code mapping (the raw code stays insource_severityetc. regardless). Not part of/alerts's default response — with ~2,200 alerts in force, fetching one CAP file per alert on every list poll would be ~2,200 requests to WMO per fetch, so this is opt-in per alert, and cached bycapurlforever (a CAP file's path is content-addressed, so it can never change once published — seedocs/DECISIONS.mdD16). Returns 404 for an unknownalert_idand 502 if the CAP file can't be fetched or parsed — never a silently empty or partial record.GET /health— per-source health. Returns HTTP 503 whenever the result is partial, so a standard monitor sees the degradation.GET /sources— discovery: what alertmux covers and what it misses. Per-source identity, structural gaps, authorities actually seen, andhazard_coverage/uncovered_hazards(a heuristic keyword classification — seesources.py).GET /authorities— the WMO Register of Alerting Authorities: 300 official alerting authorities across 199 countries, a directory of who is allowed to issue CAP alerts, not a source of alerts itself. Optional?country=(alpha-2 or alpha-3).countries_covered/countries_uncoveredjoin at country level only — WMO's own authority abbreviations disagree with the ones alertmux's sources use, so authority-level matching is refused; seeregistry.pyanddocs/DECISIONS.md. Cached with a long TTL;register_cache_age_secondsalways reports how stale the served copy is.
Any response where a source failed, returned fewer alerts than it holds, or
quarantined one or more unparseable records sets partial: true. Incomplete
results are always labelled — sources[].invalid_count and invalid_samples
say how many records were dropped and why.
With ?authority= applied, sources[].alert_count describes the whole
fetch and will not equal len(alerts). Source health is about the fetch,
not the filter.
If ?authority= matches nothing, the response also carries
available_authorities — the authorities present in this fetch — so a typo
is distinguishable from a genuinely quiet day. That list is only ever built
from alerts the current fetch actually returned, so it also carries
available_authorities_partial: true means the underlying fetch was
partial (a source was down, truncated, or dropped bad records), in which
case a missing authority may simply have a source that failed right now,
not one that does not exist. Check that flag before treating a short list
as proof an authority is invalid.
Results are cached for 60 seconds, so polling /health does not repeatedly
pull ~774KB from WMO.
alertmux ships an MCP server so a Claude user can query live alerts from
inside a conversation, without going through the HTTP API. mcp is an
optional dependency — installing it pulls in a second HTTP client
(httpx2), cryptography, opentelemetry-api and a few other packages
that the core library does not otherwise need, so it stays opt-in (see
docs/DECISIONS.md).
pip install "alertmux[mcp]"This installs the alertmux-mcp console script, which speaks MCP over
stdio:
alertmux-mcpAdd it to a client's MCP config (e.g. Claude Desktop's
claude_desktop_config.json):
{
"mcpServers": {
"alertmux": {
"command": "alertmux-mcp"
}
}
}Four tools are exposed — list_alerts, list_sources,
get_hazard_coverage, and find_duplicates — all reading through the same
cached fetch path the HTTP API uses, so an MCP client never hammers WMO
independently of anyone else querying alertmux. Every tool response carries
the relay disclaimer and the partial/truncated flags from the query
layer, so a model relaying the answer can say plainly when data is missing
or incomplete rather than reporting it as a clean "no alerts."
alertmux-notify is a self-hosted SMTP notifier: you configure rules
("email me when NiMet issues a Severe alert for Nigeria"), point it at
your own SMTP relay, and run it on a schedule (cron, systemd timer). It
ships no mail infrastructure and no default sender (D11) — you bring
your own SMTP and your own subscriptions, for yourself, which is what
keeps this on the safe side of the line between relaying official feeds
and mass-notifying strangers.
The design problem this had to solve. A rule that says "notify on
Severe or above" cannot be evaluated for a source that never carries a
mapped severity — GDACS's alertlevel is an impact score, not CAP
severity, and tsunami.gov's bulletin category is deliberately never
mapped either (D1/D17/D18). Naively, such a rule would silently deliver
zero GDACS earthquakes and zero tsunami bulletins while the operator
believed they were covered — a life-safety failure. alertmux-notify
makes this impossible to walk into three ways:
- Every severity-threshold rule reports how many alerts it could not
evaluate (
unevaluable_count), never silently drops them from consideration. - At the start of every run it warns loudly, naming the specific sources, when a severity rule would touch a source that never carries a mapped severity in that fetch.
- The default is to deliver, not withhold. An alert whose severity
cannot be evaluated is still sent unless a rule explicitly sets
include_unmapped_severity = false. A false positive costs an extra email; a false negative costs a missed hazard warning — seedocs/DECISIONS.md's notifier entry for the full reasoning.
TOML, loaded from a file plus environment variables for credentials
(ALERTMUX_SMTP_HOST / _PORT / _USERNAME / _PASSWORD / _SENDER /
_USE_TLS / _USE_SSL override the [smtp] block, so a config file
committed to version control need not carry the password). Credentials
are never logged, echoed, or included in any error message.
Every rule is capped by default. A rule with no max_per_run gets
40, not unlimited -- verified live, this exact config shape (a single
severity_at_least = "Severe" rule, no cap set) reported "Dry run: 1137
alert(s) would be sent" on a first run against real NOAA data. Raise the
cap (max_per_run = 200) or remove it deliberately (max_per_run = 0,
TOML has no null) only once you have decided you actually want that
many individual emails; the usual fix for a rule that keeps hitting the
cap is digest = true (one email summarising many) or a narrower rule,
not a higher ceiling. When the cap suppresses anything,
alertmux-notify says so loudly, by name and count, in both dry-run and
real-run output -- see "What it guarantees" below.
[smtp]
host = "smtp.example.org"
port = 587
username = "alerts@example.org" # or leave unset and rely on env
password = "set-via-env-instead" # ALERTMUX_SMTP_PASSWORD overrides this
use_tls = true # STARTTLS; mutually exclusive with use_ssl
sender = "alerts@example.org"
[state]
path = "alertmux_notify_state.json" # survives restart, prunable
prune_after_days = 30
[run_log]
path = "alertmux_notify_runs.jsonl" # every real run's outcome, appended
max_entries = 500 # so alertmux-dashboard can show it
[[rules]]
name = "nigeria-severe"
to = ["ops@example.org"]
authority = "ng-nimet"
severity_at_least = "Severe"
# include_unmapped_severity defaults to true (the safe direction) --
# this rule's severity filter is scoped to ng-nimet, which does carry a
# mapped severity, so the default rarely matters here.
# max_per_run raised above the default of 40: this authority is scoped
# narrowly enough (one country) that a higher ceiling is a deliberate,
# considered choice here, not an oversight.
max_per_run = 200
[[rules]]
name = "global-earthquakes"
to = ["ops@example.org"]
event_contains = "earthquake"
severity_at_least = "Severe"
# GDACS's earthquakes never carry a mapped severity (D1/D18) -- left at
# the default, they are still delivered, and alertmux-notify prints a
# WARNING at startup naming gdacs explicitly so this is never a
# surprise. No max_per_run here: this rule relies on the default cap of
# 40, and digest = true means a capped run still arrives as one email,
# not 40.
digest = true
[[rules]]
name = "everything-else"
to = ["ops@example.org"]
# No filters at all: every new, non-expired, deduplicated alert. No
# max_per_run here either -- this is the widest rule in the file, so it
# is the one most likely to ever hit the default cap of 40. If it does,
# alertmux-notify names it explicitly in the run output; that is the
# signal to add digest = true or narrow the filters, not to raise the
# number.Run it:
alertmux-notify --config notify.toml --dry-run # preview, sends nothing
alertmux-notify --config notify.toml # actually sendsNon-zero exit on any SMTP failure or invalid config — schedule it with a runner that alerts on a failing exit code, since a notifier that fails silently is worse than no notifier at all.
- Cross-source dedupe. Uses
dedupe.py's duplicate groups: when SWIC and NWS both carry the same NOAA warning, only thepreferred_idrecord is ever considered, so a matching rule fires once, not once per source. - Across-poll dedupe. A JSON state file keyed by alert id (ids are stable by design, D2), so the same alert is never re-notified on the next run. Survives restart; prunable so it never grows forever.
- Respects
expires. An alert past its stated expiry is never sent. Where a source does not stateexpiresat all, the alert is kept — the schema cannot distinguish "never expires" from "the source did not say" (unavailable_fields), and treating an unknown expiry as already expired would silently withhold a possibly-still-live hazard. - Rate limit and digest.
max_per_runcaps how many new alerts a rule sends in one run — the rest are retried next run, never marked notified. It defaults to 40, not unlimited (max_per_run = 0opts a rule deliberately into no cap). The cap applies first;digest = truethen batches whatever survives it into a single email instead of one per alert. A severe-weather day producing thousands of NOAA alerts needs both; an unthrottled notifier is a mailbomb. Whenever the cap suppresses anything,alertmux-notifysays so by name and count in both dry-run and real-run output (CAPPED: rule '...' suppressed N alert(s) ...), so a truncated run never reads as a complete one. - Verbatim relay. Each email carries the official
headlineanddescriptionunmodified, plus authority, source URL, retrieval time, onset, expiry (or an explicit "not stated by source" note) and the standing relay disclaimer. Nothing is reworded or summarised.
The spec called for SMTP failure to also surface on the dashboard and in
/health. The dashboard half now ships (see "Dashboard" below): every
real run — clean or failed — is appended to [run_log]'s JSONL file, and
a failed run is impossible to miss on the dashboard's front page. The
/health half remains out of scope: api.py currently has open PRs
rewriting its cache, so touching it here was ruled explicitly out for
this build, and the notifier is a separate process with no HTTP endpoint
of its own. SMTP failure is still loud independently of both (non-zero
exit, ERROR-level log, and the alert stays unmarked in state for retry).
alertmux-dashboard is a local, read-only, single-operator dashboard.
Its purpose is operational confidence — knowing the system is actually
working — not presentation. It has no accounts and no control that could
originate, edit, or suppress an alert; every route is a GET.
It serves one self-contained HTML page (inline CSS/JS, no build step, no CDN — it works offline on a laptop) plus the JSON endpoints that page polls, in the spec's priority order:
- Source health — per adapter: ok/down, latency, error, alert count, and consecutive-failure count tracked across polls (something no existing module computes, since every other consumer only ever looks at one fetch at a time).
- Coverage by hazard family and by authority — reuses
sources.pydirectly. Families with zero contributing sources are named explicitly (uncovered_hazards), because an authority count alone reads as healthy while whole hazard families have no source at all. - Live alerts, filterable by authority, severity, and event substring.
- Volume over time per source — a source going quiet usually means
it broke, not that the weather improved. Backed by a new append-only
JSONL recorder (
dashboard/volume.py), one snapshot per refresh. History begins when recording began: an empty file renders as "no data yet," never as a flat zero. - Notification log — what fired, which rule, when, and what was
suppressed, read from
alertmux-notify's[run_log]file (notify/runlog.py). A failed run is impossible to miss on the front page; a clean run produces no false alarm. - Registry freshness — when the WMO register was last refreshed,
and its cache age (via
registry.get_register, already cached).
It runs its own small TTL cache over alertmux.query.collect (60s for a
complete fetch, 10s for a partial one, same policy as api.py, but its
own instance — no import of api.py's internals, since two open PRs are
actively rewriting that cache). It never fetches WMO/USGS a second time
per page load.
Run it:
alertmux-dashboard --port 8288
# --run-log path/to/alertmux_notify_runs.jsonl # point at your notifier's [run_log].path
# --volume-log path/to/alertmux_dashboard_volume.jsonlThen open http://127.0.0.1:8288/. A partial fetch, an uncovered hazard
family, and a failed notifier run are all called out at the top of the
page rather than left for the operator to notice by their absence.
A real warning from the Nigerian Meteorological Agency, as alertmux returns it:
{
"id": "wmo-swic:ng-nimet-en/2026/08/17/14/50/16-e28162fa92b40b8a59c979ba00b562e9.xml",
"event": "THUNDERSTORMS",
"headline": null,
"description": null,
"instruction": null,
"area_description": "Some states in Nigeria will be affected.",
"severity": "Severe",
"urgency": "Expected",
"certainty": "Observed",
"source_severity": "3",
"source_urgency": "3",
"source_certainty": "4",
"sent": "2026-08-17T06:50:16Z",
"onset": null,
"expires": null,
"geometry": null,
"provenance": {
"authority": "ng-nimet",
"source_id": "wmo-swic",
"source_url": "https://severeweather.wmo.int/g/wfs",
"retrieved_at": "2026-08-17T23:39:03Z",
"raw_reference": "ng-nimet-en/2026/08/17/14/50/16-e28162fa92b40b8a59c979ba00b562e9.xml"
},
"unavailable_fields": [
"description",
"expires",
"geometry",
"headline",
"instruction",
"onset"
],
"unmapped_fields": []
}Read it as: the authority stated a severity code of 3, which is confirmed to
mean CAP Severe, so both are reported. It supplied no headline, description,
instruction, onset, expires or polygon — the SWIC list view carries none of
them; they live only in the raw CAP file at
https://severeweather.wmo.int/v2/cap-alerts/<raw_reference>. Every one of
those absences is named in unavailable_fields, which is exhaustive: if a
field is null, its name is in unavailable_fields or unmapped_fields
(empty here — see below). GET /alerts/{alert_id}/detail fetches that CAP
file and fills every one of those gaps it can.
unavailable_fields and unmapped_fields answer two different questions,
and every null field is in exactly one, never both:
unavailable_fields— the source supplied nothing for this field.unmapped_fields— the source supplied a value, but alertmux declined to translate it, either because the mapping is unverified (an SWICs/u/ccode outside the confirmed tables) or because it is deliberately never attempted (GDACS'salertlevel, a tsunami bulletin category, the USGS PAGER level — none of which are CAP severity). The raw value is still insource_severity/source_urgency/source_certaintyeither way.
A GDACS alert with source_severity: "Green" and severity: null has
"severity" in unmapped_fields, not unavailable_fields — the authority
said something specific, and alertmux is refusing to translate it, which is a
different claim from "the authority said nothing."
The id is derived from raw_reference, not from the GeoServer feature id —
GeoServer's synthetic fids embed the request timestamp and change on every
fetch, so they cannot be used as a deduplication key.
- Nothing is inferred. A field a source does not supply is
null, and its name appears inunavailable_fields— or, if the source did supply a value that alertmux declined to translate,unmapped_fields. Never both. - Only verified severity codes are translated. SWIC's
s/u/care integers. The mapping was confirmed against raw CAP files, soseverity,urgencyandcertaintycarry proper CAP names — but any code that was never observed staysnullrather than being guessed, and lands inunmapped_fields(the code was there, it just wasn't trusted). The raw value is always preserved insource_severity/source_urgency/source_certainty, even when a mapping exists. - A malformed record is quarantined, never defaulted. A feature missing
an identity, an event type, or a timezone on its timestamp is skipped and
counted rather than guessed at or allowed to discard every other record in
the same response — one bad SWIC feature out of 2,200 no longer costs the
other 2,199. The source stays
ok: true(it answered),partialis forcedtrue(the answer is incomplete), andsources[].invalid_count/invalid_samplessay how many records were dropped and why. - A malformed envelope still fails loudly. A 200 response that is not
valid GeoJSON/RSS at all — not one bad record but a broken feed — still
turns into
ok=falsefor that source. Quarantine only applies once the envelope is confirmed genuine. - Adapters never raise outward. A failing source returns a status, so one broken feed cannot take down a response.
pytest # the whole unit suite; no network, fixtures only
pytest -m live # hits the real WMO SWIC, USGS, NOAA/NWS, GDACS and NASA EONET endpointsLive tests are excluded from the default run and from CI, so neither depends
on third-party uptime. A new adapter's live test belongs in
tests/test_live_smoke.py, under the live marker.
-
Create
src/alertmux/adapters/yoursource.pywith a class exposingsource_id: strandfetch() -> FetchResult. Copyusgs.py— it is the simplest example. Note thatFetchResultrequiresretrieved_atandlatency_ms; neither has a default. -
Record a real payload to
tests/fixtures/yoursource_<feed>.json, matching the existing naming (swic_effective.json,usgs_4.5_day.json). -
Write
tests/test_yoursource.pyagainst that fixture. No network in unit tests — userespxto mockfetch(). -
Register it in
src/alertmux/adapters/__init__.py. This is three edits, not one:from alertmux.adapters.yoursource import YourSourceAdapter # 1. import __all__ = ["SwicAdapter", "UsgsAdapter", "YourSourceAdapter", # 2. __all__ "default_adapters"] def default_adapters(): return [SwicAdapter(), UsgsAdapter(), YourSourceAdapter()] # 3. register
-
Add a live smoke test to
tests/test_live_smoke.pyunder thelivemarker.
- Never map an unverified code. Translate a source's severity, urgency or
certainty to a CAP name only after confirming that meaning against that
source's own raw CAP output. Anything unconfirmed leaves the named field
Noneand keeps the raw value insource_*. A guessed severity is either a missed warning or a false alarm — do not complete a partial table. - Record every
nulloptional field in exactly one ofunavailable_fields/unmapped_fields.unavailable_fieldsmeans the source supplied nothing;unmapped_fieldsmeans it supplied a value your adapter declined to translate (an unverified code, or a value deliberately never mapped, like GDACS'salertlevel). Both lists are exhaustive by contract and must never overlap. Derive them from the values you actually built and from whether the source's raw field was present, asswic.pyandgdacs.pydo, rather than appending entries by hand.
See CONTRIBUTING.md for the workflow and commit style.
Deeper reference, kept in docs/ and updated as the project grows:
| Document | What it covers |
|---|---|
docs/ARCHITECTURE.md |
How the modules fit together, and what each defensive check is guarding against |
docs/DATA-SOURCES.md |
Every endpoint in detail — including WMO SWIC's undocumented WFS API, the 59 authority codes, and the confirmed CAP severity mapping |
docs/DECISIONS.md |
Why things are the way they are, what each choice costs if wrong, and what evidence would justify changing it |
If you are about to "fix" something that looks obviously wrong — particularly the
incomplete severity tables — read docs/DECISIONS.md first. It is probably
deliberate, and the entry will tell you what evidence would change our mind.
MIT.