Files
VolcanicAgenticCore/docs/telegram-bot.md
EugeneTes 6ab98a8155 fix(telegram): address review findings
- A speech or download timeout (TaskCanceledException nobody asked for) is a failure
  with the user notice, not a silent shutdown; OpenAiSpeechToText maps it to
  "the request timed out."; UpdateRouter only treats the caller's cancellation as shutdown.
- send: WorkspacePaths resolves the real path component by component (realpath),
  closing the directory-link-inside-target bypass; `.` reports a directory.
- Image documents are attached inline only when their magic bytes are JPEG/PNG/GIF/WebP,
  under the detected type.
- Audio goes to STT as an ASCII audio.<ext> name.
- MCP Browser section mentions send only when it is active.
- RunInOrderAsync takes a cancellation token; queued work is skipped once disposal gives up;
  SendReplyAsync stays private behind ReplyInOrderAsync.
- Speech HttpClient handler recycles pooled connections; BuiltInTools.Send();
  IExtensionHost.Channel is abstract; WorkspacePaths in its own file.
- Tests: timeouts, symlink chains (Linux), sniffing, channel delivery failures,
  queued-work cancellation, gated voice ordering. Spec and docs updated.
2026-09-26 22:35:20 +02:00

13 KiB

Telegram bot

hosts/Volcanic.AgenticCore.Telegram lets you chat with the agent from Telegram. It uses long polling (no public URL, no webhook), gives every private chat its own agent session and workspace, and runs the model through the Claude Code provider (claude-code/claude-opus-5-5) on your Claude subscription.

Setup

  1. Create a bot with @BotFather (/newbot) and copy the token.
  2. Find your numeric Telegram user id, for example by messaging @userinfobot.
  3. Install Claude Code on the machine that runs the bot and log in (claude, then /login). Check with claude --version; see claude-code-provider.md.
  4. Run:
TELEGRAM_BOT_TOKEN=123456:ABC... \
TELEGRAM_ALLOWED_USER_IDS=111111111 \
dotnet run --project hosts/Volcanic.AgenticCore.Telegram

Open the bot in Telegram and say hello.

Configuration

Environment variables, or the Telegram section of appsettings.json (Telegram__Name in the environment; the Google settings use the GoogleCalendar section):

Setting Default Notes
TELEGRAM_BOT_TOKEN / Telegram:BotToken — Required
TELEGRAM_ALLOWED_USER_IDS / Telegram:AllowedUserIds — Required, comma separated. The bot refuses to start without it
Telegram:WorkspaceRoot ~/.volcanic/telegram-workspaces Chat <id> works in <root>/<id>; put AGENTS.md, PERSONA.md, MEMORY.md there
Telegram:Model claude-code/claude-opus-5-5 Any provider/model the Claude Code provider serves
Telegram:EditInterval 00:00:01.5 Minimum time between edits of the "Working..." status message
Telegram:InputDebounce 00:00:00.6 How long the bot waits after a message for more before sending them to the agent as one request. 0 disables the wait; messages still being read together (e.g. a photo still downloading and a text after it) are still sent as one request. Must not be negative
Telegram:ReconnectInitialDelay / ReconnectMaxDelay 1 s / 60 s Backoff after lost connection
OPENAI_API_KEY / Telegram:Speech:ApiKey — Turns voice transcription on. Hidden from bash and redacted from tool output
STT_MODEL / Telegram:Speech:Model gpt-4o-transcribe gpt-4o-mini-transcribe and whisper-1 also work
Telegram:Speech:BaseUrl https://api.openai.com/v1 Any OpenAI-compatible transcription endpoint
Telegram:Speech:Language — Optional ISO-639-1 hint, e.g. de
Telegram:InlineImageMaxBytes 3750000 Image files up to this size are also shown to the model; must be positive
GOOGLE_CLIENT_ID / GoogleCalendar:ClientId — Optional. With the secret, every chat gets the calendar tool and the /calendar_* commands; see google-calendar.md
GOOGLE_CLIENT_SECRET / GoogleCalendar:ClientSecret — Optional; redacted from tool output and hidden from bash
PROCESS_ENV_ALLOW — Comma-separated names or globs let through to bash, scheduled scripts and MCP servers despite the deny-list (e.g. GH_TOKEN for gh). Added to ProcessEnvironment:Allow
ProcessEnvironment:Deny / ProcessEnvironment:Allow built-in deny-list Added to the defaults (*_API_KEY, *_TOKEN, *_SECRET, *_PASSWORD, their Section__Key forms and the named provider credentials), never replacing them. Allowing a name that is denied by name stops startup
ProcessEnvironment:HardenHostProcess true Linux: the bot process is made non-dumpable. ProcessEnvironment__HardenHostProcess=false for a debugging session only
PLAYWRIGHT_ENABLED / Mcp:Playwright:Enabled false Browser tools through @playwright/mcp (mcp.md)
PLAYWRIGHT_HEADLESS, PLAYWRIGHT_BROWSER, PLAYWRIGHT_EXTRA_ARGS, PLAYWRIGHT_COMMAND true, chromium, —, npx See mcp.md
Mcp:Servers:<name> — Other stdio MCP servers (mcp.md)

