Skip to main content
Glama

telegram-mcpl

Telegram Bot API 10.3 connector for Connectome, using grammY 1.46 and the real @animalabs/mcpl-core stdio transport. Bot topics are distinct channels (telegram:<bot-id>:<chat-id>:<topic-id>). Topic 0 is the unthreaded private main chat or a forum supergroup's General topic.

This milestone handles text, rich incoming metadata, voice with local CPU transcription, photos/albums, original attachment reads, native private-chat rich drafts, one authoritative final rich reply, action keyboards, callbacks and connector restart. Enable Topics for your bot through BotFather to use topics in its private chat. Account-wide history is outside this connector.

Guides

  • Agents and prompt authors: docs/AGENT-GUIDE.md covers how an agent should behave in Telegram with the library feature defaults: messages, buttons and decisions, reactions, files, topics, formatting and common gotchas.

  • Operators: the rest of this README covers configuration, grants and limits. "Changed defaults (upgrading operators)" lists this release's default changes and how to restore the old behaviour.

Related MCP server: telegram-mcp

Default behaviour at a glance

With Telegram feature variables unset, required credentials/allow-lists configured, MCPL negotiated and the host granting the advertised features and opening channels:

  • Admitted people and other bots in authorized groups are delivered, even without mention/reply (TELEGRAM_BOT_ADMISSION=any). Host inference policy decides what wakes the agent; ambient bot input can wake it too. Static user allow-lists still apply; owner enrollment is a separate opt-in. Enable Bot-to-Bot Communication in BotFather for bots that should receive other bots' messages; Telegram privacy/permissions still control delivery.

  • Eligible messages with callback buttons are automatically enrolled as decisions (TELEGRAM_DECISION_RECORDING=all), subject to grants, source/keyboard validation and capacity. Auto-pin attempts to pin open questions silently and unpin the exact automatically pinned message after a confirmed decision (TELEGRAM_DECISION_AUTO_PIN=1). A successful choice update records the choice in the message and replaces the keyboard with Change decision only.

  • Admitted, resolvable reactions reach the agent attached to the next ordinary message in the same chat/topic, not callbacks, and never wake it on their own (TELEGRAM_REACTIONS=on, TELEGRAM_REACTIONS_WAKE=off). Notes expire after five minutes and disappear on restart.

  • Forwarding and retained-keyboard decision copies are available (TELEGRAM_MESSAGE_FORWARDING=1); copying a decided question is refused until it is explicitly restored.

  • Pins, topics, reactions, uploads, forwarding and decisions are advertised. Topic deletion stays a separate opt-in feature. Uploads from files also need TELEGRAM_UPLOAD_ROOT; inline base64 uploads do not.

  • Plain MCP (without MCPL) gets only the six basic messaging/media/catalog tools; ordinary buttons work, decision recording does not.

Run

Requires Node 22+, a BotFather bot, and an explicit chat allow-list. Send the bot /start so Telegram permits replies. Keep the bot token outside recipes/source/control-plane logs.

npm ci
npm run typecheck
npm test
npm run build
install -d -m 700 "$HOME/.config/telegram-mcpl"
# Write the BotFather token into $HOME/.config/telegram-mcpl/bot-token privately.
chmod 600 "$HOME/.config/telegram-mcpl/bot-token"
export TELEGRAM_BOT_TOKEN_FILE="$HOME/.config/telegram-mcpl/bot-token"
export TELEGRAM_ALLOWED_CHAT_IDS=123456789
export TELEGRAM_ALLOWED_USER_IDS=123456789
export TELEGRAM_STATE_FILE="$HOME/.local/state/telegram-mcpl/bot.state.json"
node dist/src/index.js

Stdout carries only MCP/MCPL JSON-RPC. Diagnostics go to stderr; Telegram errors expose status codes without Telegram request URLs or the bot token. The token file must be owned by the service user and inaccessible to group/other users. TELEGRAM_BOT_TOKEN is also supported, but a private token file is preferable.

Only one poller may use a given bot. A webhook must not be configured; this connector does not automatically remove it or discard pending updates. A polling conflict is reported and backed off.

Configuration

Variable

Value

TELEGRAM_BOT_TOKEN_FILE

Private file containing the bot token

TELEGRAM_BOT_TOKEN

Alternative token input

TELEGRAM_ALLOWED_CHAT_IDS

Required comma-separated nonzero numeric chat IDs; no wildcard or empty default

TELEGRAM_ALLOWED_USER_IDS

Optional comma-separated positive numeric actor IDs; when present, missing actors are refused

TELEGRAM_OWNER_USER_ID

Optional positive owner ID enabling private /groups, /topics and /bots commands; requires chat/user allow-lists containing exactly this owner ID

TELEGRAM_ROUTING_HEADER

Exact on (default) or off; incoming text includes short reply coordinates and sender ID/escaped username. off preserves the previous wire payload byte-for-byte

TELEGRAM_NEW_TOPICS

Exact open (default/unset) or closed; controls initial owner approval of newly learned forum topics in enrolled groups. Invalid values abort startup

TELEGRAM_BOT_ADMISSION

any (fresh default) or opt-in listed; controls ambient/addressed bot delivery, not host inference. See migration precedence below

TELEGRAM_BOT_WAKE

Deprecated any|listed admission alias; owner /bots wake commands remain aliases

TELEGRAM_ALLOWED_BOT_IDS

Optional comma-separated positive safe-integer bot IDs, at most 64 IDs / 1087 characters; no duplicates, usernames or owner ID. Unioned with owner-added runtime IDs; list is inactive in any mode; empty denies bots in listed mode; see TELEGRAM_BOT_ADMISSION

TELEGRAM_BOT_MIN_INTERVAL_MS

Minimum interval per bot delivery attempt; default 10000, range 1–600000

TELEGRAM_BOT_HOURLY_CAP

Maximum delivery attempts per bot per one-hour window from its first attempt; default 30, range 1–1000

TELEGRAM_BOT_MAX_REPLY_DEPTH

Maximum consecutive bot-authored ancestors present in the update; default 3, range 1–8

TELEGRAM_STATE_FILE

Durable JSON state path; default ./telegram.state.json

TELEGRAM_DEFAULT_BUTTONS_JSON

Optional callback-only rows, e.g. [[{"text":"Continue","callback_data":"continue"}]], attached to authoritative host publishes

TELEGRAM_TRANSCRIBE_COMMAND_JSON

Optional executable argv array; connector appends the local audio filename; stdout must contain transcript text

TELEGRAM_WHISPER_MODEL

Model name or local model directory read by scripts/transcribe.py

TELEGRAM_TRANSCRIBE_TIMEOUT_MS

Default 120000; range 100–600000

TELEGRAM_PREVIEW_MS

Minimum interval for chunk previews; default 900; range 50–10000

TELEGRAM_DRAFT_STALE_MS

Stop draft previews after this long without new text; default 25000; range 1000–120000

TELEGRAM_DRAFT_LIFETIME_MS

Hard draft lifetime from the first chunk, even while growing; default 120000; range 1000–600000

TELEGRAM_ALBUM_QUIET_MS

Album quiet interval; default 800; range 20–10000; hard coalescing window 5 seconds

TELEGRAM_MAX_MEDIA_BYTES

Max original download, default 20 MiB; up to Telegram's 20 MiB cloud-download limit

TELEGRAM_MEDIA_DIR

Optional canonical absolute path of a private (mode 0700, owned by the connector user) directory for non-image originals. Required for non-image fetch_media; must already exist. Linux only

TELEGRAM_MAX_MEDIA_TOTAL_BYTES

Ceiling for all stored non-image originals in TELEGRAM_MEDIA_DIR, default 1 GiB (min 20 MiB, max 64 GiB). No automatic cleanup; the operator removes files

TELEGRAM_MANAGED_BOTS

1 enables owner-private managed-bot onboarding (off by default; 0 or unset keeps behavior unchanged). Requires TELEGRAM_OWNER_USER_ID and a bot with Bot Management Mode enabled in BotFather

TELEGRAM_MANAGED_BOT_CREDENTIAL_DIR

Required with TELEGRAM_MANAGED_BOTS=1, no default. Canonical absolute path to an existing private directory (real directory, connector user, mode 0700; every ancestor a real directory owned by root or the connector user and not group/other-writable, except root-owned sticky directories such as /tmp; no symlink anywhere in the path) outside the media and state directories and outside every model-visible workspace mount. The connector never creates it

TELEGRAM_MANAGED_BOT_INTENT_TTL_SECONDS

Lifetime of one live creation request, default 1800 (60–86400)

TELEGRAM_MAX_INLINE_BYTES

Max raw photo bytes in one incoming delivery, default 8 MiB; remaining originals stay fetchable

An inherited provider secret is unnecessary for this connector. Use a service wrapper such as env -u ANTHROPIC_AUTH_TOKEN -u ANTHROPIC_API_KEY node ... where the parent host inherits provider credentials. Transcription subprocesses receive only local path/home/locale/model/cache/thread variables, never the parent's provider or Telegram secrets.

Owner-managed conversation groups

Opt in with TELEGRAM_OWNER_USER_ID=123456789 and keep both TELEGRAM_ALLOWED_CHAT_IDS and TELEGRAM_ALLOWED_USER_IDS equal to that one ID. This mode admits the owner's private chat and explicitly enrolled groups; other private chats and groups remain blocked. Without this opt-in, the existing static chat/user allow-lists keep their behavior.

Add the bot to a trusted group. Then send new messages in your private chat with the bot:

/groups candidates
/groups add -1001234567890
/groups list
/groups remove -1001234567890

candidates shows IDs from the most recent 32 bot-membership events observed by this connector, with only group ID/type, a bounded title and membership status. It is not a complete Telegram group inventory; older membership events may be absent. An independently known negative group ID also works. A stranger adding the bot only creates candidate metadata: it never enrolls the group, registers a channel, forwards content to the model or causes an unsolicited owner/group reply. Candidate discovery is available only through the authenticated owner's private command.

add checks getChat for a group/supergroup and getChatMember for this bot's current membership. It sends no group message. All commands require Telegram's actual owner sender ID and the matching private chat ID. Edited/forwarded messages, messages sent via another bot, group messages and callbacks cannot enroll. Names, quoted IDs, forwarded identity claims and model tool arguments grant no authority. Commands and confirmations stay in the transport; no enrollment MCP tool is exposed. The connector does not interpret natural-language enrollment; no trusted model request-context integration is implemented. Use exact /groups controls.

