Send Claude's output — or any script's output — to Telegram, formatted properly.
Claude writes GitHub-flavoured Markdown: headings, nested lists, tables, fenced
code, task lists. Telegram supports none of those. Piping one into the other
gives you either a wall of **asterisks** or, worse, a 400: can't parse entities and a notification that never arrives.
This project parses the Markdown and re-renders it into what Telegram actually understands, then handles the unglamorous parts: retries, rate limits, the 4096-character cap, and a plain-text fallback so a formatting bug can never cost you a notification.
It is a send-only, personal notifier. There is no command handling, no polling loop, no database, nothing to monetize. One bot, one chat, one direction.
claude -p "summarise today's commits" | uv run tg-notify --title "Daily digest"1. Create a bot. Message @BotFather → /newbot →
copy the token.
2. Install.
uv sync3. Find your chat id. Send your new bot any message first (a bot cannot start a conversation), then:
cp .env.example .env # paste your token into TELEGRAM_BOT_TOKEN
uv run tg-notify chat-idAdd one of these to .env as TELEGRAM_CHAT_ID:
987654321 private: Иерархиус Николай Вселенович
-1001234567890 supergroup: Государственный НИИ "Зла"
4. Verify.
uv run tg-notify doctorIt prints the bot identity and sends a formatting test message. If something is wrong, it says what.
Only the first two are required:
| Variable | Default | Purpose |
|---|---|---|
TELEGRAM_BOT_TOKEN |
— | From @BotFather |
TELEGRAM_CHAT_ID |
— | User id, group id (negative), or @channelname |
TELEGRAM_MESSAGE_THREAD_ID |
— | Forum topic id, for groups that use topics |
TELEGRAM_PARSE_MODE |
HTML |
HTML or MarkdownV2 |
TELEGRAM_DISABLE_NOTIFICATION |
false |
Deliver silently by default |
TELEGRAM_DISABLE_WEB_PAGE_PREVIEW |
true |
Link previews off, so alerts stay compact |
TELEGRAM_TIMEOUT |
15 |
Per-request timeout, seconds |
TELEGRAM_MAX_ATTEMPTS |
5 |
Attempts before giving up |
TELEGRAM_SEND_INTERVAL |
0.5 |
Pause between parts of a split message |
TELEGRAM_API_BASE |
https://api.telegram.org |
Point at a local bot API server |
.env is gitignored. Real environment variables win over the file, so CI can
just set secrets normally.
Long agent runs are the whole reason this exists — start one, walk away, get a
phone buzz when it needs you. Add a hook to .claude/settings.json:
{
"hooks": {
"Stop": [{
"hooks": [{
"type": "command",
"command": "cd /path/to/claude-telegram-notifier && uv run tg-notify 'Claude finished — your turn' --title 'Claude Code' --level success"
}]
}],
"Notification": [{
"hooks": [{
"type": "command",
"command": "cd /path/to/claude-telegram-notifier && uv run tg-notify 'Claude needs input' --level warning"
}]
}]
}
}Anything Claude prints on a schedule — a digest, a triage pass, a review — goes straight through. The Markdown survives intact:
#!/usr/bin/env bash
# ~/bin/morning-digest.sh — run from cron at 08:00
set -euo pipefail
cd ~/Developer/PycharmProjects/claude-telegram-notifier
claude -p "Summarise yesterday's commits across ~/work/*, grouped by repo.
Flag anything that touched auth or billing." \
| uv run tg-notify --title "Morning digest" --silent0 8 * * * ~/bin/morning-digest.sh--silent delivers without a sound: right for a scheduled digest, wrong for an
alert you need to see.
if output=$(./nightly-backup.sh 2>&1); then
printf '%s' "$output" | uv run tg-notify --title "Backup OK" --level success --silent
else
printf '%s' "$output" | uv run tg-notify --code --title "Backup failed" --level error
fi--code wraps the input in a code block, which is what you want for logs and
tracebacks — no Markdown interpretation, monospace, line breaks preserved.
uv run pytest 2>&1 | uv run tg-notify --code --title "Test run" --level errorOutput longer than 4096 characters is split across several messages, cut on paragraph and line boundaries rather than mid-word. For something genuinely long, send the file instead:
uv run tg-notify --document build.log --title "Full build log"from telegram_notifier import notify
notify("## Deploy complete\n\n- API `v2.4.1`\n- 0 errors in 5m", title="prod")Reuse one client when you send more than once:
from telegram_notifier import TelegramNotifier
with TelegramNotifier() as tg:
tg.send("Starting import…", level="info", silent=True)
try:
rows = run_import()
except Exception as error:
tg.send(f"Import failed:\n\n```\n{error}\n```", level="error")
raise
tg.send(f"Imported **{rows:,}** rows", level="success")
tg.send_document("import.log", caption="Full log")Async, for asyncio schedulers:
from telegram_notifier import AsyncTelegramNotifier
async with AsyncTelegramNotifier() as tg:
await tg.send("Worker pool drained", level="warning")- run: uv sync
- run: uv run tg-notify --file report.md --title "Nightly run"
env:
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}One group, several forum topics, one bot:
uv run tg-notify "deploy finished" --thread-id 12 # #deploys
uv run tg-notify "disk at 91%" --thread-id 34 # #alertsThe CLI only works where a shell can reach api.telegram.org. Agents often run
in a sandbox with an allow-listed network, where it cannot — the send fails with
a proxy 403, not a Telegram error, and no amount of correctness in this
package helps. MCP servers are spawned by the client on the host instead, so
they get the host's Python and the host's network.
uv sync --extra mcpRegister it (Claude Code; Claude Desktop and Cowork take the same JSON):
claude mcp add telegram -- \
uv run --directory /path/to/claude-telegram-notifier --extra mcp tg-notify-mcp{
"mcpServers": {
"telegram": {
"command": "uv",
"args": ["run", "--directory", "/path/to/claude-telegram-notifier",
"--extra", "mcp", "tg-notify-mcp"]
}
}
}Two tools: send_telegram_notification(text, title, level, silent) and
check_telegram_connection(), which validates the token without sending
anything. Markdown handling, splitting and retries are the same code the CLI
uses.
--directory matters. A stdio server is launched by the client, not by a
shell, so its working directory is whatever the client picked — and .env is
found by walking up from the CWD. The server also looks for .env beside its
own package, and TELEGRAM_ENV_FILE overrides that, but passing --directory
is the least surprising of the three. To keep secrets out of a file entirely,
put them in the MCP config's env block; real environment variables still win.
tg-notify [MESSAGE] [options] send (the default command)
tg-notify doctor check the token, then send a test message
tg-notify chat-id list chat ids from recent updates
Message text comes from the argument, from --file, or from stdin — whichever
you provide. - also means stdin.
| Option | Effect |
|---|---|
-t, --title TEXT |
Bold heading above the message |
-l, --level LEVEL |
Prefix an emoji: info, success, warning, error, debug |
-f, --file PATH |
Read the message body from a file |
-d, --document PATH |
Upload a file; the message becomes its caption |
--code [LANG] |
Wrap the input in a code block |
--plain |
Do not interpret the input as Markdown |
--parse-mode MODE |
HTML or MarkdownV2 |
--chat-id ID |
Override the configured chat |
--thread-id ID |
Target a forum topic |
-s, --silent |
Deliver without a sound |
--preview |
Enable link previews |
--env PATH |
Use a specific .env file |
-n, --dry-run |
Render and print, send nothing |
-v, --verbose |
Log retries and API errors |
Exit codes: 0 sent, 1 send failed, 2 configuration problem. Useful in a
shell — tg-notify ... || echo "notification failed".
Preview exactly what Telegram will receive, including where long input gets split:
uv run tg-notify --file report.md --dry-runTelegram has no headings, lists, tables or rules — only a flat set of inline entities. Rather than escaping the text and hoping, the Markdown is parsed and re-rendered:
| Claude writes | Telegram gets |
|---|---|
# Heading |
bold + underlined (### and deeper: bold) |
- a / 1. a |
• ◦ ▪ bullets or numbers, nested levels aligned |
- [x] done |
☑ done |
| a | b | table |
aligned monospace block |
```py fence |
code block, language preserved for highlighting |
> quote |
native blockquote |
--- |
──────────── |
 |
a link labelled alt |
**b** _i_ ~~s~~ `c` [t](u) |
the matching entity |
||secret|| |
spoiler |
HTML is the default parse mode because its escaping is unambiguous — one
character, one meaning. MarkdownV2 is fully supported (--parse-mode MarkdownV2), including the escaping of all sixteen special characters wherever
they appear, list markers included. That is exactly the fiddly part MarkdownV2
gets wrong by hand.
Anything Telegram would reject — raw HTML in the source, a stray <, an
ampersand in a URL — is escaped rather than dropped.
The parts you only notice when they are missing:
- Retries. Transport errors and
5xxresponses are retried with exponential backoff and jitter, up toTELEGRAM_MAX_ATTEMPTS. - Rate limits. On
429, Telegram's ownretry_afteris honoured rather than guessed at. Multi-part messages are paced to stay under the ~1 message/second per-chat limit. - Permanent errors fail fast.
chat not foundis not retried five times — it raises immediately, because no amount of waiting will fix it. - Splitting that does not corrupt. Messages are cut between blocks, then
lines, then words — never inside an HTML tag or an
&entity;. Tags left open at a cut are closed and reopened on the other side. Code blocks are re-fenced per piece, so every part is independently valid. - A fallback that cannot lose a message. If Telegram ever rejects the formatting, the same content is immediately resent as plain text. A rendering bug degrades the message; it does not delete it.
- Length counted the way Telegram counts it — UTF-16 code units, so emoji and CJK text do not silently overflow the cap.
uv sync
uv run pytest # 61 tests, no network required
uv run main.py # send yourself a formatting demoLayout:
src/telegram_notifier/
├── config.py .env / environment loading
├── formatting.py HTML, MarkdownV2 and plain-text escaping
├── markdown.py Markdown -> Telegram, via a parsed syntax tree
├── splitting.py packing and splitting within the 4096 cap
├── client.py sync + async clients, retries, fallback
└── cli.py tg-notify
Tests use httpx.MockTransport, so the suite runs offline and covers the paths
that are awkward to reach in real life: rate limits, transport failures, retry
exhaustion, and the plain-text fallback.
- A bot cannot message you first. Send it something once, or add it to your group, before anything can be delivered.
tg-notify chat-idreadsgetUpdates. It returns nothing if a webhook is set on the bot, and Telegram only retains updates for 24 hours.- To post to a channel, add the bot as an administrator and use
@channelnameor the numeric id asTELEGRAM_CHAT_ID. - Telegram's caption limit is 1024 characters, so
--documentcaptions are truncated to fit — put the detail in the file, not the caption. - Keep
.envout of git. It is already in.gitignore; the token in it is full control of the bot.