A skill directory may hold INSTRUCTIONS.md, with or without SKILL.md. Its body (frontmatter cut) goes into every system prompt in <skill_instructions>, re-read on every run: 16 KB per file, 64 KB in total, truncation marked with the file's path. /skills marks skills with instructions and lists instruction-only dirs.
440 lines
33 KiB
Markdown
440 lines
33 KiB
Markdown
# 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](#groups-and-triggers).
|
|
|
|
## Setup
|
|
|
|
1. Create a bot with [@BotFather](https://t.me/BotFather) (`/newbot`) and copy the token.
|
|
2. Find your numeric Telegram user id, for example by messaging [@userinfobot](https://t.me/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](claude-code-provider.md).
|
|
4. Run:
|
|
|
|
```bash
|
|
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](#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` 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](#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 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 |
|
|
| `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](#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-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](mcp.md)) |
|
|
| `PLAYWRIGHT_HEADLESS`, `PLAYWRIGHT_BROWSER`, `PLAYWRIGHT_EXTRA_ARGS`, `PLAYWRIGHT_COMMAND` | `true`, `chromium`, —, `npx` | See [mcp.md](mcp.md#the-playwright-preset) |
|
|
| `Mcp:Servers:<name>` | — | Other stdio MCP servers ([mcp.md](mcp.md#configuration)) |
|
|
| `MCP_ENCRYPTION_SECRET` | the bot token | Seals the keys of each chat's remote MCP servers ([mcp.md](mcp.md#remote-servers-per-chat)). 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](#a2a) and [a2a.md](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](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](#a2a) and [a2a.md](a2a.md#client)). Independent of `A2A__Enabled` |
|
|
| `A2AClient__*` | see [a2a.md](a2a.md#client-configuration) | `WaitSeconds`, `WatchTimeout`, `CardTimeoutSeconds`, `MaxFileBytes` |
|
|
| `A2A__*` | see [a2a.md](a2a.md#configuration) | `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](#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](#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](#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](mcp.md#mcp)).
|
|
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](claude-code-provider.md#session-expiry-and-re-login).
|
|
|
|
### 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.
|
|
- 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](https://docs.claude.com/en/docs/agents-and-tools/agent-skills/overview) 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. `/skills`
|
|
marks skills with `(+ INSTRUCTIONS.md)` and lists instruction-only directories as `INSTRUCTIONS.md only`.
|
|
|
|
```markdown
|
|
---
|
|
name: trip-planner
|
|
description: Plans trips and books hotels. Use when the user asks about travel.
|
|
---
|
|
1. Ask for dates and budget first.
|
|
...
|
|
```
|
|
|
|
### 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](google-calendar.md#watcher-scripts)). 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 `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](https://t.me/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](https://openrouter.ai/typesafe/jev-1.13), 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
|
|
|
|
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](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](mcp.md#remote-servers-per-chat).
|
|
|
|
### A2A
|
|
|
|
With `A2A__Enabled=true` the bot also serves [A2A](a2a.md): 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](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](a2a.md#files)). 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.md#client).
|
|
|
|
`/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. `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](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](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.
|