# Telegram host — design Date: 2026-09-25. Status: approved in chat. ## Goal Chat with the agent through a Telegram bot. A new host project in this repo, run with `dotnet run`, long polling (no public URL). Implements the contract described in `docs/ui-integration.md`. ## Decisions | Topic | Decision | |---|---| | Location | `hosts/Volcanic.AgenticCore.Telegram` (console app, Generic Host) + `tests/Volcanic.AgenticCore.Telegram.Tests` | | Library | `Telegram.Bot` 22.x | | Polling | The library's built-in loop (`ReceiveAsync` with an `IUpdateHandler`), hardened for connection loss (below) | | Provider | `ClaudeCodeProviderExtension` only | | Model | `claude-code/claude-opus-5-5`, a pinned entry added to `ClaudeCodeModels` (the CLI accepts full ids in `--model`) | | Tools | All built-in tools; each chat works in its own directory `/` | | Access | Mandatory user-id allow-list; empty list = refuse to start; other users are ignored silently | | Chats | Private chats only | | Impersonation | Optional admins `AdminUserIds` (`ADMIN_USER_IDS`, comma separated; the older `AdminUserId`/`ADMIN_USER_ID` is merged in), each may `/impersonate ` independently (one admin per target, an impersonating admin can't be a target) (`0` stops, in memory). The admin's updates are then routed as the target's (allow-list, chat, workspace, session, settings, jobs) and the target chat's output (replies, questions, files, scheduled deliveries) goes to the admin's chat. The target's own updates are dropped with a "an admin is working with your account" notice | Out of scope: webhooks, Docker/Coolify (see `2026-09-26-docker-deployment-design.md`), voice and documents, group chats, several bot tokens, a custom polling loop. ## Configuration `appsettings.json` section `Telegram`, overridable by environment variables (`Telegram__...`): - `BotToken` (also read from `TELEGRAM_BOT_TOKEN`), required. - `AllowedUserIds`, required, non-empty (`Telegram__AllowedUserIds__0=123`, or `TELEGRAM_ALLOWED_USER_IDS=1,2`). - `WorkspaceRoot`, default `~/.volcanic/telegram-workspaces`. - `Model`, default `claude-code/claude-opus-5-5`. - `AdminUserIds` (also `ADMIN_USER_IDS`, comma separated), optional, positive: the users who may `/impersonate`, all with the same rights. The older `AdminUserId` (`ADMIN_USER_ID`, which also takes a list) is merged in; non-numeric entries fail validation. At start the host logs whether the `claude` CLI was found; a missing CLI is a warning (the doctor command explains). ## Polling and connection recovery Findings from the Telegram.Bot 22.x source (`TelegramBotClientExtensions.Polling.cs`): 1. After a `getUpdates` failure the loop calls `HandleErrorAsync(PollingError)` and then polls again **immediately**: without a delay it spins while the network is down. 2. If `HandleErrorAsync` throws anything but `OperationCanceledException`, the exception leaves `ReceiveAsync` and polling is over. 3. The long-poll timeout equals `HttpClient.Timeout`, so an idle poll occasionally times out client side ("Bot API Request timed out"). Harmless. 4. The offset advances before an update's handler runs and handlers run one at a time. 5. `429` with `retry_after <= 60` is retried inside the client. Design (`PollingService : BackgroundService`, `BotUpdateHandler : IUpdateHandler`, `ReconnectPolicy`): - `HandleErrorAsync(PollingError)` never throws. It asks `ReconnectPolicy` for the next delay and awaits it, which is what holds the built-in loop back: 1 s, 2 s, 4 s … capped at 60 s, ±20 % jitter. - `401 Unauthorized` (token revoked or wrong) is fatal: log and stop the application. - `409 Conflict` (another instance polling) is logged as such and backed off like any other error. - The first failure is logged at debug level (covers the idle timeout race), later ones as warnings with "Telegram unreachable (attempt n), retrying in x s". - `OnApiResponseReceived` for a successful `getUpdates` resets the policy and, if it was failing, logs "Telegram connection restored after n failed attempts". - `PollingService` wraps `ReceiveAsync` in an outer loop: if it ever returns or throws while the host is still running, it is restarted after the policy delay with the last offset kept, so no update is lost or repeated. - `HandleUpdateAsync` returns fast: prompts run in background tasks, so the loop keeps receiving (`/stop` arrives while a run is active). ## Components - `TelegramOptions` + validation. - `BotUpdateHandler`: allow-list, then route: callback query → `TelegramUi`; reply to a pending input → `TelegramUi`; bot command → `ChatCommands`; text/photo → prompt in the background. Errors are reported to the chat. - `ChatSessions`: one `AgentSession` per chat, lazy; resumes the most recent session in scope `telegram-`. - `ChatRenderer`: subscribes once per session, events into a channel, one render task per chat. Each assistant message is sent once, complete, on `MessageEnd` (no streaming); text over 4096 chars continues in new messages; typing action every 4 s while running; one status message per run, "⚙️ Working..." with the last 5 tool calls (`▶` running, `✓` done, `✗` failed), kept as the last message in the chat (moved below anything sent under it on its next tool update, edited at most every 1.5 s) and deleted when the run settles; retry, compaction, errors and notifications as short lines. Telegram send/edit failures are logged and never stop the render task. - `TelegramFormatter`: Markdown → Telegram HTML (code blocks, inline code, bold, italics, links; everything else escaped); a send that Telegram rejects as bad markup is retried as plain text. Splitting at 4096. - `TelegramUi : IUserInterface`: inline keyboards for select/confirm, ForceReply for input, timeouts. - `ChatCommands`: `/start`, `/help`, `/new`, `/stop`, `/status`, `/sessions`, `/resume `, `/compact`, `/thinking `. Other `/commands` go to `PromptAsync` (extension commands). ## Testing xunit, a fake `ITelegramBotClient` that records requests and scripts `getUpdates` responses, `FauxProvider` as model. - Polling survives thrown network errors and keeps delivering every update exactly once in order; delays grow and reset. - A 401 stops the application. - A throwing handler does not stop polling. - Unknown users are ignored. - A reply is sent once, unedited; long text is split; `/stop` aborts a running prompt. - A recorded Claude Code event stream replayed with its timing leaves only the answers in the chat. - `TelegramUi` select round-trip through a callback query. - Formatter escaping and splitting.