Notifications
dotagent ships with built-in notification drivers baked into the daemon. No plugin protocol, no subprocess fork, no extra binary on $PATH. The most common path (notify on failure) is the cheapest.
What changed. Earlier versions shipped five
dotagent-plugin-notify-*binaries (desktop, imessage, slack, ntfy, pushover). Each notification forked a process and spoke JSON over stdio. That worked but cost ~5-20ms per fire and forced users to keep five extra binaries on$PATH. Notifications are now in-process. The plugin protocol stays alive forsink/preflightand third-party notifiers (driver = "plugin").
Shape
Declare notifiers as a top-level array on the manifest:
[[notifiers]]
driver = "desktop"
title = "dotagent"
sound = true
events = ["attempt_failed", "given_up"]
[[notifiers]]
driver = "slack"
webhook_url = "${SLACK_WEBHOOK_URL}"
events = ["given_up", "recovered"]events is optional. Empty (or absent) means "all events".
The same entry shape is reused at daemon level by [[daily_summary.notifiers]] in config.toml, which decides where the end-of-day health roll-up goes — see [daily_summary]. The one difference: events is ignored there, since that list is already scoped to a single event.
Credentials go in
${VAR}, not in the manifest. Anagent.tomllives in a versioned repo; a webhook URL written literally into one is a webhook URL in git history forever. Every credential-bearing field on every HTTP driver accepts${VAR}, resolved at send time from~/.config/dotagent/secrets.env— see Secrets and credentials below andsecrets.md.
attempt_failed
The agent exited non-zero (a retry may still happen)
timed_out
The agent exceeded agent.timeout_seconds
given_up
All retries exhausted — operator action expected
stale
The schedule stopped running at all (see below)
recovered
A previously-failing schedule passed
success
Every successful run (use sparingly)
preflight
A preflight plugin blocked the run
stale, and why alerts repeat
Every other event fires from something that happened. An agent that quietly stops being scheduled does nothing at all: no run, no failure, no event. Its window ages past stale_after_minutes, the daemon stops even attempting it, and the last thing you heard was a success weeks ago. Silence reads as fine.
stale fires from the condition instead of an event, on every daemon tick while it holds. The same applies to given_up, which used to be said once and never again — an agent broken for a week keeps asking.
Because a condition is true continuously, dotagent spaces re-notifications on a rising ladder: on entry, then after 1h, 6h, and once a day for as long as it lasts. The state survives daemon restarts (state/notify/alerts.json) and is forgotten the moment the schedule succeeds again — so the next failure is loud from its first second instead of inheriting yesterday's silence.
You do not have to add "stale" to an existing events list. A notifier that asked for given_up asked to be told the agent is broken, and stale is the same news only worse, so it is delivered on the given_up channel when nothing lists stale explicitly. Listing it makes the routing explicit.
Drivers
driver
Transport
Subprocess?
desktop
NSUserNotification (macOS) / D-Bus (Linux)
No (native FFI)
slack
HTTPS POST to Slack Incoming Webhooks
No (in-process reqwest)
ntfy
HTTPS POST to ntfy.sh (or self-hosted)
No (in-process reqwest)
pushover
HTTPS POST to api.pushover.net
No (in-process reqwest)
telegram
HTTPS POST to api.telegram.org (Bot API)
No (in-process reqwest)
imessage
osascript Messages.app automation
Yes — Apple has no API
plugin
Falls back to the plugin protocol (kind = "notify")
Yes (legacy escape hatch)
desktop
slack
ntfy
title and tags travel as the X-Title / X-Tags HTTP headers, and a header value is bytes, not text. Raw UTF-8 there is obs-text — RFC 9110 says a sender should not generate it, and ntfy's own docs warn it can arrive as ?. So anything that is not plain visible ASCII is emitted as an RFC 2047 =?UTF-8?Q?…?= encoded word, which the ntfy server decodes before reading the header. You write title = "Falha na execução 🚨"; it survives the wire intact. Control characters are flattened to spaces first — a newline is not a header value at all, and HeaderValue would reject the whole request over it.
pushover
telegram
bot_tokenaccepts${VAR}references — resolution happens at send time against the daemon-loaded secrets file (~/.config/dotagent/secrets.env), falling back to the process env.Debugredacts the token explicitly. A literal token also works but committing it to the manifest is not recommended. See secrets concept for the loader's posture (0600-enforced, never echoed, audit-logged by key count only).chat_idis not${VAR}-expanded. It is an address, not a credential — write it literally.When
parse_mode = "MarkdownV2", dotagent escapes the 19 characters Telegram reserves (\_*[]()~`>#+-=|{}.!) — the backslash included, since an unescaped one is itself an escape opener that desynchronizes everything after it. The body always goes through the escaper, so pre-escaping it produces doubled backslashes rather than formatting. If you want live markup, build the message withparse_mode = "HTML"instead, or leaveparse_modeunset for plain text.Escaping runs before the length cut, because escaping grows the text: a body that fits under 4096 characters plain can exceed it once every
.and-has a backslash in front. If the cut lands between a backslash and the character it escapes, the orphaned backslash is dropped — Telegram rejects the entire message over one dangling escape.When the Bot API refuses a send, the log line carries the API's own
description("Bad Request: message is too long") rather than a bare status code, with the bot token scrubbed out of it first.Outbound only, by default. Receiving Telegram updates is a separate, explicitly-enabled ingress — see
telegram.md.
imessage (macOS only)
Apple does not expose any public API to send iMessages. This driver spawns
osascriptper send — it is the only built-in driver that forks. Rate-limit state lives at$DOTAGENT_HOME/state/notify/imessage/<slug>.json.
plugin (escape hatch)
For third-party notifiers (Discord, Teams, custom relays), use the legacy plugin protocol:
The binary dotagent-plugin-notify-discord is resolved via $DOTAGENT_PLUGIN_PATH and the standard discovery order (see docs/reference/plugin-protocol.md).
Message size limits
Every backend rejects the whole request when the body is over its limit, so an alert that is too long is an alert that never arrives. dotagent trims instead, turning "silently undelivered" into "delivered, minus the tail". The cut is marked: [truncated] on bodies, … on titles.
slack
40,000 characters
—
telegram
4,096 characters
—
pushover
1,024 characters
250 characters
ntfy
4,096 bytes
250 bytes
desktop
(OS-dependent)
(OS-dependent)
imessage
(none applied)
—
Characters versus bytes is not a pedantic distinction here. ntfy counts bytes, and pt-BR alert text is not ASCII: ç and ã cost 2 bytes each, an emoji costs 4. A character-based trim can hand ntfy four times its limit while believing it stayed under. The byte cutter also walks the cut back to a UTF-8 character boundary, so a truncated message may land a few bytes below the cap — under is correct, over is a rejected request.
ntfy's title cap applies to the raw text, before RFC 2047 encoding, because encoding multiplies: a 4-byte emoji becomes 12 characters of =XX, and the cap has to bite before the encoder runs rather than after.
Empty bodies
The mirror image of the size limits: too little body is also a rejected request. Telegram answers an empty sendMessage with 400 Bad Request: message text is empty, and the drivers that accept it deliver a blank line.
What dotagent does depends on why the body is empty, and the two cases pull in opposite directions:
success
nothing is sent
The agent had nothing to report. A sweeper that finds no follow-ups is working — the run is already in dotagent status and in the agent's log.
everything else
body synthesized as agent/schedule: event (no output)
The state change is the news. Losing a given_up because the process died too fast to print anything is the worst outcome available.
A skipped success is logged at debug level, not warn — it is the expected outcome for an agent that reports by exception. The decision happens before any driver runs, so there is no plugin_invoked audit entry either: nothing was invoked.
This is a floor, not a substitute for a good message. An agent that always prints something useful never reaches either branch.
Secrets and credentials
Every credential-bearing field accepts ${VAR}, resolved at send time against ~/.config/dotagent/secrets.env first and the process environment second:
Driver
${VAR}-expanded fields
Left literal
slack
webhook_url
—
ntfy
token, base_url, topic
—
pushover
token, user
—
telegram
bot_token
chat_id
imessage
—
to
chat_id and to are addresses. Routing them through a secrets resolver would turn a typo into "env var unset" instead of a message delivered to the wrong place, which is the failure you actually want to see.
An unresolved reference fails the send rather than falling back to the literal "${SLACK_WEBHOOK_URL}" — sending that string is a request authenticated as a placeholder, which answers 404 and looks like an outage rather than a typo. The error names the field and the variable and nothing else.
Credentials also never reach tracing. reqwest::Error's Display appends for url (…), and for Slack and Telegram that URL is the secret — a single ? on an HTTP call used to write a live webhook into ~/.config/dotagent/logs/daemon/dotagent.log from a failure as mundane as a DNS blip. NotifyError has no From<reqwest::Error> conversion at all, so that ? no longer compiles: transport failures are reduced to a kind plus a status (slack transport error (timeout)) at the call site, and the leak is unrepresentable rather than merely unwritten. See threat-model.md.
Network allow-list. If you declare
[security] network = [...], include"api.telegram.org"so the (future) sandbox lets the bot reach the API. v0 is schema-only, so it's a no-op today, but the declaration documents intent.
Tiered notifications pattern
Combine drivers + events filters to keep noisy channels cheap and pager channels rare:
Failure semantics
A notifier failing is logged but does not fail the run — the run already happened. Each invocation lands in the audit log ($DOTAGENT_HOME/audit.log) as a plugin_invoked event with plugin = "notifier:<driver>".
If a notifier rate-limits or dedups, it returns Skipped { reason } which is treated as a success outcome.
Legacy [[on_failure]] / [[on_success]]
The legacy plugin-style hooks still work — they always meant "fire these plugins on these events". They are now reserved for sink-style hooks (persist output, publish to Roam, etc.). For notifications, prefer [[notifiers]] — it's faster, has fewer moving parts, and ships with the daemon.
Migration is a 1-to-1 rename:
Last updated
Was this helpful?