Files
VolcanicAgenticCore/docs/telegram-bot.md
EugeneTes 644f72068c feat(skills): session-start and turn-end hooks and protected paths declared in SKILL.md
A skill's SKILL.md frontmatter may declare session-start (output into
<skill_session_context>, at session start, /new, resume and after compaction),
turn-end (after every run, in the background; output or a failure reaches the
next run as a hidden notice, repeats suppressed until a clean run) and
protected-paths (write/edit refused inside them). Hooks run from the skill
directory with VOLCANIC_WORKSPACE, VOLCANIC_SKILL_DIR and the chat's TZ; waits
are bounded by each hook's timeout and failures never break a run. /skills
shows declared hooks and broken fields.
2026-10-16 12:27:44 +00:00

45 KiB
Raw Permalink Blame History

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. In groups it stays silent and only watches for the topics its users set up as triggers.

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, 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_IDS / Telegram:AdminUserIds — (off) Telegram user ids that may use /impersonate (see Impersonation), comma separated (,, ; or spaces; empty entries and duplicates are ignored). All admins have the same rights. Need not be on the allow-list. Must be positive; an entry that isn't a number stops the bot at startup
ADMIN_USER_ID / Telegram:AdminUserId — The older single-admin setting, still supported. The variable also takes a list; both variables are merged, as are both settings. The environment is read only when neither setting is configured
Telegram:WorkspaceRoot ~/.volcanic/telegram-workspaces Chat <id> works in <root>/<id>; put AGENTS.md, PERSONA.md, MEMORY.md and .volcanic/skills/ 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 (the schedule tool's)
Scheduler:MaxSkillJobsPerTarget 50 Skill jobs per chat, counted apart (see Skill jobs)
Scheduler:SkillJobSyncInterval 00:01:00 How often every chat's skill job files are re-read
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 and the voice tool 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
TTS_MODEL / Telegram:Speech:TtsModel gpt-4o-mini-tts Speech model of the voice tool; tts-1 and tts-1-hd also work (they ignore instructions)
TTS_VOICE / Telegram:Speech:TtsVoice nova Default voice of the voice tool
Telegram:Speech:TtsInstructions — Default delivery instructions (tone, pace) for the voice tool (OpenAI only)
TTS_PROVIDER / Telegram:Speech:TtsProvider — elevenlabs or openai. Empty prefers ElevenLabs. The preferred service is used when configured, else the other
ELEVENLABS_API_KEY / Telegram:Speech:ElevenLabs:ApiKey — With a voice id, turns on ElevenLabs for the voice tool. Hidden from bash and redacted from tool output
ELEVENLABS_VOICE_ID / Telegram:Speech:ElevenLabs:VoiceId — Default ElevenLabs voice: a voice id, or the name of a voice in the account
ELEVENLABS_MODEL / Telegram:Speech:ElevenLabs:Model eleven_multilingual_v2 ElevenLabs model; eleven_v3 reads instructions as audio tags, other models ignore them
Telegram:Speech:ElevenLabs:OutputFormat opus_48000_64 Must be an opus_48000_* format (OGG/Opus)
Telegram:Speech:BaseUrl https://api.openai.com/v1 Any OpenAI-compatible audio endpoint (transcription and speech)
Telegram:Speech:Language — Optional ISO-639-1 hint, e.g. de
OPENROUTER_API_KEY / Telegram:Triggers:ApiKey — Turns on groups and triggers. Hidden from bash and redacted from tool output
TRIGGER_MODEL / Telegram:Triggers:Model typesafe/jev-1.13 The Jev model that classifies group messages
Telegram:Triggers:BaseUrl https://openrouter.ai/api/v1 Requests go to {BaseUrl}/systemone
Telegram:Triggers:DefaultThreshold 0.7 Yes-probability a message needs when the trigger sets none
Telegram:Triggers:Debounce 00:00:10 Matches of one trigger within this window (opened by the first) reach the chat as one prompt
Telegram:Triggers:MaxRetries 3 Retries after a failed Jev call (network errors, timeouts, 408, 429, 5xx)
Telegram:Triggers:MaxConcurrency 4 Jev calls in flight at once
Telegram:Triggers:MaxTriggersPerChat 20 Triggers per private chat
Telegram:Triggers:MaxSnooze 30.00:00:00 Longest snooze
Telegram:Triggers:MaxQueuedPerTrigger 5000 Messages kept per snoozed trigger; past it the oldest are dropped
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; GOOGLE_CLIENT_SECRET is passed to bash, scheduled scripts and MCP servers (for skills), its value redacted from tool output
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)
MCP_ENCRYPTION_SECRET the bot token Seals the keys of each chat's remote MCP servers (mcp.md). Set it so a new bot token doesn't make the stored servers unreadable. Hidden from bash and redacted
A2A__Enabled / A2A:Enabled false Serve other agents over A2A (see A2A and a2a.md). Opens Kestrel: port 8080 in the container, ASPNETCORE_URLS or Kestrel's default (http://localhost:5000) under dotnet run
A2A__PublicUrl / A2A:PublicUrl — External base URL for the agent card and push webhooks, e.g. https://bot.example.com. Required for push to work; see docker.md
A2AClient__Enabled / A2AClient:Enabled false The A2A client: the a2a tool in every allowed chat, for calling other agents listed in the chat's .volcanic/a2a/agents.json, and watches for their long tasks (see A2A and a2a.md). Independent of A2A__Enabled
A2AClient__* see a2a.md WaitSeconds, WatchTimeout, CardTimeoutSeconds, MaxFileBytes, MaxReceivedFileBytes
A2A__* see a2a.md MaxHops, SessionIdleTimeout, MaxFileBytes, AllowPrivatePushUrls, Card:Name, Card:Description

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 default Telegram: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
/a2a This chat's A2A callers and recent tasks, remote agents and watches (see A2A)
/mcp_list This chat's remote MCP servers: status, header names (never values), allowed and active tools
/mcp_add <name> <url> Add a remote MCP server; the bot asks for the key and deletes your answer (see Remote MCP servers)
/mcp_remove <name> Remove one; its tools disappear at once
/mcp_tools <name> [a,b|all] Show a server's tools, or limit which ones the agent gets

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> (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.
  • Voice replies. With an OpenAI key every chat's agent has a voice tool (text, optional voice and instructions): it speaks the text with gpt-4o-mini-tts (voice nova by default) as OGG/Opus and sends it with sendVoice, so it shows as a voice message, in order with the other replies. One call takes up to 4500 characters; text over 1500 characters is split at paragraph, sentence or word boundaries into several voice messages. Longer text is refused. Every part is synthesized before the first is sent, so an API error (returned to the agent as text, key hidden) never leaves half a message; synthesis is retried like transcription. ElevenLabs takes over when ELEVENLABS_API_KEY and ELEVENLABS_VOICE_ID are set (and TTS_PROVIDER isn't openai); it needs no OpenAI key. It calls POST /v1/text-to-speech/{voice_id}?output_format=opus_48000_64 with eleven_multilingual_v2 by default, so the audio is OGG/Opus already. The tool's voice is a voice id or the name of a voice in the account (looked up once through /v2/voices); instructions become audio tags ([warm]) on eleven_v3 and are ignored on other models. Errors such as HTTP 401 quota_exceeded: … or HTTP 429 … come back as text; 429 and 5xx are retried. The tool description follows the active service. The startup log line names it.
  • 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.

