Files
VolcanicAgenticCore/docs/docker.md
EugeneTes d542fcfe42 docs(a2a): A2A server guide, architecture, Telegram and Docker notes
Add docs/a2a.md (concepts, file formats, endpoints, sessions, task
lifecycle, configuration, security notes, curl examples) describing
what src/Volcanic.AgenticCore.A2A.Server actually does. Add an A2A
project paragraph to architecture.md, an "A2A" section to
telegram-bot.md (/a2a, notify_owner, isolated sessions, port 8080),
A2A_ENABLED/A2A_PUBLIC_URL and the aspnet image to docker.md, and
project-map/test-command updates to CLAUDE.md.

Mark the A2A server design spec implemented and record the deviations
found while building it (A2AHostOptions, synchronous
IA2ACallerDirectory.Find, generic WorkspaceCallerDirectory, per-status
push payload, split file-part error handling, the outbound guarded
handler, the per-caller server/router architecture, the restart grace
period, the owner= log field, the aspnet image, and the actual push
retry schedule). Note the aspnet image in the docker-deployment spec
too, without rewriting its historical decisions.
2026-10-07 14:20:19 +00:00

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

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

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 .
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_ID — Telegram user id that may /impersonate other users (telegram-bot.md) 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
OPENROUTER_API_KEY — Turns on group watching with topic triggers, classified by Jev through OpenRouter (telegram-bot.md) 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) Yes
GOOGLE_CLIENT_SECRET — See above No, and redacted
A2A_ENABLED false Serves other agents over A2A on port 8080 (a2a.md, 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
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, GOOGLE_CLIENT_SECRET, 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.

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

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/<chatId>): AGENTS.md or CLAUDE.md, PERSONA.md, MEMORY.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 (.volcanic/a2a/, a2a.md)
agent /data/agent Sessions (sessions/telegram-<chatId>/), 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 <dir> 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. 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: <ids>. 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 requests. The compose file already exposes 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.

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:

docker exec -u app -it <container> 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/<pid>/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/<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> 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).

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<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. The configured secrets are kept out of its way: 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, 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 <workspace>/.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.