Install
openclaw skills install @psyb0t/telethon-plusHTTP + MCP control plane over a REAL Telegram MTProto userbot (Telethon) — a full user account, not the Bot API. Send/read/edit/delete/forward messages, browse dialogs, resolve entities/contacts, download + send media, manage chats (create/join/leave/admin), run polls, and forward every incoming message to a webhook (TELETHON_POST_TO_URL). JSON in / JSON out on one port; MCP streamable-HTTP at /mcp/ mirrors the same tools; optional bearer auth. Built-in anti-flood throttling keeps the account under Telegram's limits. Use when the user wants an agent, script, or curl one-liner to drive their own Telegram account over HTTP/MCP — read a chat, DM someone, post to a channel, react/pin, or pipe incoming DMs into an app. Drives a real account acting AS the user with full account access.
openclaw skills install @psyb0t/telethon-plusYour Telegram account behind an HTTP API. Wraps Telethon — the real MTProto userbot client, a full user account, not the neutered Bot API — behind a JSON HTTP API and a Model Context Protocol endpoint. Same tool registry, two doors: POST JSON at /api/..., or point an MCP-aware agent at /mcp/. Either way it talks to Telegram as you, with full account access.
Capabilities:
@username / phone / t.me link / numeric ID to a profile, bulk-resolve.TELETHON_POST_TO_URL).For installation, login, and container setup, see references/setup.md.
permissions: in the frontmatter): shell (setup-only), network (runtime HTTP), and full Telegram account access. Setup-only shell vs. runtime API is a hard line — docker run / docker compose / curl in references/setup.md are one-time operator commands to stand up the container; every runtime call this skill makes afterward is a plain HTTP request (curl as an HTTP client, or MCP), never a new shell/docker invocation against the host.curl/HTTP request to the telethon-plus server ($TELETHON_PLUS_URL), and the server itself makes outbound calls to Telegram's MTProto servers and, when file_url is used, to whatever URL is given (see the SSRF note below).DELETE /api/chats, ban/kick/promote, bulk DELETE /api/messages).localhost/loopback or put it behind TLS on a reverse proxy, set TELETHON_AUTH_KEY (empty = no auth, wide open), and never expose /mcp/ or /api/ to untrusted agents/networks — either surface hands out full account control. See references/setup.md for the full deployment + auth guidance.This drives a REAL Telegram user account, not a bot. Whoever holds the session string is the account owner — full read/write access to every DM, group, and channel that account can reach. Treat it like the account's password.
DELETE /api/chats is irreversible; deleting / editing messages and banning users are visible actions others see. Confirm intent before running writes on shared chats.TELETHON_API_HASH, TELETHON_AUTH_KEY, phone numbers, or message contents beyond what the task needs.chat=me).access_hash. Numeric IDs only resolve for entities the session has already seen (via a prior @username / t.me lookup, a dialog list, or an incoming message). Resolve by @username first.The container should already be running and logged in (operator-side — see setup). Set the base URL:
export TELETHON_PLUS_URL=http://localhost:8080
If the server was started with TELETHON_AUTH_KEY, export it too — every request except /healthz and /metrics then needs the bearer header:
export TELETHON_AUTH_KEY=<the-key>
# add to every call below: -H "Authorization: Bearer $TELETHON_AUTH_KEY"
Verify:
curl -s $TELETHON_PLUS_URL/healthz
# {"status": "ok", "authorized": true}
If the session is dead (bad/revoked string, or Telegram unreachable) the container fails to start rather than booting with authorized: false — the process exits and /healthz never comes up. Check docker logs and re-login. /healthz is always public.
For install / first-time login / env vars / ports, see references/setup.md.
{"result": ...} envelope. Lists are JSON arrays, singles are JSON objects. Errors return {"detail": ...} (FastAPI standard).chat, from_chat, to_chat) accept whatever Telethon accepts: @username, phone +1234567890, https://t.me/name, numeric ID 123456789, supergroup/channel ID -1001234567890, or me (your Saved Messages). In {chat} path segments, phone and t.me links must be URL-encoded (+ → %2B); usernames and IDs work inline.400 bad JSON / validation / bad input · 401 missing-or-wrong bearer (only when TELETHON_AUTH_KEY set) · 403 write attempted in read-only mode · 404 unknown endpoint / admin action · 502 Telegram RPC error, body {"telegram_error": "...", "message": "..."}.GET /api/meThe authorized account's own profile.
curl -s $TELETHON_PLUS_URL/api/me | jq
{ "id": 123456789, "type": "User", "username": "someone", "first_name": "Some", "phone": "+..." }
GET /api/entitiesResolve any chat reference to a full profile.
| Param | Type | Default | Notes |
|---|---|---|---|
chat | string | required | @username / phone / t.me link / numeric ID |
curl -s "$TELETHON_PLUS_URL/api/entities?chat=@telegram" | jq
# { "id": 1234567, "type": "Channel", "username": "telegram", "title": "Telegram" }
POST /api/entities/bulkResolve many references at once, honoring the resolve-username bucket.
| Field | Type | Default | Notes |
|---|---|---|---|
chats | list[string] | required | References to resolve |
GET /api/dialogsList the account's dialogs, newest activity first.
| Param | Type | Default | Notes |
|---|---|---|---|
limit | int | 20 | 1–200 |
archived | bool | false | Include archived chats |
search | string | — | Case-insensitive substring on title / username |
curl -s "$TELETHON_PLUS_URL/api/dialogs?limit=10&archived=false" | jq
Each dialog carries id, type, username, first_name/title, unread_count, pinned, and a last_message object.
GET /api/messagesRead recent messages from a chat (newest first).
| Param | Type | Default | Notes |
|---|---|---|---|
chat | string | required | Chat to read from |
limit | int | 20 | 1–200 |
offset_id | int | 0 | Start before this message ID (pagination) |
search | string | — | Full-text filter |
curl -s "$TELETHON_PLUS_URL/api/messages?chat=me&limit=5&search=hello" | jq
[
{ "id": 4242, "date": "2026-04-29T12:00:00+00:00", "chat_id": 12345,
"sender_id": 67890, "text": "hello", "out": false,
"reply_to_msg_id": null, "media": false, "media_type": null }
]
Returns [] if nothing matches.
GET /api/messages/{id}Fetch a single message. chat is a required query param.
curl -s "$TELETHON_PLUS_URL/api/messages/4242?chat=me" | jq
POST /api/messagesSend a message. One endpoint, two flavors — if the body has file_url, that URL is fetched and sent as media (with text as the caption); otherwise text is sent as a plain message.
SSRF note:
file_urlis fetched server-side — the container makes the HTTP request, not the caller. An attacker-controlledfile_urlcan be used to probe internal/private network addresses reachable from the container. Don't pass arbitrary caller-supplied URLs through asfile_url. Restrict it tohttpsscheme and a small set of trusted, publicly-known domains you control or explicitly trust — never buildfile_urlfrom untrusted input (a message someone else sent, a scraped page, an LLM-generated guess). Prefer local file upload over server-side URL fetches: fetch the file yourself (client-side, where you can validate it) and send it as a direct upload instead of handing the container a URL to fetch blind.
| Field | Type | Default | Notes |
|---|---|---|---|
chat | string | required | Target chat |
text | string | required if no file_url | Message text or caption (1–4096 chars) |
file_url | string | null | HTTPS URL to fetch and send as media |
parse_mode | string | null | md / markdown / html / null |
reply_to | int | null | Message ID to reply to |
silent | bool | false | Send without notification |
link_preview | bool | true | Show link previews (text only) |
schedule | string | null | Future ISO datetime to schedule the send |
force_document | bool | false | With file_url: send as generic document |
max_bytes | int | 52428800 | With file_url: reject files larger than this (max 2 GB) |
# Text.
curl -s -X POST $TELETHON_PLUS_URL/api/messages \
-H 'Content-Type: application/json' \
-d '{"chat":"me","text":"**hello** from a container","parse_mode":"md","silent":true}' | jq
# Media from a URL (text becomes the caption).
curl -s -X POST $TELETHON_PLUS_URL/api/messages \
-H 'Content-Type: application/json' \
-d '{"chat":"me","file_url":"https://example.com/photo.jpg","text":"a caption"}' | jq
Returns the created message object.
PATCH /api/messages/{id}Edit a message you sent. ID in the URL, rest in the body.
| Field | Type | Default | Notes |
|---|---|---|---|
chat | string | required | Chat containing the message |
text | string | required | New text (1–4096 chars) |
parse_mode | string | null | md / html / null |
link_preview | bool | true | Show link previews |
curl -s -X PATCH $TELETHON_PLUS_URL/api/messages/4242 \
-H 'Content-Type: application/json' \
-d '{"chat":"me","text":"fixed version","parse_mode":"md"}' | jq
DELETE /api/messagesBulk delete by ID (body carries the list).
| Field | Type | Default | Notes |
|---|---|---|---|
chat | string | required | Chat containing the messages |
message_ids | list[int] | required | IDs to delete (max 100) |
revoke | bool | true | Delete for everyone, not just yourself |
Irreversible, and with
revoke: true(the default) it deletes for everyone in the chat, not just you. Confirm the exactchatandmessage_idswith the user before calling this on a shared chat.
curl -s -X DELETE $TELETHON_PLUS_URL/api/messages \
-H 'Content-Type: application/json' \
-d '{"chat":"me","message_ids":[4242,4243],"revoke":true}' | jq
# { "deleted": 2, "requested": 2 }
POST /api/messages/forward| Field | Type | Default | Notes |
|---|---|---|---|
from_chat | string | required | Source chat |
to_chat | string | required | Destination chat |
message_ids | list[int] | required | IDs to forward (max 100) |
silent | bool | false | Forward without notification |
POST /api/messages/readMark messages as read.
| Field | Type | Default | Notes |
|---|---|---|---|
chat | string | required | Chat to mark read |
max_id | int | 0 | Mark up to this message ID. 0 = all. |
curl -s -X POST $TELETHON_PLUS_URL/api/messages/read \
-H 'Content-Type: application/json' -d '{"chat":"me","max_id":0}' | jq
# { "ok": true }
GET /api/messages/{id}/mediaDownload a message's attachment as raw bytes (binary stream). Content-Type comes from Telegram; Content-Disposition: attachment; filename=... is set. chat is a required query param.
| Param | Type | Default | Notes |
|---|---|---|---|
chat | string | required | Chat containing the message |
max_bytes | int | 52428800 | Reject files larger than this |
curl -s "$TELETHON_PLUS_URL/api/messages/4242/media?chat=me" -o attachment.bin
MCP clients: use the download_media tool instead — it returns base64 in the JSON result.
| Method + path | Body / params | What it does |
|---|---|---|
POST /api/messages/{id}/reactions | chat, emoji, big? | React to a message |
DELETE /api/messages/{id}/reactions | chat | Remove your reaction |
POST /api/messages/{id}/pin | chat, silent?, pm_oneside? | Pin |
POST /api/messages/{id}/unpin | chat | Unpin |
curl -s -X POST $TELETHON_PLUS_URL/api/messages/4242/reactions \
-H 'Content-Type: application/json' -d '{"chat":"me","emoji":"🔥"}' | jq
GET /api/participantsList members of a group or channel.
| Param | Type | Default | Notes |
|---|---|---|---|
chat | string | required | Group or channel |
limit | int | 100 | 1–1000 |
search | string | — | Filter by name |
Large public channels may return a limited set or require admin rights.
| Method + path | Body / params | What it does |
|---|---|---|
POST /api/chats | title, megagroup? (default true) | Create a supergroup (true) or broadcast channel (false) |
DELETE /api/chats | chat | Delete a supergroup/channel you own — irreversible |
POST /api/chats/join | chat | Join a public channel/group |
POST /api/chats/invite | invite | Join a private chat via a t.me/+hash link |
POST /api/chats/leave | chat | Leave a channel/group |
GET /api/chats/{chat}/linked | — | Resolve a channel's linked discussion group |
Destructive.
DELETE /api/chatspermanently deletes the chat for everyone in it and cannot be undone. Require explicit user confirmation naming the exact chat before calling it — prefer leaving it disabled/unused unless the task genuinely needs it.
curl -s -X POST $TELETHON_PLUS_URL/api/chats \
-H 'Content-Type: application/json' -d '{"title":"my-group","megagroup":true}' | jq
# { "id": 1234567890, "type": "Channel", "title": "my-group" }
POST /api/chats/{chat}/admin/{action} where {action} is one of ban, unban, kick, promote, demote. Requires admin rights in the chat.
| Action | Body | Notes |
|---|---|---|
ban | user, until_seconds? | Ban; until_seconds=0 = permanent — visible, disruptive |
unban | user | Lift a ban |
kick | user | Ban + immediate unban (they can rejoin via invite) — visible, disruptive |
promote | user, title? (≤16 chars), plus booleans change_info, post_messages, edit_messages, delete_messages, ban_users, invite_users, pin_messages, add_admins, anonymous, manage_call (all default false) | Grant admin rights — pass the specific booleans you want, omitting all of them grants admin with no permissions — privilege escalation, irreversible without another admin demoting them back |
demote | user | Strip admin rights |
Admin ops act on other people's membership/permissions and are visible to the whole chat. Require explicit user confirmation naming the exact chat and target user before calling
ban,kick, orpromote.
curl -s -X POST "$TELETHON_PLUS_URL/api/chats/-1001234567890/admin/ban" \
-H 'Content-Type: application/json' -d '{"user":"@baduser"}' | jq
| Method + path | Body / params | What it does |
|---|---|---|
POST /api/polls | chat, question, options, multiple_choice?, quiz?, correct_option?, solution? | Create a poll |
POST /api/polls/{id}/vote | chat, options (0-based indices) | Vote |
GET /api/polls/{id}/results | chat | Current vote counts |
| Path | What it returns |
|---|---|
GET /healthz | {"status": "ok", "authorized": <bool>} — always public |
GET /api/throttle/status | Live bucket usage, adaptive multiplier, recent flood events, cache stats |
GET /api/account/health | {"authorized", "risk", "multiplier", "flood_events_1h", ...}; risk is ok / warning / high |
GET /metrics | Prometheus exposition (public even when auth is on) |
Every /api/... response also carries X-Throttle-Multiplier, X-Throttle-Flood-Events-1h, and X-RateLimit-Remaining-<bucket> headers, so clients can self-throttle without polling.
The server ships conservative anti-flood throttling on by default (per-method token buckets, per-chat send/read intervals, global gap + jitter, adaptive backoff on
FLOOD_WAIT, plus a persistent entity cache). If a call seems slow, it may be the throttle protecting the account — checkGET /api/throttle/status. Tuning the buckets is an operator concern (see setup); don't try to defeat them.
TELETHON_POST_TO_URL)Data exfiltration warning: enabling
TELETHON_POST_TO_URLforwards every incoming Telegram event — full message content, sender metadata, and account activity — to that external endpoint, for as long as the container runs. Point it ONLY at a trusted HTTPS endpoint you control. Anyone who controls that endpoint gets a live copy of everything the account receives.
When the operator sets TELETHON_POST_TO_URL on the server, every incoming Telegram event (new message, edit, delete, chat action) is POSTed to that URL as JSON — fire-and-forget, so a slow or failing webhook never blocks Telethon's loop. This is the way to pipe incoming DMs/messages into a separate app or worker that can't hold a WebSocket open.
Event payload:
{
"type": "NewMessage",
"message": {
"id": 4242, "date": "2026-04-29T12:00:00+00:00", "text": "hi",
"out": false, "sender_id": 12345, "chat_id": -1001234567890,
"reply_to_msg_id": null, "media": false, "media_type": null
},
"chat_id": -1001234567890
}
type is NewMessage, MessageEdited, MessageDeleted, or ChatAction — same shape, fewer fields on the non-message ones.
The same events also stream over WebSocket at GET /ws/updates (pass ?token=<TELETHON_AUTH_KEY> when auth is on — browsers can't set the Authorization header). Multiple subscribers each get their own queue; slow consumers drop events at TELETHON_UPDATES_BUFFER_SIZE (default 256). Updates are only registered when TELETHON_UPDATES_ENABLED=true (the default).
An MCP server is mounted at /mcp/ using the streamable HTTP transport, on the same port as the HTTP API, behind the same bearer auth. It's stateless — every request is independent, no session juggling. Every tool in the registry (same names, same schemas as the endpoints above) is exposed automatically.
Point an MCP-aware agent at:
http://your-host:8080/mcp/
Wire into Claude Code:
claude mcp add --transport http telethon-plus $TELETHON_PLUS_URL/mcp/
# with auth:
claude mcp add --transport http telethon-plus $TELETHON_PLUS_URL/mcp/ \
--header "Authorization: Bearer $TELETHON_AUTH_KEY"
The 34 tools, grouped: get_me, get_entity, bulk_resolve, get_dialogs, get_messages, get_message, send_message, send_file, edit_message, delete_messages, forward_messages, mark_read, download_media, set_reaction, remove_reaction, pin_message, unpin_message, get_participants, create_group, delete_chat, join_chat, leave_chat, join_via_invite, get_linked_chat, ban_user, unban_user, kick_user, promote_user, demote_user, create_poll, vote_poll, get_poll_results, throttle_status, account_health.
send_messageandsend_fileare separate MCP tools (thePOST /api/messagesroute dispatches between them by whetherfile_urlis present).download_mediareturns base64 indata_base64(the HTTP/mediaroute streams raw bytes instead).
The transport requires Accept: application/json, text/event-stream. For debugging or non-MCP callers, hit it directly:
# tools/list
curl -s $TELETHON_PLUS_URL/mcp/ \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
# tools/call — send a message
curl -s $TELETHON_PLUS_URL/mcp/ \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{
"jsonrpc":"2.0","id":2,"method":"tools/call",
"params":{
"name":"send_message",
"arguments":{"chat":"me","text":"hi from mcp"}
}
}'
MCP tool errors come back inside the result (not as HTTP status): read-only → {"error":"read_only",...}, Telegram RPC → {"error":"telegram_rpc","type":...}, bad args → {"error":"invalid_argument",...}.
If TELETHON_AUTH_KEY is set on the server, every route except /healthz and /metrics requires Authorization: Bearer <key> (constant-time compared). Missing/wrong → 401 {"detail":"unauthorized"}. The WebSocket takes the key as ?token=. Empty/unset key = wide open — fine on a private network; for untrusted networks pair the key with a reverse proxy doing TLS.
curl -s -H "Authorization: Bearer $TELETHON_AUTH_KEY" $TELETHON_PLUS_URL/api/me | jq
curl -s "$TELETHON_PLUS_URL/api/messages?chat=@somechannel&limit=10" \
| jq -r '.[] | "\(.sender_id): \(.text)"'
curl -s -X POST $TELETHON_PLUS_URL/api/messages \
-H 'Content-Type: application/json' \
-d '{"chat":"@someone","text":"heads up — deploy is done"}' | jq
curl -s -X POST $TELETHON_PLUS_URL/api/messages \
-H 'Content-Type: application/json' -d '{"chat":"me","text":"remember this"}' | jq
# Resolve by @username once (caches the access_hash) ...
curl -s "$TELETHON_PLUS_URL/api/entities?chat=@somebot" | jq .id
# ... then subsequent calls can use the numeric ID.
MARKER="ping-$(date +%s)"
ID=$(curl -s -X POST $TELETHON_PLUS_URL/api/messages \
-H 'Content-Type: application/json' \
-d "{\"chat\":\"me\",\"text\":\"$MARKER\"}" | jq .id)
curl -s "$TELETHON_PLUS_URL/api/messages?chat=me&search=$MARKER" | jq -r '.[].text'
curl -s -X DELETE $TELETHON_PLUS_URL/api/messages \
-H 'Content-Type: application/json' \
-d "{\"chat\":\"me\",\"message_ids\":[$ID]}" | jq
curl -s $TELETHON_PLUS_URL/api/account/health | jq '{risk, multiplier, flood_events_1h}'
# risk "ok" → go; "warning"/"high" → back off, the account is being rate-limited.
For a small send/read wrapper over TELETHON_PLUS_URL, see scripts/telethon.sh.