Notify
An autonomous loop that stalls while its operator is away stays stalled
until someone happens to look. This skill is the outbound half of that
gap: one script that sends a short message on a configured channel and
tells the truth about whether it worked.
Reach for this when
- A caller has already decided a human needs to know something now —
typically because it owns a health/escalation state machine (a
supervisor's
escalate state, a CI failure gate) — and needs to
actually deliver that decision outside the terminal.
Do not reach for this when
- You are deciding whether something is worth interrupting a human
for. That judgement belongs to the caller, not this skill. A
notify
that also decided when to fire would make its caller's escalation
logic impossible to reason about separately.
- The task just "feels important." This is a deliberate, user-invoked
tool, not a discipline the agent reaches for on its own.
What this owns, and what it does not
Owns: sending a short message on whichever channel is configured, dry-run
by default, real send behind an explicit flag, dedup/rate-limit so a loop
messaging on every tick doesn't become worse than silence, and a failure
path that is loud rather than swallowed.
Does not own: deciding when to notify, what the message should say beyond
"short and state-bearing," or wiring this into any particular watchdog or
loop. That integration is the caller's job.
Channel priority
Per jonhill90/skills#146,
Jon later re-ordered this (2026-08-11) once Telegram proved to be the only
channel that actually reached his phone: Telegram works from any machine
and does not depend on macOS automation permissions, so it goes first and
iMessage becomes the fallback rather than the primary.
- Telegram — built, tried first. Bot API over HTTPS; works from any
machine, not just a Mac.
- iMessage — built, Mac-only fallback. No credential on a Mac;
Messages.app is already signed in. Tried only if Telegram isn't
configured or its send fails.
- Discord, 4. Teams — designed, not built. See
references/channels.md for the shape each
would take if a future change needs one. Build only if both Telegram and
iMessage are genuinely blocked for a given setup — one working channel
closes the operational gap; five is not the goal.
- Slack — deferred indefinitely, unused since 2019. Do not build it.
Configuration
Everything channel- and credential-related comes from the environment.
Nothing is hardcoded and nothing is committed.
| Variable |
Required |
Default |
Meaning |
NOTIFY_CHANNEL |
no |
auto |
auto tries telegram then imessage, in that order, stopping at the first that accepts the message; telegram or imessage forces exactly one channel with no fallback |
AGENT_NOTIFY_TELEGRAM_TOKEN |
yes, for Telegram |
— |
bot token from @BotFather |
AGENT_NOTIFY_TELEGRAM_CHAT_ID |
yes, for Telegram |
— |
the chat to send to (must have started a chat with the bot at least once) |
AGENT_NOTIFY_IMESSAGE_TO |
yes, for iMessage |
— |
canonical. The phone number or Apple ID email to send to (your own, for a self-notification). Matches the AGENT_NOTIFY_* prefix Telegram already uses and is the name agent-supervisor/scripts/supervisor/notify.sh reads. |
NOTIFY_IMESSAGE_TARGET |
no |
— |
deprecated alias for AGENT_NOTIFY_IMESSAGE_TO, kept working so an existing notify.env or shell profile doesn't break. If both are set, AGENT_NOTIFY_IMESSAGE_TO wins. New config should use the canonical name (jonhill90/skills#152). |
NOTIFY_STATE_DIR |
no |
~/.local/state/notify |
where the dedup/rate-limit state and local log live |
NOTIFY_DEDUP_WINDOW_SECONDS |
no |
300 |
suppress an identical message sent again within this window |
NOTIFY_MIN_INTERVAL_SECONDS |
no |
60 |
suppress any send within this long of the last one |
Credentials must come from the environment — an untracked, 0600 env file
loaded by the caller is the pattern this skill assumes, the same one
agent-supervisor/scripts/supervisor/notify.sh uses. Never put a token
inline in a command, a script, or anything committed to this repository.
There is no config file in this repository to edit; set the environment
where the caller runs.
Usage
# Dry run (default) — prints exactly what would be sent, sends nothing, exits 0.
python3 scripts/notify.py --message "watchdog: escalate — 3 restarts/hr, stopped."
# Real send, auto channel selection (Telegram, then iMessage fallback).
AGENT_NOTIFY_TELEGRAM_TOKEN="..." AGENT_NOTIFY_TELEGRAM_CHAT_ID="..." \
AGENT_NOTIFY_IMESSAGE_TO="you@example.com" \
python3 scripts/notify.py --message "watchdog: escalate — check tmux." --send
# Force a single channel — no fallback to the other if it fails.
AGENT_NOTIFY_IMESSAGE_TO="you@example.com" \
python3 scripts/notify.py --message "..." --channel imessage --send
--message (required) — kept under 200 characters by the script; a
message that doesn't fit on a lock screen defeats the point. State
what stopped, what it needs, and where to look — not a transcript
dump.
--send — actually deliver. Every other invocation, including every
test, omits this and gets a dry run instead.
--force — bypass dedup/rate-limit suppression for one send. Use
sparingly; the suppression exists because a loop that messages on
every tick is worse than silence.
--channel — override $NOTIFY_CHANNEL. auto (default) tries
telegram then imessage with no further fallback once one is named
explicitly. discord, teams, and slack exit 2 with a message
pointing at references/channels.md, rather than silently doing
nothing.
Exit codes
| Code |
Meaning |
0 |
dry run printed, message sent, or message intentionally suppressed (deduped / rate-limited) |
1 |
a send was attempted and failed on every candidate channel (just --channel imessage itself, when forced explicitly) — always logged to $NOTIFY_STATE_DIR/notify.log first |
2 |
usage or configuration error (missing --message, oversized message, unknown or unbuilt channel, no channel configured at all) |
An unreachable channel must never look like "nothing to report" — that
is the fail-open shape this comes from
(jonhill90/skills#146). Exit 1 plus a local log line is the contract a
caller can check.
Testing this skill
Sending a message is an outward-facing action — treat it like any other
send to a live system.
Always dry-run while iterating. Every invocation above without
--send prints what would happen and touches no state, no network,
no Messages.app.
--self-test exercises the dedup, rate-limit, config-validation,
and logging logic against a scratch state directory with no real send
involved:
python3 scripts/notify.py --self-test
At most one live send, ever, per change. If you need to confirm
the real path works, send exactly one message to yourself, clearly
marked as a test, and record its exact content in whatever report
you're producing. Do not loop, retry, or fan this out to confirm it
"really" worked — one send is the check.
If a self-send doesn't produce a notification, report that. Do
not start experimenting with dedicated threads, group chats, or other
recipients to chase a notification — that's a scope creep this skill
explicitly avoids (see Channel priority above; the issue anticipates
self-messaging may not notify and treats that as information, not a
bug to iterate around).
Bundled scripts
| Script |
Use |
notify.py |
dry-run/live sender for the configured channel; owns dedup, rate-limiting, and local failure logging |
Notes
- This skill does not modify, read, or depend on any watchdog, roster,
or supervisor state file. Wiring a caller's escalation logic to this
script is a separate change (jonhill90/agent-dotfiles#50).
- Rate-limiting and dedup state live under
$NOTIFY_STATE_DIR
(~/.local/state/notify by default) — local to the machine, never
committed, never read by this repository's validator.
1---2name: notify3description: Send a short, structured message to a human on a configured outbound channel (Telegram first, iMessage as a Mac-only fallback) from the terminal, so a stalled or escalated agent loop can reach someone who's away from the machine. Owns sending only, not escalation policy — the caller decides what's worth interrupting a human for. Dry-run by default; sending requires an explicit flag and a real send failure exits non-zero. User-invoked only — call this deliberately from a caller that has already decided to notify (e.g. a supervisor's escalate state), never automatically because a task felt important.4---56# Notify78An autonomous loop that stalls while its operator is away stays stalled9until someone happens to look. This skill is the outbound half of that10gap: one script that sends a short message on a configured channel and11tells the truth about whether it worked.1213## Reach for this when1415- A caller has already decided a human needs to know something *now* —16 typically because it owns a health/escalation state machine (a17 supervisor's `escalate` state, a CI failure gate) — and needs to18 actually deliver that decision outside the terminal.1920## Do not reach for this when2122- You are deciding *whether* something is worth interrupting a human23 for. That judgement belongs to the caller, not this skill. A `notify`24 that also decided when to fire would make its caller's escalation25 logic impossible to reason about separately.26- The task just "feels important." This is a deliberate, user-invoked27 tool, not a discipline the agent reaches for on its own.2829## What this owns, and what it does not3031Owns: sending a short message on whichever channel is configured, dry-run32by default, real send behind an explicit flag, dedup/rate-limit so a loop33messaging on every tick doesn't become worse than silence, and a failure34path that is loud rather than swallowed.3536Does not own: deciding when to notify, what the message should say beyond37"short and state-bearing," or wiring this into any particular watchdog or38loop. That integration is the caller's job.3940## Channel priority4142Per [jonhill90/skills#146](https://github.com/jonhill90/skills/issues/146),43Jon later re-ordered this (2026-08-11) once Telegram proved to be the only44channel that actually reached his phone: Telegram works from any machine45and does not depend on macOS automation permissions, so it goes first and46iMessage becomes the fallback rather than the primary.47481. **Telegram — built, tried first.** Bot API over HTTPS; works from any49 machine, not just a Mac.502. **iMessage — built, Mac-only fallback.** No credential on a Mac;51 Messages.app is already signed in. Tried only if Telegram isn't52 configured or its send fails.533. Discord, 4. Teams — designed, not built. See54 [`references/channels.md`](references/channels.md) for the shape each55 would take if a future change needs one. Build only if both Telegram and56 iMessage are genuinely blocked for a given setup — one working channel57 closes the operational gap; five is not the goal.585. Slack — deferred indefinitely, unused since 2019. Do not build it.5960## Configuration6162Everything channel- and credential-related comes from the environment.63Nothing is hardcoded and nothing is committed.6465| Variable | Required | Default | Meaning |66|---|---|---|---|67| `NOTIFY_CHANNEL` | no | `auto` | `auto` tries telegram then imessage, in that order, stopping at the first that accepts the message; `telegram` or `imessage` forces exactly one channel with no fallback |68| `AGENT_NOTIFY_TELEGRAM_TOKEN` | yes, for Telegram | — | bot token from `@BotFather` |69| `AGENT_NOTIFY_TELEGRAM_CHAT_ID` | yes, for Telegram | — | the chat to send to (must have started a chat with the bot at least once) |70| `AGENT_NOTIFY_IMESSAGE_TO` | yes, for iMessage | — | **canonical.** The phone number or Apple ID email to send to (your own, for a self-notification). Matches the `AGENT_NOTIFY_*` prefix Telegram already uses and is the name `agent-supervisor/scripts/supervisor/notify.sh` reads. |71| `NOTIFY_IMESSAGE_TARGET` | no | — | **deprecated alias** for `AGENT_NOTIFY_IMESSAGE_TO`, kept working so an existing `notify.env` or shell profile doesn't break. If both are set, `AGENT_NOTIFY_IMESSAGE_TO` wins. New config should use the canonical name (jonhill90/skills#152). |72| `NOTIFY_STATE_DIR` | no | `~/.local/state/notify` | where the dedup/rate-limit state and local log live |73| `NOTIFY_DEDUP_WINDOW_SECONDS` | no | `300` | suppress an identical message sent again within this window |74| `NOTIFY_MIN_INTERVAL_SECONDS` | no | `60` | suppress *any* send within this long of the last one |7576Credentials must come from the environment — an untracked, 0600 env file77loaded by the caller is the pattern this skill assumes, the same one78`agent-supervisor/scripts/supervisor/notify.sh` uses. Never put a token79inline in a command, a script, or anything committed to this repository.80There is no config file in this repository to edit; set the environment81where the caller runs.8283## Usage8485```bash86# Dry run (default) — prints exactly what would be sent, sends nothing, exits 0.87python3 scripts/notify.py --message "watchdog: escalate — 3 restarts/hr, stopped."8889# Real send, auto channel selection (Telegram, then iMessage fallback).90AGENT_NOTIFY_TELEGRAM_TOKEN="..." AGENT_NOTIFY_TELEGRAM_CHAT_ID="..." \91AGENT_NOTIFY_IMESSAGE_TO="you@example.com" \92 python3 scripts/notify.py --message "watchdog: escalate — check tmux." --send9394# Force a single channel — no fallback to the other if it fails.95AGENT_NOTIFY_IMESSAGE_TO="you@example.com" \96 python3 scripts/notify.py --message "..." --channel imessage --send97```9899- `--message` (required) — kept under 200 characters by the script; a100 message that doesn't fit on a lock screen defeats the point. State101 what stopped, what it needs, and where to look — not a transcript102 dump.103- `--send` — actually deliver. Every other invocation, including every104 test, omits this and gets a dry run instead.105- `--force` — bypass dedup/rate-limit suppression for one send. Use106 sparingly; the suppression exists because a loop that messages on107 every tick is worse than silence.108- `--channel` — override `$NOTIFY_CHANNEL`. `auto` (default) tries109 telegram then imessage with no further fallback once one is named110 explicitly. `discord`, `teams`, and `slack` exit 2 with a message111 pointing at `references/channels.md`, rather than silently doing112 nothing.113114### Exit codes115116| Code | Meaning |117|---|---|118| `0` | dry run printed, message sent, or message intentionally suppressed (deduped / rate-limited) |119| `1` | a send was attempted and failed on every candidate channel (just `--channel imessage` itself, when forced explicitly) — always logged to `$NOTIFY_STATE_DIR/notify.log` first |120| `2` | usage or configuration error (missing `--message`, oversized message, unknown or unbuilt channel, no channel configured at all) |121122An unreachable channel must never look like "nothing to report" — that123is the fail-open shape this comes from124(jonhill90/skills#146). Exit 1 plus a local log line is the contract a125caller can check.126127## Testing this skill128129Sending a message is an outward-facing action — treat it like any other130send to a live system.131132- **Always dry-run while iterating.** Every invocation above without133 `--send` prints what would happen and touches no state, no network,134 no `Messages.app`.135- **`--self-test`** exercises the dedup, rate-limit, config-validation,136 and logging logic against a scratch state directory with no real send137 involved:138139 ```bash140 python3 scripts/notify.py --self-test141 ```142143- **At most one live send, ever, per change.** If you need to confirm144 the real path works, send exactly one message to yourself, clearly145 marked as a test, and record its exact content in whatever report146 you're producing. Do not loop, retry, or fan this out to confirm it147 "really" worked — one send is the check.148- **If a self-send doesn't produce a notification, report that.** Do149 not start experimenting with dedicated threads, group chats, or other150 recipients to chase a notification — that's a scope creep this skill151 explicitly avoids (see Channel priority above; the issue anticipates152 self-messaging may not notify and treats that as information, not a153 bug to iterate around).154155## Bundled scripts156157| Script | Use |158|---|---|159| `notify.py` | dry-run/live sender for the configured channel; owns dedup, rate-limiting, and local failure logging |160161## Notes162163- This skill does not modify, read, or depend on any watchdog, roster,164 or supervisor state file. Wiring a caller's escalation logic to this165 script is a separate change (jonhill90/agent-dotfiles#50).166- Rate-limiting and dedup state live under `$NOTIFY_STATE_DIR`167 (`~/.local/state/notify` by default) — local to the machine, never168 committed, never read by this repository's validator.