Sessions are stored as usual under ~/.volcanic/agent/sessions/telegram-<chatId>/; after a restart each chat resumes its most recent conversation.

Using it

Write normally. While the agent works, a status message stays at the bottom of the chat:

⚙️ Working...
✓ read: src/Program.cs
✗ bash: dotnet test
▶ bash: dotnet build

It lists the last five tool calls (▶ running, ✓ done, ✗ failed) and is deleted when the run finishes. Answers are not streamed: each one is sent complete when the model finishes it (split into several messages past Telegram's 4096 character limit). A message sent while the agent works steers the current run, and the bot reacts to it with 👀 so you can see it was added. Besides text, you can send photos, files and voice messages (see What you can send).

Along with your text, the agent sees:

  • Replies and quotes. Reply to one of the bot's messages (or one of yours), or quote part of it, and the agent gets a line such as [Reply to your message: "Which plant do you mean?"] before your text.
  • Forwards, labelled with their source: [Forwarded from Daily News (Anna K.)].
  • The local time you sent the message, in the chat's time zone (for now, the zone of the machine running the bot): [Sat 2026-09-26 14:03 Europe/Berlin], once per request.

Messages sent within about 0.6 s of each other (Telegram:InputDebounce), such as a forward with your comment or an album, reach the agent as one request. Every message waits that long before it's sent. Commands that an extension handles (for example /claude-code-doctor) are sent at once, as typed, with no time header. /new, /stop and /resume drop a message that is still waiting. A forwarded message is never taken as a command.

After a start the bot sends 🟢 Online. to the owner (the first id in AllowedUserIds), once. If the owner never started a chat with the bot, Telegram refuses the message; the bot logs that and carries on.

Command
/new Start a new conversation
/stop Stop the current run
/status Model, thinking level, session, context usage, workspace
/sessions, /resume <id> List and resume earlier conversations (an id prefix is enough)
/compact Summarise the conversation to free context
/thinking [level] Show or set the thinking level (Claude Code --effort)
/help Short help
/calendar_connect Connect this chat's Google Calendar (link and code; the bot confirms when you approve)
/calendar_status The connected account and time zone, or the pending code
/calendar_disconnect Revoke and delete this chat's calendar token, or cancel a pending connect

Any other /command is passed to the session, so extension commands such as /claude-code-doctor and /claude-code-login work. /mcp lists the MCP servers (see mcp.md). When an extension asks something, the question appears with buttons (or as a reply prompt for free text).

The /calendar_* commands exist only when both the Google client id and secret are set (GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET or the GoogleCalendar section).

If the Claude subscription session behind the bot expires, the first id in AllowedUserIds (the bot owner) gets the re-login link and code prompt in their own private chat, regardless of which chat's request triggered it. That user must already have started a chat with the bot — Telegram bots cannot message a user first — or the login goes unanswered and the triggering request fails ("no code was entered"). The link, the steps and the code prompt arrive as one message. While that prompt is pending, the owner's next plain text message in that chat is taken as the code and is not sent to the agent; photos, files, voice messages and /commands still behave normally. A forwarded message never counts as the code. Don't ask the bot something else in the owner chat until the code is in: a second free-text question in that chat (for example from an extension) replaces the pending prompt and cancels the login. See claude-code-provider.md.

What you can send

  • Text and photos. A photo, with or without a caption, is passed to the model as an image.
  • Documents, videos and audio files are saved to inbox/<yyyy-MM-dd>-<name> in the chat's workspace (a number is added when the name is taken: -1, -2, ...). The agent gets the caption plus a line such as [File received: inbox/2026-09-26-report.pdf (application/pdf, 1.2 MB)]. An image sent "as file" (JPEG, PNG, WebP, GIF) up to 3.75 MB (Telegram:InlineImageMaxBytes) is also shown to the model; a larger one, or one whose contents aren't really one of those formats, reaches it as a path only.
  • Voice messages and audio files are transcribed with OpenAI (gpt-4o-transcribe by default). The bot echoes 🎤 <transcript> and the agent gets [Voice message] <transcript> (an audio file also gets its [File received: …] line). Without an API key the bot replies with how to set one up (OPENAI_API_KEY).
  • Telegram lets bots download at most 20 MB. A bigger file is refused with a note, and nothing reaches the agent.
  • A message with an attachment is never a command and never answers a pending question: its caption is prompt text.
  • Anything else (stickers, video notes, locations, polls) gets I can read text, photos, files and voice messages.

Files from the agent

The agent's send tool delivers a file from the chat's workspace into the chat, before the agent's reply. Images go as photos (up to 10 MB), other files as documents (up to 50 MB), with an optional caption of up to 1024 characters. A photo Telegram rejects (for example for its dimensions) is resent as a document. send accepts only files inside the chat's workspace and delivers only to that chat.

Google Calendar

Each chat connects its own Google account with /calendar_connect, and the agent's calendar tool only uses that chat's account, so nobody sees another chat's calendar through the agent. The bot polls Google by itself after sending the link and code and confirms in the chat once access is approved. Setup of the Google Cloud app, token storage and time zones are in google-calendar.md.

Security

The agent has the built-in tools, including bash, and runs them as the bot's OS user. Only allow-listed user ids are served (everyone else is ignored and logged), and only in private chats. Run the bot as a dedicated user or in a container, and treat the workspace root as writable by anyone on the allow-list.

send refuses paths outside the chat's workspace, symlinks included, so a prompt-injected "send ~/.ssh/id_rsa" fails visibly (it is no barrier against bash cp). The speech API key is kept out of the agent's processes like the bot token.

Secrets are removed from the environment of bash, scheduled scripts and MCP servers: every variable on the deny-list (see ProcessEnvironment:Deny above) is dropped, and so is any variable whose value is the bot token, whatever its name (a Coolify alias, for example). PROCESS_ENV_ALLOW lets a named variable through, for example GH_TOKEN so the agent can use gh. At startup the bot logs which rules apply, names only: Process environment: 15 deny rules, allow: GH_TOKEN.

On Linux the bot's own environment can't be read through /proc/<pid>/environ or ptrace: at startup the process makes itself non-dumpable (prctl(PR_SET_DUMPABLE, 0)). Set DOTNET_EnableDiagnostics=0 in the bot's environment too (a container image should set it): otherwise the .NET diagnostic socket still hands the environment to any process of the same user, and the bot logs a warning saying so.

The bot token and the other configured secrets are replaced by [REDACTED] in tool output, so the model never sees them in a tool result, whichever file or process they were read from. Encoded forms (base64, URL-encoded, split across lines) aren't matched.

ProcessEnvironment:HardenHostProcess=false (ProcessEnvironment__HardenHostProcess=false in the environment), together with unsetting DOTNET_EnableDiagnostics, turns the hardening off for a debugging session. While it is on:

  • the bot writes no core dumps, including .NET's own createdump on a crash;
  • /proc/<pid>/* of the bot is owned by root, so tools that read it for that pid (such as lsof -p) see less;
  • dotnet-dump, dotnet-trace, dotnet-counters and debuggers can't attach.

The agent's own processes are unaffected. Turn it back on once you're done.

This is not a sandbox. The agent still runs as the bot's user and can read the agent dir, session transcripts and any file that user can read. The container is the isolation boundary.

Connection loss

Polling uses Telegram.Bot's built-in loop with a hardened error handler:

  • a failed getUpdates waits 1 s, 2 s, 4 s … up to 60 s (±20 % jitter) before the next attempt, instead of retrying in a tight loop; the first successful poll resets the delay and logs Telegram connection restored;
  • the update offset only moves past updates that were handed on, so reconnecting neither loses nor repeats messages;
  • if the loop itself ever ends while the host runs, it is restarted from the last offset;
  • a 401 (revoked or wrong token) is the one error it does not retry: the host logs it and exits with code 1.

A reply that fails to send (Telegram unreachable mid-run) is retried after 1 s and 3 s, then logged as lost; the render loop keeps going either way.