ADR 0005 — Per-manager Telegram bots and `teamctl bot setup`
- Status: accepted (implemented in v0.6.0)
- Date: 2026-05-02
- Author: Alireza
- Reviewers:
Context
The shipping Telegram adapter (v0.2.0 → v0.5.1) is one global bot per team. To talk to a manager you DM the bot with /dm <project>:<role> <text>. To set it up you hand-write a top-level interfaces: block
in team-compose.yaml, paste a token into .env, find your chat id via @userinfobot (or run the bot once and read the /start echo introduced by v0.2.9), and start team-bot yourself.
Three friction points keep biting:
/dm role textis not “messaging”. It feels like an IRC incantation. Operators forget the role name, mistype the project prefix, and cannot use Telegram’s drafts or reply threads in a meaningful way.- Setup is a documentation walk. The Telegram guide is six steps spread across BotFather,
.env, YAML, and a separateteam-botprocess. New users either skip it or get stuck partway. - One bot is a routing puzzle. With three managers (PM, eng_lead, marketing) you see every approval and every reply on the same thread. The v0.2.7 per-manager scoping (
--manager) helped, but only if you ran multiple bots — and there was no command to set any of them up.
Decision
-
One Telegram bot per manager. Each manager that wants Telegram carries its own
interfaces.telegramblock on the manager definition itself — not in the top-levelinterfaces:array. The presence of the block is the only signal needed; the legacytelegram_inbox: trueandreports_to_user: trueflags are both retired (the latter was already functionally inert —reply_to_usergates onis_manager, not the flag).managers:pm:runtime: claude-codeinterfaces:telegram:bot_token_env: TEAMCTL_TG_PM_TOKENchat_ids_env: TEAMCTL_TG_PM_CHATS -
teamctl bot setupis the wizard. It enumerates every manager in compose, walks BotFather → token →/start→ chat id for each, prompts for env-var names (with sensible defaults), persists the values into.team/.env, and upserts theinterfaces.telegramblock intoprojects/<id>.yaml. The wizard is resumable — already-configured managers skip silently, partials only ask for the missing piece, and YAML-fixed env-var names are reused without re-prompting. A positional[manager]argument scopes to one manager (teamctl bot setup news:head_editor). -
teamctl upspawns oneteam-botper manager-with-interfaces.telegram. Each runs in its own tmux session named<prefix>bot-<project>-<role>with--manager <project>:<role>so approvals and replies route to exactly that bot.teamctl downstops them. -
DMing the bot Just Works.
team-botinterprets any plain text on a manager-scoped bot as a message to that manager (not as an IRC command)./dm,/pending, and inline approval buttons stay as escape hatches. -
The top-level
interfaces:array stays for non-Telegram adapters (Discord, iMessage, CLI, webhook) since those don’t fit the 1:1-with-manager model as cleanly. Telegram migrates off it.
Why not …
-
One bot, smarter routing? Tried (v0.2.0–v0.5.1). Operators read the resulting thread as a firehose; reply context is lost; per-bot approval routing is what the v0.2.7
--managerscoping was already reaching for. Per-bot is the cleaner shape — let Telegram’s chat metaphor do the routing instead of inventing one inside the bot. -
Keep the top-level
interfaces:array and just teach the wizard to write to it? Tried during the spike. The top-level array is one level removed from the manager it serves; you have to re-derive the relationship every time you read the YAML. Puttinginterfaces.telegramdirectly on the manager keeps related fields together (reports_to_user, the per-manager interfaces map) and removes a YAML cross-reference. Theinterfaces:map on the manager (rather than a baretelegram:key) leaves room fordiscord:/imessage:siblings without another schema bump. -
HTTP client dependency for the wizard? Avoided. Setup shells out to
curlforgetMeandgetUpdates.curlships with macOS and is on every Linux distro we support, so we keep teamctl’s dep tree small and don’t pull a TLS stack into the CLI for one interactive code path.
Consequences
- Migration: existing teams with a top-level
interfaces:Telegram entry keep working as long as they keep startingteam-botthemselves — the schema accepts the legacy block (it lives underGlobal.interfacesfor non-Telegram adapters anyway). The wizard ignores it and writes to the newinterfaces.telegramshape. Bothtelegram_inbox: trueandreports_to_user: trueare removed (they were silently ignored on existing YAML during the 0.5.x series; they’re noise to drop now). Presence ofinterfaces.telegramis the new signal. Examples and the dogfood team move to the new shape in 0.6.0. - Cost: one Telegram bot per manager. BotFather is free and unlimited; each bot is one tmux session and one teloxide long-poll. Resource impact is negligible.
- Security: every bot has its own token and its own allow-list. A leaked PM token does not expose eng_lead’s approvals.