Skills

Each chat can have its own Agent Skills in its workspace: <workspace>/.volcanic/skills/<name>/SKILL.md, a YAML frontmatter with name and description followed by the instructions, with scripts or reference files next to it if needed. The system prompt lists every skill's name, description and path, re-read on every run, so a skill the agent (or you) just wrote is used from the next message on, no /new needed. The model reads the full SKILL.md with read when a task matches. Skills without a description are skipped. A chat only sees the skills in its own workspace. /skills lists them.

A skill directory may also hold an INSTRUCTIONS.md (name matched case-insensitively): standing rules that apply all the time, not only when the skill is used. Its whole body (a leading YAML frontmatter is cut) goes into every system prompt in a <skill_instructions> section after <skills>, as <instructions skill="<name>" location="<path>"> blocks in directory order. It is re-read on every run, so an edit counts from the next message. The name is the SKILL.md name, else the directory name. A directory may hold only an INSTRUCTIONS.md; it is then not listed among the skills, but its rules still apply. Limits: 16 KB (16,384 characters) per file and 64 KB (65,536) for all files together; a file over its share is cut and the prompt says […truncated, read the full file at <path>], and once the total is used up, later files appear with only that note. Empty and unreadable files are skipped.

Every part of a skill directory is optional: a directory is a skill when it holds a SKILL.md, an INSTRUCTIONS.md or a jobs/ directory (skill jobs), in any combination. /skills lists every such directory in directory order: a skill with a SKILL.md as • name – description (+ INSTRUCTIONS.md, 2 jobs), any other as • dir – INSTRUCTIONS.md, 2 jobs, and a broken job file under its skill as ⚠️ <file>: <problem>. Declared hooks and protected paths are listed the same way.

