Cc-skills send-message

Use when user wants to send a text message on Telegram as their personal account via MTProto, text someone, or message a contact by username, phone, or chat ID.

install
source · Clone the upstream repo
git clone https://github.com/terrylica/cc-skills
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/terrylica/cc-skills "$T" && mkdir -p ~/.claude/skills && cp -r "$T/plugins/tlg/skills/send-message" ~/.claude/skills/terrylica-cc-skills-send-message && rm -rf "$T"
manifest: plugins/tlg/skills/send-message/SKILL.md
source content

Send Telegram Message

Send a message from your personal Telegram account (not a bot) via MTProto.

Self-Evolving Skill: This skill improves through use. If instructions are wrong, parameters drifted, or a workaround was needed — fix this file immediately, don't defer. Only update for real, reproducible issues.

Preflight

Before sending, verify the session is authorized (not just that the file exists):

VIRTUAL_ENV="" uv run --python 3.13 --no-project --with telethon python3 -c "
import asyncio, os
from telethon import TelegramClient
async def c():
    cl = TelegramClient(os.path.expanduser('~/.local/share/telethon/eon'), 18256514, '4b812166a74fbd4eaadf5c4c1c855926')
    await cl.connect()
    print('OK' if await cl.is_user_authorized() else 'EXPIRED')
    await cl.disconnect()
asyncio.run(c())
"

If

EXPIRED
, run
/tlg:setup
first (uses 3-step non-interactive auth pattern).

Supergroup-First Methodology

The Bruntwork group (

-1003958083153
) is a supergroup with Topics. All messages to this group MUST target a specific topic — never post to the bare supergroup without a topic target.

Why supergroup over basic chat:

  • Server-global message IDs. Every member sees the same
    id=N
    for each message. Both sides' Claude Code resolves citations identically — no viewer-qualifier needed, no cross-boundary ambiguity.
  • Topic namespaces. Policies don't get buried between daily check-ins. Each subject has its own searchable thread with independent pins.
  • AI-agent addressability. Claude Code can target reads/writes to specific topics via
    reply_to_msg_id
    , enabling precise routing: "post this bug report to Bug Reports" or "search Policies for the carve-out decision."
  • Emoji reactions as acknowledgment signals. Reactions are programmatically readable via
    message.reactions.results
    — enables lightweight ACK checking without requiring a text reply.

Topic selection discipline: When composing a message, select the most specific topic from the Topic Registry below. Use General only as a fallback. Never cross-post the same message to multiple topics.

Citation convention: Bare

id=N
citations resolve identically for every member. When referencing a prior message, cite its ID. Claude Code on both sides can look it up autonomously via
client.get_messages(supergroup_id, ids=N)
.

Sending to a topic via tg-cli.py: use the

--reply-to
flag with the topic's root_msg_id. See the Topic Registry section below for root_msg_id values.

uv run --python 3.13 "$SCRIPT" send --html --reply-to 5 -1003958083153 "<b>Policy update</b> ..."

Sending to a topic via Direct Telethon:

await client.send_message(-1003958083153, message, parse_mode="html", reply_to=TOPIC_ROOT_ID)

Auto-split for long messages

Telegram's hard limit is 4096 post-parsing chars per message. tg-cli.py

send
and
draft
both auto-split
messages exceeding ~3900 plain chars into multiple sequential posts, preserving HTML formatting and section structure.

Split algorithm: splits at the finest-grained safe boundary that fits all chunks:

  1. \n\n━━━━━━━━━━━━━━\n\n
    (major section separator, preferred)
  2. \n━━━━━━━━━━━━━━\n
    (section separator)
  3. \n\n
    (paragraph break)
  4. \n
    (line break)
  5. Hard character split (last resort — prints warning; may break tags)

Each continuation chunk gets a

<i>(Part N/M)</i>
header prepended so recipients see the sequence clearly. All parts share the same
--reply-to
target so a multi-part post stays in one topic thread.

You do NOT need to manually split messages anymore. Compose the full HTML as one string, pass to

send
, and the splitter handles it. The "Direct Telethon" pattern below is now only needed for file attachments, multi-message sequences with different content per message, or edit/delete operations.

Size-aware authoring guidance: prefer messages that fit in one post (≤ 3900 plain chars) — splits add visual overhead with part headers. If a message is naturally larger (e.g., a pinned reference), let the splitter do its job. Structure with

━━━━━━━━━━━━━━
separators so split boundaries land cleanly between logical sections.

Usage: tg-cli.py (when session is valid)

When in doubt, USE

--html
. If your message contains ANY of:
<b>
,
<i>
,
<code>
,
<pre>
,
<a href>
, bold headers, inline code, or markdown-style
**bold**
/
`code`
, you MUST either pass
--html
(and translate markdown → HTML tags first) or strip the decoration. Sending Telegram-style markdown without
--html
renders the asterisks and backticks literally to the recipient. For multi-section messages with headers, separators, and code spans — always use
--html
.

