Image contents, build arguments, the environment table with what the agent's processes can't see, volumes, Coolify steps, first Claude login, updating (the npm layer is cached: build without cache or pin CLAUDE_CODE_VERSION), extensions, troubleshooting and security.
13 KiB
Running the Telegram bot in Docker
The repo's Dockerfile and docker-compose.yml run the Telegram bot 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:
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) stays the development path.
What's in the image
- The .NET 10 runtime and the published host in
/app. No .NET SDK, so the agent can't build .NET projects. - Tools for the agent's
bash:bash,git,ripgrep,python3(withpython-is-python3andpython3-venv),curl,ca-certificates,tzdata,jq,less,procps,openssh-client. - Node.js 24 from NodeSource, with
npmandnpx. - The
claudeCLI (@anthropic-ai/claude-code), which the Claude Code provider drives. - Optionally
@playwright/mcp0.0.82 (theplaywright-mcpbinary) and Chromium under/opt/ms-playwright(PLAYWRIGHT_BROWSERS_PATH), for the browser tools in 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.
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.
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: docker build --build-arg INSTALL_PLAYWRIGHT=true -t volcanic-agentic-core .
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 |
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 | No, and redacted |
STT_MODEL |
gpt-4o-transcribe |
Transcription model | Yes |
GOOGLE_CLIENT_ID |
— | With the secret, turns on Google Calendar (google-calendar.md) | Yes |
GOOGLE_CLIENT_SECRET |
— | See above | No, and redacted |
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 |
same as INSTALL_PLAYWRIGHT |
Browser tools on or off | 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 |
TELEGRAM_MODEL, LOG_LEVEL, INSTALL_PLAYWRIGHT and CLAUDE_CODE_VERSION are read by the compose file only; the
bot itself reads the names in the right-hand form.
Never set
ANTHROPIC_API_KEY,ANTHROPIC_AUTH_TOKEN,ANTHROPIC_BASE_URL,CLAUDE_CODE_OAUTH_TOKEN,CLAUDE_CODE_USE_BEDROCKorCLAUDE_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,*_PASSWORDand theirSection__Keyforms (*__APIKEY,*__*TOKEN,*__*SECRET,*__*PASSWORD), plusTELEGRAM_BOT_TOKEN,OPENAI_API_KEY,GOOGLE_CLIENT_SECRET,ANTHROPIC_API_KEY,ANTHROPIC_AUTH_TOKENandCLAUDE_CODE_OAUTH_TOKEN; - any variable whose value is the bot token, the speech API key or the Google client secret, whatever its name.
Those values are also replaced by [REDACTED] in tool output. The startup log names the rules, never values:
Process environment: 15 deny rules, allow: none.
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.
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 and claude-code-provider.md.
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
| Volume | Path | Contents |
|---|---|---|
workspaces |
/data/workspaces |
One directory per chat (/data/workspaces/<chatId>): AGENTS.md or CLAUDE.md, PERSONA.md, MEMORY.md, inbox/ (received files), browser/ (screenshots, downloads), project-level .volcanic/extensions/ |
agent |
/data/agent |
Sessions (sessions/telegram-<chatId>/), settings.json, extensions/*.dll, chat time zones (telegram/chat-settings.json), scheduler/jobs.json, Google Calendar tokens (google-calendar/) |
claude |
/data/claude |
The Claude login (credentials and onboarding state) |
Named volumes survive redeploys. Coolify prefixes them with the resource's uuid and deletes them only when you delete
the resource together with its volumes. Bind mounts work too if the host directories belong to uid 1654
(chown -R 1654:1654 <dir>).
HOME (/home/app) isn't a volume: ~/.gitconfig, ~/.ssh, gh logins and the npm cache are lost on redeploy.
Deploying on Coolify
- Create a resource from this repository with the Docker Compose build pack.
- Set
TELEGRAM_BOT_TOKENandTELEGRAM_ALLOWED_USER_IDS(owner first) and any optional variables from Configuration. Leave the domain empty. - Deploy. The log shows lines such as
Using Claude Code at /usr/bin/claude, model claude-code/claude-opus-5-5andAllowed users: <ids>. Workspaces under /data/workspaces, and the owner gets🟢 Online..
There is no healthcheck, because 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.
First run and Claude login
- The owner opens the bot, sends
/start, then/claude-code-login. - 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. /claude-code-doctorshould now report that you're logged in throughclaude.aiand 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: docker exec -u app -it <container> 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.
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/<chatId>/.volcanic/extensions/
(one chat), then restart:
docker cp MyExtension.dll <container>:/data/agent/extensions/
docker exec <container> chown app:app /data/agent/extensions/MyExtension.dll
docker restart <container>
Build them against the same Volcanic.AgenticCore.Abstractions version as the image. See 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 <container> bash.
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 |
Bind-mounted directories owned by another user: chown -R 1654:1654 <dir> 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<token>/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 |
| 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.
What it can't get at are the configured secrets: they are filtered out of its environment, the bot process is
non-dumpable (its /proc/<pid>/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, and secret values are redacted from tool output.
None of this is a sandbox. Anyone on the allow-list can run any command in the container.
Details in telegram-bot.md.