Alerts¶
Alerts turn the Feed into a push channel. You pick a subset of your feed monitors (or all of them), pick a schedule (daily / weekly / manual), and ALMa delivers the new papers to the channels you ticked each time it fires.
The model is intentionally close to the older scholar-slack-bot
script — small, opinionated, and bounded. Alerts are not a generic
notifier; they are "the new-papers digest for the things I told ALMa
to watch."
Mental model¶
Three primitives:
| Concept | Table | What it is |
|---|---|---|
| Monitor | feed_monitors |
A "thing ALMa watches." Today this means a followed author, but the schema is open to keyword / topic monitors as the Feed grows. |
| Rule | alert_rules |
The matching predicate that selects papers from one monitor (or other source). For v1 the only UI-supported rule type is feed_monitor. |
| Alert (digest) | alerts |
The delivery config: name, schedule, channels, plus a list of assigned rules. One alert can compose many rules. |
A paper is delivered when all of these hold:
- It matches at least one rule assigned to the alert.
- Its
publication_dateis within the rolling 30-day window (max_age_days, configurable per rule, default 30). - Its
feed_items.fetched_atis afteralert.created_at— the cold-start watermark (see below). - It has not already been sent by this same alert in a previous
fire (
alerted_publicationsper-alert dedup).
Why these filters?¶
Two specific failure modes the filters prevent:
1. Backfill spam (Layer 1: 30-day publication-date window)¶
When you add a new monitor, ALMa backfills the author's recent
publications into feed_items. Without a publication-date filter,
the first alert fire would dump every recent backfill into Slack —
papers that are new TO THE MONITOR but not new TO THE WORLD.
The 30-day window means: "only papers published in the last 30 days are eligible." Older papers stay in the Feed where you can browse them; they do not become Slack notifications.
Papers with no publication_date (rare on OpenAlex; common on
imports) are dropped, not back-filled with fetched_at. Per the
project's "don't fabricate timestamps" rule, a missing pub date is
not the same as "today."
2. Cold-start floods (Layer 2: alert.created_at watermark)¶
If you create an alert covering a monitor that already has a few weeks of papers in the Feed, Layer 1 alone would still send the recent-but-already-seen ones on the first fire.
Layer 2 says: "the alert starts caring from the moment it was
created." Anything fetched into the Feed before alert.created_at
is treated as historical and skipped, even if its publication_date
is recent.
This mirrors scholar-slack-bot's cache semantics — a brand-new
author starts with everything already in the cache, so nothing
fires until truly new papers arrive.
Per-alert dedup, NOT global¶
alerted_publications is keyed on (alert_id, paper_id). The same
paper can deliver through two distinct alerts — once each. This is
deliberate: each alert is its own deliberate subscription with its
own scope, and collapsing across alerts would let a noisy
"follow-this-author" alert silence a more curated topic alert for
the same paper.
Inside a single alert, the same paper is sent at most once.
Slack message format¶
Each fire posts one Slack message (or several, if there are more than 15 papers — see Chunking below). Per paper:
*<https://doi.org/…|Title of paper>*
Authors: First, [+N], Last
2026-04-26 | Nature Machine Intelligence
Match: Alice Smith, Bob Jones
The first ~280 chars of the abstract, truncated …
- Title is bold and links out (DOI > url > pub_url).
- Authors abbreviate to
First, [+N], Lastpast four authors. - The metadata line is
publication_date | journal. Citations are intentionally omitted — these are new papers by construction, so the count is always 0 / near-0 and adds noise. - Match lists the entities inside the rule(s) that triggered
this paper — author names, topic labels, keywords. When one paper
matches multiple rules in the same alert, the entries are joined
with
,so you see all the reasons it surfaced. - Abstract is truncated to 280 chars; missing abstracts produce no line.
The footer reads Sent by ALMa | YYYY-MM-DD HH:MM UTC.
Chunking past 15 papers¶
A single Slack Block-Kit message has a 50-block limit, which works out to ~15 papers. When a fire produces more than that, ALMa splits into multiple messages with headers like:
The dispatch is all-or-nothing: papers are only marked
delivered (alerted_publications) after every chunk has succeeded.
A partial Slack outage leaves the un-acked papers eligible for the
next fire.
Schedules¶
| Schedule | When it fires |
|---|---|
| Manual | Only when you click "Evaluate" on the Alerts page (or POST /alerts/{id}/evaluate). |
| Daily | Each day at a configurable hour, evaluated by the in-process scheduler sweep. |
| Weekly | Once per week on a configurable day + hour. |
The scheduler sweep checks every hour by default
(ALERT_CHECK_INTERVAL_HOURS) and fires every alert whose
_is_due() predicate returns true. Schedule times are stored as
naive UTC; users in non-UTC time zones will see the local fire
time shift by their offset.
Async + Activity envelope¶
Every Slack-touching call runs through the canonical activity envelope:
| Endpoint | Operation key |
|---|---|
POST /alerts/{id}/evaluate |
alerts.evaluate:<alert_id> |
POST /plugins/slack/test |
integrations.slack.test |
POST /plugins/email/test |
integrations.email.test |
| Periodic sweep | alerts.evaluate_scheduled |
The HTTP request returns in ~100 ms with a JobEnvelope
({ job_id, status: "queued", operation_key, … }). The actual
evaluation runs on the scheduler thread pool; progress lands in
operation_status so the Activity tab shows the job moving
from queued → running → completed (or failed) with a punch-line
terminal message like "Sent 7 new paper(s) for 'Weekly digest'".
Concurrent re-fires of the same alert dedupe via find_active_job:
clicking "Evaluate" twice returns the same job_id on the second
call, with status: "already_running".
Delivery integrations¶
Two working integrations: Slack and Email. An Alert delivers to
whichever active send-capable manifests are checked on its channels list;
each integration receives the same matched-paper set independently. The
frontend discovers those choices from alma.plugins.registry, and the API
rejects unknown or receive-only ids before they reach durable Alert state.
Alerts remain core: rules, schedules, matching, deduplication, history, and
Activity live here. Integration plugins only render and deliver through the
manifest's AlertSender seam. See External integrations
and the plugin pages.
Slack¶
SlackNotifier posts Block-Kit messages with a bot token. The target
is one string — a channel name, a user display name, or a Slack ID —
resolved to an ID at send time and cached for the lifetime of the
process, so a wrong name produces a precise channel_not_found in the
Activity row rather than a generic "API failed."
Email¶
EmailNotifier (alma.mailer.client) is a stdlib smtplib sender
that mirrors SlackNotifier: an is_configured gate (host + from +
recipients), an async send_paper_alert, a send_test_message, and a
test_connection handshake. It renders the same paper-dict shape Slack
does into a combined HTML + plaintext email, capped at 50 papers per
email. Transport is STARTTLS on port 587 (the default) or implicit
TLS on port 465.
Both are configured in Settings → Plugins, each on its own page: Slack, Email.
Current limits¶
- Single global Slack target. Per-alert channel override is on the roadmap — today every alert delivers to whatever Send alerts to is set to on the Slack plugin.
- Schedule times are timezone-naive (UTC). A daily 09:00 alert fires at 09:00 UTC, which is 11:00 in CET / 10:00 in CEST.
- Only
feed_monitorrules are exposed in the v1 dialog. The other rule types (author,collection,keyword,topic,similarity,discovery_lens,branch,library_workflow) exist in code (VALID_RULE_TYPES) and accept API calls, but the new-alert form centres on monitors.
Schema¶
alert_rules (id, name, rule_type, rule_config, channels,
enabled, created_at)
alerts (id, name, channels, schedule, schedule_config,
format, enabled, created_at, last_evaluated_at)
alert_rule_assignments (alert_id, rule_id) -- M:N
alert_history (id, alert_id, channel, sent_at, status,
publications, publication_count,
message_preview, error_message)
alerted_publications (id, alert_id, paper_id, alerted_at)
-- UNIQUE(alert_id, paper_id)
channels is a JSON array of channel names; the implemented values
are slack and email (an alert may list either or both).
rule_config is a JSON blob; for feed_monitor rules it must
include monitor_id (or monitor_name). max_age_days (default
30) is the only other key that affects matching.
API surface¶
# Rules (the matching predicates)
GET /api/v1/alerts/rules
POST /api/v1/alerts/rules
PUT /api/v1/alerts/rules/{rule_id}
DELETE /api/v1/alerts/rules/{rule_id}
POST /api/v1/alerts/rules/{rule_id}/toggle
POST /api/v1/alerts/test/{rule_id} # dry-match, no Slack send
# Alerts (the delivery configs)
GET /api/v1/alerts/
POST /api/v1/alerts/
GET /api/v1/alerts/{alert_id}
PUT /api/v1/alerts/{alert_id}
DELETE /api/v1/alerts/{alert_id}
POST /api/v1/alerts/{alert_id}/rules # assign rules
DELETE /api/v1/alerts/{alert_id}/rules/{rule_id}
# Evaluation (async-enveloped)
POST /api/v1/alerts/{alert_id}/evaluate # returns JobEnvelope
POST /api/v1/alerts/{alert_id}/dry-run # sync; returns matched papers
# History
GET /api/v1/alerts/history
GET /api/v1/alerts/templates # one-click suggestions
# Channel tests
POST /api/v1/plugins/slack/test # returns JobEnvelope
POST /api/v1/plugins/email/test # sends a test email (SMTP)
For the request/response shapes see the API reference.