Puts the agent behind a Telegram bot. The lightest connector to set up — one token, no app manifest, no scopes.
Verified against fakes only. The transport's logic is covered by the shared channel contract suite, but it has never been run against a live bot. Expect to find rough edges, and please record them in
internal/roadmap.md.
Set up the bot
- Message @BotFather and send
/newbot. - Follow the prompts; it returns a token.
- That token →
TELEGRAM_BOT_TOKEN.
For the bot to see all messages in a group (rather than only commands and mentions), disable
privacy mode via BotFather's /setprivacy.
Run it
export TELEGRAM_BOT_TOKEN=123456:ABC...
bun packages/cli/src/index.ts my-space serve
Naming it instead
TELEGRAM_BOT_TOKEN is the default detection: one bot per process. A token maps to exactly one
bot, so a second bot — a pilot alongside the family one — used to need a second process. Declare
them instead:
connections:
family-telegram:
type: telegram
token: ${FAMILY_TELEGRAM_TOKEN}
pilot-telegram:
type: telegram
token: ${PILOT_TELEGRAM_TOKEN}
A literal token there is a load-time error. With the block present it is the whole truth —
ambient TELEGRAM_BOT_TOKEN adds nothing. See
connections:.
Behavior
A chat is a conversation. The key is telegram:<chat-id>, or
telegram:<chat-id>:<thread-id> in a forum topic — so each forum topic keeps its own session
while a plain group chat shares one.
Replies are chunked at 4096 characters, Telegram's message limit.
Nothing acknowledges receipt unless you ask for it. Set ack: typing on the connection and
the native "typing…" indicator shows from the moment the Bridge admits a message until the reply
lands — which is what a user sees during a turn that spends a minute in an
inbound stage:
connections:
pilot:
type: telegram
token: ${OPENBOOK_PILOT_TOKEN}
ack: typing # or `none`, the default
Telegram expires a chat action after about five seconds, so the connector re-sends it every four
until the turn resolves — a single call would lapse mid-turn. A message the platform refuses is
never acked, because the indicator means "I will answer this", and a failed chat action is logged
while the turn continues. With
bridge.coalesce the
indicator covers the whole burst, since it isn't attached to any one message.
Buttons work natively. An approval prompt renders as inline buttons, and so does any set of
options the agent offers in a ```send-buttons block
(authoring them). A press comes back
as an ordinary inbound message whose text is the button id, in the same conversation. On
channels without buttons the same id can be typed; Telegram just gets the nicer affordance.
Buttons ride the last chunk of a chunked reply.
Telegram can post unprompted. A trigger's or schedule's
deliver: can
name a telegram:<chat-id> conversation and the connector posts into it — chunked, with files
sent as albums, and no progress placeholder, since nobody is waiting on it. A key that isn't a
Telegram key throws rather than posting somewhere plausible.
Addressing forms are not stripped from the text. A mention is detected — isMention is
what a trigger: mention space reads — but @yourbot stays in the text the agent and any
inbound stage see, and Telegram's group form /find@yourbot keeps its suffix. So anything that
matches text exactly — a bridge.reset
or a bridge.inbound
prefix: — does not fire on @yourbot /find or on /find@yourbot in a group. Reliable in DMs
and wherever people type the bare command; tracked in
internal/roadmap.md.
Attachments in
Telegram is the only connector that materializes an inbound attachment: it downloads the file
into okf/scratch/inbound/ and appends the path to the prompt, so the agent can actually open it.
serve wires the workspace's okf/scratch/ directory automatically. A download failure degrades
to the un-materialized message rather than losing the turn, and a transport-supplied filename is
sanitized so it can't escape okf/scratch/inbound/ via path segments.
Attachments out
The agent sends files back by naming them in a fenced block:
Here are the photos of Mom:
```send-files
scratch/out/mom-1.jpg
scratch/out/mom-2.jpg
```
The block never reaches the user. Paths resolve against the workspace root and must stay inside it — anything outside, missing, or beyond the 30-file cap is dropped and logged, so a reply can't be used to exfiltrate a file. Photos go out in albums of up to 10 (Telegram's own media-group limit), so 30 photos arrive as three albums rather than 30 notifications; anything that is not an image follows as a document. One failed album or file doesn't lose the others or the text.
Tell the agent about the block in its charter; nothing prompts it to use one on its own.
One agent per process
serve passes no channel to agent selection, so the default: true agent answers everything
regardless of channels:. Two agents means two processes with different -a values — and two
bot tokens, since a token maps to one bot.