While on, the admin's updates are routed as the target user's (allow-list, chat, workspace, session, settings, jobs) and the target chat's output goes to the admin's chat. The target's own updates are dropped with a notice that an admin is working with their account. In memory; /impersonate 0 stops.
20 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
- Create a bot with @BotFather (
/newbot) and copy the token. - Find your numeric Telegram user id, for example by messaging @userinfobot.
- Install Claude Code on the machine that runs the bot and log in (
claude, then/login). Check withclaude --version; see claude-code-provider.md. - 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, the scheduler the Scheduler 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 |
ADMIN_USER_ID / Telegram:AdminUserId |
— (off) | The Telegram user id that may use /impersonate (see Impersonation). Need not be on the allow-list. Must be positive |
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 |
DEFAULT_TIMEZONE / Telegram:DefaultTimeZone |
— (the machine's zone) | IANA zone for chats that haven't set one with /timezone, e.g. Europe/Berlin. An unknown name stops startup. Also the default for the core (AgenticCoreOptions.DefaultTimeZone) |
Scheduler:Enabled |
true |
Scheduled prompts and watcher scripts (see Scheduling) |
Scheduler:LegacyStorePath |
<agent dir>/scheduler/jobs.json |
The retired shared job file. Jobs live in each chat's <workspace>/.volcanic/scheduler/jobs.json; on start the jobs found here move into their chats' files and this file is renamed jobs.json.migrated-<timestamp> |
Scheduler:DefaultScriptTimeout |
00:05:00 |
Watcher timeout when a job sets none (1 s to 1 h) |
Scheduler:MaxJobsPerTarget |
50 |
Jobs per chat |
Scheduler:Shell |
/bin/sh -c (cmd.exe /d /s /c on Windows) |
The watcher shell as a list, command appended; same as bash by default |
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. Each chat's time zone is kept in <agent dir>/telegram/chat-settings.json, so it
survives /new and restarts.
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 (
/timezone; by defaultTelegram:DefaultTimeZone, else 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, time zone, number of scheduled jobs, 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) |
/timezone [name|reset] |
Show or set this chat's time zone (IANA name such as Asia/Tokyo; reset returns to the default) |
/jobs |
This chat's scheduled jobs with their next run |
/cancel <id> |
Cancel one of them |
/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-transcribeby default). The bot echoes🎤 <transcript>(cut to 1024 characters) 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). A transcription is tried up to 3 times (1 s, then 2 s apart) when the request fails on the network, times out, or gets HTTP 408, 429 or 5xx; other errors (a bad key, a rejected file) fail at once. - 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.
Scheduling
Ask the agent in plain words. It has a schedule tool with two kinds of job:
- "Remind me at 9 tomorrow to call the dentist" or "Every weekday at 8, summarise what's in
inbox/" is a prompt: when it fires, the agent handles it like a message from you, with the same time header, and writes the reminder or the summary. - "Check this page every 10 minutes and tell me only if the price drops below 50" is a watcher.
For a watcher the agent writes a script (usually in scripts/, tested with bash first). Every run the bot runs the
script itself, without the agent:
- exit 0 with no output means "nothing yet" and costs nothing: no model call, nothing in the conversation;
- exit 0 with output means the condition is met: the output always goes to the agent, which writes you the message (one model call per match);
- a non-zero exit or a timeout is reported once, straight to the chat, with the last 20 lines of the script's errors. Later identical failures stay quiet until the script succeeds again.
Scripts run in the chat's workspace with the same filtered environment as bash and TZ set to the chat's zone.
When the chat has a connected Google Calendar they also get GOOGLE_ACCESS_TOKEN, a short-lived token for that
calendar (google-calendar.md). Known secrets and that token are redacted from
their output.
When a job fires while the agent is working, a scheduled prompt waits until the current work is done; it never interrupts. Occurrences that come due while the same job is still running are skipped.
Times are in the chat's zone. /timezone Asia/Tokyo (or telling the agent where you are; it has a set_timezone
tool) changes it for the date in the agent's instructions, bash, scheduled scripts, message timestamps and Claude
Code. Recurring jobs follow the new zone ("every day at 9" means 9 where you are now); one-off jobs keep their moment
and are just shown in the new zone. The reply says how many of each.
After a restart, a one-off that came due while the bot was down is delivered as soon as it's back, with "This was due at …; the bot was offline." in the prompt. Recurring jobs don't catch up: missed occurrences are skipped.
Jobs are per chat: each chat's jobs are stored in its workspace, in .volcanic/scheduler/jobs.json, and /jobs and
the agent only see this chat's jobs. The file is read on start and rewritten on every change, so edit it only while the
bot is stopped; a job in a chat's file always belongs to that chat. Watcher failure alerts aren't part of the
conversation, so the agent can't see them later. With
Scheduler:Enabled=false, /jobs and /cancel reply that scheduling is turned off, and /timezone still works.
Importing VolcanicSecretary jobs
Copy VolcanicSecretary's .secretary/jobs.json to the chat's .volcanic/scheduler/jobs.json
(/data/workspaces/<chatId>/.volcanic/scheduler/jobs.json in the container) and start the bot. The scheduler
recognises the old format (a bare array without chat ids), converts it for the chat whose folder it is in, saves it in
the current format, and keeps the original next to it as jobs.json.secretary-<timestamp>. Each job keeps its id
(a new one if another chat already uses it), schedule and last result:
- a
textjob becomes a prompt. Withdeliver: direct(sent as is, no model call) the text getsRemind me:, so the agent passes it on, as it does for the retired reminder kind; - a
scriptjob becomes a watcher with the same command and timeout. Its output now always goes to the agent; - a 6-field cron with seconds
0loses that field. Other jobs our scheduler can't express (seconds, invalid fields) are dropped with a warning in the log.
Copy the workspace's scripts/ into the owner's workspace too: watcher commands such as python scripts/check.py run
from there. A one-off that is already due fires on the first start.
Impersonation
The admin (ADMIN_USER_ID) can act as another user with /impersonate <user_id>; /impersonate 0 (or the admin's
own id) stops. The command shows up in the admin's command menu only, and from anyone else it is an ordinary unknown
command passed to the agent.
While it is on, the bot treats the admin's messages, commands, button presses and answers exactly as the target user's: the allow-list check (impersonating someone outside it means the bot ignores the admin until they stop), the chat, its workspace, session, time zone, jobs and calendar. Everything the target's chat sends goes to the admin's chat instead: replies, questions, files, notices and scheduled deliveries, including runs started by the target user's own messages. Meanwhile the target user is blocked: whatever they send (messages, commands, button presses) is dropped, and the bot answers "🔒 An admin is working with your account right now. Please try again later." The admin's own chat (their own jobs, for example) keeps working as before.
Impersonation is kept in memory only: a restart ends it.
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. ADMIN_USER_ID can act as any
allow-listed user with /impersonate, so it gets that user's workspace, sessions, jobs and connected calendar. Run it in the container image
(docker.md), which is the recommended setup, or at least as a dedicated user, and treat the workspace root
as writable by anyone on the allow-list.
Scheduled watcher scripts run unattended as the bot's OS user, with the same trust as the bash tool that every
allow-listed user already has, and the same filtered environment.
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 (the Docker image, docker.md, sets 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
createdumpon a crash; /proc/<pid>/*of the bot is owned by root, so tools that read it for that pid (such aslsof -p) see less;dotnet-dump,dotnet-trace,dotnet-countersand 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
getUpdateswaits 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 logsTelegram 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.