# Running the Telegram bot in Docker The repo's `Dockerfile` and `docker-compose.yml` run the [Telegram bot](telegram-bot.md) as one container, on Coolify or any Docker host. The bot uses long polling, so the container needs no open port and no domain. Local run: ```bash cp .env.example .env # fill in TELEGRAM_BOT_TOKEN and TELEGRAM_ALLOWED_USER_IDS docker compose up -d --build docker compose logs -f bot ``` `dotnet run` (see [telegram-bot.md](telegram-bot.md#setup)) stays the development path. ## What's in the image - The ASP.NET Core 10 runtime (`mcr.microsoft.com/dotnet/aspnet:10.0-noble`) and the published host in `/app`. No .NET SDK, so the agent can't build .NET projects. The image needs the ASP.NET Core runtime, not just the plain .NET runtime, because the [A2A server](a2a.md) hosts Kestrel when it's turned on. - Tools for the agent's `bash`: `bash`, `git`, `ripgrep`, `python3` (with `python-is-python3` and `python3-venv`), `curl`, `ca-certificates`, `tzdata`, `jq`, `less`, `procps`, `openssh-client`. - Node.js 24 from NodeSource, with `npm` and `npx`. - The `claude` CLI (`@anthropic-ai/claude-code`), which the Claude Code provider drives. - Optionally `@playwright/mcp` 0.0.82 (the `playwright-mcp` binary) and Chromium under `/opt/ms-playwright` (`PLAYWRIGHT_BROWSERS_PATH`), for the browser tools in [mcp.md](mcp.md). The bot and everything it starts run as `app` (uid/gid 1654, `HOME=/home/app`). The image's own user stays root: the entrypoint `setpriv` drops to `app` before it starts `dotnet`, so PID 1 (`docker-init`, from compose's `init: true`) is owned by root and the agent can't read the container's environment from `/proc/1/environ`. It also empties the capability bounding set and sets `no_new_privs`, so nothing the agent runs regains root through a setuid binary such as `su` or `mount`. Don't set `user:` in the compose file: `setpriv` needs root to switch users. For the same reason `docker exec` opens a root shell; add `-u app` to act as the bot. Any command that writes under `HOME` (`npm`, `npx`, `git config`, `gh auth`, `claude`) must run with `-u app`: as root it leaves root-owned files in `/home/app` (`HOME` is set image-wide), and the bot's `npx` and `git` then fail with `EACCES` until the next redeploy. An interactive `app` session should also start from a clean environment (see [First run and Claude login](#first-run-and-claude-login)). ## Build arguments | Argument | Default | Purpose | |---|---|---| | `INSTALL_PLAYWRIGHT` | `false` in the `Dockerfile`, `true` in `docker-compose.yml` | Adds Playwright MCP and Chromium, about 400 MB | | `CLAUDE_CODE_VERSION` | `2.1` | npm version spec for `claude`: `2.1` is the newest 2.1.x at build time; an exact version such as `2.1.3` pins it | | `NODE_MAJOR` | `24` | Node.js major version from NodeSource | | `PLAYWRIGHT_MCP_VERSION` | `0.0.82` | Must equal the version in `PlaywrightPreset.DefaultPackage`; `PlaywrightPresetTests.Dockerfile_version_matches_DefaultPackage` fails when they differ. The compose file doesn't override it | The compose file passes `INSTALL_PLAYWRIGHT` and `CLAUDE_CODE_VERSION` from `.env` (or Coolify's variables). Without compose: ```bash docker build --build-arg INSTALL_PLAYWRIGHT=true -t volcanic-agentic-core . docker run -d --init --shm-size 1g --restart unless-stopped --env-file .env \ -e PLAYWRIGHT_ENABLED=true -e PLAYWRIGHT_HEADLESS=true -e PLAYWRIGHT_EXTRA_ARGS=--no-sandbox \ -e PLAYWRIGHT_COMMAND=playwright-mcp \ -v workspaces:/data/workspaces -v agent:/data/agent -v claude:/data/claude volcanic-agentic-core ``` `--init` is required: without it `dotnet` is PID 1, so nothing reaps the zombies that killed `claude` trees and `bash` children leave behind, and PID 1 is no longer root's. `--shm-size 1g` keeps Chromium from crashing. The compose file's defaults (the `-e` lines, which win over `--env-file`) don't apply to a plain `docker run`, and neither does its `TELEGRAM_MODEL` / `LOG_LEVEL` mapping: pass `-e Telegram__Model=...` instead. ## Configuration Everything is set through environment variables: `.env` locally, the resource's environment variables on Coolify. The compose file maps them into the container. | Variable | Default | Purpose | Agent's processes see it | |---|---|---|---| | `TELEGRAM_BOT_TOKEN` | — | Required. The token from @BotFather | No, and redacted from tool output | | `TELEGRAM_ALLOWED_USER_IDS` | — | Required. Comma-separated Telegram user ids; the first one is the owner (the `🟢 Online.` message, Claude re-login prompts) | Yes | | `ADMIN_USER_IDS` | — | Telegram user ids, comma separated, that may `/impersonate` other users ([telegram-bot.md](telegram-bot.md#impersonation)) | Yes | | `ADMIN_USER_ID` | — | Older single-admin form; also takes a list and is merged with `ADMIN_USER_IDS` | Yes | | `TELEGRAM_MODEL` | `claude-code/claude-opus-5-5` | Model every chat uses; compose passes it as `Telegram__Model` | Yes, as `Telegram__Model` | | `TZ` | `UTC` | The container's time zone | The agent's `bash` gets the chat's zone as `TZ` instead | | `LOG_LEVEL` | `Information` | Passed as `Logging__LogLevel__Default` | Yes | | `OPENAI_API_KEY` | — | Turns on speech to text for voice notes and the `voice` tool | No, and redacted | | `STT_MODEL` | `gpt-4o-transcribe` | Transcription model | Yes | | `TTS_MODEL` | `gpt-4o-mini-tts` | Speech model of the `voice` tool | Yes | | `TTS_VOICE` | `nova` | Default voice of the `voice` tool | Yes | | `TTS_PROVIDER` | — (ElevenLabs if configured) | `elevenlabs` or `openai`: which service the `voice` tool prefers; the other is the fallback | Yes | | `ELEVENLABS_API_KEY` | — | With `ELEVENLABS_VOICE_ID`, makes ElevenLabs speak for the `voice` tool | No, and redacted | | `ELEVENLABS_VOICE_ID` | — | Default ElevenLabs voice (id, or a voice name in the account) | Yes | | `ELEVENLABS_MODEL` | `eleven_multilingual_v2` | ElevenLabs model; `eleven_v3` also reads `instructions` as audio tags | Yes | | `OPENROUTER_API_KEY` | — | Turns on group watching with topic triggers, classified by Jev through OpenRouter ([telegram-bot.md](telegram-bot.md#groups-and-triggers)) | No, and redacted | | `TRIGGER_MODEL` | `typesafe/jev-1.13` | The Jev model triggers use | Yes | | `GOOGLE_CLIENT_ID` | — | With the secret, turns on Google Calendar ([google-calendar.md](google-calendar.md)) | Yes | | `GOOGLE_CLIENT_SECRET` | — | See above. Passed to the agent's processes (gmail/gdrive skills), its value redacted from tool output | Yes, and redacted | | `MCP_ENCRYPTION_SECRET` | — | Seals each chat's remote MCP keys; without it the bot token does ([mcp.md](mcp.md#remote-servers-per-chat)) | No, and redacted | | `A2A_ENABLED` | `false` | Serves other agents over A2A on port 8080 ([a2a.md](a2a.md), [telegram-bot.md#a2a](telegram-bot.md#a2a)); passed as `A2A__Enabled`. Needs a domain in Coolify (see below) | No | | `A2A_PUBLIC_URL` | — | External base URL for the agent card and push webhooks, e.g. `https://bot.example.com`; passed as `A2A__PublicUrl`. Required for push notifications to work | No | | `A2A_CLIENT_ENABLED` | `false` | Lets each chat's assistant call other A2A agents (the `a2a` tool, agents in the chat's `.volcanic/a2a/agents.json`; [a2a.md](a2a.md#client)); passed as `A2AClient__Enabled`. Independent of `A2A_ENABLED`; needs no port or domain, only outbound HTTPS | No | | `DEFAULT_TIMEZONE` | the container's `TZ` | IANA zone for chats that never ran `/timezone`, e.g. `Europe/Berlin`. An unknown name stops startup | Yes | | `PROCESS_ENV_ALLOW` | — | Comma-separated names or globs let through despite the deny rules, e.g. `GH_TOKEN` | Yes | | `PLAYWRIGHT_ENABLED` | `true` | Browser tools on or off. Set it to `false` for an image built with `INSTALL_PLAYWRIGHT=false` | Yes | | `PLAYWRIGHT_HEADLESS` | `true` | The container has no display | Yes | | `PLAYWRIGHT_BROWSER` | `chromium` | Only Chromium is installed in the image | Yes | | `PLAYWRIGHT_EXTRA_ARGS` | `--no-sandbox` | Extra `playwright-mcp` arguments. Keep `--no-sandbox` when you override it: Chromium's sandbox needs user namespaces, which Docker's default seccomp profile denies | Yes | | `PLAYWRIGHT_COMMAND` | `playwright-mcp` | The preinstalled server, so the first browser call doesn't wait for an `npx` download | Yes | | `GIT_CONFIG_COUNT`, `GIT_CONFIG_KEY_0`, `GIT_CONFIG_VALUE_0` | `1`, `safe.directory`, `*` | Fixed in the compose file: git trusts every repository, so repositories copied in from the host (owned by another uid) don't fail with "dubious ownership" | Yes | `TELEGRAM_MODEL` and `LOG_LEVEL` are read by the compose file only, which passes them to the bot as `Telegram__Model` and `Logging__LogLevel__Default`; `INSTALL_PLAYWRIGHT` and `CLAUDE_CODE_VERSION` are build arguments (see above). > **Never set `ANTHROPIC_API_KEY`, `ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_BASE_URL`, `CLAUDE_CODE_OAUTH_TOKEN`, > `CLAUDE_CODE_USE_BEDROCK` or `CLAUDE_CODE_USE_VERTEX`.** The Claude Code provider runs on a Claude subscription only > and refuses to run when one of them is present. In Coolify, check shared, project and team variables too: they are > the likely way one gets in. ### What the agent can't see The process environment filter removes variables from the environment of the agent's `bash`, scheduled scripts and MCP servers: - anything matching the default deny rules: `*_API_KEY`, `*_APIKEY`, `*_TOKEN`, `*_SECRET`, `*_PASSWORD` and their `Section__Key` forms (`*__APIKEY`, `*__*TOKEN`, `*__*SECRET`, `*__*PASSWORD`), plus `TELEGRAM_BOT_TOKEN`, `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `ANTHROPIC_AUTH_TOKEN` and `CLAUDE_CODE_OAUTH_TOKEN`; - any variable whose value is the bot token, the speech or OpenRouter API key or the Google client secret, whatever its name. The one exception is `GOOGLE_CLIENT_SECRET` itself: the bot always allows it, so skills such as gmail and gdrive can use the OAuth client. Its value is still replaced by `[REDACTED]` in tool output, like all the values above. The startup log names the rules, never values: `Process environment: 14 deny rules, allow: GOOGLE_CLIENT_SECRET`. To give the agent a credential on purpose, add the variable in Coolify, add it to `environment:` in the compose file, and list its name in `PROCESS_ENV_ALLOW` (`PROCESS_ENV_ALLOW=GH_TOKEN` lets `gh` work). A name that is on the deny list by name, such as `TELEGRAM_BOT_TOKEN`, can't be allowed: trying stops startup. Listing `GOOGLE_CLIENT_SECRET` there is harmless. ### Other settings Any setting of the bot is reachable as `Section__Key`: add the variable in Coolify and a matching line under `environment:` in `docker-compose.yml`. For example `Telegram__ReconnectMaxDelay=00:02:00`, `Telegram__InputDebounce`, `Telegram__Speech__Language`, `Scheduler__MaxJobsPerTarget`, `GoogleCalendar__*`, `ProcessEnvironment__Deny__0`, and the provider's `VOLCANIC_CLAUDE_CODE_IDLE_TIMEOUT_MS` / `VOLCANIC_CLAUDE_CODE_TOTAL_TIMEOUT_MS`. The settings are listed in [telegram-bot.md](telegram-bot.md#configuration) and [claude-code-provider.md](claude-code-provider.md#configuration). The image sets these; normally leave them alone: | Variable | Value | | |---|---|---| | `Telegram__WorkspaceRoot` | `/data/workspaces` | One directory per chat | | `VOLCANIC_AGENT_DIR` | `/data/agent` | Sessions, settings, scheduler jobs, calendar tokens | | `CLAUDE_CONFIG_DIR` | `/data/claude` | The Claude login | | `HOME` | `/home/app` | | | `DOTNET_EnableDiagnostics` | `0` | Turns off the .NET diagnostic socket, which would hand the bot's environment to the agent's processes. Unset it, together with `ProcessEnvironment__HardenHostProcess=false`, only to attach `dotnet-dump` or `dotnet-trace` while debugging | | `DOTNET_CLI_TELEMETRY_OPTOUT`, `DOTNET_NOLOGO` | `1` | | ## Volumes The compose file binds them to directories under `/media/disk1/containers/volcanic_secretary_core/` on the host (`workspaces/`, `agent/`, `claude/`); edit the three `volumes:` lines to put them elsewhere. | Volume | Path | Contents | |---|---|---| | `workspaces` | `/data/workspaces` | One directory per chat (`/data/workspaces/`): `AGENTS.md` or `CLAUDE.md`, `PERSONA.md`, `MEMORY.md`, skills (`.volcanic/skills//SKILL.md`), `inbox/` (received files), `browser/` (screenshots, downloads), project-level `.volcanic/extensions/`, the Google Calendar token (`.volcanic/google-calendar/token.json`), scheduled jobs (`.volcanic/scheduler/jobs.json`), A2A callers and tasks, remote agents and watches (`.volcanic/a2a/`, [a2a.md](a2a.md#files), [a2a.md](a2a.md#client)) | | `agent` | `/data/agent` | Sessions (`sessions/telegram-/`), `settings.json`, `extensions/*.dll`, chat time zones (`telegram/chat-settings.json`); a `scheduler/jobs.json` from before jobs were per chat is moved into the workspaces on start | | `claude` | `/data/claude` | The Claude login (credentials and onboarding state) | Bind mounts survive redeploys and deleting the resource. Docker creates missing host directories as root; the entrypoint chowns the three mount roots (not their contents) to uid 1654 before it drops to `app`, so files copied in from elsewhere need `chown -R 1654:1654 ` on the host. Named volumes (`-v workspaces:/data/workspaces`, as in the `docker run` example) work too. On an NTFS disk (`fuseblk`, as `/media/disk1` is) ownership and modes aren't stored: everything shows as root with mode 777, `chown` is a silent no-op, and `app` can still write. `HOME` (`/home/app`) isn't a volume: `~/.gitconfig`, `~/.ssh`, `gh` logins and the npm cache are lost on redeploy. ## Deploying on Coolify 1. Create a resource from this repository with the *Docker Compose* build pack. 2. Set `TELEGRAM_BOT_TOKEN` and `TELEGRAM_ALLOWED_USER_IDS` (owner first) and any optional variables from [Configuration](#configuration). Leave the domain empty, unless you're turning A2A on (next). 3. Deploy. The log shows lines such as `Using Claude Code at /usr/bin/claude, model claude-code/claude-opus-5-5` and `Allowed users: . Workspaces under /data/workspaces`, and the owner gets `🟢 Online.`. There is no healthcheck, because by default the bot serves no HTTP. Lost connectivity is retried with backoff inside the process. A rejected token (`401`) exits with code 1 and invalid configuration exits with code 2; `restart: unless-stopped` turns either into a restart loop you can see in the logs. ### A2A With `A2A_ENABLED=true` the bot also listens on port 8080 for [A2A](a2a.md) requests. The compose file already `expose`s it to the Coolify network; give the resource a **domain** so Coolify's proxy routes it there (without a domain, nothing outside the container can reach the port). Set `A2A_PUBLIC_URL` to that domain (`https://bot.example.com`) so the agent card and push webhooks advertise the right address — without it they fall back to the inbound request's own host, which is wrong behind a reverse proxy. The log gains `A2A: listening on ...`; if port 8080 is somehow already taken, the bot logs a critical line and exits with code 3 instead of starting half-working. Leaving `A2A_ENABLED` unset or `false` opens no port at all, same as before. `A2A_CLIENT_ENABLED=true` turns on the other direction: the bot calls other agents. That needs no domain. With the server on as well and `A2A_PUBLIC_URL` set, remote agents can push task updates to `/a2a/push/`; without it the bot subscribes or polls. The log shows `A2A client: on, ...` and, shortly after start, `A2A client: N watch(es) restored`. ## First run and Claude login 1. The owner opens the bot, sends `/start`, then `/claude-code-login`. 2. The login link and the code prompt arrive as one message. Open the link, sign in, and paste the code shown on the page back into the chat. The credentials are stored in `/data/claude`. 3. `/claude-code-doctor` should now report that you're logged in through `claude.ai` and that the bridge works. When the login expires later, the owner gets the same prompt automatically and the paused request continues after the code is pasted. Without the bot: ```bash docker exec -u app -it env -i HOME=/home/app \ PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin CLAUDE_CONFIG_DIR=/data/claude TERM=xterm \ claude auth login --claudeai ``` The `-u app` matters: `docker exec` runs as root by default, and credentials written by root can't be read by the bot. So does `env -i`: a `docker exec` process gets the whole container environment, bot token and API keys included, and while it runs as `app` the agent's `bash` or a scheduled watcher can read it from `/proc//environ`. ## Updating Redeploy to rebuild the image. The `claude` CLI can't update itself inside the container (its install directory is owned by root), so a new version only arrives with a build. The `npm install` layer is cached, though: to pick up a newer 2.1.x, build without cache (Coolify's force deploy without cache, or `docker compose build --no-cache`), or set `CLAUDE_CODE_VERSION` to an exact version, which also pins it. A Claude Code upgrade invalidates the prompt cache once. Bumping Playwright MCP means changing `PlaywrightPreset.DefaultPackage` and `ARG PLAYWRIGHT_MCP_VERSION` in the `Dockerfile` in the same commit; the unit test above catches a mismatch. ## Extensions and project context Extension assemblies go into `/data/agent/extensions/` (every chat) or `/data/workspaces//.volcanic/extensions/` (one chat), then restart: ```bash docker cp MyExtension.dll :/data/agent/extensions/ docker exec chown app:app /data/agent/extensions/MyExtension.dll docker restart ``` Build them against the same `Volcanic.AgenticCore.Abstractions` version as the image. See [extensions.md](extensions.md). Project context files (`AGENTS.md` or `CLAUDE.md`, `PERSONA.md`, `MEMORY.md`) live in the chat's workspace directory. The agent can edit them, or you can with `docker exec -u app -it env -i HOME=/home/app PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin TERM=xterm bash` (clean environment, for the reason given under [First run and Claude login](#first-run-and-claude-login)). ## Troubleshooting | Symptom | Cause and fix | |---|---| | `[executable_missing]` in a reply, or `Claude Code (claude) was not found on PATH` at startup | The image has no working `claude`. Rebuild and check that `claude --version` ran in the build log | | `[auth]` in a reply | The Claude login is missing or expired: `/claude-code-login`, or the `docker exec -u app` command above | | `[mcp_startup]` in a reply | The bridge files are missing from `/app`: `Volcanic.AgenticCore.Extensions.ClaudeCode.Bridge.dll` and `Volcanic.AgenticCore.Extensions.ClaudeCode.Bridge.runtimeconfig.json`. The build checks for both, so rebuild from a clean tree | | `Permission denied` under `/data` | Files in the bind-mounted directories owned by another user: `chown -R 1654:1654 /media/disk1/containers/volcanic_secretary_core` on the host | | `Telegram reports another poller or a webhook for this bot (409)` | Another instance polls with the same token (an old container, a local `dotnet run`), or the bot has a webhook. Stop the other instance; remove a webhook with `https://api.telegram.org/bot/deleteWebhook` | | `Telegram rejected the bot token (401 Unauthorized)`, restart loop | Wrong or revoked `TELEGRAM_BOT_TOKEN` | | Browser tools missing or failing | The image was built with `INSTALL_PLAYWRIGHT=false`, `PLAYWRIGHT_ENABLED` is off, or `PLAYWRIGHT_EXTRA_ARGS` lost `--no-sandbox`. Chromium crashes also come from a small `/dev/shm`; keep `shm_size: 1gb` | | Build fails at `dotnet publish` with `NETSDK1004: Assets file '.../obj/project.assets.json' not found` | The `Dockerfile` restores packages in a cached layer that copies an explicit list of project files; a project the host references is missing from it. Add its `.csproj` `COPY` line next to the others (the A2A server and client projects were added there after exactly this failure) | | The provider refuses to run because of an API key | One of the `ANTHROPIC_*` / `CLAUDE_CODE_*` variables above is set, possibly as a shared Coolify variable. Remove it | ## Security The container is the isolation boundary. Inside it, the agent's `bash` runs as `app`, the same user as the bot: it can read the workspaces, the agent directory with every chat's sessions, and the Claude login in `/data/claude`. The configured secrets are kept out of its way: they are filtered out of its environment, the bot process is non-dumpable (its `/proc//environ` is unreadable and it can't be attached to), PID 1 is root's so `/proc/1/environ` is unreadable too, the diagnostic socket is off, setuid binaries can't raise its privileges, and secret values are redacted from tool output. That stops secrets leaking by accident, not an agent that goes after them on purpose. `app` owns `/data/workspaces` and `/data/agent`, so the agent can drop a DLL into `/.volcanic/extensions/` (loaded into the bot process, with its unfiltered environment, at the next `/new`) or add an MCP server whose `env` uses `${env:TELEGRAM_BOT_TOKEN}` to `/data/agent/settings.json`, then kill the bot so that `restart: unless-stopped` brings it back with that server. Treat everyone on the allow-list as able to obtain every secret you configure. None of this is a sandbox: anyone on the allow-list can run any command in the container. Details in [telegram-bot.md](telegram-bot.md#security).