How Hermes Formats Telegram Messages So Well
A deep dive into Hermes Agent's dual-path rendering pipeline — Bot API 10.1 Rich Messages versus MarkdownV2 fallback — and the engineering decisions that keep agent replies readable on every Telegram client.
1. The Problem
Telegram bots communicate through the Bot API, which historically only supported MarkdownV2 and HTML as parse modes. Standard Markdown — the lingua franca of LLM outputs (tables, task lists, math blocks, collapsible sections) — has no native representation in MarkdownV2. This creates a gap:
| What LLMs output | What MarkdownV2 supports |
|---|---|
Pipe tables (| col | col |) | ❌ No table syntax — | is just an escaped literal |
Task lists (- [x] done) | ❌ No checkbox syntax |
Collapsible <details> blocks | ❌ Not supported |
Block math ($$...$$) | ❌ Not supported |
| Standard links, bold, italic | ✅ Supported with different syntax |
Without careful conversion, agent replies to Telegram users would look broken: tables rendered as noisy backslash-pipe text, special characters triggering parse errors, and formatting silently dropped.
2. The Architecture: Two-Path Rendering
Hermes does not force the agent to "think in Telegram syntax." Instead, the agent outputs standard Markdown, and the Telegram gateway adapter routes it through one of two paths:
Path A: Bot API 10.1 Rich Messages (Default)
When the content contains constructs that MarkdownV2 degrades, Hermes sends via Telegram's native sendRichMessage endpoint using the agent's raw, unmodified markdown.
| Feature | Rich Message Handling |
|---|---|
| Pipe tables | Native Telegram table rendering |
Task lists (- [ ] / - [x]) | Native checkboxes |
Collapsible details (<details>) | Native expand/collapse blocks |
Block math ($$...$$) | Native math rendering |
| Streaming previews | sendRichMessageDraft in private chats |
Delivery mechanics:
- The streaming preview is sent as a draft
- The final answer is delivered by editing the existing preview in-place via
editMessageTextwith therich_messageparameter - No duplicate messages, no delete-and-resend flicker
Limits & safeguards:
- Max 32,768 UTF-8 characters [1] — content above this automatically skips to the fallback path
- If Telegram rejects the payload (older
python-telegram-bot, parser error, oversized blocks), it transparently falls back to MarkdownV2 - Transient/network errors are not silently retried — avoids duplicate final messages
- A known TDesktop crash with math-inside-details blocks is detected and routed to fallback
Path B: MarkdownV2 Fallback
For ordinary replies or when rich messages are unavailable, Hermes runs the markdown through a 10-step conversion pipeline:
3. The MarkdownV2 Conversion Pipeline
All logic lives in TelegramAdapter.format_message() in gateway/platforms/telegram.py.
Step 0: Table Rewrite
Before any other conversion, GFM pipe tables are detected and rewritten into Telegram-friendly row groups via _wrap_markdown_tables():
Input:
| Name | Status | Uptime |
|-------|--------|--------|
| poller| on | 99.9% |
| dash | on | 99.8% |
Output:
**poller**
• Name: poller
• Status: on
• Uptime: 99.9%
**dash**
• Name: dash
• Status: on
• Uptime: 99.8%
This is necessary because MarkdownV2's \| is just a literal escaped pipe — tables would otherwise render as unreadable backslash noise.
Step 1–2: Protect Code Regions
Fenced code blocks and inline code spans are extracted into placeholder tokens so their contents are never touched by the escaping steps that follow. Inside code:
\→\\(backslash escaping per MarkdownV2 spec)`→\`(backtick escaping)
Step 3: Convert Links
Standard markdown links are converted with proper escaping:
- Display text: all MarkdownV2 special chars escaped
- URL: only
)and\escaped (per MarkdownV2 URL rules)
Input: Click [here](https://example.com/path_(foo))
Output: Click [here](https://example.com/path_\(foo\))
Step 4–7: Convert Formatting Syntax
| Standard Markdown | MarkdownV2 | Implementation |
|---|---|---|
## Title | *Title* (bold) | re.sub(r'^#{1,6}\s+(.+)$', ...) |
**bold** | *bold* | re.sub(r'\*\*(.+?)\*\*', ...) |
*italic* | _italic_ | re.sub(r'\*([^*\n]+)\*', ...) |
~~strike~~ | ~strike~ | re.sub(r'~~(.+?)~~', ...) |
||spoiler|| | ||spoiler|| | Protected from \| escaping |
Step 8: Escape All Remaining Special Characters
Any character outside a protected code region that is special in MarkdownV2 gets a preceding backslash:
_MDV2_ESCAPE_RE = re.compile(r'([_*\[\]()~`>#\+\-=|{}.!\\])')
def _escape_mdv2(text: str) -> str:
return _MDV2_ESCAPE_RE.sub(r'\\\1', text)
Characters escaped: _ * [ ] ( ) ~ ` > # + - = | { } . ! \
Step 9: Restore Protected Regions
The placeholder tokens are swapped back with their protected code block/inline code content, now properly escaped for MarkdownV2.
4. Streaming & Delivery
| Phase | Private Chats | Groups / Supergroups |
|---|---|---|
| Streaming | sendRichMessageDraft for real-time token preview | Falls back to editMessageText (drafts are DM-only) |
| Finalization | editMessageText with rich_message edits the preview in-place | Sends final message directly |
| Fallback | Any draft failure switches to edit-based streaming for that turn | Already on edit path |
5. Key Engineering Decisions
The agent should not be burdened with platform-specific syntax. Separation of concerns: agent generates content, gateway adapts delivery.
MarkdownV2 is the safe, universal baseline. Rich messages are used only when they materially improve output. This avoids client compatibility issues.
More readable on mobile than fenced code blocks for small tables. Larger tables fall back to code blocks.
A clean pattern that prevents the escaping regex from corrupting code contents, while still applying MarkdownV2's required internal escapes.
Deleting the preview and sending a new message causes flicker. Editing the existing message's rich_message field is seamless.
If rich delivery fails for any reason, the user still gets a perfectly readable MarkdownV2 message. No error bubbles, no lost messages.
The 32,768 char pre-check avoids a round-trip to Telegram's servers for oversized content.
6. Configuration
Operators can tune behavior in ~/.hermes/config.yaml:
gateway:
streaming:
enabled: true
transport: auto # auto | draft | edit | off
platforms:
telegram:
extra:
rich_messages: true # Bot API 10.1 rich messages
disable_link_previews: false
rich_messages: false— Forces all replies onto the legacy MarkdownV2 pathtelegram.pretty_tables: false— Disables table-to-bullet normalization, forces code blocks
7. Results
The pipeline successfully bridges the gap between "what LLMs naturally output" and "what Telegram can render." Users see:
- Tables that are readable on mobile (bullet groups) or natively rendered (rich path)
- Code blocks that are never corrupted by escaping
- Links, bold, italic that render consistently across clients
- Math, task lists, collapsible sections that render natively when supported
- No parse errors — every special character is either properly escaped or inside a protected region
- No duplicate messages — streaming and finalization edit the same message
- No silent failures — every error path degrades gracefully to a readable fallback
8. Files Referenced
| File | Purpose |
|---|---|
gateway/platforms/telegram.py | Main adapter: format_message, rich send logic, streaming |
gateway/platforms/base.py | BasePlatformAdapter with REQUIRES_EDIT_FINALIZE contract |
gateway/stream_consumer.py | Streaming edit/draft orchestration |
gateway/rich_sent_store.py | Index of rich messages for continuation tracking |
tests/gateway/test_telegram_format.py | Unit tests for escaping, table wrapping, edge cases |
website/docs/user-guide/messaging/telegram.md | Operator-facing documentation |
Case study compiled from live source inspection of the Hermes Agent codebase. Hermes is a personal AI agent platform by Nous Research.
References
- Telegram. (2025). Bot API Documentation — Sending Rich Messages. core.telegram.org/api/richtext — 32,768 UTF-8 character limit for rich message payloads.
- Nous Research. (2025). Hermes Agent. GitHub Repository. github.com/NousResearch/hermes-agent — Source code for gateway/platforms/telegram.py and related modules cited in this case study.