Enrollment is limited to 100 groups and persisted atomically in the existing private version-1 state file; old states without enrollment fields remain compatible. Adding/removing groups takes effect in the running connector and survives reconnect/restart. Every human actor with a real Telegram sender ID in an enrolled group may converse despite the private-chat owner user allow-list; anonymous/channel identities are refused, and other bots use the admission policy below. Incoming messages and callbacks retain Telegram sender/chat/topic metadata and additionally identify the configured owner and conversation enrollment. This does not give participants server-command authority or permission to disclose unrelated private context.

Removal blocks future connector ingress, tools, attachment reads and output, notifies the host that learned group channels were removed, discards queued group updates and invalidates pending operation epochs. Operations recheck authorization after waits; a send already submitted to Telegram or an event already delivered to the host cannot be recalled. Existing agent history remains. Membership candidates may still show a removed group, but the catalog never enables it.

This feature intentionally supports one agent with shared history. Any rule that only the owner or owner-authorized people may request consequential mutations belongs in the agent policy; group enrollment itself is not an OS/tool authorization boundary. A model with unrestricted shell access can still be influenced by shared history. The connector cannot reliably classify arbitrary shell commands as “serious,” enforce model-side permission grants or isolate private memory.

Telegram must deliver ordinary group messages to the bot: disable privacy mode through BotFather or make the bot a group administrator. With privacy enabled and no admin role, Telegram may deliver only addressed commands/replies; enrollment cannot restore messages Telegram never sent. The connector does not change privacy settings, grant administrator rights, remove webhooks or reset the polling queue.

Optional owner approval for new forum topics

Telegram cannot subscribe a bot to selected forum topics. Group Privacy and the bot's administrator status decide what Telegram delivers; this connector can only discard delivered updates, not change Telegram's subscription/privacy settings. Enrollment is not a Telegram subscription.

Set TELEGRAM_NEW_TOPICS=closed to require owner approval for newly learned topics in enrolled forum groups. This uses owner enrollment (TELEGRAM_OWNER_USER_ID); static allow-listed groups and private chats are unaffected. The exact values are open and closed (case-sensitive, no whitespace); unset means open. Existing learned channels keep their current state when the setting changes: enabling it does not retroactively close topics. “New” means first learned by this connector, not creation time in Telegram.

With closed, enrolling a new forum group starts it silent until the owner opens General and each desired topic. General (threadId:0) is gated only when first learned with forum evidence (is_forum or a forum service update); a non-forum group without topics is unaffected. A thread-0 channel already learned without that evidence stays unchanged if forum evidence arrives later. Regular supergroup topics (threadId > 0) supply their own topic evidence. Private topics are unaffected. Telegram topic-created/edited/closed/reopened and General lifecycle service messages still update descriptors and availability, independent of bot-list size or admission mode; they never approve a topic or start model work. Bot edit-wrapped services are rejected.

Send fresh messages only in the owner's private chat with the bot:

/topics list
/topics open telegram:100:-1001234567890:0
/topics open telegram:100:-1001234567890:42
/topics close telegram:100:-1001234567890:42

The model-facing list_topics tool retains all learned allow-listed rows: for explicitly approval-controlled topics it adds approved:true|false and reports effective subscribed (false when unapproved, host-closed, Telegram-unavailable or deleted). Legacy rows without an approval field remain byte-identical, including their raw subscription preference. It does not filter topics or grant approval.

list shows only learned topics in currently enrolled forum groups: canonical channel ID, approved/unapproved, host choice host:open|closed|unset, effective connector open/closed preference (also closed when Telegram-unavailable/deleted), and a sanitized topic title capped at 64 Unicode code points. It does not show message content or constitute a Telegram inventory. The single reply is bounded below 4000 UTF-16 code units; a fixed marker reports omitted topics. General is identified as topic 0. “Open” is not a claim of current host acceptance; host grants/acceptance/open and Telegram availability are still required.

The envelope is the shared /groups envelope: actual configured human owner sender and matching private chat, new message, not bot/edited/forwarded/via-bot/sender-chat/business traffic. Service messages and callbacks cannot execute /topics. Unauthorized copies are silently consumed with the fixed topic command denied receipt, with no reply or information disclosure to group members. Authorized malformed commands and IDs get one fixed private usage refusal without echoing input. IDs must be canonical safe-integer components identifying an existing learned topic of an enrolled group; private, non-topic, unknown, deleted and unenrolled destinations are refused. Commands and confirmations stay in the transport; there is no MCP approval tool or natural-language interpretation. /topics is available whenever owner enrollment is enabled, including with the open default.

open sets only owner approval; close clears only owner approval. Neither changes the host subscription choice or the existing subscribed flag. If the host explicitly closed a topic, owner open records approval but leaves delivery blocked and replies with the fixed text “Topic approved, but the host currently has this topic closed.” Only a later host channels/open can lift that independent host close. With host choice unset/open, approval supplies initiallyOpen:true as a hint; actual delivery still waits for host open. Owner close then open preserves the host choice. Both invalidate pending operations and re-register the channel through existing lifecycle mechanisms. Closing immediately discards queued ordinary topic updates/callbacks, stops typing, and blocks ingress, sends/replies/edits/reactions, topic tools, media reads and previews. Operations recheck the approval and channel generation after waits, including registration and media work; close then open cannot replay discarded work. Commands share the enrollment FIFO queue, so removal and approval cannot race to install a stale approval. Already submitted Telegram writes or events delivered to the host cannot be recalled. A failed topic-command state write leaves the channel denied in memory and the command pending, but disables retries of that update until restart; a persistent failure does not repeatedly notify the host.

Approval is an independent safety gate: host channels/open can still record its desired subscription but cannot approve a topic. The host sees its learned descriptor registered even for a denied first message, with initiallyOpen:false whenever unapproved or host-closed, and true when approved and host choice is open/unset. Legacy channels without approval controls retain their existing subscription hint. Owner changes send channels/changed with removed:[channelId], clear local accepted/opened state, then re-register the updated descriptor; in-flight registrations are generation-checked and reconciled. Registration acceptance is not owner approval. Host channels/close still blocks approved topics; owner open cannot override an explicit host close. The behavior on false hints and re-registration has only been tested against the mock host, not a production host. A real host might treat removed as forgetting the conversation binding/history; that risk is untested and this lifecycle mechanism is unchanged. No host change is needed, and an unapproved topic cannot be made readable/writable by host open/reconnect/re-registration.

The only schema additions are optional boolean channels[id].topicApproved and optional channels[id].hostSubscription (open or closed; omission means unset) in the existing version-1 state file, within existing 500-channel/32 MiB bounds. Missing approval means legacy approval; new closed topics start with topicApproved:false, subscribed:false and host choice unset. Host choice is recorded from real channels/open/channels/close calls for approval-controlled topics or enrolled forum topics in closed-default mode; untouched feature-OFF channels acquire no fields. Old states load without migration: legacy subscribed:false is evidence of a past host close (legacy creation always subscribed true). The first owner decision materializes that closed choice, or an observed current-connection host open, before adding approval, so legacy host closes cannot be lost. Owner decisions never invent a host open or close. New-state omission is never confused with the initial false subscription of an unapproved topic. Explicit decisions persist even if the default setting is later changed to open/unset. Removing a group forgets explicit topic approvals under every default setting: every learned forum topic whose topicApproved is defined is set to false, retaining the discriminator even for topics that were already unapproved. After removal/re-enrollment, previously approved topics require /topics open again even with open or the setting unset. Unapproved topics never become readable through removal/re-enrollment. Genuinely legacy topics with no approval field remain exactly unchanged. Removal changes neither subscribed nor hostSubscription, so an initial default-close can never be mistaken for an explicit host close. Descriptors/history are retained, never used as enrollment authority. No separate registry, eviction or eager schema migration is added; old states load unchanged, invalid approval values and invalid host-choice values fail startup. Both choices survive restart; removal forgets approvals, not host choices.

Held ordinary updates use the distinct fixed receipt status topic not approved, without text, names or extra identifiers. Counts are frequencies within the existing bounded 2000-receipt window, not lifetime totals. The usual durable pending queue can briefly contain raw updates before disposition; this adds no message content to receipts. Unset/open with no explicit topic decisions keeps existing non-/topics message payloads, receipts and golden fixtures unchanged. This byte-identity promise excludes /topics-prefixed message text: owner enrollment intercepts that command surface even with open/unset, including unauthorized copies. Callback data is not a command surface; /topics-looking button data follows ordinary callback admission/delivery and cannot change approval.

Other bots and channel posts (untrusted input)

Other bots are delivered by default, including ambient messages without a mention/reply, in already authorized groups/supergroups and static allow-listed channels. Owner-enrollment mode still supports groups, not channels.

Setup

Enable bot-to-bot communication in BotFather for each bot that should see other bots.

We recommend hosts set proseRouting: disabled when other bots are admitted: incoming messages move the host's default channel, and auto-prose would follow it. A better design on the host and connector side is future work. No recipe changes are made; disabling auto-prose does not suppress inference.

Policy and migration

Setting

Meaning

TELEGRAM_BOT_ADMISSION=any|listed

Fresh default any: no bot list or addressing requirement. listed: only listed bot IDs enter, ambient or addressed; empty means deny bots.

TELEGRAM_ALLOWED_BOT_IDS

Up to 64 positive numeric IDs, env union owner-added IDs. A nonempty legacy env list without an explicit mode selects listed.

TELEGRAM_BOT_WAKE=any|listed

Deprecated admission alias, not mention-only wake. Explicit any overrides implicit restriction from a legacy env list.

TELEGRAM_BOT_MIN_INTERVAL_MS

Default 10000 ms.

TELEGRAM_BOT_HOURLY_CAP

Default 30/hour.

TELEGRAM_BOT_MAX_REPLY_DEPTH

Default 3 consecutive bot ancestors actually present.

Precedence: matching-owner persisted botAdmission.mode > matching-owner legacy botWake.mode > explicit new env admission > explicit legacy env wake > nonempty legacy env bot list implies listed > any. Runtime beats env; new beats legacy at the same source level. Invalid values/IDs fail startup. A persisted mode belonging to another configured owner refuses startup. With owner enrollment off, runtime policy/IDs are retained and validated but ignored.

This deliberately changes existing any/listed users' ambient delivery, not human tagging. The legacy static listed private path remains: listed bots still need a genuine mention/reply/addressed command and static chat/user authorization. Unlisted private bots remain denied; the owner's DM stays human-owner-only.

Owner-private commands

