Skip to content

Per-task notifications

When it’s just one task you care about, skip the routing rules and name the channels right on the task. The setting sits next to the task it belongs to, so a glance at the file tells you which task pages which channel.

Every [tasks.*] and [services.*] block takes two fields: notify_on_failure (run ended as failed, timeout, crashed, log_overflow, or start_failed; also run.missed and service.fatal) and notify_on_success (run ended as succeeded). They work the same on tasks and services. The field tables in [tasks.*] and [services.*] list the event kinds that apply in each context.

[tasks.nightly-db-backup]
cron = "30 2 * * *"
run = "/usr/local/bin/backup.sh"
notify_on_failure = ["slack-ops"]

That’s the whole setup. A failed backup lights up the slack-ops channel, drops a row in the bell, and lands in the notification history. The bell part is free: "inapp" sits in [notify] global_notifiers by default, so it tags along on top of whatever channels you name. Don’t want it? Set global_notifiers = [] to silence it (see Global settings). And if a notification rule happens to match the same event too, each channel still gets exactly one message — duplicates are dropped.

[services.payments-api]
instances = 2
run = "/usr/local/bin/payments-api"
notify_on_failure = ["slack-ops", "tg-oncall"]
notify_on_success = ["slack-ops"]

A failure sends to two channels. A clean shutdown sends to one. The two lists are independent.

There’s no notify_on_missed = [...] channel list, and that’s deliberate. A missed scheduled run — one the daemon was down for — is a failure, so it rides the exact same channels you already named in notify_on_failure (plus the bell, via global_notifiers). You don’t wire it up; it’s just there.

What you can do is turn it off for a task that’s expected to miss ticks — a laptop job that only runs while you’re at your desk, say:

[tasks.daytime-sync]
cron = "*/30 9-17 * * 1-5"
run = "/usr/local/bin/sync"
notify_on_failure = ["slack-ops"]
notify_on_missed = false # failures still page; missed ticks stay quiet

notify_on_missed is a boolean, not a channel list. It defaults to true, inherits from [defaults], and only silences the alert — the missed run row is still recorded and browsable, so nothing actually goes invisible. Use it instead of hollowing out notify_on_failure, which would also drop your real failure alerts.

Each entry in the list is one of three forms: a bare notifier id ("slack-ops"), the literal "inapp", or an inline override of the form "<id>:<target>" that reuses the credentials of the named notifier but sends to a different target. The override form is useful when one workspace or one bot serves several destinations — one Slack workspace covering several channels:

[[notifier]]
id = "slack"
type = "slack"
webhook_url = "${RUNWISP_SLACK_URL}"
# no `channel` set — each task picks one inline
[tasks.nightly-db-backup]
notify_on_failure = ["slack:#ops"]
[tasks.weekly-deploy]
notify_on_failure = ["slack:#deploys"]
notify_on_success = ["slack:#deploys"]
[services.audit-stream]
notify_on_failure = ["slack:#audit-alerts"]

The form of <target> depends on the provider: for Slack it is a channel (#name or @user); for Telegram it is the chat id.

[[notifier]]
id = "tg"
type = "telegram"
bot_token = "${RUNWISP_TG_TOKEN}"
chat_id = "-1001"
[tasks.payments-reconcile]
notify_on_failure = ["tg:-1009998887"]

Notifier ids cannot contain : — the colon separates the id from the target, so id = "slack:foo" is rejected at load time. "inapp" has no target and "inapp:anything" is rejected. Twenty tasks all writing "slack:#ops" share one connection — #ops is not messaged twice for the same event.

Use an explicit [[notifier]] block (with channel = "#…" set on the block itself) when the same channel is used by enough tasks that the override gets repetitive. Use the inline override when each task has its own destination.

A per-task field is for one task; a [[notification_route]] block is for one rule covering many tasks. Reach for a rule when you need a pattern across many tasks (match.task = "backup-*"), a custom set of event kinds (the per-task failure list covers failed, timeout, crashed, log_overflow, start_failed, run.missed, and service.fatal), or routing for kinds that have no task — for example notify.delivery_failed. Per-task fields and rules mix freely: the router collects every channel that matches and sends one message per unique channel.

[[notifier]]
id = "slack-ops"
type = "slack"
webhook_url = "${RUNWISP_SLACK_OPS_URL}"
[tasks.nightly-db-backup]
cron = "30 2 * * *"
run = "/usr/local/bin/backup.sh"
notify_on_failure = ["slack-ops"]
[tasks.weekly-deploy]
cron = "0 9 * * 1"
run = "/usr/local/bin/deploy.sh"
notify_on_failure = ["slack-ops"]
notify_on_success = ["slack-ops"] # confirm Mondays went green
[tasks.audit-log]
cron = "0 0 * * *"
run = "/usr/local/bin/audit"
notify_on_failure = ["slack-ops"]
[tasks.process-event-queue]
cron = "*/10 * * * *"
run = "/usr/local/bin/process"
notify_on_failure = ["slack-ops"]
# Backups also send to the on-call channel, in addition to the per-task setting
[[notification_route]]
match = { kind = ["run.failed", "run.timeout", "run.crashed"], task = "*-backup" }
notify = ["tg-oncall"]

A failed audit-log run sends one message to slack-ops and adds one row to the bell. A failed *-backup run sends one message to slack-ops, one to tg-oncall, and adds one row to the bell.

The loader rejects unknown notifier ids in notify_on_failure / notify_on_success (anything that isn’t declared in [[notifier]] and isn’t "inapp"); inline overrides whose parent doesn’t exist, whose target is empty, or — for Slack — doesn’t start with # or @; and "inapp:something". An empty list (notify_on_failure = []) is fine — it’s equivalent to omitting the field, and [notify] global_notifiers still fires.