> ## Documentation Index
> Fetch the complete documentation index at: https://docs.mesh.texturehq.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Telegram

# Telegram connector

Telegram is the second connector, and that is most of why it exists: the
`Renderer` contract, the route-keyed registry, and the connector-neutral turn
pipeline were all built with the claim that a second surface would drop in
without copying Slack assumptions into the graph. This connector is that claim
being tested. Where the claim held, this document says so briefly; where a seam
had to be carved (mention detection, the reply effect, the shared dispatch), the
carving is part of this connector's landing and is documented at the seam.

## What the connector is responsible for

* receive Telegram webhook updates
* authenticate and normalize inbound payloads
* map Telegram identities to Mesh actors inside the owning RuntimeAgent's perspective
* map Telegram chats (and forum topics) to perspective-scoped Mesh conversations
* emit normalized inbound events
* send outbound replies back to Telegram

The not-responsible-for list is identical to Slack's
([`slack.md`](/connectors/slack)): no inference, no persona, no memory policy, no
provisioning UX, no peer-topology leaks across perspectives.

## Which agent is this for?

Telegram's payload carries no application identity: an `Update` names the chat
and the sender, never the bot it was delivered to. So the routing key cannot
come from the body the way Slack's `(api_app_id, team_id)` does — it comes from
the URL. Mesh registers each install's webhook at
`POST /telegram/events/{bot_id}`, and `{bot_id}` is the route:

* `agent.Route{ConnectorType: "telegram", AppID: <bot_id>, WorkspaceID: ""}`
* `agent_connectors.external_app_id` = the bot's numeric user id (the prefix of
  the token BotFather issues, confirmed via `getMe` at save time)
