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.
594 lines
45 KiB
Markdown
594 lines
45 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_IDS` / `Telegram:AdminUserIds` | — (off) | Telegram user ids that may use `/impersonate` (see [Impersonation](#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](#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](#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](#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; `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](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`, `MaxReceivedFileBytes` |
|
||
| `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.
|
||
**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](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.
|
||
|
||
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](#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](#skill-hooks-and-protected-paths) are listed the same way.
|
||
|
||
```markdown
|
||
---
|
||
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.
|
||
|
||
```markdown
|
||
---
|
||
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](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.
|
||
|
||
#### 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.
|
||
|
||
```markdown
|
||
---
|
||
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](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
|
||
|
||
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](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. 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](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](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.
|