---
name: trip-planner
description: Plans trips and books hotels. Use when the user asks about travel.
---
1. Ask for dates and budget first.
...

Skill hooks and protected paths

A skill's SKILL.md frontmatter may declare three more things, all optional. A skill without them works as before.

---
name: gtd
description: GTD: buckets, projects, reviews. Use for tasks and contacts.
session-start: bin/gtd brief              # session start hook
session-start-timeout: 20                 # seconds, 1–120, default 30
turn-end: bin/gtd check --quiet           # end-of-turn hook
turn-end-timeout: 20                      # seconds, 1–120, default 30
protected-paths: Входящие/, Сегодня/, Проекты/
protected-hint: "use `gtd move` or `gtd done` (see SKILL.md)"
---

The frontmatter is the simplified YAML of every SKILL.md: one key: value per line, and # after a space starts a comment in these fields (quote a command that needs a #). protected-paths is a comma-separated list or a flow list [a, b], each entry optionally quoted. The file is re-read on every run, so an edit counts from the next message. /skills shows what a skill declares, e.g. (+ hooks: session-start, turn-end; 3 protected paths), and a field it can't use as ⚠️ .volcanic/skills/gtd/SKILL.md: <problem> (bad timeout, empty command, a path outside the workspace); the skill's other fields still work.

