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
messagetool. 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
- Skill path โ so it can read the formatting rules
- Bot account name โ which Telegram bot account to use (must be specified, never auto-selected)
- Chat ID โ where to send
- Thread ID โ topic thread if applicable
- 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.
# 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
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
- Faux headings:
EMOJI <b>CAPS TITLE</b>with blank line after - Emojis: 1-3 per message as visual anchors, not decoration
- Whitespace: Blank lines between sections
- Long content: Use
<blockquote expandable> - 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:
- Split at section boundaries (blank lines between
<b>HEADING</b>blocks) - Each chunk must be valid HTML (don't split inside a tag)
- Send chunks sequentially with a 1-second delay between them
- 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:
- Parse the task โ extract Bot account name, Chat ID, Thread ID (if any), skill path, and raw content
- Read this SKILL.md โ load the formatting rules
- Format the content โ apply HTML tags, structure pattern, style rules, mobile-friendly data display
- Escape special chars โ
&then<then>in text content only (not in your HTML tags) - Check length โ if >4,096 chars, split at section boundaries
- Get bot token โ auto-detect config path, extract token for the specified account (error if not found)
- Send via curl โ use the appropriate template (with/without thread ID)
- Check response โ parse curl output for
"ok": true - Handle errors โ follow the error handling table above
- Report back โ reply with message_id on success, or error details on failure