Skip to main content
Glama
README.md
# 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](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.

## 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.

```sh
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**:

```text
/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**:

```text
/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

```text
/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](https://core.telegram.org/bots/features#managed-bots): 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`):

```text
/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).

```sh
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:

```text
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.

```json
{"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:

```json
{"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:

```text
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](https://core.telegram.org/bots/api#editmessagetext),
[rich formatting](https://core.telegram.org/bots/api#rich-message-formatting-options),
[callback acknowledgment](https://core.telegram.org/bots/api#callbackquery), and
[pinning](https://core.telegram.org/bots/api#pinchatmessage).
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](https://core.telegram.org/bots/api#setmessagereaction) 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](https://core.telegram.org/bots/api#createforumtopic), [editing](https://core.telegram.org/bots/api#editforumtopic), [deletion](https://core.telegram.org/bots/api#deleteforumtopic), [General management](https://core.telegram.org/bots/api#hidegeneralforumtopic).

## 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:

```sh
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:

```json
{"content":{"file":"reports/result.pdf"}}
```
```json
{"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.