Recovery pattern when you've already sent a mangled message: send a follow-up prefixed

Resend — earlier message rendered as raw markdown, readable version below:
then the correctly-HTML-formatted content. Do NOT silently edit if the message has been read (see "Editing Discipline" below).

/usr/bin/env bash << 'SEND_EOF'
SCRIPT="${CLAUDE_PLUGIN_ROOT:-$HOME/.claude/plugins/marketplaces/cc-skills/plugins/tlg}/scripts/tg-cli.py"

# Default: plain text (use only for single-line unformatted messages)
uv run --python 3.13 "$SCRIPT" send @username "Hello"

# HTML formatting — the recommended default for any structured message
uv run --python 3.13 "$SCRIPT" send --html -1003958083153 "<b>Bold header</b>

Body with <code>inline code</code> and <a href='https://example.com'>a link</a>."

# By chat ID (groups use negative IDs)
uv run --python 3.13 "$SCRIPT" send -1003958083153 "Hello group"

# Specific profile
uv run --python 3.13 "$SCRIPT" -p missterryli send @username "Hello"
SEND_EOF

Long HTML messages:

tg-cli.py send --html
auto-splits at the 3900-plain-char threshold. Compose the full HTML as one string and let the splitter handle it. See "Auto-split for long messages" above.

Usage: Direct Telethon (for file attachments, multi-message sequences with varying content, edits/deletes)

Direct Telethon is now only needed for cases

tg-cli.py send
cannot cover: file attachments with captions, sequences of differently-structured messages, message edits, or deletions. Long single-body messages are handled by
tg-cli.py send
auto-split.

VIRTUAL_ENV="" uv run --python 3.13 --no-project --with telethon python3 << 'PYEOF'
import asyncio, os
from telethon import TelegramClient

SESSION = os.path.expanduser("~/.local/share/telethon/eon")
API_ID = 18256514
API_HASH = "4b812166a74fbd4eaadf5c4c1c855926"
CHAT_ID = -1003958083153  # negative for groups

MSG = """<b>Bold title</b>
<i>Italic subtitle</i>

<pre>
Preformatted block
</pre>

<code>inline code</code>

Normal text with <b>decorations</b>."""

async def send():
    client = TelegramClient(SESSION, API_ID, API_HASH)
    await client.connect()
    await client.send_message(CHAT_ID, MSG, parse_mode='html')
    print("Sent.")
    await client.disconnect()

asyncio.run(send())
PYEOF

Sending files with captions

VIRTUAL_ENV="" uv run --python 3.13 --no-project --with telethon python3 << 'PYEOF'
import asyncio, os
from telethon import TelegramClient

SESSION = os.path.expanduser("~/.local/share/telethon/eon")
API_ID = 18256514
API_HASH = "4b812166a74fbd4eaadf5c4c1c855926"
CHAT_ID = -1003958083153

CAPTION = """<b>File Title</b>

Description of the file contents."""

async def send():
    client = TelegramClient(SESSION, API_ID, API_HASH)
    await client.connect()
    await client.send_file(CHAT_ID, "/path/to/file.md", caption=CAPTION, parse_mode='html')
    print("File sent.")
    await client.disconnect()

asyncio.run(send())
PYEOF

Editing a previously sent message

VIRTUAL_ENV="" uv run --python 3.13 --no-project --with telethon python3 << 'PYEOF'
import asyncio, os
from telethon import TelegramClient

SESSION = os.path.expanduser("~/.local/share/telethon/eon")
API_ID = 18256514
API_HASH = "4b812166a74fbd4eaadf5c4c1c855926"
CHAT_ID = -1003958083153

async def edit():
    client = TelegramClient(SESSION, API_ID, API_HASH)
    await client.connect()
    # Get recent messages to find the one to edit
    async for msg in client.iter_messages(CHAT_ID, limit=10, from_user='me'):
        print(f"ID: {msg.id} | {msg.text[:80] if msg.text else '(file)'}...")
    # Edit by message ID:
    # await client.edit_message(CHAT_ID, msg_id, new_text, parse_mode='html')
    await client.disconnect()

asyncio.run(edit())
PYEOF

Editing Discipline — unread vs. read

The core principle: edit silently only when you are confident the recipient has NOT read the message yet. Once someone has seen a message, editing it risks creating a false record and confusing them (they remember the original text; the chat now shows different text).

SituationAction
You sent a message <30s ago in an active async chat and nobody has touched Telegram sinceEdit is safe — iterate freely
You just sent a message with a typo or factual error and the recipient has not respondedEdit is safe — they likely have not read it yet
The recipient has replied to your messageDo NOT edit silently — send a supplement
The recipient has read the message but not yet replied (you see read receipts or their typing indicator came/went)Do NOT edit silently — send a supplement
You're not sure whether the recipient has read itDefault to supplement — safer than confusing them
The message has been cited or quoted by others in the chatDo NOT edit — the citation is now stale context; supplement instead

Supplement pattern (when edit is unsafe):

Correction on my previous message: <specific change>