* `external_workspace_id` = NULL — Telegram has no workspace; the partial
  unique index from `0004` ("providers with no workspace concept get exactly
  one row per app id") is doing the job it was written for

A bot id is not a secret (any chat member can read it off the bot's profile),
which is the same trust level as Slack's routing pair arriving in an unverified
body. Authentication is separate and per install:

### Lookup happens before verification

Telegram signs nothing, but `setWebhook` accepts a `secret_token` that Telegram
then echoes on every delivery as `X-Telegram-Bot-Api-Secret-Token`. Mesh
GENERATES that secret itself when the connector is saved — the operator never
sees or types it — stores it encrypted beside the bot token, and compares it in
constant time on every request. So the order is Slack's order: read the route
from the URL, look up the install, compare the header against that install's
secret. A route miss and a secret mismatch both answer the same opaque `401`;
an unauthenticated caller cannot learn which bot ids this install serves.

## Normalization model

Same stable contract as Slack — actor identity, conversation key, parent
relationship, direction, body, body format, timestamp — and the same refusal to
classify actors as human/agent/service. `from.is_bot` travels as the
per-message `AuthorIsBot` claim for the engagement gate, exactly like Slack's
`bot_id`, and is never actor ontology.

## Wire contract: Telegram's payload, not ours

Ingress decodes the real Bot API `Update`. Only `message` updates with text are
handled today; everything else (edits, channel posts, callback queries,
media-only messages) is acked and dropped with a reason. The mapping:

| Telegram field | Mesh field | Notes |
| - | - | - |
| `message.chat.id` | `ExternalChannelID` | The only value `sendMessage` accepts as `chat_id`. Kept as the verbatim decimal string. |
| `message.chat.id` (+ `/<message_thread_id>` in forum topics) | `ExternalThreadID` | A Mesh conversation is a thread; Telegram's thread unit is the chat, except in forum supergroups where each topic is its own conversation. |
| `message.message_id` | `ExternalMessageID` | Decimal string, unique per chat — which is enough, because message idempotency is keyed `(conversation_id, external_message_id)`. |
| `message.reply_to_message.message_id` | `ParentExternalID` | Present only when the message is an explicit reply. |
| `message.date` | `ReceivedAt` | Unix seconds, UTC. |
| `message.from.id` | `ExternalActorID` | Connector-scoped participant identity. |
| `message.from.first_name [+ last_name]`, else `@username` | `ActorDisplayName` | Telegram sends names inline on every update, so the connector needs no `users.info` analogue and the runtime's name-resolver seam stays nil. |
| `message.text` | `Body` | Recorded as `plain` — Telegram sends clean text with formatting carried separately in `entities`, which v1 does not consume. |
| `update_id` | *(event claim id)* | Monotonic per bot; the claim triple is `("telegram", <bot_id>, <update_id>)`, route-scoped so two bots' counters never collide. |
| `message.chat.type` | `ChannelTopology` | `private` → direct; `group` and `supergroup` → group (participant count decides the shape — Telegram does not distinguish small from vast); `channel` → channel. |

`ConversationKey` is `telegram:<namespace>:<thread-id>` — same
`<type>:<namespace>:<id>` convention as Slack, built in exactly one place (the
connector's `Normalize`), with the namespace coming from the stored install
(explicit `conversation_namespace` → agent slug, since there is no workspace id
to fall back on). The send path rejects a `chat_id` carrying the `telegram:`
prefix, the same channel-addressing regression guard the Slack sender carries.

`ConversationTitle` carries `chat.title` when Telegram sends one (groups and
channels do; private chats do not). It arrives free on every update, so unlike
Slack there is no reason to leave it empty.

## Sending a reply

Outbound is `telegram.Outbound`, a distinct type from the wire client for the
same reason `slack.Outbound` is: `sendMessage` is Telegram's API, `Send` is
Mesh's contract. The renderer is hardwired, not injected — a connector does not
have a wire format, it is one.

| Direction | `body_format` | What the bytes are |
| - | - | - |
| inbound | `plain` | Telegram's `text`, verbatim. |
| outbound | `markdown` | Mesh's canonical form, as persisted. |

The reply Telegram receives is neither row: canonical Markdown translated to
**Telegram HTML** by `internal/render/telegram` at the send boundary, never
stored. HTML rather than MarkdownV2 deliberately: MarkdownV2 requires escaping
eighteen punctuation characters in prose and shares delimiters with canonical
Markdown while meaning different things by them — the exact dialect trap
`render/slack`'s package doc describes. HTML has three escapable characters,
unambiguous tag pairs, and no meaning collisions with Markdown punctuation.

Replies are addressed to `ChannelExternalID` with
`reply_parameters.message_id = ParentExternalID ?: MessageExternalID` in
group-shaped chats (attribution in a room full of people) and no reply linkage
in private chats (a DM thread is already unambiguous). Forum-topic replies also
carry `message_thread_id`. Message length is capped at 4096 characters;
chunking follows the same block → line → word → rune policy as the mrkdwn
renderer, with `<pre>` scaffolds closed and reopened across parts.

## Lifecycle reactions

`telegram.Reactor` acknowledges admitted turns on the triggering message via
[`setMessageReaction`](https://core.telegram.org/bots/api#setmessagereaction):

* Working: 👀
* Done: the closest whitelisted emoji for what the turn was about — 👾 bug,
  🫡 fix, 🤣 joke, 🎉 celebration, 🤷 could not complete, 🤔 needs your input,
  and so on (`connector.ReactionTopic`; the full table is `topicEmoji` in
  `internal/connector/telegram/react.go`). 👍 when no topic is known, including
  a declined reply.
* Failed: 👎

Bots may only react with Telegram's fixed `ReactionTypeEmoji` whitelist, which
does not include Slack's hourglass, X, or many topic emoji (🐛, 🗺️), so the
lifecycle semantics and topics are shared but each topic is rendered with its
nearest whitelisted emoji. A test pins every topic to the whitelist. If a chat
restricts reactions further and refuses a topic emoji, the reactor falls back
to 👍 once.
Each call sets one reaction, replacing the bot's previous reaction atomically.
The target is `ChannelExternalID` plus `MessageExternalID`, never the reply
parent, forum topic, or Mesh conversation key. The reactor shares the install's
bot credentials and API root with outbound replies.

Reactions are best-effort: chats must permit the chosen emoji, and Telegram may
reject reactions on unsupported messages. The runtime logs API failures without
failing or suppressing the reply. No extra incoming update subscription is needed.

## Self-recognition, not bot classification

Identical contract to Slack, with one twist: Telegram's self-identity and its
mention form are different strings. The numeric `from.id` equal to the bot's
own id is the self-echo test (defense in depth — Telegram does not deliver a
bot its own messages), while a mention is `@<bot_username>` in plain text. The
engagement gate therefore takes mention TOKENS from the connector
(`addressing.Gate.Mentions`) instead of assuming Slack's `<@U…>` markup: Slack
supplies `<@U…>`, Telegram supplies `@username` with a token-boundary check so
`@daedalus_bot` does not match inside `@daedalus_bot_test`. The username comes
from `getMe` at save time and is stored in `agent_connectors.metadata`.

A message from some OTHER bot is persisted as context and gated normally,
never dropped — the perspective principle, unchanged.

One Telegram-specific behavior worth knowing when reasoning about engagement:
bots default to "privacy mode", in which Telegram only delivers group messages
that mention the bot or reply to it. Mesh does not rely on this (an operator
can disable it via BotFather, and DMs are unaffected), but it means a
privacy-mode bot in a group sees a pre-filtered stream — the gate still runs,
it just rarely observes.

## Events we ack and drop

All 200s, because a non-2xx makes Telegram redeliver and "we chose not to act"
is a successful delivery:

* updates that are not a `message` (edits, channel posts, joins, callbacks)
* messages with no text (media-only, until those have normalized semantics)
* messages with no sender (`from` absent — e.g. anonymous channel posts)
* a self-authored echo (the bot's own id — defensive, see above)
* a duplicate `update_id` (durable event claim, same store as Slack's)

## Provisioning

The operator flow is deliberately shorter than Slack's: BotFather issues a
token; the operator pastes it. Mesh validates it with `getMe` (which also
yields the bot id and username — the route and the mention token), stores the
token and a generated webhook secret encrypted per install, reloads the serving
registry, and then registers the webhook itself via `setWebhook`. Registering
the webhook is an external effect performed at save time — the tools.md
`on_enable` shape — because a Telegram connector without a webhook is
configured-but-dead, and Telegram offers no manifest-style flow where the
operator could do it themselves. A `setWebhook` failure does not roll back the
save: the rows are committed and the response says the webhook registration
failed so the operator can retry by re-saving, rather than re-pasting a token
Mesh already holds.

A re-save REUSES the stored webhook secret rather than rotating it. Rotation
would open a brick window: the new secret goes live in Mesh (rows committed,
registry reloaded) before `setWebhook` tells Telegram about it, so a failed or
interrupted registration would leave Telegram delivering with the old secret
against a Mesh that only accepts the new one — every update a silent `401` on
a connector that was working before the re-save. Reuse keeps both sides in
agreement no matter where the flow stops; a fresh secret is minted only when
none exists (first save) or the stored one no longer decrypts.

Both entry points use the same endpoint: the first-boot wizard's connector step
(pick Telegram instead of Slack) and adding Telegram to an existing agent from
its detail page. One agent may hold Slack and Telegram installs simultaneously
— installs were always a list, and the conversation graph keys connectors per
`(perspective, type, name)`, so histories never mix.

## First implementation goal

Get one Telegram message into one RuntimeAgent's perspective, persist it, and
send one reply back out — through the same gate, governor, claims, runtime, and
turn engine Slack uses, with the connector-specific code confined to the
webhook handler, the normalizer, the renderer, and the sender.

Then connect one agent to Slack and Telegram at once and prove the histories
stay distinct while the agent stays one identity.

## Seam ledger: what the second connector had to touch

[`layers.md`](/layers) sets the review test for a seam's second
implementation: it may touch its own package and one catalog line, and every
additional file is a leak to name. Telegram is that second implementation for
conversation connectors, so this is the measured spread — the honest bill,
recorded here per that document's rule, to be closed before a third connector
pays it again:

* **Own packages** (legitimate): `internal/connector/telegram`,
  `internal/render/telegram`, this document.
* **The switch, written once** (a fine catalog at N=2): `rebuildServing` in
  `internal/app/wire.go` maps install type → serving construction.
* **Leaks — each a descriptor field the seam does not yet declare:**
  * `internal/agent`: secret-kind constants plus the `connectorSecretKinds`
    mapping, the `TelegramRoute`/`TelegramInstalls` helpers, and the
    metadata-carried bot username.
  * `internal/app`: a per-connector ingress handler and mux route (shape of
    inbound auth is genuinely per-vendor; the gate/turn dispatch behind it is
    shared in `ingress_dispatch.go` — that half was *generalized* by this
    change, not leaked into).
  * `internal/admin`: a hand-written save handler and route, where a
    descriptor-driven credentials form should render.
  * `web/`: a wizard branch and an agent-detail card per connector.
  * `internal/run`: one effect constant per connector's reply.

The contract half of the seam is now proven from both sides — one outbound
interface, one shared engagement/turn dispatcher, two connectors through them.
The descriptor and catalog halves are the open work: a connector descriptor
(route shape, credential fields with dispositions, mention-token form) would
collapse the agent/admin/web leaks the same way `model.Provider` collapsed the
model-selection switches.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.