Stop babysitting terminals. Get a private ping when the agent finishes.
Privacy-first push notifications for coding agents: Discord, Telegram, Signal. Zero telemetry, one Go binary. Brigade can plan messages; send is always opt-in.
go install github.com/escoffier-labs/agent-notify/cmd/agent-notify@latest
# Brigade station
brigade add notifications| Job | What you get | |
|---|---|---|
| Notify | When long work ends | Discord, Telegram, Signal from one binary |
| Stay private | No product telemetry | Your notify path is not an analytics surface |
| Opt-in send | Plan free, send explicit | Brigade status/plan without auto-spam |
Generated from docs/assets/workflows/routing.json with plating workflow.
Install the latest tagged release with go install:
go install github.com/escoffier-labs/agent-notify/cmd/agent-notify@latestOr build from source:
git clone https://github.com/escoffier-labs/agent-notify.git
cd agent-notify
make install # builds and copies to ~/bin/agent-notifyPrebuilt binaries (linux, macOS, windows for amd64 and arm64) plus a checksums.txt are attached to each release. Download the archive for your platform, verify the checksum, extract, and drop the binary in ~/bin/ or /usr/local/bin/:
tar -xzf agent-notify_*_linux_amd64.tar.gz
install -m 0755 agent-notify_*_linux_amd64/agent-notify ~/bin/agent-notifyConfirm the installed binary:
agent-notify versionSet env vars for the channel(s) you want and run:
export DISCORD_WEBHOOK_URL='https://discord.com/api/webhooks/...'
agent-notify "hello from agent-notify"The explicit subcommand form is equivalent:
agent-notify send "hello from agent-notify"Multiple channels at once:
export DISCORD_WEBHOOK_URL='...'
export TELEGRAM_BOT_TOKEN='...'
export TELEGRAM_CHAT_ID='...'
agent-notify "build finished" # fans out to bothGenerate a starter config:
agent-notify initOr create ~/.config/agent-notify/config.toml manually:
[channels.tg-personal]
type = "telegram"
bot_token_env = "TELEGRAM_BOT_TOKEN"
chat_id_env = "TELEGRAM_CHAT_ID"
[channels.discord-main]
type = "discord"
webhook_url_env = "DISCORD_WEBHOOK_URL"
[channels.signal-personal]
type = "signal"
url_env = "SIGNAL_CLI_URL"
from_env = "SIGNAL_FROM"
to_env = "SIGNAL_TO"
[profiles.agent-stop]
channels = ["tg-personal", "discord-main"]
default = true
[profiles.error]
channels = ["tg-personal", "discord-main", "signal-personal"]
prefix = "🚨 "Secrets stay in env vars (the config references env-var names, not literal tokens).
Validate the wiring without sending a live notification:
agent-notify status --json
agent-notify doctor
agent-notify doctor --jsonIf you set DISABLE_TELEMETRY=1 to keep your agent harness from phoning home, you've also disabled the harness's built-in mobile push feature (which routes through the same telemetry plumbing). agent-notify gives you the same UX with zero data flow you didn't ask for: messages go from your machine directly to Discord's API, the Telegram Bot API, or your self-hosted Signal CLI, and nowhere else.
- No telemetry endpoints. Ever.
- No update checks at startup or runtime.
- No persistent state. No state file. No log file. No cache.
- Outbound HTTP only to channel URLs you configured.
- Test
cmd/agent-notify/privacy_test.goasserts the above.
doctor checks your config and channel env vars without sending a notification. With a config and the agent-stop profile's env vars set, it reads:
$ agent-notify doctor
[OK ] config: loaded
[OK ] routing: 2 channel(s) selected
[OK ] channel:discord-main: env present
[WARN] channel:signal-personal: inactive channel: url/from/to env missing or empty
[OK ] channel:telegram-personal: env presentstatus prints the resolved routing for the active profile:
$ agent-notify status
configured: true
config: ~/.config/agent-notify/config.toml
profile: agent-stop
channels: [telegram-personal discord-main]Add --json to either command for machine-readable output you can pipe into a script.
--to <names>(explicit, comma-separated) - overrides everything else.--profile <name>- channels from the named profile in config.- Profile in config with
default = true. - All configured channels.
--skip <names> filters from any of the above.
agent-notify "build done" # default profile or all channels
agent-notify --profile error "5 critical alerts" # error profile
agent-notify --to tg-personal "ack" # only Telegram
agent-notify --profile error --skip signal "minor" # error profile minus SignalGenerate the snippet:
agent-notify hooks print claude-code --profile agent-stop{
"hooks": {
"Stop": [{
"hooks": [
{ "type": "command", "command": "agent-notify --hook claude-code-stop --profile agent-stop" }
]
}],
"Notification": [{
"hooks": [
{ "type": "command", "command": "agent-notify --hook claude-code-notification --profile agent-stop" }
]
}]
}
}Not applicable - Claude Desktop does not expose a hook surface that runs local commands. Use the Claude Code integration instead, or call agent-notify from a shortcut/script bound to whatever event you care about.
OpenClaw has its own multi-channel delivery built in, so you typically would not wire agent-notify for OpenClaw's own events. If you do want to use it (e.g., uniformity across all your agents), call it from a plugin's agent_end hook:
api.on("agent_end", async (event, ctx) => {
const proc = spawn("agent-notify", ["--hook", "custom", "--profile", "agent-stop"]);
proc.stdin.write(JSON.stringify({
title: "OpenClaw session ended",
body: `Session ${event.sessionId} done`,
source: "openclaw",
}));
proc.stdin.end();
});Same pattern as OpenClaw - wire agent-notify to whichever scheduled-task or session-end hook Hermes exposes in your version. Pass canonical JSON via stdin and use --hook custom (the default).
Generate the snippet:
agent-notify hooks print codex --profile agent-stopnotify = ["agent-notify", "--hook", "codex-notify", "--profile", "agent-stop"]If a built-in adapter ever breaks because an upstream tool changes its event schema, write a small shell wrapper that extracts the fields you want and pipes canonical JSON to agent-notify:
#!/usr/bin/env bash
# my-tool-notify.sh - wrapper for some-future-agent
event=$(cat)
body=$(echo "$event" | jq -r '.message_field // "(no message)"')
title=$(echo "$event" | jq -r '.title_field // "MyTool"')
jq -n --arg t "$title" --arg b "$body" \
'{title: $t, body: $b, source: "my-tool"}' \
| agent-notify --profile agent-stopThen point the upstream tool's hook config at my-tool-notify.sh instead.
0- all sends succeeded2- config or input error before any send was attempted3- one or more channel sends failed (other channels still received the message; the per-channel failure count is logged to stderr)
| Channel | Format |
|---|---|
| Discord | Embed with title + body. Color by level (info=blue, warn=yellow, error=red, success=green). Tags as inline fields. Source as footer. |
| Telegram | Markdown V2. Level emoji prefix (ℹ️ / |
| Signal | Plain text. Level emoji prefix. Title on its own line. Tags as [tag1, tag2] footer. |
- Why not the harness's built-in mobile push? It rides the same telemetry channel you disabled with
DISABLE_TELEMETRY=1. Turning off the data exhaust turns off the notifications too.agent-notifydecouples the two: notifications stay, telemetry stays off. - Why not ntfy, Pushover, or a hosted push SaaS? Those route your messages through a third party's servers and (for the SaaS options) an account you have to trust.
agent-notifytalks only to the channel APIs you already control: your own Discord webhook, your own Telegram bot, your own Signal CLI host. - Why not a hand-rolled
curlin your hook? You can, for one channel. The moment you want two channels, level-based formatting, named routing profiles, a--skipfor a noisy channel, and adapters that already understand each agent's event JSON, you are rebuilding this.agent-notifyis that wrapper, with adoctorto tell you when the wiring is wrong. - Why not a webhook relay like Apprise? Apprise is excellent and supports far more services.
agent-notifyis deliberately narrow: three channels, zero runtime dependencies, one static Go binary, and first-class hook adapters for coding agents. If you need 80 notification targets, use Apprise. If you want a no-deps binary that drops into an agent stop-hook, use this.
- Not a hosted service. There is no server to sign up for, no API key from us, no dashboard. It is a binary you run.
- Not a message queue. There is no retry queue. A rate-limited or down channel means a dropped notification (exit code
3), not a redelivery later. - Not a templating engine. The canonical message goes through as-is. Level, title, body, tags, and source are the whole model.
- Not a general-purpose alerting platform. It does not poll, schedule, or evaluate conditions. Something else decides when to notify;
agent-notifyonly delivers. - Not a secrets manager. Tokens and webhook URLs live in your environment. The config references env-var names, never literal secrets.
- No retry queue. A rate-limited or down channel means dropped notification.
- No templating. The canonical message goes through as-is.
- Three channels only. Adding more is straightforward (one file in
internal/channels/).
Bug reports and patches are welcome. See CONTRIBUTING.md for what lands easily, SECURITY.md for how to report a vulnerability privately, and CODE_OF_CONDUCT.md.
MIT - see LICENSE.