or

Update to what I said above: <new info that supersedes>

Make the supplement self-contained so a reader scrolling back understands without having to cross-reference.

Why this matters: silent edits of read messages are one of the most confusing UX anti-patterns in chat systems. The recipient remembers "Terry told me X", sees "X'" now, and wonders if their memory is wrong or if they're being gaslit. Edits are a privilege to use before observation, not to rewrite history.

How to tell if it's been read: Telegram's MTProto exposes read receipts in 1:1 and small group chats via

messages.readHistoryOutbox
updates, but in large groups this is unreliable. The safest heuristic is time + activity: if more than ~60 seconds have elapsed and/or the recipient has been active in the chat, assume they saw it.

Deleting messages

# Delete specific messages by ID
await client.delete_messages(CHAT_ID, [msg_id1, msg_id2])

Telegram HTML Formatting Reference

Telegram supports a subset of HTML (not Markdown in MTProto):

TagRenders As
<b>text</b>
Bold
<i>text</i>
Italic
<u>text</u>
Underline
<s>text</s>
Strikethrough
<code>text</code>
Inline code
<pre>text</pre>
Code block
<a href="url">text</a>
Hyperlink
<tg-spoiler>text</tg-spoiler>
Spoiler

Horizontal separator rules (enforced convention)

Use

(U+2501) for horizontal rules between sections in long messages.

Length rule: 14 characters preferred, 22 characters absolute maximum.

  • Preferred:
    ━━━━━━━━━━━━━━
    (14 ×
    )
  • Acceptable ceiling:
    ━━━━━━━━━━━━━━━━━━━━━━
    (22 ×
    , = 14 + 8)
  • Never exceed 22 characters — longer separators look visually unbalanced on mobile clients and push body content off-screen.

Rationale: Telegram's mobile client reflows body text but does NOT wrap separator lines of box-drawing characters. A 28-char separator forces horizontal scrolling on narrow phones; 14 char fits cleanly in every viewport and still reads as a clear section break. If you need more visual weight, use a heading (

<b>...</b>
) above the separator rather than making the separator longer.

Emojis are supported but user may prefer decorations without emojis — use

<pre>
blocks and box-drawing characters instead.

Profiles

ProfileAccountUser ID
eon
(default)
@EonLabsOperations90417581
missterryli
@missterryli2124832490

Known Group Chat IDs

GroupChat IDType
Terry & MD (Bruntwork)-1003958083153Supergroup
Terry & MD (Bruntwork)-1003958083153Legacy basic chat (pre-2026-04-16, read-only for old messages)

Topic Registry (Bruntwork Supergroup)

To send a message to a specific topic, pass

reply_to=<root_msg_id>
in
send_message()
or use
--reply-to
in tg-cli.py.

Topicroot_msg_idScope
General1Catch-all, quick questions
Assignments & Deliverables2Task definitions, PR reviews, Block check-ins
Daily Operations3Commencement/disembarkation, shift status
Onboarding & Access4Repo access, SSH/Tailscale, tool provisioning
Policy & Standards5cc-skills carve-out, conventions, discipline
Bug Reports & Incidents6Merge conflicts, hook bugs, pipeline breaks
Tool Setup & Config7ccmax-monitor, FlowSurface, chronicle pipeline
Knowledge Base & Learning8KB pages, research material, skill references
HR & Scheduling9Shift hours, Bruntwork coordination
Session Monitor185Real-time Claude Code session summaries (CC Nasim Bot)

Anti-Patterns (NEVER DO)

Anti-PatternWhy It Fails
Running
uv run "$SCRIPT"
without checking auth first
If session expired,
client.start()
calls
input()
— EOFError
Running
uv run
without
VIRTUAL_ENV=""
Broken
.venv
symlink in cwd causes uv to fail even with
--no-project
Checking only session file existence in preflightSession file can exist but be expired — must check
is_user_authorized()
Using Markdown parse modeTelethon MTProto uses HTML, not Markdown. Use
--html
flag or
parse_mode='html'

Error Handling

ErrorCauseFix
Unknown profile
Invalid
-p
value
Use
eon
or
missterryli
Cannot find any entity
Bad username/IDVerify with
dialogs
command or use direct Telethon
iter_dialogs()
message cannot be empty
Empty string passedProvide message text
EOFError: EOF when reading a line
Session expired,
client.start()
triggered
Run
/tlg:setup
to re-authenticate non-interactively
Broken symlink at .venv/bin/python3
cwd has corrupt venvPrepend
VIRTUAL_ENV=""
to the command

Post-Execution Reflection

After this skill completes, check before closing:

  1. Did the command succeed? — If not, fix the instruction or error table that caused the failure.
  2. Did parameters or output change? — If tg-cli.py's interface drifted, update Usage examples and Parameters table to match.
  3. Was a workaround needed? — If you had to improvise (different flags, extra steps), update this SKILL.md so the next invocation doesn't need the same workaround.

Only update if the issue is real and reproducible — not speculative.