Skip to content

Telegram Bot Formatting and Features Playbook

URL: https://mkdocs.justinsforge.com/docs/telegram-bot-playbook/

Canonical reference for how the forge Telegram bot fleet (capture, coordinator, notify, remote-bridge) formats outbound messages and uses richer Telegram features (pins, inline keyboards, progress edits, callbacks). All helpers live in forge_telegram_format.py and the callback registry in forge_telegram_callbacks.py.

Why this exists

Telegram is the primary mobile surface for the forge stack. It does not render markdown tables, has limited markdown syntax (MarkdownV2 is strict and easy to break), and offers structural primitives (inline keyboards, pinned messages, expandable blockquotes, edit-in-place, chat actions) that materially improve UX. Without a single canonical formatter, every bot reinvents this badly.

The defense-in-depth pattern matches the em-dash sanitizer: prompt rules tell Sonnet what to do, and the boundary helper enforces it on the way out. Sonnet drifts; the boundary does not.

Formatting rules

Parse modes

  • Default: no parse mode (plain text). Safest, no escaping headaches.
  • MarkdownV2: use only when bold/italic/monospace adds real value. ALL 18 reserved chars must be escaped via escape_md_v2. Reserved set: _ * [ ] ( ) ~ \ > # + - = | { } . !`
  • HTML: avoid; MarkdownV2 covers everything we need.

Tables: forbidden, get folded

Telegram does not render markdown tables. Pipe rows show as broken inline text. The boundary sanitizer (sanitize_for_telegram) auto-detects markdown-table line runs (3+ pipe-separated cells) and converts them to monospace blocks. So if Sonnet emits a table anyway, it still renders OK; but the right thing is to use kv_block or mono_table directly.

Two-column key/value

Use kv_block({"Sleep": "7h 12m", "HRV": "62", "RHR": "55"}). Renders as:

*Sleep:* 7h 12m
*HRV:* 62
*RHR:* 55

with parse_mode="MarkdownV2".

Three-or-more-column data

Use mono_table(headers, rows, max_width=30). Wraps in triple-backtick fence. Cells longer than max_width get ellipsized. Columns are space-padded to align.

Long content

