Telegram connector
Telegram is the second connector, and that is most of why it exists: theRenderer 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
slack.md): 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: anUpdate 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 viagetMeat save time)external_workspace_id= NULL — Telegram has no workspace; the partial unique index from0004(“providers with no workspace concept get exactly one row per app id”) is doing the job it was written for
Lookup happens before verification
Telegram signs nothing, butsetWebhook 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 APIUpdate. 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:
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 istelegram.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.
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:
- 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 istopicEmojiininternal/connector/telegram/react.go). 👍 when no topic is known, including a declined reply. - Failed: 👎
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 numericfrom.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 (
fromabsent — 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 withgetMe (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 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):
rebuildServingininternal/app/wire.gomaps install type → serving construction. - Leaks — each a descriptor field the seam does not yet declare:
internal/agent: secret-kind constants plus theconnectorSecretKindsmapping, theTelegramRoute/TelegramInstallshelpers, 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 iningress_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.
model.Provider collapsed the
model-selection switches.