watush-mcp
watush-mcp
A robust Telegram MCP server for agents. Any agent — Claude Code, Claude Desktop, or Codex — can use it to message, send files, update, clean up after, and ask questions of you or named people (partner, family, friends) through your own Telegram bot.
So you can say:
"message me on telegram once the build is done" "send me a screenshot of the dashboard" "telegram me the report as a PDF when it's written" "keep me updated on the GPU temps in one telegram message" "ping the family: dinner at 7" "message me on watush if you need anything while I'm out"
…and it routes to the right person. You (the owner) always get an audit note for anything sent to, edited for, deleted from, or asked of someone else.
Configure several bot tokens and you get several channels — separate Telegram threads you can mute or read independently ("use the 2nd bot for the noisy stuff") — plus automatic failover when one bot is rate-limited or its token dies. See Channels.
It cannot browse your chats: the only thing it ever reads is the reply to a question it is actively waiting on.
How it works
Seven tools are exposed to the agent:
send_telegram_message({ text, to?, channel? })— send a message.tois a person's name or a tag (case-insensitive). Omitto(or use"me") to message yourself. A tag shared by several people fans out to all of them.channelpicks which bot to send through. Returns a handle like4021@main.send_telegram_file({ path, caption?, to?, channel?, as? })— send any file: screenshots, images, video, PDFs,.md/.docx/.csv, logs, archives.pathis a file on the machine running the server (or anhttp(s)URL for Telegram to fetch). The kind is picked from the extension — images preview inline,.mp4plays, everything else arrives as a downloadable attachment — andasoverrides that. See Sending files.edit_telegram_message({ message_id, text, to? })— rewrite a sent message in place. Perfect for live status messages: one message that keeps updating instead of a wall of notifications. No time limit. On a message sent bysend_telegram_file, this rewrites its caption.delete_telegram_message({ message_id, to? })— remove a sent message to cut noise. Telegram allows this within 48 hours of sending.ask_telegram_question({ question, options?, to?, timeout_seconds? })— ask a question with tap-to-answer buttons (or free-text replies — you can always just type instead). The agent blocks until you answer; your tap or reply is returned to it directly. The question message updates itself to show what you answered.list_recipients()— lets the agent discover who/what tags it can address (names + tags only; chat IDs are never exposed).list_channels()— lets the agent discover which bots exist, their positions and tags, and which are currently rate-limited or unable to reach someone (tokens are never exposed).
Markdown formatting works in all message/question text:
**bold**,*italic*,`code`, fenced blocks,[links](url),~~strike~~, headers. Rendered as native Telegram formatting, with an automatic plain-text fallback so a message is never lost to a formatting error.Timing is the agent's job. "in 30 minutes" is handled by the agent that calls the tool; the tool itself always sends now.
No background process. Stdio MCP servers are spawned on demand by each client, so there's nothing to "start on login" — installing simply registers the server with your agents so it's available whenever they run.
Setup
1. Install (build + wire all agents)
cd ~/Code/watush-mcp
./install.shThis installs dependencies, compiles the TypeScript, creates config.yaml (if
missing), and registers the server with Claude Code, Codex, and Claude Desktop —
whichever are present. Existing agent configs are backed up first. Re-run it any
time (e.g. after a Node version change).
2. Configure config.yaml
config.yaml holds your secrets and is git-ignored — it is never committed.
Copy the example if you don't have one yet:
cp config.example.yaml config.yaml && chmod 600 config.yamldefaultChannel: "main"
channels: # one bot per channel; order = fallback order
- name: "main"
token: "123456789:ABC..." # from @BotFather
tags: ["general"]
- name: "backup1"
token: "987654321:XYZ..."
tags: ["backup"]
owner:
name: "Owner"
chatId: "111111" # your numeric Telegram ID (gets audit copies)
recipients:
- name: "Alice"
chatId: "222222"
tags: ["partner", "household"]
- name: "Bob"
chatId: "333333"
tags: ["friend", "team"]
auditToOwner: true3. Find chat IDs
Everyone you want to message (including you) must DM the bot once — and with
several channels configured, once per bot. Chat IDs are global to Telegram,
but a bot cannot open a conversation on its own, so a perfectly valid chat ID
still fails on a bot that person never started. npm run doctor shows exactly
which pairs are missing.
You have two ways to capture the numeric IDs — neither runs in the background:
a) On-demand /chatinfo listener (recommended)
npm run chatinfoThis starts a temporary listener on every channel at once, so one pass
confirms who has started which bot. Each person DMs a bot /chatinfo and
that bot replies with their own chat ID (it's also printed in your terminal,
prefixed with the channel). Press Ctrl-C when you're done — it does not keep
running.
npm run chatinfo -- --once # auto-stop after the first /chatinfo
npm run chatinfo -- --timeout=60 # auto-stop after 60 seconds
npm run chatinfo -- --channel=backup1 # listen on one channel onlyb) One-shot snapshot
npm run chatsPrints the chat ID + name of everyone who recently messaged any of your bots, and which bots saw each chat. (Reads only — never writes your config.)
Copy the right IDs into config.yaml, then verify the whole matrix:
npm run doctorCHANNEL BOT TOKEN Mike (you) Meg&Mike
1. main * @watushbot ok ok ok
2. backup1 @watush1bot ok ok MISSING
3. backup2 @watush2bot ok ok MISSINGMISSING means that bot cannot message that chat yet — press Start in a DM with
it, or add it to that group.
4. Restart Claude Desktop
If you use Claude Desktop, restart it so it loads the new server. Claude Code and Codex pick it up on their next run.
Usage
Just ask, in any agent:
"message me on telegram: deploy finished ✅" "text my partner: leaving now" "notify the family: flight landed"
Live-updating messages — one message, continuously rewritten:
"track the download and update me in a single telegram message" "keep a running status of the migration on watush"
Cleanup — the agent can delete its own noise:
"delete those progress messages now that it's done"
Questions — the agent asks, you tap (or type), the agent continues:
"message me on watush if you need anything" → later, your phone shows "Deploy to prod now, or wait for the tests?" with [Deploy now] [Wait] buttons. Tap one and the agent carries on with your answer.
Behind the scenes the agent calls send_telegram_message,
edit_telegram_message, delete_telegram_message, or
ask_telegram_question. Anything involving a person other than you also
sends you an audit note like:
🔔 Audit — sent to Alice: leaving now
✏️ Audit — edited message to Alice: updated ETA 18:30
❓ Audit — asked Alice: pizza or sushi?Sending files
"send me a screenshot of the desktop" "telegram me that chart" "write the summary to a .md and send it over"
The agent takes the screenshot or writes the document first, then passes the path. The file has to live on the machine running the server — each machine runs its own copy, so "send me a screenshot" means that machine's screen.
Extension | Arrives as | Method |
| inline photo (recompressed) |
|
| looping animation |
|
| inline video player |
|
| audio track |
|
| voice note |
|
everything else — | downloadable file |
|
asoverrides detection.as: "document"keeps an image at full quality (Telegram recompresses photos);as: "photo"forces an inline preview.Limits. Telegram caps bot uploads at 10 MB for photos and 50 MB for everything else. An oversized image is downgraded to a document automatically; over 50 MB is refused with a clear message.
Nothing gets stuck. If Telegram rejects a file as a photo/video (odd dimensions, an unsupported codec), it is retried once as a document — but only when a different kind could actually help, never on a block or a rate limit.
Captions take Markdown, and are trimmed to Telegram's 1024-character cap.
URLs work for images, video, audio, PDF and ZIP. Telegram will not fetch other file types by URL — download it locally first, then send the path.
Audit copies. A file sent to someone else is
copyMessaged into your chat, so you get the actual media without a second upload.
How questions work (and their limits)
The question arrives as a normal Telegram message from your bot, with one button per option. Tapping answers instantly; typing a message answers free-form. With several questions pending in one chat, swipe-reply to a specific question message to target it; a plain message answers the newest.
The asking agent waits as long as its
timeout_seconds(default 15 min). In Claude Code, long waits are automatically moved to a background task, so the agent keeps working until your answer arrives.Several agent sessions can wait on questions at once — watush coordinates them so only one process polls Telegram (Telegram allows a single poller per bot token), and answers are routed to whichever session asked.
One machine at a time. The coordination state is per-machine, but the bot token's update stream is global. If agents on two different machines wait on questions at the same moment, one machine's poller can consume (and lose) the other machine's answers, and they compete for the single polling slot. Keep simultaneous questions on one machine, or give each machine its own bot token (each token gets its own state dir automatically).
Channels (several bots)
Each channel is its own Telegram bot, which means its own conversation thread. Give the noisy things their own bot and you can mute that thread without missing an alert. Configure as many as you like — @BotFather allows 20 per account.
defaultChannel: "main"
channels:
- name: "main"
token: "..."
tags: ["general"]
- name: "backup1"
token: "..."
tags: ["backup"]
- name: "backup2"
token: "..."
tags: ["backup"]
# fallback: false # keep this one out of automatic failoverOrder matters. The list order is the failover order. Channel 1 is the
default unless defaultChannel names another — and a channel is always
reachable by its own name, even one literally named main or default.
Addressing a channel
You say… |
| Resolves to |
"send it on backup1" |
| the channel named |
"use the 2nd bot" |
| channel at position 2 |
also | the same, by position | |
"use a backup" |
| the first channel tagged |
(omitted) | — |
|
A name always wins over a position reading, so backup1 means the channel
named backup1 — even though it sits at position 2.
Failover
A send walks the chain until a bot gets through, and the result says so:
Sent to you — message_id 4@backup1. (watush: Unauthorized — fell back to backup1.)It falls over on rate limits (429), dead tokens (401/404), server errors (5xx), network failures, and "this bot can't reach that chat". It does not fall over on content errors — bad markup or an over-long message fails identically on every bot, so retrying would only spend a second bot's quota to reproduce the error. At most 6 bots are tried per delivery, and if that cap is reached the result names the channels it never got to.
Messages are at-least-once. Telegram has no idempotency keys, so a send whose
response times out may already have arrived; retrying it on the next bot can
duplicate a notification. For messages and files that is the intended trade — a
rare double ping beats a silently lost alert, which is the entire reason for
having spare bots. Where a duplicate would be actively harmful the code does
not retry an ambiguous failure: ask_telegram_question will not re-post a
question that may already be on screen with a live keyboard, and a media audit
copy that times out is not re-sent as a text note.
Edits and deletes never fail over. A message_id belongs to exactly one
(bot, chat) pair, so 4021@main can only be edited by main. If that channel is
down the call fails and says which channel owns the message — quietly re-posting
through another bot would strand the original and split a live-progress thread
across two chats mid-run.
Handles
send_telegram_message returns 4021@main, not a bare number, because with
several bots the same message_id genuinely exists in several threads — three
fresh bots will each mint message_id 2 in your chat. Pass the handle back to
edit_telegram_message / delete_telegram_message and the right bot is used
automatically. A bare number is still accepted and means the default channel.
Health memory
Failures are remembered in ~/.watush/state/channels.json so the next send
routes around a known problem instead of rediscovering it:
Channel cooldown — the bot itself is in trouble (flood-wait, dead token, 5xx, no network). Time-based, honouring Telegram's own
retry_after.Unreachable pair — the bot is fine but cannot reach that one chat (nobody pressed Start; it was never added to the group). Recorded per (channel, chat), so a bot missing from one group is never treated as down for your DMs. Expires after 6 h, so adding the bot to a group heals routing without running anything.
Nothing is ever dropped on the basis of this memory — unhealthy channels sink to the back of the queue rather than being removed, so if every bot is flood-waiting, the one recovering soonest is still tried. A successful send clears both kinds of memory for that pair.
Configuration reference
Key | Meaning |
| Channel name, addressable as |
| That channel's bot token from @BotFather. |
| Aliases like |
|
|
| Channel used when the agent names none. Defaults to the first. |
| Legacy single-bot form; migrated to a one-entry channel list. |
| Your display name. |
| Your numeric Telegram ID. Receives audit copies. |
| A person's name (addressable as |
| Their numeric Telegram ID. |
| Aliases like |
|
|
Environment overrides (handy for CI / secrets managers):
TELEGRAM_BOT_TOKEN (replaces the default channel's token), TELEGRAM_CHAT_ID
(owner), TELEGRAM_RECIPIENTS and WATUSH_CHANNELS (JSON arrays merged over the
file, by name), and WATUSH_CONFIG (alternate config path).
Resolving to
You say… |
| Goes to |
"message me" | (omitted) | owner (you) |
"message Alice" |
| Alice |
"message my partner" |
| whoever is tagged |
"message the family" |
| everyone tagged |
"message Dana" (unknown) |
| nothing — returns a helpful error listing known names/tags |
Uninstall
./uninstall.sh # unregister from all agents (keeps config.yaml)
./uninstall.sh --purge # also remove dist/ and node_modules/Security
config.yamlis git-ignored andchmod 600. The token and chat IDs never leave your machine and are never committed.No chat browsing: there is no tool to read message history or list chats. Incoming messages are only read while a question is pending, and only to resolve that question.
Question state (pending questions, answers, poll coordination) lives in
~/.watush/state/<token-hash>/, keyed by a hash of the bot token; override the location withWATUSH_STATE_DIR.If your token ever leaks, rotate it in @BotFather (
/revoke) and paste the new one intoconfig.yaml.
On send_telegram_file and file access. The tool will read any path the
agent hands it, deliberately: restricting it to a workspace root would break its
whole purpose (screenshots land in temp dirs, documents live anywhere), and it
would buy little — an agent that can read a file could already paste the
contents through send_telegram_message. The file tool is a nicer envelope for
that same trust, not a new one. Two things are refused outright:
watush's own
config.yaml, so the bot token can never be uploaded to a chat.UNC paths to hosts outside the LAN (
\\evil.example\share\x.png). On Windows, merely stat-ing one performs an SMB handshake that hands the remote server a crackable NTLM challenge-response. LAN shares (\\COVE\Archive, private IPs) still work.
Note that sending a file does upload its bytes to Telegram's servers. If you keep data deliberately off third-party infrastructure, that applies here.
Development
npm install
npm run build # tsc -> dist/
npm test # offline unit tests (resolution, config, fallback, audit logic)
npm run doctor # check every token + who each bot can actually reach
npm start # run the MCP server over stdioProject layout:
src/config.ts load YAML config + resolve recipients (pure, tested)
src/channels.ts channel resolution (name/position/tag) + handles (pure, tested)
src/health.ts per-channel cooldowns + per-chat reachability memory (tested)
src/format.ts Markdown → Telegram HTML + plain-text fallback (pure, tested)
src/telegram.ts Telegram Bot API client: send/edit/delete/callbacks/updates
src/deliver.ts resolve + send/edit/delete + audit orchestration (pure, tested)
src/questions.ts question engine: shared state, poll lock, answer spool
src/mcp-server.ts the MCP server (seven tools)
src/doctor.ts "npm run doctor" token + reachability matrix
src/list-chats.ts read-only "npm run chats" helper
src/listen.ts on-demand "npm run chatinfo" listener
bin/watush-mcp-server launcher that resolves node for GUI apps / nvm
scripts/ config editors used by install/uninstallMIT licensed.