How a hook runs. Like a skill watcher: /bin/sh -c "<command>" from the skill directory (so bin/gtd is relative to the skill), stdin closed, with the same filtered environment as bash, plus VOLCANIC_WORKSPACE (the chat's workspace), VOLCANIC_SKILL_DIR (the skill directory) and TZ = the chat's time zone. Known secrets are redacted from the output. At the timeout the whole process tree is killed. A hook that fails, times out or can't start never breaks the conversation: the failure becomes text for the agent (below) and a line in the bot's log.

session-start runs when a session starts: after a bot restart (on the chat's first message), /new, a resume, and again after every compaction (the context is rebuilt then). It runs in the background; the first run of the session waits for it at most its timeout. A skill (or command) added later is run on the next message. Its stdout goes into the system prompt, after <skill_instructions>:

<skill_session_context>
…
<context skill="gtd" command="bin/gtd brief" at="2026-10-16 10:48 (Europe/Warsaw)">
…stdout…
</context>
</skill_session_context>

at is when the hook started, in the chat's zone. The output stays as it is until the next session start or compaction (so the cached prompt prefix doesn't change); the agent is told to run the command itself when it needs fresh data. Limits: 16 KB per skill, 32 KB for all skills (cut output ends with […truncated]). A non-zero exit or a timeout adds [session-start hook failed: exit 2] (or timed out after 20 s) and the last 20 lines of stderr; a hook still running at its timeout shows [session-start hook still running after 20 s; …] until it finishes.

turn-end runs after each of the agent's turns, that is after every run (an answer to you, a scheduled prompt, a watcher match), in the background: the answer goes out without waiting for it. Exit 0 with no output means all is well and nothing happens. Anything else (output, a non-zero exit, a timeout) reaches the agent at the start of its next turn, right after your message, hidden from the chat:

[Skill hook "gtd" turn-end (`bin/gtd check --quiet`, exit 1) after your previous turn:]
…stdout…
stderr:
…last 20 lines, only when the hook failed…

If the next turn starts while the hook is still running, it waits for the hook at most until the hook's timeout (counted from when the hook started), then goes on; a late result comes with the turn after. The same notice is not repeated: once delivered, an identical one stays quiet until the hook has a clean run. If a hook is still running when the next turn ends, that turn's hook is skipped. Limit: 8 KB per skill.

protected-paths are folders or files of the workspace (relative to the workspace, not to the skill; a trailing / is optional) that the skill manages with its own commands. write and edit refuse to change anything inside them, and the agent gets an error instead:

"Сегодня/Позвонить.md" is inside "Сегодня/", which the skill "gtd" manages: change it through the skill's CLI
(see .volcanic/skills/gtd/SKILL.md), not with write or edit. Reading is allowed. Hint: <protected-hint>

read still works, and bash (so the skill's CLI) is not restricted. Paths are compared after resolving .., symbolic links and Unicode normalisation (NFC), so notes/../Сегодня/x.md, an absolute path or a link into the folder are refused too; the comparison is case-sensitive, as on Linux. The agent's <skills> list shows each skill's <protected_paths> so it knows before trying. An absolute path, .., the workspace itself and .volcanic/skills/ (the skills must stay editable) can't be protected. At most 50 paths per skill; protected-hint is at most 500 characters.

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.

Skill jobs

A skill can declare scheduled jobs as files, one per job: <workspace>/.volcanic/skills/<skill>/jobs/<job>.md (subdirectories of jobs/ aren't read). The skill needs nothing else: jobs/ alone is enough. The job's id is <skill>/<job> (directory name and file name without .md, whatever name says), so a restart or an edit never duplicates it.

---
kind: prompt                  # prompt | watcher, required
cron: "0 8 * * 1-5"           # recurring, 5 fields, chat's zone; or instead
at: 2026-10-20T09:00          # one-off, ISO 8601; without an offset in the chat's zone
command: python3 scripts/check.py   # watcher only, required there
timeoutSeconds: 60            # watcher only, 1–3600, default 300
enabled: true                 # false: the job is off
name: Morning digest          # optional label (default: the file name)
description: Weekday summary  # optional, shown in /jobs
---
For a prompt job: what the agent should check and write when it fires (up to 16 KB).

Exactly one of cron and at. The frontmatter is the same simplified YAML as SKILL.md, and # after a space starts a comment. A watcher's body is ignored (use it for notes).

  • The file is the source of truth. The bot re-reads every chat's jobs/ every minute, before and after each agent run, and when /jobs or /skills is used: a new file is scheduled, an edit re-plans the job, and a deleted file or enabled: false takes it off. No restart is needed. The agent can create jobs itself by writing these files; its system prompt has the format and this chat's skill jobs in a <skill_jobs> section.
  • prompt: the body reaches the agent as [Scheduled job "<skill>/<job>" (skill job, .volcanic/skills/<skill>/jobs/<job>.md)] followed by the text, like a schedule prompt.
  • watcher: the bot runs command itself, from the skill directory (so scripts/check.py is relative to the skill), with VOLCANIC_WORKSPACE (the chat's workspace) and VOLCANIC_SKILL_DIR (the skill directory) added to the usual watcher environment (filtered like bash, TZ, GOOGLE_ACCESS_TOKEN). Output, exit codes and failure alerts work as for other watchers; the header carries the file path.
  • One-off (at): fires once, then it is done (recorded by the bot, not in the file) until at changes. One that came due while the bot was down fires after the start with the "bot was offline" note. An at that was already past when the bot first saw the file (for example a copied skill) never fires and is shown as not scheduled. Missed cron occurrences aren't caught up.
  • Errors aren't silent: a file without kind, with neither or both of cron/at, a watcher without command, a bad cron, at or timeoutSeconds isn't scheduled and appears in /jobs and /skills with the reason.
  • Managed through files only. /jobs and the schedule tool's list show skill jobs in their own block (id, kind, schedule, next run, disabled, done or the error); /cancel <skill>/<job> and schedule cancel answer with the file to edit (enabled: false). Up to 50 skill jobs per chat (Scheduler:MaxSkillJobsPerTarget), separate from the 50 schedule jobs; the rest are listed as over the limit.
  • The bot's own record (last run and result, fired one-offs, when it first saw each at) is .volcanic/scheduler/skill-jobs-state.json in the workspace; skill jobs never go into jobs.json.

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 text job becomes a prompt. With deliver: direct (sent as is, no model call) the text gets Remind me: , so the agent passes it on, as it does for the retired reminder kind;
  • a script job becomes a watcher with the same command and timeout. Its output now always goes to the agent;
  • a 6-field cron with seconds 0 loses 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.

Groups and triggers

With OPENROUTER_API_KEY set, the bot can sit in Telegram groups and tell you, in your private chat, when something you care about comes up. It never writes in a group and never answers there; a group gets no agent session.

Setup, once per bot and group

  1. In @BotFather, /setprivacy → your bot → Disable. With privacy mode on, a bot only sees commands, mentions and replies in groups (the startup log warns about it). Alternatively make the bot a group admin. Telegram applies the change to groups the bot joins afterwards, so re-add it to groups it is already in.
  2. Add the bot to the group. It watches a group only if an allowed user added it, or an allowed user posts there once while it is a member; groups anyone else adds it to are ignored. Removing the bot pauses the group's triggers.

Triggers

Ask the bot in your private chat, for example "tell me when someone in Dev Team talks about deploys or outages". The agent uses its trigger tool: it lists the watched groups, writes a yes/no question about one message with what counts and what doesn't, and confirms it with you before saving. You must be a member of the group. /triggers lists your triggers.

Every message in a watched group (text or caption; voice notes, files and stickers without a caption are skipped) goes to TypeSafe's Jev, a fast classifier, through OpenRouter's /systemone endpoint: one call per message carries every trigger on that group as its own question, with the message, its sender, the group and the message it replies to. Jev returns the probability that the answer is yes; a trigger matches at or above its threshold (0.7 unless set). Matches of one trigger within 10 s reach your chat as one prompt listing each message with its score and a link, and the agent decides what to tell you. A failed call is retried 3 times.

Matches carry their scores, so tuning is conversational: say a match wasn't relevant and the agent tightens the question or raises the threshold; describe a message it missed and it lowers the threshold or broadens the question.

Snooze. "Keep quiet until tonight" snoozes a trigger (or all of yours) for a duration or until a time. Meanwhile messages are collected in your workspace (.volcanic/triggers/snoozed/), and when the snooze ends they go to Jev as one batch; if anything is related you get one prompt with the whole batch (a large one is saved under .volcanic/triggers/digests/ and the prompt points to it). Unrelated batches stay silent. A snooze that ends while the bot is down is handled when it starts.

Logs. Every group message gets one log line with its Jev results, for example Group "Dev Team" (-100123…) msg 789 from Alice (@alice): "prod deploy is failing…" → 1001/t1 deploys=0.93 MATCH, or why it wasn't classified (unknown group, no text, no triggers, snoozed, Jev failed). Message texts are cut to 200 characters there.

Watched groups live in <agent dir>/telegram/groups.json; each chat's triggers in its workspace under .volcanic/triggers/triggers.json.

Impersonation

Each admin (ADMIN_USER_IDS, or the older 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 admins' command menus only, and from anyone else it is an ordinary unknown command passed to the agent.

With several admins each one impersonates independently, under these rules: a user can be impersonated by one admin at a time (a second admin is told who already is), an admin who is impersonating someone can't be impersonated, and an admin who is being impersonated can't start /impersonate until the other admin stops.

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.

Remote MCP servers

Each chat can connect its owner's remote MCP servers (Streamable HTTP or SSE; no processes are started):

/mcp_add notes https://mcp.example.com/mcp
→ Key for notes: send the token (sent as "Authorization: Bearer <token>"), or one "Header-Name: value" per line, or - for none.
tok_abc123                       ← your reply; the bot deletes it and the agent never sees it
→ Added notes (https://mcp.example.com/mcp): 12 tools on the server.

Options: --tools a,b (allowlist), --transport auto|http|sse, --bearer <token> or --header "Name: value" instead of the question (the bot deletes that message too), --no-key, --force (save even when the server can't be reached now). The tools appear in the same conversation as notes__<tool>. Only these commands change the servers: the agent can't add one, even by editing .volcanic/mcp/servers.json. Details in mcp.md.

A2A

With A2A__Enabled=true the bot also serves A2A: other agents can call it over HTTP once given a key. It opens Kestrel on port 8080 in the container (the aspnet image sets ASPNETCORE_HTTP_PORTS=8080; Coolify needs a domain pointed at that port — see docker.md); under dotnet run it listens on ASPNETCORE_URLS, or Kestrel's default http://localhost:5000 when that is unset. It logs A2A: listening on .... With it left off (the default) no port is opened at all. If the port is already taken, the bot logs a critical line and exits with code 3 instead of coming up half-working.

Callers live only in allowed chats' workspaces: each chat's own .volcanic/a2a/callers.json and .volcanic/a2a/tasks/ (see a2a.md). A caller's session is isolated from the chat's own conversation — same workspace, same AGENTS.md/CLAUDE.md, PERSONA.md and MEMORY.md, separate history — and opens with that chat's current time zone and Telegram:Model; a later /timezone only reaches a caller's session once it's been idle long enough to be evicted and reopened (SessionIdleTimeout, 30 minutes by default).

notify_owner delivers as 🤖 A2A · <caller>: <text> in the owner's chat, through the same channel as everything else the bot sends there — including during /impersonate, when it reaches the admin instead, like any other output to that chat.

/a2a shows this chat's callers (name, mode, and the key's last four characters — only once a key is at least 12 characters long) and its ten most recent tasks with their caller, state and age.

With A2AClient__Enabled=true (independent of the server) each allowed chat's assistant can also call other agents: its a2a tool reads the chat's .volcanic/a2a/agents.json (name, url, key) on every call. A remote task that takes longer than A2AClient:WaitSeconds (60 s) is watched, and its result arrives in the chat later as a follow-up prompt with the local-time header, like a scheduled prompt (never merged into your messages). Watches survive restarts (.volcanic/a2a/pending.json) and get pushes from the remote only when the server is on with A2A__PublicUrl; otherwise they subscribe or poll. A2A callers' isolated sessions never get the tool. Details in a2a.md.

/a2a then also lists the chat's remote agents (url, key masked the same way, entries skipped as invalid) and its watches (agent, task, push/subscribe/poll, age). With both halves off it replies A2A is off and names both switches.

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. In groups the bot only classifies messages for triggers: it never runs the agent for a group member, and the messages of everyone in a watched group (not only allowed users) are sent to OpenRouter and logged. Every admin (ADMIN_USER_IDS) 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. GOOGLE_CLIENT_SECRET is always allowed, so skills such as gmail and gdrive can use the Google OAuth client; its value stays among the redacted secrets. At startup the bot logs which rules apply, names only: Process environment: 14 deny rules, allow: GH_TOKEN, GOOGLE_CLIENT_SECRET.

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 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.