/bots admission any
/bots admission listed
/bots list
/bots add <botId>
/bots remove <botId>

/bots wake any|listed is a deprecated alias with an explanatory confirmation. These commands change delivery, not inference. list shows effective policy and sorted env/owner provenance. IDs are inactive as restrictions in any mode: first add does not mute other bots, and removal is not a mute. Removing the last ID in listed mode still denies bots. Environment IDs cannot be removed or shadowed. Own/owner IDs cannot be added; inherited own IDs remain inert/hidden. The effective union cannot exceed 64 IDs; numeric names/identity are never inferred.

Commands retain the human-owner private envelope/FIFO shared with /groups and /topics: not edited, forwarded, via-bot, sender-chat, service or callback input. Unauthorized copies get no reply. Durable saves precede enables/confirmations; failed enables revert, failed disables remain denied in memory, and failed command IDs retry only after restart. Restrictions purge queued input and invalidate in-flight authorization; remove/re-add cannot revive it. On failed disable, purged queued updates may remain on disk: a restart restores the durable mode, which may be any, and those queued updates can then be delivered. Already submitted events/history cannot be recalled.

Attribution and safety

Verified immediate bots get chat:from-bot, never human/owner authority, and fromBot:true,senderBotId,actorIsOwner:false. Unlisted display authors are numeric bot <id>; raw metadata is untrusted. Every ordinary input still has chat:addressed, including ambient humans. No chat:context-only lane/tag is emitted or advertised. Inference follows the same host policy as humans; without a filter, ambient input can wake.

Anonymous channel posts use their channel identity and telegram:channel-post,telegram:sender-unknown, neither human nor bot authorship. Metadata has senderChatId,actorIsOwner:false; hidden publishing-bot identity is not invented. Listed mode rejects unattributable posts, including anonymous human posts, because no allowed bot ID is proven. Static user restrictions still apply. Direct sender_chat must match that authorized channel. Group sender_chat and linked automatic forwards remain dropped. Human via-bot/forwards remain human; unlisted bot indirect envelopes remain denied; listed legacy forward/via-bot handling stays. Forward origins never grant owner authority.

Known own channel sent IDs are suppressed. Updates racing a send wait at most 10 seconds for ID reconciliation, then drop fail-closed. An unconfirmed channel send persists channelSendUncertain:true, quarantining anonymous ingress across restart; later successful sends cannot identify an earlier uncertain echo. This fence is permanent until an operator reconciles the uncertain send; later successful sends do not clear it. To recover:

  1. Find bot-drop:channel-echo-uncertain in the private state's receipts and the affected channel in channels (key telegram:<botId>:<chatId>:0, with chatId matching the Telegram channel). State lives at TELEGRAM_STATE_FILE, default ./telegram.state.json relative to the connector's working directory.

  2. Stop the connector/its host service, back up that private state file, and inspect the channel in Telegram to reconcile the possibly sent post(s).

  3. Remove only that channel object's channelSendUncertain property (or set it to false), preserve all other state and file permissions, then restart. Do not reset offsets or delete sent bindings/the state file to clear the fence. There is no fence-clear command. Confirmed-ID retention is bounded, not permanent echo history.

The interval/hour guards charge every admitted bot attempt, ambient included, once per album, across chats. Anonymous channel identities use the same knobs and a separate 64-entry anonymous-channel pool with negative-ID keys. Unlisted bots have their own 64-entry pool; neither pool can consume the other's capacity. Listed budgets are independent (at most 64). There is no eviction of live entries: entries expire only after both interval and hourly windows. New identities at capacity fail closed. Explicit membership changes forget that bot's counters; restart resets budgets. These are not aggregate/distributed limits. Telegram does not nest reply_to_message inside reply_to_message. Production loop termination rests on the minimum interval and hourly cap; depth bounds only nested chains actually present.

Self/owner bots, bot edits, unsafe/unsupported envelopes, malformed IDs/entity ranges, and owner-command impersonation remain denied. Every album member is rechecked. Topic lifecycle services are catalog-only independent of list/mode: never model input, inference or topic approval. Bot edit-wrapped services remain rejected. Chat/user/topic approval, unavailable/deleted, host accepted/open and grant fences are unchanged. Bot/channel attempts are durably consumed before asynchronous media/host work, terminal on ambiguous failure; humans keep their retries.

Pending queue (1000), 32 MiB state, deduplication and 2000-receipt retention remain, not new traffic caps. Broad ambient caps remain deferred. Bot floods, restart resets, media cost and untrusted context steering explicit tools remain risks.

Enabled-path statuses are bot-drop:not-allowlisted, bot-drop:no-mention-or-reply (legacy private), bot-drop:rate-limited, bot-drop:hourly-cap, bot-drop:depth-limit, bot-drop:edit, bot-drop:allowlisted-id-not-bot, bot-drop:owner-command, bot-drop:indirect-sender, bot-drop:delivery-failed, bot-drop:malformed, bot-drop:unlisted-capacity, bot-drop:self-echo, bot-drop:channel-echo-uncertain. Counts are retained receipt frequencies, not lifetime counters. Receipts add no content/names. Shared authorization rejection statuses are unchanged.

The messaging manifest hash changes; a host may require a fresh grant. The description and channel ontology are updated truthfully. Existing human payloads are unchanged. Historical stdio probes used Node v22.23.3 with mcpl-core's reader on both ends; this does not exercise the framework's own transport reader. This increment is tested offline with mocks, not live platform qualification.

Owner-only managed-bot onboarding

Opt in with TELEGRAM_MANAGED_BOTS=1 (see Configuration). This is not unattended bot creation and bot creation is not agent deployment: Telegram requires the owner to confirm every new bot, and the output of this feature is a credential file plus a reference, never an installed agent. It uses Telegram's documented Bot Management Mode: the owner enables it once for this bot in BotFather, a creation link https://t.me/newbot/{manager_username}/{suggested_username}?name=… is opened by the owner, and Telegram then sends a managed_bot update whose user is the creator and bot the new bot. getManagedBotToken returns the secret.

