Pluggable notification hub for AI agents and apps. One MCP server + one REST API that fan out to multiple notification channels.
Built-in channels: Telegram · Bale · Pushover · Gotify · ntfy.
No web UI — interact via the Telegram bot, the REST API, or the MCP server. Adding a new channel takes ~50 lines of code.
╭──────────────╮ ╭──────────────╮ ╭─────────────╮
│ AI agent │ │ Your app │ │ Telegram │
│ (MCP tool) │ │ (REST) │ │ bot user │
╰──────┬───────╯ ╰──────┬───────╯ ╰──────┬──────╯
│ │ │
▼ ▼ ▼
╰──────▶ ╭──────────────────────────╮ ◀──╯
│ Watch Tower │
│ dispatcher (fan-out) │
╰────┬────┬────┬────┬──────╯
▼ ▼ ▼ ▼ ▼
Telegram Bale Push Gotify ntfy
git clone https://github.com/MMTE/watch-tower.git
cd watch-tower
cp .env.example .env # fill in only the channels you want
npm install
npm start # REST API + Telegram bot
npm run mcp # MCP server over stdio (for AI agents)
npm test # smoke test, no network requiredRequires Node ≥ 20 (uses global fetch / FormData).
With Docker:
cp .env.example .env
docker compose up -d --buildUses the existing Dockerfile and railway.json (healthcheck on /api/health, restart on failure). After the first deploy:
- Set
API_KEYand any channel credentials (TELEGRAM_BOT_TOKEN,NTFY_TOPIC, …) in the Variables tab. - Under Settings → Networking, generate a public domain.
- Set
WEBHOOK_URLto that public HTTPS URL (no trailing slash) — Telegram then uses webhook mode instead of polling. - Redeploy.
Railway injects PORT; the app picks it up automatically.
Heads up on the free / Hobby tier: Railway sleeps idle apps, which means alerts sent while the service is asleep are lost (Telegram drops webhook updates after retries; the others have no retry at all). For production alerting, use the paid plan or a host that doesn't idle.
A channel is enabled when its environment variables are set. Disabled channels are silently skipped.
| Channel | Required env | Notes |
|---|---|---|
telegram |
TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID |
Bot UI + native file uploads |
bale |
BALE_BOT_TOKEN, BALE_CHAT_ID |
Telegram-compatible BotAPI |
pushover |
PUSHOVER_APP_TOKEN, PUSHOVER_USER_KEY |
Priority maps from level |
gotify |
GOTIFY_URL, GOTIFY_APP_TOKEN |
Files sent as inline text fallback |
ntfy |
NTFY_TOPIC (+ optional NTFY_URL, auth) |
Native Title / Priority / Tags |
Set DEFAULT_CHANNELS=telegram,ntfy to scope the default fan-out list. Every API/MCP request can also override with its own channels field.
- Support multiple configured instances of the same channel type, while keeping current single-instance env vars backward compatible. Example selectors:
telegram:ops,telegram:dev,ntfy:prod,ntfy:dev.
| Level | Pushover | Gotify | ntfy |
|---|---|---|---|
info |
-1 | 2 | 2 |
warn |
0 | 5 | 3 |
error |
1 | 7 | 4 |
critical |
2 (emergency) | 10 | 5 |
Base path: /api. All endpoints except /health require an x-api-key header (or ?key=).
Every send endpoint accepts an optional channels field — an array or comma-separated string of channel names.
Public. Reports per-channel configured state.
{ "ok": true, "channels": [{ "name": "telegram", "enabled": true }, ...] }curl -X POST $BASE/api/send -H "Content-Type: application/json" -H "x-api-key: $KEY" \
-d '{"text":"deploy done","title":"prod","channels":["telegram","ntfy"]}'curl -X POST $BASE/api/alert -H "Content-Type: application/json" -H "x-api-key: $KEY" \
-d '{"level":"error","title":"Deployment Failed","message":"Build #42 failed"}'curl -X POST $BASE/api/log -H "Content-Type: application/json" -H "x-api-key: $KEY" \
-d '{"source":"nginx","log":"502 Bad Gateway"}'curl -X POST $BASE/api/file -H "x-api-key: $KEY" \
-F "file=@report.pdf" -F "filename=daily-report.pdf" -F "caption=Daily report" \
-F "channels=telegram,pushover"{ "ok": true, "delivered": ["telegram","ntfy"], "errors": [] }ok is true if at least one channel accepted the message. Returns HTTP 502 when zero channels delivered.
Stdio transport. Configure your client with node /path/to/src/mcp.js.
{
"mcpServers": {
"watch-tower": {
"command": "node",
"args": ["/absolute/path/to/watch-tower/src/mcp.js"],
"env": {
"TELEGRAM_BOT_TOKEN": "...",
"TELEGRAM_CHAT_ID": "...",
"NTFY_TOPIC": "...",
"WATCHTOWER_MCP": "1"
}
}
}
}| Tool | Params | Description |
|---|---|---|
send_message |
text, title?, parse_mode?, channels? |
Plain text notification |
send_alert |
level, title, message, channels? |
Severity-tagged alert |
send_log |
source, log, channels? |
Code-formatted log entry |
send_file |
path, caption?, filename?, title?, level?, channels? |
Send a local file |
list_channels |
— | Show which channels are configured |
channels is an array of channel names; omit to use defaults / all enabled.
| Command | Description |
|---|---|
/start |
Register and get chat ID |
/ping |
Liveness check |
/id |
Show your chat ID |
/status |
Uptime, memory, active channels |
/channels |
List configured channels |
/help |
Show help |
/time |
Server time |
/echo <text> |
Echo back |
src/
├── index.js REST + Telegram webhook
├── api.js Express routes
├── bot.js Telegram bot UI (commands)
├── mcp.js MCP stdio server
└── channels/
├── index.js Registry + dispatcher (notify / notifyFile)
├── telegram.js
├── bale.js
├── pushover.js
├── gotify.js
└── ntfy.js
test/
└── smoke.js No-network unit + HTTP smoke
See CONTRIBUTING.md. TL;DR: drop a module in src/channels/ exporting { name, enabled, sendMessage, sendFile } and register it in src/channels/index.js.
- Contributions: see CONTRIBUTING.md.
- Vulnerability reports: see SECURITY.md. Please don't open a public issue for security problems.
- Changes by release: see CHANGELOG.md.
MIT — see LICENSE.