Anything over ~15 lines wraps in expandable_quote(text) (Telegram's **>...|| collapsible blockquote, MarkdownV2 only).

MarkdownV2 escaping

Always run user-supplied strings (and Sonnet output going into MarkdownV2 templates) through escape_md_v2. The kv_block and mono_table helpers escape internally; you only need this for free-form content you're inlining.

Feature catalog

Feature Helper When to use
Plain send (sanitizing) tg_send(token, chat_id, text, ...) Default outbound. Routes through em-dash + table sanitize. Returns first-chunk message_id.
Edit existing message edit_or_send(..., message_id=...) Updating a status / pinned summary in place. Falls through to send-new on edit failure.
Progress message progress_message(token, chat, initial_text) Long-running tasks. Returns a handle with .update(text) rate-limited to one edit per 1.5s, plus .finalize(text) for the last write.
Inline keyboard inline_keyboard([[(label, callback_data), ...]]) Yes/no/choice prompts. Returns the reply_markup payload; pass to tg_send(..., reply_markup=...).
Pin (slot-tracked) pin_and_track(token, chat, msg_id, slot) One persistent pinned reference per slot per chat (e.g. slot="daily" for the morning report). Auto-unpins prior occupant. State in forge/data/telegram_pins.json.
Chat action chat_action(token, chat, action) Typing / upload signals. Lasts 5s. Use over text status bubbles.
Key/value block kv_block({...}) 2-column data. MarkdownV2.
Monospace table mono_table(headers, rows, max_width) 3+ column data. Triple-backtick fence.
Expandable quote expandable_quote(text) Long blocks (>15 lines). MarkdownV2.
Boundary sanitize sanitize_for_telegram(text) Auto-applied by tg_send. Strips em-dashes, folds markdown tables to mono.
Reserved-char escape escape_md_v2(text) Inlining free-form into MarkdownV2 templates.

Callback queries

forge_telegram_callbacks.register(prefix, handler) binds a callback_data prefix to a handler. Each callback_data is shaped <prefix>:<payload>. Polling loops in every bot now call callbacks.dispatch(callback_query, token=...) on incoming callback_query updates. Handler signature: (payload, callback_query, token) -> popup_text_or_None.

Pre-registered prefixes:

  • noop: clears the spinner, no popup. Use for placeholder buttons.
  • ack:<text> echoes <text> as the popup.

Skills add their own at module-import time. Example wiring is in forge_telegram_callbacks.py.

System-prompt addendum

Inject this verbatim into every bot's system prompt assembly so Sonnet knows the surface rules. The constant is exported as TELEGRAM_FORMATTING_RULES from forge_telegram_format.py; the brain (forge_telegram_brain.py) appends it to both persona rule blocks automatically.

Telegram has no table support. Format rules:
- For 2 columns of data, return key-value lines: *Key:* value, one per row.
- For 3 or more columns, return a tool call to mono_table with headers and rows.
- For yes/no/choice prompts, return inline_keyboard buttons, not "reply with 1 or 2".
- Long content (>15 lines) wrap in expandable_quote.
- Never emit em dashes. Use commas, colons, parens.
- Reactions and chat_action signals over text status messages.

Build order: adding a new feature

  1. Update this playbook with the new pattern and helper name.
  2. Add the helper to forge_telegram_format.py. Keep formatters pure; only the four IO helpers (tg_send, edit_or_send, pin_and_track, chat_action, progress_message) actually call Telegram.
  3. Bots auto-pick-up: every send path in the fleet routes through sanitize_for_telegram, so any new pure-formatter output is safe by construction.
  4. If the feature involves callbacks, register the prefix in the skill module via forge_telegram_callbacks.register(...).
  5. Update TELEGRAM_FORMATTING_RULES if Sonnet needs to know about it.

MarkdownV2 sentinel (Phase 3)

Tool outputs that need bold / mono rendering (kv_block, mono_table, expandable_quote) prepend a \x00MDV2\x00 sentinel to their return value. Every bot's send() boundary detects the sentinel via resolve_mdv2_sentinel(text), which:

  1. Splits the text on the sentinel.
  2. MarkdownV2-escapes the pre-sentinel prose (Sonnet may concatenate free-form text in front of the formatted block).
  3. Keeps the post-sentinel formatted block raw (already escaped by kv_block / mono_table).
  4. Returns (clean_text, "MarkdownV2").

The send wrappers (capture, coordinator, notify, remote-bridge, forge_coordinator_proactive, plus the central tg_send) auto-attach parse_mode="MarkdownV2" when the sentinel is present.

Defense: every tg() call retries once without parse_mode if Telegram returns 400 with can't parse entities, so a malformed MDV2 payload still reaches Justin as plain text instead of getting silently dropped.

Why a sentinel and not an explicit parse_mode kwarg threaded through the brain? The Sonnet brain returns a single reply string. Threading a separate flag would require schema changes and Sonnet compliance. The sentinel rides inside the string, survives concatenation, and lets the boundary be the single point of truth for parse-mode decisions.

Variable naming

snake_case throughout. Source/state tags where applicable: raw_table_text (untouched input), clean_lines / parsed_rows (normalized internal), payload_keyboard (outbound-formatted). Module follows FORGE-DOCTRINE.md Section 4.

Cross-references

  • Helper module: forge_telegram_format.py (only listed if mkdocs indexes scripts/; otherwise read at /home/justinwieb/forge/scripts/forge_telegram_format.py)
  • Callback dispatcher: forge/scripts/forge_telegram_callbacks.py
  • Em-dash sanitizer (canonical, folded into the boundary): forge/scripts/forge_text_sanitize.py
  • Telegram robustness layers (defense-in-depth around brain failure): reference_telegram_bot_robustness_layers