Owner-private commands (same envelope as /groups: owner's private chat, owner as sender, not a bot, not edited, not forwarded, no via_bot/sender_chat):

/managedbot new <username ending in bot> [display name]   one live request, expires after the TTL
/managedbot status                                        request, bots, credential references
/managedbot cancel                                        cancel the live request
/managedbot adopt <botId>                                 recover one observed, quarantined bot

Every other copy of /managedbot text (group, other user, forwarded, edited, …) is consumed without effect and never reaches the model. The managed_bot_created service message that Telegram also emits is consumed as metadata too: it is never forwarded to the model and, having no independent creator field, never authorizes a fetch or an adoption. Only the managed_bot update does. With the feature enabled, management commands and managed_bot updates are handled before model delivery, serialized with each other, and recorded as terminal outcomes under the existing durable queue contract.

Automatic binding needs all of: an unknown bot id, creator equal to the configured owner and not a bot, exactly one live request, and a case-insensitive username match. The published payload carries no request id, so none is assumed. The token is fetched only for the bot id of the validated event, after the fetch intent is durably recorded, and is written, through a descriptor obtained by walking the configured path from / one component at a time without following links, with link(2) from a 0600 temporary file to <botId>.token in the credential directory. An existing name is never overwritten. No token, hash, length or absolute path is written to state, logs, receipts, replies or tool output.

A managed_bot event for an already known bot id (duplicate, token change or owner change are indistinguishable in the payload) only increments a counter: no fetch, no overwrite, no new approval. Events from other creators are counted and ignored. Unsolicited events, or events whose username differs from the live request, are recorded as quarantined and nothing is fetched. adopt is the only recovery: it works only for a quarantined record whose original creator was the configured owner, and counts as the owner's new explicit approval of that already observed bot.

Read-only model tool: managed_bot_status (feature set telegram.managed-bots) returns safe metadata and credential references only. There is no model-facing tool that creates, adopts or fetches.

Deliberate limits: no automatic retry, refetch, rotation or revocation; records in fetching, uncertain or failed are kept for manual reconciliation and adopt refuses them; a restart turns an in-flight fetching record into uncertain; at most 50 bot records and no eviction. If getMe().can_manage_bots is not true, or the credential directory fails its checks at startup, the commands answer with a fixed "unavailable" diagnostic and managed_bot is not requested. The token exists as a JavaScript string in memory (not wiped) and any process running as the connector user can read the credential files; keeping the directory out of every model workspace mount is a deployment precondition this code cannot prove. Telegram semantics come from the published Bot API types and documentation and have not been exercised against the real service.

Local voice transcription

The included wrapper requires a Python virtual environment and a faster-whisper model. The tested runtime uses faster-whisper==1.2.1 and av==18.1.0 (PyAV 19 removed a required argument).

python3 -m venv venv
venv/bin/pip install faster-whisper==1.2.1 av==18.1.0
export TELEGRAM_TRANSCRIBE_COMMAND_JSON='["/absolute/path/venv/bin/python","/absolute/path/telegram-mcpl/scripts/transcribe.py"]'
export TELEGRAM_WHISPER_MODEL=/absolute/path/models/faster-whisper-small

The process executes directly with argv, without a shell. Audio is downloaded into a private temporary directory and removed after completion/failure. CPU int8 and beam size 1 keep this local. The small model may misrecognize names; transcription output is an interpretation, and the original voice remains retrievable. An unavailable/failed/timed-out command produces an explicit text notice plus the original attachment identifier rather than silently dropping the voice message. No raw audio block is sent to the model because the current host provider path does not consume audio input.

Host integration

See examples/connectome-recipe.json. Update paths and IDs for your service user. The source stack may link node_modules/@animalabs/mcpl-core to a built current upstream clone; the connector also typechecks with published core 0.3.0. MCPL 0.5 grants are required before channel delivery or writes. A notification cannot establish an initial grant, and all pending operations recheck grant/lifecycle after waits. Plain MCP exposes tools but cannot receive MCPL events and still obeys operator allow-lists.

Channels register with per-item acceptance and initiallyOpen reflecting the legacy subscription preference or the independent approval/host-choice policy above (false for unapproved or host-closed topics). The host opens accepted destinations through channels/open; its durable desired subscription state remains authoritative, but cannot override the independent owner topic-approval gate. Channels closed through channels/close stop accepting messages and block tools, media reads and queued replies. Learned descriptors and subscription preferences persist across connector restart. Topic creation/edit/close/reopen and bot removal/rejoin/unblock update the learned descriptors. Rejoining restores reachable topics while preserving explicitly closed topics. Legacy unavailable state without its cause remains closed until an explicit Telegram topic reopen.

A distinct channel ID preserves reply routing. It does not by itself create an isolated host context. Set the host's top-level conversations binding policy if topics need separate agent conversations. The connector can preserve its state across restart; continuity across a complete host restart depends on host session persistence/binding behavior.

Each incoming message retains Telegram message/sender/chat/topic IDs, reply metadata, captions/entities, original media IDs, update IDs and tags. Albums are ordered by Telegram message ID. Photos use bounded inline base64 image blocks so the host's vision path receives actual pixels. Original files also receive a learned telegram-media://... identifier, printed as model-visible text: call fetch_media with that exact URI. Current stdio resource stubs are not HTTP-fetchable by the host's generic fetch_reference; resources/read and the connector's fetch_media provide authorized original reads.

Replies add one text block before current content. Only untrusted names/excerpts are JSON-quoted; connector-owned attribution and type markers are outside those quotes:

In reply to this bot (message 42): "Original question"
In reply to "alice" (message 42): "Original question"
In reply to "alice" (message 42): type=photo
In reply to unknown author (message 42): type=unavailable

metadata.replyTo has {messageId?, author?, authorId?, excerpt, kind, fromThisBot?}: messageId is the numeric target ID if present; author is the sanitised username/first-name (or sender-chat username/title) if available, never a synthetic label; excerpt is sanitised text/caption or ""; fromThisBot:true is present only for this connector's numeric bot ID as the sender, never for a sender_chat post. Bot names remain in metadata but are not interpolated into the advisory this bot line. Available targets without a usable name retain a valid numeric sender ID as authorId and render unquoted sender <id> (for users or sender chats); this is not a synthetic name in author. Empty usernames fall back to first names or chat titles. Only targets with no usable name or valid sender ID render unknown author, with unavailable targets also withholding author information. Unknown target IDs omit messageId: available targets render (message id unknown), while inaccessible targets without a valid ID keep (message unavailable): type=unavailable. Neither names nor is_bot grant bot attribution.

kind is connector-owned: text for a nonempty text/caption excerpt; otherwise photo, voice, video, document, sticker, animation, audio, video_note, contact, venue, location, poll, dice, game, story, paid_media, rich_message, message (other textless content), or unavailable. Textless kinds render as unquoted type=<kind>, not as synthetic strings in excerpt. Thus literal user text such as [photo] or [reply target unavailable] remains quoted text with kind:text, distinct from a connector type marker. The target is never fetched.

Names and excerpts flatten whitespace (including NEL and C0 information separators), remove control/format characters and lone surrogates, strip leading combining marks/spaces so they cannot attach to the opening quote, and cap at 120 Unicode code points including … only when truncated. JSON quoting escapes embedded quotes/backslashes. The reply-context text line is ADVISORY. Forgery through the reply target's fields (author, excerpt, caption, sender_chat) is prevented. A user can still type text that looks like the line in their own message, so integrators and the model must treat metadata.replyTo (set only by the connector) as the authoritative source and the text line as a convenience that can be mimicked. The fromThisBot field appears only in metadata.replyTo and is set only from the numeric ID comparison with this connector's bot ID, not names, usernames or is_bot. Hosts should render or trust metadata.replyTo rather than parse the advisory text line.

Albums get one context from the first member in ascending message-ID order carrying a non-null, non-topic-service reply_to_message; differing later targets remain only in raw metadata. Topic-service targets are skipped when a later member has a real reply; if all actual targets are topic-service messages, context remains suppressed. null is a defensive mock/legacy marker (Telegram omits absent fields), considered only if no actual target exists; date:0 is the inaccessible-target marker. Both use kind:unavailable. Edits keep their edit header first, then context, then current content. A target matching the connector's forum-topic service predicate adds no context or replyTo; raw reply_to_message is still retained. This service-target suppression is verified on mock payload shapes, not live Telegram. If reply_to_message is absent, delivery stays byte-identical, including no replyTo key. Forwarded/external/quoted-reply variants remain uninterpreted.

Tools and output

  • send_message: {channelId,text,format?,buttons?}; rich Markdown by default, optional HTML.

  • reply_message: same plus an explicit positive messageId, replied to within the same topic. The connector must have learned the target's exact chat/topic binding from ordinary authorized input or a confirmed bot send; unknown, evicted and cross-topic IDs are refused.

  • edit_message: {channelId,messageId,text?,parse_mode?,entities?,rich_message?,link_preview_options?,reply_markup?}; edit only a uniquely tracked message sent by this bot in the exact channel/topic. Select the existing telegram.messaging feature and allow the bare edit_message tool in the Host recipe. It advertises the comms tool class.

  • set_buttons: {channelId,messageId?,buttons}; omit message ID for the last tracked bot message. Empty rows remove its keyboard.

  • fetch_media: {uri}; learned original only, images return inline image blocks. Other files are written to TELEGRAM_MEDIA_DIR as <sha256>.<ext> (extension from sniffed content only, mode 0600, created exclusively and published without overwrite; an existing identical file is verified and reused) and a plain-text receipt returns the path, size, SHA-256, sniffed type, the sender-claimed MIME type reduced to a safe token, and an untrusted-content notice. The connector never opens, parses or executes the file, and the receipt contains no sender-supplied file name. Without TELEGRAM_MEDIA_DIR the call is refused. resources/read is unchanged.

  • list_topics: learned allow-listed descriptors, subscription status and last tracked outgoing message ID.

  • pin_message: {channelId,messageId,silent?}; pin a known message in this exact channel/topic. silent defaults to true and is passed as Telegram disable_notification.

  • unpin_message: {channelId,messageId}; unpin that one known message, not all topic messages. Both pin tools use the separate telegram.pins feature (requiring tools) and the comms class, with the same operator/host authorization as messaging. Unknown, wrong-topic and forwarded-source message IDs are refused before any Bot API call. Groups require creator status or administrator can_pin_messages; restricted members are refused even with can_pin_messages: true. Private chats skip membership checks and defer to Telegram. Telegram 4xx errors pass through (subject to credential redaction); network failures and 5xx errors report an uncertain outcome that may have been applied. Neither is automatically retried. A confirmed write after a policy change returns policyChangedAfterSubmit: true with a notice that the already-submitted write cannot be recalled.

  • add_reaction: {channelId,messageId,emoji}; set or replace this bot's one ordinary emoji reaction on a known message in this exact topic.

  • remove_reaction: {channelId,messageId}; clear only this bot's own reaction on that known message.

The new telegram.pins declaration changes the manifest hash; hosts may require a fresh grant. Select it and allow the bare pin_message/unpin_message tools in the Host recipe; unpin_topic_messages remains on telegram.topics. Messaging remains available without selecting pins.

Outside the decision lifecycle, pin/unpin only run for explicit host tool calls under the existing channel authorization and the pins grant, including topic approval and independent host subscription. Inbound messages never invoke these tools automatically (the decision lifecycle below can use the same authorized pin API core); eligible listed and unlisted bot messages remain untrusted context tagged chat:from-bot. The connector cannot attribute a tool call to its originating message; model or host policy decides whether that context may lead to a tool call.

Pin targets follow add_reaction's exact-topic known/sent-message rule: admitted bot messages, including ambient messages, are delivered and remembered and can be pin, reaction and forward targets. Listed bots are eligible in either admission mode; unlisted bots require any. This is untrusted context, not authorization to invoke a tool. edit_message still requires a tracked message sent by this connector bot. chat:context-only is removed, not reserved. Rejected messages, catalog-only bot topic services, and consumed /bots commands do not create known targets. Pin tools do not change /bots or bot-admission policy.

Buttons in send_message, reply_message and set_buttons use only {text,callback_data}; callback data is limited to 1–64 UTF-8 bytes. Callback actions are untrusted strings, never executable commands. Only an allowed actor clicking a durably tracked bot message with a tracked action can generate an MCPL event. If Telegram returns an inaccessible older message without its topic, the saved chat/message binding supplies the topic; conflicting topic claims are refused. Acknowledgment runs independently of slow ASR and host delivery; a failed/expired acknowledgment does not discard the durable action.

Message editing exposes Telegram's editMessageText through the same messaging/allow-list/lifecycle checks. This wrapper requires exactly one content representation: plain text with optional parse_mode OR entities (offsets/lengths are UTF-16 code units), or rich_message:{markdown:"..."} / {html:"..."} compatible with the connector's rich sends. Text without parse_mode is plain text; editing a rich send does not guess or inherit formatting. Rich blocks/media and embedded rich buttons are outside this increment. Plain text supports 4096 characters; parsed source and rich Markdown/HTML are bounded at 32768 characters, with Telegram enforcing the rendered/formatting limits. Optional is_rtl and skip_entity_detection are passed within rich_message.

reply_markup uses a raw inline_keyboard (up to eight rows of eight buttons), with supported callback/URL/copy/inline/web/login/disabled/style/icon fields; explicit inline_keyboard:[] removes the keyboard. Omitted markup is sent as omitted, and Telegram's confirmed returned keyboard determines the tracked callback actions. Non-callback button actions never become callback inputs. link_preview_options is passed directly. The existing simpler buttons arguments and set_buttons behavior are unchanged.

The edit destination always comes from the persisted authorized channel; raw chat/thread/inline/business IDs are rejected. A known incoming message is not bot ownership: editing requires exactly one retained sent-message binding in that chat and topic. Unknown, evicted, foreign or conflicting targets are refused, including older bot messages beyond the 2000 retained sent records. Queued edits recheck permission/lifecycle generation and ownership before submission. A confirmed write after revocation reports policyChangedAfterSubmit without changing callback tracking for the new policy generation.

Every edit records a bounded uncertain receipt before the one API request and confirmation/error afterward. No edit error is automatically retried, including 429; no fallback message is sent. Ambiguous network/server failures explicitly report that the edit may have applied. Telegram's specific 400 message is not modified returns changed:false,unchanged:true and preserves the last confirmed local callback state; other 400/403 errors remain errors. No raw API error descriptions, URLs or credentials are exposed. A later successful edit with a full confirmed response reconciles callback state after an earlier uncertain outcome. With decision recording off, editing adds no automatic status messages, timers or task workflow.

Private-chat channels/outgoing/chunk produces throttled native sendRichMessageDraft previews, refreshed every 20 seconds until completed, superseded by any successful normal send to the same chat (including other topics), stale for TELEGRAM_DRAFT_STALE_MS, or capped by TELEGRAM_DRAFT_LIFETIME_MS. Ended drafts immediately leave the active 100-stream map and discard their stored text. Recoverable policy/channel lifecycle interruptions allow the same inference to resume after access is restored. A FIFO cache of the 1000 most recently terminally ended keys ignores late chunks (including after completion); older keys can be forgotten as new drafts end. Stale/lifetime expiry only stops refreshing: it does not clear the native draft already visible. Once refreshes stop, a stale fragment is expected to disappear by itself within about 30 seconds (per the Bot API typings the connector uses; not observed live). At the lifetime cap a still-growing reply freezes its preview, but the final reply is unaffected because publish uses the host’s final content. Each draft logs one text-free end summary with the existing channel ID, elapsed milliseconds, attempted native updates (including refreshes), final Unicode character count, and reason; policy/shutdown cancellation and the existing size ceiling use cancelled and size-limit. channels/outgoing/complete stops previews and never sends a final message. channels/publish waits for in-flight previews and sends one persistent sendRichMessage, regardless of completion notification order. Drafts are isolated by channel, conversation and inference. TELEGRAM_DEFAULT_BUTTONS_JSON attaches buttons to that final message. No native Stop button is advertised because host cancellation forwarding is outside this milestone.

Forum-group topics route final rich messages and callbacks, but this milestone streams previews only in private chats. Rich output permits Markdown tables, code and mathematics through Telegram's native parser. The connector refuses rich source above 32768 Unicode characters with an explicit error; it does not silently truncate or split a reply. Telegram's other rich-message limits (500 nested blocks, table/media limits and formatting validity) are enforced by Telegram and surfaced as explicit errors. Incomplete draft formatting may be rejected until later chunks complete it.

Forwarding and shared-decision reminders

Forwarding defaults to TELEGRAM_MESSAGE_FORWARDING=1, advertising forward_message and telegram.forwarding. Set it to 0 to disable both; direct calls are then refused. Compared with the old unset default, this changes the manifest/hash: hosts may need to re-consent. Select telegram.forwarding, grant tools, and allow forward_message (class comms) in the Host recipe. No new MCPL capability name is introduced. Normal telegram.messaging grants, operator allow-lists and exact accepted/open channel/topic checks still apply to both the source and destination.

Forwarding or copying content out of a private chat is not blocked by code, including private-to-group transfers. Privacy restrictions must be supplied as agent instructions. The destination must still pass the normal send access and allow-list checks. A future hard opt-out is not implemented.

{"sourceChannelId":"telegram:100:1000:11","messageId":123,"targetChannelId":"telegram:100:-200:12","mode":"forward","silent":true,"protectContent":false}
  • Required: exact known sourceChannelId and positive numeric Telegram messageId. Optional targetChannelId defaults to the source. No raw chat IDs, usernames, message links or bulk IDs are accepted. Only locally learned/sent message IDs qualify; a forward's remote origin and quoted IDs do not become readable history. Closed, deleted, unapproved, unaccepted or unenrolled channels/topics are refused. Evicted IDs are unknown.

  • mode defaults to forward: one forwardMessage, retaining Telegram's native forward provenance/header. It does not restore callback authority. Only the selected album member is sent, not its siblings. Visible provenance depends on Telegram's privacy/client presentation; no header is fabricated.

  • copy explicitly uses copyMessage with this bot's retained validated callback-only keyboard. It has no forwarded-from header. There is no automatic mode switch, retry, or fallback to a send/copy after a failed forward. silent defaults to true; protectContent defaults to false and protects the output, not permission to copy a protected source.

  • Current protected-chat metadata is checked before either mode. Telegram can still refuse inaccessible/protected/unsupported messages. Errors are safe rejections; timeouts, server failures and invalid confirmations report an uncertain outcome that might already have sent a message. Inspect before another explicit attempt. Exactly one transfer request is attempted.

  • Results include new numeric messageId, target channelId, source channel/ID and mode. Managed copies also identify decisionSource. A confirmed write after policy revocation reports policyChangedAfterSubmit:true: the submitted message cannot be recalled and no new copy callback authority is installed.

Copy history and one shared decision

Copy requires an authoritative retained source and full keyboard, normally captured while TELEGRAM_DECISION_RECORDING=manual or all was enabled. A source-only manual candidate can be copied without enrolling a new decision. Old messages sent without a retained source cannot be restored. Ordinary sent-message records contain callback values, not the original button labels or full keyboard. The connector never guesses labels, adopts callback-update text, or silently sends a buttonless copy. A confirmed explicit content/keyboard edit through enabled recording can establish a supported new source. Other bots' keyboards, mixed URL/action keyboards, uncertain sources and already decided questions are refused; explicitly restore a decided question first.

A managed copy requires the existing telegram.decisions feature with tools and channels.incoming on participating channels. It is a persisted view of the ONE original decision generation, not a second enrollment. There is at most one active reminder per source. Creation does not select an option, change the original's buttons, renew its seven-day expiry or pin the copy. A press on either view updates the canonical choice and attempts to synchronize both views from the pristine source. Each view has its own restore control; copied/old tokens cannot borrow another message's authority. Distinct human presses still deliver distinct untrusted actions; one Telegram update never becomes a synthetic second click on the original. Copy events retain the actual target message/actor and add metadata.telegram.decisionSource. Successful restoration is silent to the agent, as for original decisions.

UI intent and confirmation are tracked separately per view. A failed copy edit cannot overwrite confirmed original UI state. Ambiguous edits are not replayed on restart; a fresh allowed action can reconcile the views. Source generation replacement/detachment, expiry, eviction, independent view edits or source revocation fence old controls. Sent aliases store no ordinary callback authority, so disabling the feature cannot turn them into unmanaged actions. Existing canonical auto-pin policy is unchanged; copies are never auto-pinned. Alias and uncertain-creation counts are in practice bounded by the existing 256-record decision cap, with a defensive 256-entry combined check and the existing 8 MiB decision/admission budget. An uncertain creation is not blindly retried; explicitly replacing the source generation retires that reservation.

Both admission modes (any and listed, including their legacy wake aliases) deliver and remember admitted ambient bot messages, which can become forwarding sources. Rejected messages do not. Callbacks are still allowed-human-only. This tool adds no automatic forwarding or wake.

UNVERIFIED offline: whether Telegram retains callback buttons on a native forward, their callback destination, exact protected-source errors and protected copy behavior. Local Bot API types prove that copyMessage accepts explicit reply_markup; they do not prove forward keyboard retention. Incoming keyboards are retained in structured Telegram metadata but not rendered in text content. Mock tests verify connector routing/state, not live Telegram behavior.

Optional automatic button decisions

Recording now defaults to all: eligible shared button questions enroll unless explicitly opted out. Use TELEGRAM_DECISION_RECORDING=manual for per-message enrollment or off to disable recording (and unset auto-pin). This is a shared decision interface, not a general navigation keyboard or a per-user poll.

Setting

Default

Meaning

TELEGRAM_DECISION_RECORDING

all

off, manual, or all

TELEGRAM_DECISION_AUTO_PIN

1 when recording is on; 0 otherwise

0 or 1; explicit 1 requires recording enabled

With recording explicitly off, the existing tool definitions, manifest, callbacks, and outgoing Telegram requests are unchanged. There is no reserved callback namespace on ordinary messages. Enabling recording changes the manifest and requires host re-consent: select telegram.decisions, with tools and channels.incoming, as well as the existing telegram.messaging grant and channel/topic admission. Legacy MCP without incoming delivery cannot enroll. A setting is not a grant. Auto-pin additionally requires telegram.pins.

Only while recording is enabled, send_message, reply_message and set_buttons advertise an optional boolean record_decision:

  • manual: only record_decision:true enrolls a new question.

  • all: omission enrolls eligible questions; false opts out. Final channels/publish output with default callback buttons is also eligible.

  • Messages without callback buttons are not decisions: implicit all-mode sends and edits create no decision record or enrollment notice. Removing buttons still retires existing management. Explicit true still requires an eligible keyboard. Document captions do not enroll.

  • A send-time record_decision:false is not sticky: in all mode, pass record_decision:false again to set_buttons to stay opted out.

  • An already managed message stays managed when set_buttons omits the flag. false or [] retires management and restores the pristine question text.

  • Explicit true fails before sending/editing if enrollment cannot be safely preflighted (including missing grants). Implicit all-mode enrollment can be skipped; the ordinary send still succeeds, with a separate fixed diagnostic.

  • Old messages sent with recording off cannot be adopted from callback update text. set_buttons needs a retained authoritative source captured while enabled, or a source established by a confirmed explicit content edit.

For example, in manual mode:

{"channelId":"telegram:100:1000:11","text":"Proceed?","record_decision":true,"buttons":[[{"text":"Yes","callback_data":"yes"},{"text":"No","callback_data":"no"}]]}

Enrollment requires a callback-only keyboard with unique callback values. Duplicate labels are allowed, although visually ambiguous. URL/pay/game and other mixed-action keyboards are not implicitly collapsed. Enrolled callbacks must not start with mcpl:decision:v1:. The connector's restore control uses that prefix and a random 128-bit token (39 UTF-8 bytes in total), bound to the exact bot/chat/topic/message/generation. A copied token never grants user authority.

An admitted original click produces one combined editMessageText: source plus the latest record and a single Change decision button. For example:

Proceed?

____
options: [Yes], [No]
chosen: [Yes]

The record contains labels only: no callback values, names, IDs or timestamps. Brackets and backslashes inside labels are backslash-escaped; control, bidi, lone-surrogate and line-separator characters display as \u{hex}. Labels cannot inject another record line. Plain text retains its original UTF-16 entity offsets; native rich Markdown/HTML gets a separately escaped preformatted record. Formatting/preview options are retained. Every rendering starts from stored pristine source, not the last rendered message, so records never accumulate.

Group semantics: every human already authorized by the connector may choose or reopen. The latest admitted concurrent choice wins visually; every admitted original callback is still delivered through the ordinary durable path, byte-unchanged, with its usual actor metadata. A late original click from a stale client can still change the latest choice for the active generation. This is not an anonymous poll and does not grant participants server authority. Bots, impersonators, denied users and owner-command-looking controls retain the existing ingress restrictions.

Change decision restores the original content and full keyboard, removes the record, rotates restore authority and is silent to the agent on success. It does not retract the earlier agent callback. Failures alone produce an error wake. Controls never become agent actions or appear in tracked agent callback values.

Storage, expiry and recovery

Sources and decision state use the existing locked, atomic Store. Limits are 256 records, 256 KiB per record, 8 MiB combined decision state (including callback-admission metadata), and a fixed seven-day lifetime from creation (clicks/reopen do not extend it). Source-only candidates count toward the same limits. Capacity eviction retains bounded hashes of original actions after confirmed choices, so stale clicks can still be delivered without UI authority. These hashes share the tombstone count/byte budget; oldest tombstones can be evicted under pressure, and confirmed agent replacements retire old action authority. The existing 32 MiB total Store ceiling still applies. Expired records go first, then least-recently-used source-only records, then the oldest managed questions. Losing the owning Sent binding or deleting/unenrolling its topic removes restore authority.

There are at most 2000 issuance tombstones, tied to retained Sent bindings, with token hashes/generations only, and 1000 small diagnostic outbox records, included in the byte budget. Enrollment reserves diagnostic capacity. Repeated failures coalesce under pressure; diagnostic pressure never blocks or removes an original callback. Tombstones cannot reconstruct question text or labels.

Eviction/expiry does not send unsolicited cleanup edits. A visible expired control can therefore remain on Telegram. With retained ownership and authority, it receives the fixed acknowledgment Decision restoration unavailable. and a fixed error diagnostic, not a guessed restore. Without ownership there is no adoption or token interception. Losing the state file loses restoration.

On restart, valid sources/tokens reload and current grants are checked again. Submitted operations become uncertain, never blindly replayed. Restarting with recording off does not activate controls or cleanup; re-enabling does not renew lifetime. Optional decision fields remain uninterpreted while off, just as on the baseline; enabling validates them and fails closed on corruption. Inspect Telegram and issue a confirmed explicit agent edit to reconcile an uncertain question. Do not delete a Store ownership lock until its previous process is known to have stopped.

Local rendering failures, API rejection/rate limits, ambiguous network/server outcomes, mismatched returned bindings/keyboards, expiry and pin failure never replace or wrap the original callback. The separate durable status has fixed text Telegram decision update failed. and routing/operation IDs plus an enumerated code only; it never includes source/labels, callback data, API error text or credentials. The original callback precedes its diagnostic. Host delivery of each retries independently; Telegram UI operations are not retried and do not fall back to new messages or separate markup-only edits. A slow UI request cannot hold up ordinary callback delivery. Policy changes cancel queued work; already submitted writes cannot be recalled.

Safe rendering is deliberately conservative: unsupported/unclosed source or an unprovable append boundary fails closed, without truncation. Plain text plus the record must fit 4096 characters; native rich source remains bounded at 32768, not flattened to plain text. Formatted plain edits require a confirmed canonical text/entity snapshot. Embedded rich media remains rich source and is never re-uploaded by an automatic edit. Standalone captions are not enrolled; there is no caption-edit tool.

Agent edits and optional auto-pin

Explicit agent edits win through the same channel queue. A confirmed content edit rebases the pristine source and clears the previous record. While collapsed, omitting reply_markup restores the stored original keyboard in that same content edit; this is an enabled-only exception to ordinary omitted-markup pass-through. A replacement callback keyboard becomes the new baseline. A mixed/ineligible keyboard leaves exactly the agent's requested content/markup and retires management. Deliberately supplied record-looking text is ordinary agent-authored source; no heuristic suffix stripping occurs.

Never-enrolled source-only set_buttons retains the ordinary markup-only edit and updates the saved keyboard (explicit enrollment can start there). Managed set_buttons uses a combined content/markup edit, clearing the old record. record_decision:false or [] restores baseline content and detaches; text-only edits otherwise retain eligible enrollment. Definitive explicit-edit failure retains the last confirmed generation. Exact Telegram not-modified returns unchanged without suspending management, including equivalent rich/plain targets. A confirmed edit after policy revocation is reported applied, with a notice; callback rebinding is skipped and only decision management is suspended. Ambiguous failure suspends automatic writes until a subsequent confirmed explicit edit/set_buttons. Already admitted original actions remain deliverable across agent replacement.

With auto-pin enabled and currently authorized, successfully enrolled persistent open questions are pinned silently (disable_notification:true). Confirmed decisions unpin only that exact message, and only after this lifecycle's automatic pin was confirmed. Reopen re-pins. Group rights are freshly checked using the existing verified bot-identity/creator/administrator can_pin_messages helper; private chats skip the group lookup. No all-message unpin or automatic retry is used. Pin failure never invalidates a send or click.

A successful explicit pin_message, unpin_message, or topic-wide unpin overrides future automatic pin/unpin for the affected lifecycle. External manual pin changes cannot always be observed: this connector does not claim ownership of Telegram's entire pin list. Drafts, typing and reactions do not enroll questions, reset sources or acquire pin ownership; automatic UI edits do not supersede drafts.

Bot API constraints cited here are from docs/knowledge, not verified live: editing, rich formatting, callback acknowledgment, and pinning. No live Telegram validation is part of this feature's mock test gates.

Typing presence

The separate telegram.typing feature uses the channels.typing grant. Select that feature and grant that capability to enable it; older messaging/streaming/media configurations remain valid without typing. No typing tool, ActivityModule, inference/tool lifecycle subscription or Host source change is required. The connector accepts the installed AgentFramework 0.21 packet channels/typing: {channelId, op:"start"|"stop", metadata?}. It routes only from the authorized, accepted, open persisted channel descriptor; metadata never supplies chat/topic routing.

A fresh Host start sends sendChatAction(action:"typing") and renews a 12-second Host lease. Telegram pulses refresh every four seconds while that lease remains valid; Telegram responses/timers never renew it. There is no absolute episode time limit: a long active Host turn can keep the indicator alive through continued Host starts. An explicit Host stop or a real 12-second gap in Host packets ends the episode. Without an inference ID in this wire contract, a delayed stop can also stop a newer turn on the same target; no turn identity is guessed.

Typing is independent per chat/topic and has at most one API request in flight per target, with a three-second abort deadline and no output queue. Even a transport ignoring abort cannot overlap a replacement typing request. Explicit Telegram 429 retry_after gates subsequent pulses, including across stop/start; 400/403 suppress the current episode. Cosmetic failures never delay or retry a final reply. Pending work is cancelled on channel/topic close, membership loss, group unenrollment, grant changes, disconnect or shutdown; typing state is never persisted/replayed. Telegram provides no cancel-typing call: a submitted pulse expires within about five seconds and cannot be recalled.

This is leased presence, not a progress/status message or evidence of model/tool/compression phase, quota state, successful delivery or eventual completion. An unresolved provider/tool wait may retain a live Host timer; a synchronous blocked Host event loop cannot renew the lease. Background work after the Host turn ends is not covered. No Telegram messages, reactions or completion claims are generated by typing itself.

Agent-controlled reactions

The separate telegram.reactions feature requires tools; select it and explicitly allow add_reaction/remove_reaction in the Host recipe to expose these tools. Both advertise the installed framework's comms tool class. Reactions are agent tool calls, never automatic acknowledgments or substitutes for typing. Tools set/change/clear only this bot's own one reaction, not other participants' reactions. This increment supports the installed grammY ReactionTypeEmoji ordinary emoji list, plus common VS16 spellings normalized to its canonical entries (for example ❤️ → ❤). Multiple/custom/paid reactions, custom_emoji_id, is_big and other extra arguments are rejected.

Telegram's setMessageReaction has a chat ID and message ID but no topic ID. The connector therefore requires a learned message-to-channel binding before sending: checking only a channel allow-list would permit cross-topic reactions. Up to 2000 bindings persist in the existing private version-1 snapshot, learned only from authorized ordinary incoming messages and confirmed bot sends; trusted retained sent records also provide a backward-compatible fallback. It learns the actual local message envelope, including a forwarded message's local wrapper, never IDs from quoted/reply/forward-source metadata. Unknown, conflicting, cross-chat/topic, closed or unenrolled targets are refused. Old states without this optional cache remain compatible; older incoming history not observed by this connector generation may be unavailable for reactions, and retention eviction refuses unlearned targets.

Reactions share existing per-channel write ordering and recheck feature, allow-list, lifecycle and epoch after waits/before each request. Grant changes and remove/re-add invalidate queued requests. Explicit Telegram 429 delays retry at most twice, only for positive retry_after up to 30 seconds; ambiguous network/server errors are not retried and report an uncertain outcome. Telegram 400/403 returns a fixed safe explanation that the message/emoji may be unsupported, disallowed or inaccessible; the connector does not guess an exact cause or disclose raw API descriptions/URLs.

Telegram's reaction API can resolve an album member to its first non-deleted member, and some service messages/chat settings reject reactions. The connector reports Telegram's outcome rather than claiming an exact album member or universal emoji availability. This increment adds no premium/custom support, message edits/status messages, Host source changes, other-service integrations or live activation.

Topic API tools

The telegram.topics feature requires tools, channels.register and channels.lifecycle. Allow the desired bare tool names in the Host recipe. Tools expose Telegram operations; the agent decides when to create, name or administer a topic. They impose no task workflow, finish-and-close policy or model identity claims. Every write derives numeric chat_id and topic ID from a persisted, admitted, subscribed channelId; raw chat/thread arguments and opaque routing hints are rejected. Creating a topic never enrolls a group or opens an unrelated private chat.

Tool

Arguments and Telegram behavior

create_topic

{channelId,name,requestId,icon_color?,icon_custom_emoji_id?}. Anchor must be an existing authorized chat channel. Name: 1–128 characters; color: one of Telegram's six colors. Returns full ForumTopic, canonical new channelId, topicId and current registered result.

edit_topic

{channelId,name?,icon_custom_emoji_id?}. Omitted/empty name keeps the name; empty icon ID removes the custom icon. General requires a nonempty name and has no icon option.

close_topic, reopen_topic

{channelId}. Forum supergroups only; General uses its dedicated API. Reopening General also unhides it.

hide_general_topic, unhide_general_topic

{channelId} for supergroup General (0) only. Hide also closes; unhide preserves the closed state.

unpin_topic_messages

{channelId}. Unpin all messages in the exact private/forum topic, with a dedicated General API.

get_topic_icon_stickers

{}. Read Telegram's available forum topic icon stickers.

delete_topic

{channelId}. Deletes the topic and ALL its Telegram messages. Never targets General or private main (0). Connector catalog, receipts and agent history remain.

Private topic create/edit/delete/unpin require the bot's current getMe.has_topics_enabled; allows_users_to_create_topics is a separate user-creation flag. Private close/reopen and General operations are rejected before an API call. Forum operations check fresh getChat forum status and this bot's getChatMember identity/membership/rights: can_manage_topics for creation/General management, with Telegram's creator exception for edit/close/reopen of verified bot-created regular topics; can_delete_messages for delete; can_pin_messages for unpin without a creator exception. Telegram makes the final permission decision.

Deletion additionally requires MCPL negotiation and explicit enabledFeatureSets selection containing telegram.topics.delete, along with its capabilities and Host delete_topic tool permission. Plain MCP, absent/unconstrained feature selection or ordinary telegram.topics does not enable deletion. This is a Host capability gate, not a human confirmation claim or a replacement for agent policy. No artificial confirmation argument is accepted.

requestId is 1–128 letters, digits, underscores or hyphens. A durable create ledger binds it to the exact anchor/name/icon arguments before the non-idempotent API call. Repeating confirmed identical arguments returns the same canonical topic and can retry Host registration without another creation call. Colliding arguments are refused. Pending requests recovered after restart and ambiguous API failures become uncertain and are never blindly retried; inspect Telegram and the local catalog before deciding whether to use a new ID. Explicit API errors are recorded and also retain their ID. The ledger has a lifetime ceiling of 200 requests with no eviction; reaching it denies creation before calling Telegram. Active creates reserve catalog capacity so incoming updates or concurrent chats cannot consume their final slot.

The confirmed canonical topic is persisted before immediate Host registration, so output can route there without waiting for the first user message. A rejected/timed-out Host registration does not undo creation; the result reports registered:false. list_topics exposes the local learned catalog, available chat-type operations and lifecycle flags, not a complete Telegram inventory. Topic service updates in admitted chats update the catalog without starting model work, including updates attributed to bots. Closed Telegram topics remain manageable while subscribed; explicit Host unsubscribe, membership loss, group unenrollment and deleted tombstones block management. Telegram has no topic-deleted service update here, so external deletion is not guaranteed to appear in the catalog until an API reports failure.

Every non-create write records a bounded uncertain receipt before submission and confirmed/error feedback afterward. Topic writes are not automatically retried, including 429: a timeout/lost response may follow an applied write. Queued operations recheck feature, admission and per-channel lifecycle generation after waits; close/reopen cannot revive an old queued publish or mutation. Already-submitted writes cannot be recalled and a confirmed result after a policy change says so. Closure/deletion/membership loss clear typing/streams and invalidate queued reactions/output; deletion keeps a tombstone against late service/message resurrection. These tools add no live activation, framework/history changes or message/status probes.

API semantics: forum topic creation, editing, deletion, General management.

Durability and limits

The entire received batch is atomically fsynced before another getUpdates call acknowledges its offset. The queue deduplicates update IDs, retains incomplete albums and retains delivery retries across restart. For human input, host rejections and transient media/network failures retry indefinitely with a delay capped at 30 seconds. Admitted bot and anonymous-channel delivery attempts are terminal and not retried, as described above. The original updates, retry count, next retry time and last failure reason remain durable across restart until the host accepts delivery; prolonged outages pause polling at the bounded queue ceiling. Withheld/revoked grants pause this queue without consuming a retry. Delivery preserves order within each topic through backoff and album coalescing, while independent topics can progress; callback acknowledgments remain immediate. Permanent media failures (size/format/path validation, unavailable file or permanent HTTP rejection) produce a structured message with the original reference and an explicit text/metadata error, so an unreadable attachment does not erase the user message. There is no exactly-once claim across a host/Telegram timeout: a remote recipient may have accepted a request before its response was lost. Outgoing sends record an uncertain outcome before calling Telegram and do not retry ambiguous failures; explicit Telegram 429 responses retry at most twice, up to 30 seconds per wait, with authorization rechecked.

Incoming edited_message and edited_channel_post use the same authorization and exact chat/topic route as new input, with an explicit model-visible Telegram message edit prefix and telegram:message-edited tag. metadata.telegram.edit records the update kind, stable target:{channelId,messageId} (the numeric Telegram message ID), timestamp and timestampSource. Each edit keeps its own update:<update_id>:message:<message_id> delivery ID; repeated update delivery is deduplicated, while later edits to the same target remain distinct. The timestamp uses a valid positive Telegram edit_date, otherwise the persisted receipt time with an explicit fallback label; raw metadata retains the original send date. Edits of album members are separate correction events, never merged into a new album or another edit. Only current content is available: no previous text or diff is invented, no original-history lookup is required, and Host history is not rewritten. The connector does not suppress inference or decide whether the agent should reply. Bot-authored edits/own echoes and unsupported channel sender contexts remain filtered; eligible new bot messages, ambient included, follow the admission rules above (default any, opt-in listed restriction).

This is a bounded JSON snapshot store: 1000 pending updates, 500 learned channels, 200 lifetime topic creation requests, 100 enrolled groups, 32 membership candidates, 2000 known message bindings, 2000 media references, 2000 sent-message/button records, 2000 recent delivery receipts, and a 32 MiB snapshot ceiling. Topic catalog/creation fields are optional version-1 additions; old snapshots remain readable. Polling pauses before the pending queue is full. Ceilings/snapshot corruption stop operation rather than quietly reset state. Media/sent/receipt retention evicts oldest records; old attachment reads/button clicks/reply targets beyond that retained set are refused. Use SQLite if larger throughput or retention becomes necessary.

Albums coalesce after quiet time or five seconds from their first item. Items arriving after an album was delivered form a later delivery rather than rewriting the original event. If the grant is withheld, incoming updates stay durable; unauthorized/business/ephemeral/guest/channel-direct-message contexts are rejected with explicit receipts and do not start model work. The connector polls only ordinary supported update kinds.

Ownership uses an exclusive <state-file>.lock directory. API requests have a 35-second timeout (above the 25-second long poll). Graceful shutdown cancels polling, active API/download requests and the ASR subprocess, waits for in-flight work, persists pending updates and releases the lock. Unfinished valid input remains queued for the next process. A crashed process can leave its lock behind: verify the PID in lock/pid is no longer alive, inspect the state, then remove that stale lock before restart. The connector deliberately refuses automatic takeover. Set the service shutdown deadline above the 35-second API ceiling with cleanup grace (e.g. 180 seconds). ASR shutdown cancels and reaps the child rather than waiting for its configured transcription timeout.

Owner-approved protocol boundary: plain MCP is basic messaging only

Plain MCP (without MCPL negotiation) exposes exactly send_message, reply_message, edit_message, set_buttons (ordinary buttons), fetch_media and read-only list_topics. Decision recording, auto-pin and restore are silently disabled regardless of env defaults; explicit record_decision arguments are refused. Incoming reactions are not subscribed to or delivered. Persisted learned topics do not bypass this boundary.

MCPL negotiation and the corresponding feature/capability grant are required for pins, topic management, reactions, forwarding, decisions and uploads. tools/list uses the same feature/grant fence as tool calls, recomputes it on every request (including after narrowing/widening featureSets/update), and hides decision-specific schemas when decisions are not granted. Channel-specific authorization and execution-time revocation checks still apply. The connector does not currently advertise/support tools/list_changed notifications; hosts must refresh tools/list after changing grants.

Changed defaults (upgrading operators)

Plain-MCP compatibility change (owner-approved): before, the configured catalog included the six basic tools below plus protected operations (uploads were already hidden). Now only the six basic tools remain: send_message, reply_message, edit_message, set_buttons, fetch_media, list_topics.

Removed from plain MCP: pin_message, unpin_message, add_reaction, remove_reaction, create_topic, edit_topic, close_topic, reopen_topic, delete_topic, hide_general_topic, unhide_general_topic, unpin_topic_messages, get_topic_icon_stickers, and (when forwarding was configured) forward_message. The optional managed_bot_status tool is also MCPL-only. Upload send_document stays MCPL-only. Ordinary buttons remain available, but decision schemas and automatic decision/restore/pin effects are removed from plain MCP.

Unset environment variables now enable the following library defaults. Explicit values still override them, including off and 0.

Setting

Old → new default

Restore old behavior

TELEGRAM_DECISION_RECORDING

off → all

off

TELEGRAM_DECISION_AUTO_PIN

0 → 1 when recording is on

0

TELEGRAM_REACTIONS

off → on

off

TELEGRAM_REACTIONS_WAKE

on → off

on

TELEGRAM_MESSAGE_FORWARDING

0 → 1

0

TELEGRAM_REACTIONS_FROM=all is unchanged; TELEGRAM_DEFAULT_BUTTONS_JSON and TELEGRAM_UPLOAD_ROOT remain unset. Inline base64 uploads need no root; path uploads still require one. Recording off with auto-pin unset starts with auto-pin disabled; explicit recording off plus auto-pin 1 remains invalid.

Pins, topics, reactions, uploads, forwarding and decisions are declared by default. Uploads no longer require a separate explicit selection list when MCPL grants the requisite capabilities. Host selection/exclusion and capability checks remain authoritative; telegram.topics.delete still requires explicit selection. No declaration schema changed, but the unset-config manifest hash does change because conditional features are now enabled: hosts may need to re-consent. An explicitly configured identical feature profile keeps its hash.

All-mode recording ignores keyboard-less messages (including replies and document captions) and keyboards without callback buttons; these do not emit enrollment failure notices. Existing source-only records need no schema migration.

Default recording uses the existing DECISION_LIMITS: 256 records, 8 MiB combined decision-state budget, 256 KiB per record, seven-day lifetime, 1,000 outbox entries and 2,000 tombstones. Default reactions share the existing 1,000-entry durable pending queue; volatile notes are bounded to eight per destination, 64 destinations, 128 total and five minutes, and disappear on restart. The existing overall state-file ceiling is 32 MiB.

Checks

npm run typecheck, npm test, npm run test:listed, npm run build. Both Node test passes are required: npm test exercises the shipped default any; npm run test:listed exercises legacy listed mode. TELEGRAM_TEST_WAKE_MODE is a mock-test-only switch, not a product setting. Explicitly parameterized cases run both modes in each pass. Bun 1.4.3 commands:

bun --no-env-file run typecheck
bun --no-env-file test --timeout 30000 ./test/protocol.test.ts ./test/typing.test.ts
bun --no-env-file run build
bun --no-env-file test --timeout 30000 ./dist/test/protocol.test.js ./dist/test/typing.test.js

The timeout accommodates the existing 20-second draft-refresh test. Tests run the real server and real MCPL connections over in-memory streams against grammY API interception and fake downloads. They verify authorization, media/routing/drafts/callbacks/restarts, typing/reactions, topic API payloads/rights/deletion grants, durable create deduplication/ambiguity, registration and catalog races, lifecycle cancellation and safe uncertain receipts. Live Telegram and real inference require deployment validation.

Incoming message reactions (default on, no standalone wake)

Setting

Values / default

Effect

TELEGRAM_REACTIONS

on (default), off

Subscribe to message_reaction and message_reaction_count only when on. Off preserves the existing poll payload, tools, manifest and ordinary state/wire behavior.

TELEGRAM_REACTIONS_FROM

all (default), owner

All admitted actors, or only the configured human TELEGRAM_OWNER_USER_ID. Owner mode requires that owner setting.

TELEGRAM_REACTIONS_WAKE

off (default), on

On submits merged incoming reaction events; off appends bounded notes to the next real incoming message in the same chat and topic.

Values are exact, case-sensitive and validated even when off. Enabling widens the existing telegram.reactions feature from outbound tools to inbound events plus tools, changing its manifest hash only when enabled. Select/re-consent to that feature and grant tools and channels.incoming, alongside normal telegram.messaging/channel grants. Tools themselves are unchanged. Wake-on events use telegram:reaction and chat:addressed; configured inference filters still decide whether to run. Wake-off never submits a standalone event or retargets a destination on its own. Notes expire after five minutes, disappear on restart, and are not attached to callbacks or another topic. A real incoming message retains its ordinary wake behavior.

Private message_reaction add/remove delivery without administrator rights was observed on a test bot (operator-supplied observation, 2026-11-03). Groups/channels were not tested live here; Telegram's administrator/delivery rules still apply, and anonymous reactions can arrive as delayed count snapshots. Enabling does not change Telegram privileges, privacy settings or webhooks. Count snapshots are labelled anonymous, never invented actors or per-user deltas.

Events retain typed user (including bot), chat or anonymous actors, added/removed typed reactions, target/update IDs and fromThisBot. Custom emoji display by ID; paid reactions display as paid. Names/excerpts are bounded and untrusted. Known sent messages can show a 60-code-point excerpt; other targets show their numeric ID. New confirmed sends/text edits capture bounded excerpts only while enabled; markup-only edits retain them. Older sends and some copies/transport confirmations may have no excerpt or ownership record. Ordinary metadata contains merged events at metadata.telegram.reactions; wake-off attachments use metadata.telegramReactions. Mixed-actor batches do not claim owner identity.

Allow-lists, group enrollment, topic approval, availability, channel acceptance/open state and grants remain mandatory and are rechecked after waits. Missing reaction grants terminally drop reactions rather than filling the durable queue and blocking ordinary polling. Bot actors follow TELEGRAM_BOT_ADMISSION/the live bot list and existing bot rate rules, with separate reaction budgets so ordinary messages retain their allowance. Private bot reactions are denied because they provide no mention/reply envelope; group/channel bots require listed admission or any. Own-bot/owner-ID bot impersonations are denied. Chat actors and anonymous counts cannot satisfy a static user allow-list or owner-only mode; in enrolled groups they remain separately typed, never human/owner identities.

Routing uses retained exact sent/known-message bindings. Unknown or evicted targets in forum/topic chats are logged and dropped, never guessed as General. Plain-chat fallback is allowed only for non-forum chats; an unknown supergroup requires explicit is_forum:false evidence. Copies refer to the reacted destination, not an original message/decision. Reactions never execute owner commands, approve decisions, click/collapse buttons, acknowledge callbacks, send confirmations, or refresh/cancel stale drafts. A later ordinary reply may still publish with configured auto-buttons.

Flood control uses fixed ten-second windows: five events per typed actor, twenty per chat, one hundred globally. Anonymous snapshots share one actor budget per chat. Events merge for 250 ms from the first note (no indefinitely extended debounce). Bounds: eight notes per destination, 64 destinations, 128 notes total; overflow is dropped with fixed receipt statuses, not replayed. Rate counters expire together and cannot be flood-evicted. At most one merged reaction request runs after ordinary messages per drain. Buffered bot notes are discarded on bot-list removal; channel close/removal and grant changes discard affected notes. Five-minute expiry also bounds stalled wake-on batches.

The durable polling queue is capped at 1,000 pending updates (MAX_PENDING), shared with ordinary input, and is saved before offset acknowledgment. Wake-on raw updates remain pending until submission/expiry; once submitted they are terminal even on rejection or ambiguous failure, preventing storm replay but allowing loss. Wake-off notes are intentionally volatile; already-durable raw updates not yet processed can still be processed after restart. No reaction side effect is sent to Telegram. Existing finite message bindings (2,000 sent and 2,000 known) mean old targets can become unresolvable. No account history lookup or new context-only protocol lane is introduced.

Incoming reaction batches retain the same bot-ID/admission generations used by ordinary messages. Removal/re-addition, unlisted admission disable/re-enable, and a discarded batch cannot revive held input after registration or in wake-off notes. Channel/topic approval generations and the global grant/enrollment epoch are retained from intake as well as checked after waits. Revoking unlisted admission purges its raw reactions and buffered destinations before a later enable. A mixed batch containing a revoked actor is discarded conservatively as a whole. Static owner/filter/chat/user allow-lists have no live generation switch; changing them requires restart, which drops volatile notes.

Incoming reply coordinates

With TELEGRAM_ROUTING_HEADER=on (default), every incoming message starts with [channelId=telegram:<bot>:<chat>:<topic> topic=<topic or 0> id=<numeric message_id>] [Telegram source user=<id or unknown> username=@<escaped username> chat=<type>]. Albums list their numeric IDs comma-separated. Callbacks use buttonMessageId= for the message containing the button, not the synthetic callback delivery ID; the existing action text and its untrusted-input warning follow unchanged. Media-only messages also carry the header. Missing usernames are omitted; anonymous channel posts say user=unknown. Usernames are bounded and percent-escaped.

The host may prepend the author name (Name: [channelId=…] on the first line). An existing author prefix is recognized with the framework 6c5c31af grammar: exact raw or framework-shown name, then :, then one space, LF, or end of text. It is normalized to the framework-shown, one-line Name: before the header (including bracketed names); only that separator is consumed, not body whitespace. Names use the same line flattening and notice-bracket escaping as that framework. An exact routing header for the same envelope is not added twice. Headers are reply aids, not authorization: message text can imitate them. Structured metadata and admission policy remain the source of truth; do not infer authority from a display name, username or header.

With both features enabled, each delivered reaction (wake-on merged inputs or wake-off notes) carries the same routing header with the reacted-to message ID, plus a typed actor=user:<id>, actor=chat:<id> or actor=anonymous field. Non-user actors retain user=unknown; mixed batches label each event separately. Turning the routing header off also leaves reaction text unheaded.

Outbound documents

send_document takes {channelId, content, replyTo?, caption?, format?} and returns {messageId,channelId}. channelId must be an exact learned, authorized, open channel/topic, as for send_message; replyTo must be a known message ID in that same topic. Sent documents are tracked for subsequent reply/edit/pin/forward operations (Telegram still decides which edits a document supports).

Choose exactly one content object:

{"content":{"file":"reports/result.pdf"}}
{"content":{"data":"aGVsbG8=","name":"result.txt"}}

Inline data must be canonical padded base64, not a URL or data URI. Empty files are refused. TELEGRAM_UPLOAD_ROOT optionally enables relative path uploads; without it only inline data works. TELEGRAM_MAX_UPLOAD_BYTES defaults to 20971520 (20 MiB), accepts 1–50000000 bytes, and cannot exceed Telegram's 50 MB Bot API limit. Both sources are capped before upload, including growing files. Documents are buffered in memory; hosts should bound concurrent calls.

Path uploads are Linux-only (/proc/self/fd required), fail closed on other platforms, and refuse absolute paths, backslashes, empty/dot/traversal components, nonregular files and all symlinks below the configured root, even internal ones. Hardlinks inside the upload root are followed; the root must be a trusted export directory. The configured root itself is canonicalized with realpath. Each path component is opened relative to a pinned parent directory descriptor with O_NOFOLLOW; directories use O_DIRECTORY, and the final descriptor's realpath must remain below the root. Reads use that same descriptor, never reopen the checked pathname, and stop at cap+1 bytes. Replacing an ancestor or final path with a symlink between check and read cannot redirect the descriptor. A local writer may still change the contents of an already-open regular file; use a trusted, dedicated export directory. Multipart filenames are bounded ASCII basenames with header metacharacters removed; the transport uses application/octet-stream rather than trusting caller MIME values.

Captions are plain text by default, capped conservatively at 1024 source characters. They are not send_message rich messages: no rich blocks or GitHub-flavored Markdown conversion. Explicit format:"html" selects Telegram HTML; format:"markdown" selects MarkdownV2 sent as written, so the caller must escape it: *bold* _italic_; escape . - ! ( ) etc. with \. GitHub **bold** is invalid. A caption parse-error 400 is a definite non-delivery, not an uncertain upload; omit format for plain text or use valid HTML. No Markdown converter is applied. No upload is automatically retried, including formatting failures, rate limits, network errors or 5xx; ambiguous network/5xx outcomes warn to inspect before another explicit send. The existing grammY HTTP client, timeout, send queue, policy rechecks, draft supersession and channel-send uncertainty fence are used.

The telegram.uploads feature declaration changes the manifest hash: hosts must re-consent to the changed manifest. Like telegram.pins, the tool is declared by default but requires its own selected/granted feature plus telegram.messaging and tools. Uploads require MCPL negotiation and a telegram.uploads grant (derived from capabilities when the host omits a selection list); plain MCP refuses calls and does not list the tool. It is not enabled in the example Host recipe. No inbound update automatically invokes it. Hosts that list send tools by name (for silencing or turn accounting, e.g. SILENCING_TOOLS) should add send_document alongside send_message; those registries live in the host, not this repository.

send_photo is intentionally omitted: image dimension/aspect/format validation and Telegram's photo-specific limits are not shared document plumbing. Send an image as a document when original bytes are desired.

Related MCP Connectors

Related MCP Servers

  • F
    license
    Not graded
    quality
    C
    maintenance
    Enables LLM clients to send messages, read recent messages, and retrieve chat information through the Telegram Bot API.
    -
  • A
    license
    Not graded
    quality
    A
    maintenance
    Enables AI agents to operate a Telegram account through the same commands as the terminal client: messaging, chat management, voice transcription, calls, stickers, and privacy settings.
    1
    MIT