Introduction
# Telegram Compose
Format and deliver rich, scannable Telegram messages via direct API with HTML formatting.
## How This Skill Gets Used
**This skill is auto-invoked by the main session agent.** No other skills need to know about it.
### Decision Rule (for the main session agent)
Before sending a message to Telegram, check:
- **Short reply (<3 lines, no structure):** Send directly via OpenClaw `message` tool. Done. - **Substantive content (>3 lines, or has lists/stats/sections/reports):** Spawn this skill as a sub-agent.
### Spawning the sub-agent
The main session agent calls `sessions_spawn` with:
``` sessions_spawn( model: "claude-haiku-4-5", task: "<task content — see template below>" ) ```
**Task template:**
``` Read the telegram-compose skill at {baseDir}/SKILL.md for formatting rules, then format and send this content to Telegram.
Bot account: <account_name> (e.g., "main" — must match a key in channels.telegram.accounts) Chat ID: <chat_id> Thread ID: <thread_id> (omit this line if not a forum/topic chat)
Content to format: --- <raw content here> ---
After sending, reply with the message_id on success or the error on failure. Do NOT include the formatted message in your reply — it's already been sent to Telegram. ```
**IMPORTANT:** The caller MUST specify which bot account to use. The sub-agent must NOT auto-select or iterate accounts.
**CRITICAL:** The sub-agent announcement routes back to the main session, NOT to Telegram. So the main session should reply `NO_REPLY` after spawning to avoid double-messaging. The sub-agent's curl call is what delivers to Telegram.
### What the sub-agent receives
1. **Skill path** — so it can read the formatting rules 2. **Bot account name** — which Telegram bot account to use (must be specified, never auto-selected) 3. **Chat ID** — where to send 4. **Thread ID** — topic thread if applicable 5. **Raw content** — the unformatted text/data to turn into a rich message
---
## Credentials
**Bot token:** Stored in the OpenClaw config file under `channels.telegram.accounts.<name>.botToken`.
**The account name is always provided by the caller.** Never auto-select or iterate accounts.
```bash # Auto-detect config path CONFIG=$([ -f ~/.openclaw/openclaw.json ] && echo ~/.openclaw/openclaw.json || echo ~/.openclaw/clawdbot.json)
# ACCOUNT is provided by the caller (e.g., "main") # Validate the account exists before extracting the token ACCOUNT="<provided_account_name>" BOT_TOKEN=$(jq -r ".channels.telegram.accounts.$ACCOUNT.botToken" "$CONFIG")
if [ "$BOT_TOKEN" = "null" ] || [ -z "$BOT_TOKEN" ]; then echo "ERROR: Account '$ACCOUNT' not found in config or has no botToken" exit 1 fi ```
---
## Sending
```bash CONFIG=$([ -f ~/.openclaw/openclaw.json ] && echo ~/.openclaw/openclaw.json || echo ~/.openclaw/clawdbot.json) # ACCOUNT provided by caller — never auto-select BOT_TOKEN=$(jq -r ".channels.telegram.accounts.$ACCOUNT.botToken" "$CONFIG")
# Without topic thread curl -s -X POST "https://api.telegram.org/bot${BOT_TOKEN}/sendMessage" \ -H "Content-Type: application/json" \ -d "$(jq -n \ --arg chat "$CHAT_ID" \ --arg text "$MESSAGE" \ '{ chat_id: $chat, text: $text, parse_mode: "HTML", link_preview_options: { is_disabled: true } }')"
# With topic thread curl -s -X POST "https://api.telegram.org/bot${BOT_TOKEN}/sendMessage" \ -H "Content-Type: application/json" \ -d "$(jq -n \ --arg chat "$CHAT_ID" \ --arg text "$MESSAGE" \ --argjson thread $THREAD_ID \ '{ chat_id: $chat, text: $text, parse_mode: "HTML", message_thread_id: $thread, link_preview_options: { is_disabled: true } }')" ```
---
## Formatting Rules
### HTML Tags
``` <b>bold</b> <i>italic</i> <u>underline</u> <s>strike</s> <code>mono</code> <pre>code block</pre> <tg-spoiler>hidden until tapped</tg-spoiler> <blockquote>quote</blockquote> <blockquote expandable>collapsed by default</blockquote> <a href="url">link</a> <a href="tg://user?id=123">mention by ID</a> ```
### Escaping
Escape these characters in **text content only** (not in your HTML tags): - `&` → `&` (do this FIRST to avoid double-escaping) - `<` → `<` - `>` → `>`
Common gotcha: content containing `&` (e.g., "R&D", "Q&A") will break HTML parsing if not escaped.
### Structure Pattern
``` EMOJI <b>HEADING IN CAPS</b>
<b>Label:</b> Value <b>Label:</b> Value
<b>SECTION</b>
• Bullet point • Another point
<blockquote>Key quote or summary</blockquote>
<blockquote expandable><b>Details</b>
Hidden content here... Long details go in expandable blocks.</blockquote>
<a href="https://...">Action Link →</a> ```
### Style Rules
1. **Faux headings:** `EMOJI <b>CAPS TITLE</b>` with blank line after 2. **Emojis:** 1-3 per message as visual anchors, not decoration 3. **Whitespace:** Blank lines between sections 4. **Long content:** Use `<blockquote expandable>` 5. **Links:** Own line, with arrow: `Link Text →`
### Examples
**Status update:** ``` 📋 <b>TASK COMPLETE</b>
<b>Task:</b> Deploy v2.3 <b>Status:</b> ✅ Done <b>Duration:</b> 12 min
<blockquote>All health checks passing.</blockquote> ```
**Alert:** ``` ⚠️ <b>ATTENTION NEEDED</b>
<b>Issue:</b> API rate limit at 90% <b>Action:</b> Review usage
<a href="https://dashboard.example.com">View Dashboard →</a> ```
**List:** ``` ✅ <b>PRIORITIES</b>
• <s>Review PR #234</s> — done • <b>Finish docs</b> — in progress • Deploy staging
<i>2 of 3 complete</i> ```
---
## Mobile-Friendly Data Display
**Never use `<pre>` for stats, summaries, or visual layouts.** `<pre>` uses monospace font and wraps badly on mobile, breaking alignment and tree characters. Reserve `<pre>` for actual code/commands only.
**For structured data, use emoji + bold + separators:**
``` ❌ BAD (wraps on mobile): <pre> ├─ 🟠 Reddit 32 threads │ 1,658 pts └─ 🌐 Web 8 pages </pre>
✅ GOOD (flows naturally): 🟠 <b>Reddit:</b> 32 threads · 1,658 pts · 625 comments 🔵 <b>X:</b> 22 posts · 10,695 likes · 1,137 reposts 🌐 <b>Web:</b> 8 pages (supplementary) 🗣️ <b>Top voices:</b> @handle1 · @handle2 · r/subreddit ```
**Other patterns:**
Record cards: ``` <b>Ruby</b> Birthday: Jun 16 · Age: 11
<b>Rhodes</b> Birthday: Oct 1 · Age: 8 ```
Bullet lists: ``` • <b>hzl-cli:</b> 1.12.0 • <b>skill:</b> 1.0.6 ```
---
## Limits and Splitting
- **Message max:** 4,096 characters - **Caption max:** 1,024 characters
**If formatted message exceeds 4,096 chars:** 1. Split at section boundaries (blank lines between `<b>HEADING</b>` blocks) 2. Each chunk must be valid HTML (don't split inside a tag) 3. Send chunks sequentially with a 1-second delay between them 4. First chunk gets the full heading; subsequent chunks get a continuation indicator: `<i>(continued)</i>`
---
## Error Handling
**If Telegram API returns an error:**
| Error | Action | |-------|--------| | `Bad Request: can't parse entities` | HTML is malformed. Strip all HTML tags and resend as plain text. | | `Bad Request: message is too long` | Split per the rules above and retry. | | `Bad Request: message thread not found` | Retry without `message_thread_id` (sends to General). | | `Too Many Requests: retry after X` | Wait X seconds, then retry once. | | Any other error | Report the error back; don't retry. |
**Fallback rule:** If HTML formatting fails twice, send as plain text rather than not sending at all. Delivery matters more than formatting.
---
## Sub-Agent Execution Checklist
When running as a sub-agent, follow this sequence:
1. **Parse the task** — extract Bot account name, Chat ID, Thread ID (if any), skill path, and raw content 2. **Read this SKILL.md** — load the formatting rules 3. **Format the content** — apply HTML tags, structure pattern, style rules, mobile-friendly data display 4. **Escape special chars** — `&` then `<` then `>` in text content only (not in your HTML tags) 5. **Check length** — if >4,096 chars, split at section boundaries 6. **Get bot token** — auto-detect config path, extract token for the specified account (error if not found) 7. **Send via curl** — use the appropriate template (with/without thread ID) 8. **Check response** — parse curl output for `"ok": true` 9. **Handle errors** — follow the error handling table above 10. **Report back** — reply with message_id on success, or error details on failure