New POST /mcp endpoint (Streamable HTTP transport, stateless — fresh McpServer+transport per request) exposing one tool, ask_claude_code: runs the real `claude` binary against a prompt, billed against the Pro/Max subscription rather than API credits. This works specifically because it's the actual claude CLI making the request server-side — the same reason Hermes itself can't authenticate with the subscription directly (proven earlier: Anthropic rejects the OAuth token from any client that isn't the real CLI's exact request fingerprint). Read-only: no Edit/Write/git-push/ git-commit tools, since this is a quick-answer bridge, not a repo editor. Tested end-to-end locally (built + ran the image, curled the full MCP handshake: initialize -> tools/list -> tools/call) before pushing — got a real 'pong' back from the actual claude CLI through the MCP protocol. To register it with Hermes (lives in its own data volume, not git — see .env.example comment): docker exec hermes hermes config set mcp_servers.claude-code.url http://claude-agent:3001/mcp docker exec hermes hermes config set 'mcp_servers.claude-code.headers.Authorization' 'Bearer <MCP_BRIDGE_KEY>'
184 lines
9.6 KiB
YAML
184 lines
9.6 KiB
YAML
services:
|
|
# Traefik deliberately does NOT live in this stack — it's shared infra fronting
|
|
# Gitea/Portainer/Matrix/agent (see ~/traefik/docker-compose.yml, a separate,
|
|
# independently-managed stack). It used to be a service here, but a GitOps redeploy
|
|
# tears every service in a stack down before bringing them back up — and claude-agent's
|
|
# image pull goes through Traefik→Gitea's registry, so a self-hosted Traefik ends up
|
|
# briefly tearing down the very route its sibling service needs to come back up.
|
|
# Circular dependency, self-inflicted outage. Don't put Traefik back in this file.
|
|
|
|
matrix-homeserver:
|
|
image: ghcr.io/continuwuity/continuwuity:latest
|
|
container_name: matrix-homeserver
|
|
restart: unless-stopped
|
|
environment:
|
|
CONTINUWUITY_SERVER_NAME: ${MATRIX_SERVER_NAME}
|
|
CONTINUWUITY_DATABASE_PATH: /var/lib/continuwuity
|
|
CONTINUWUITY_ADDRESS: 0.0.0.0
|
|
CONTINUWUITY_PORT: 8008
|
|
# Private control-room bot only — no federation, no open registration.
|
|
# Registration is flipped on temporarily, once, to create the bot account
|
|
# (see README "First boot: Matrix bot account").
|
|
CONTINUWUITY_ALLOW_FEDERATION: "false"
|
|
CONTINUWUITY_ALLOW_REGISTRATION: ${MATRIX_ALLOW_REGISTRATION:-false}
|
|
# CONTINUWUITY_REGISTRATION_TOKEN is deliberately NOT set here: Continuwuity treats
|
|
# a present-but-empty value as a config error (fails to start), so it can only be
|
|
# added here temporarily (with a real value) when you need to register a new user,
|
|
# then removed again — see README "Adding another Matrix user".
|
|
volumes:
|
|
- matrix_data:/var/lib/continuwuity
|
|
networks:
|
|
- web
|
|
labels:
|
|
- "traefik.enable=true"
|
|
- "traefik.http.routers.matrix.rule=Host(`${MATRIX_SERVER_NAME}`)"
|
|
- "traefik.http.routers.matrix.entrypoints=websecure"
|
|
- "traefik.http.routers.matrix.tls.certresolver=letsencrypt"
|
|
- "traefik.http.services.matrix.loadbalancer.server.port=8008"
|
|
|
|
litellm:
|
|
# Pinned deliberately, not :latest or :main-latest — litellm==1.82.7/1.82.8 on PyPI
|
|
# were compromised with credential-stealing malware in March 2026 (fixed within the
|
|
# hour, but a floating tag could still land on a bad release in the future). v1.98.0
|
|
# verified clean as of this writing.
|
|
image: ghcr.io/berriai/litellm:v1.98.0
|
|
container_name: litellm
|
|
restart: unless-stopped
|
|
environment:
|
|
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY}
|
|
LITELLM_MASTER_KEY: ${LITELLM_MASTER_KEY}
|
|
volumes:
|
|
# Absolute host path, NOT a repo-relative one — Portainer's git-stack deploy clones
|
|
# into its own directory (/data/compose/N/) whose checkout doesn't reliably persist
|
|
# for the container's runtime (see the act_runner config comment below for the same
|
|
# failure mode). An absolute path on the actual host filesystem always resolves the
|
|
# same way regardless of which tool ran `docker compose up`. Keep this local clone
|
|
# (/home/william/gitops-automation) pulled to latest when the config changes.
|
|
- /home/william/gitops-automation/litellm-config.yaml:/app/config.yaml:ro
|
|
command: ["--config", "/app/config.yaml", "--port", "4000"]
|
|
networks:
|
|
- web
|
|
# Internal only — no Traefik labels. No reason to expose an LLM gateway holding a
|
|
# master key and OAuth-forwarding config to the public internet.
|
|
|
|
hermes:
|
|
# Pinned to a specific dated release, not :latest — same rationale as litellm above.
|
|
image: nousresearch/hermes-agent:v2026.8.19
|
|
container_name: hermes
|
|
restart: unless-stopped
|
|
environment:
|
|
HERMES_UID: "1000"
|
|
HERMES_GID: "1000"
|
|
# Internal container address, not the public HTTPS one — same docker network as
|
|
# matrix-homeserver, no reason to round-trip through Traefik/TLS for this.
|
|
MATRIX_HOMESERVER: http://matrix-homeserver:8008
|
|
MATRIX_ACCESS_TOKEN: ${HERMES_MATRIX_ACCESS_TOKEN}
|
|
# Only you can trigger it; and only with an explicit @hermes mention in shared
|
|
# rooms (DMs to it would respond unprompted, per Hermes's own default behavior).
|
|
MATRIX_ALLOWED_USERS: ${MATRIX_HUMAN_USER_ID}
|
|
MATRIX_REQUIRE_MENTION: "true"
|
|
# Routed through the local litellm gateway, not OpenRouter directly — one place to
|
|
# hold the OpenRouter credential and swap models. Does NOT grant Hermes access to
|
|
# the Claude subscription (Anthropic-side restriction, proven earlier — the
|
|
# subscription only works through the real `claude` CLI binary, which Hermes isn't).
|
|
OPENAI_BASE_URL: http://litellm:4000/v1
|
|
OPENAI_API_KEY: ${LITELLM_MASTER_KEY}
|
|
# Left disabled: Hermes itself warns that a network-reachable API server combined
|
|
# with the default unsandboxed ('local') terminal backend gives any caller full
|
|
# terminal/file access within the container. Matrix is the actual interface in use;
|
|
# re-enable (API_SERVER_HOST: 0.0.0.0) only alongside terminal.backend: docker if
|
|
# claude-agent ever needs to call Hermes programmatically.
|
|
API_SERVER_ENABLED: "false"
|
|
# Web dashboard, supervised in-container alongside the gateway (same process group,
|
|
# same s6 tree) — see docs/user-guide/docker.md "Running the dashboard". Binds
|
|
# 0.0.0.0 so Traefik (a separate container) can reach it; that makes Hermes's own
|
|
# auth gate mandatory, which it enforces automatically once the bind isn't loopback.
|
|
HERMES_DASHBOARD: "1"
|
|
HERMES_DASHBOARD_HOST: 0.0.0.0
|
|
HERMES_DASHBOARD_PORT: "9119"
|
|
HERMES_DASHBOARD_BASIC_AUTH_USERNAME: ${HERMES_DASHBOARD_USERNAME}
|
|
HERMES_DASHBOARD_BASIC_AUTH_PASSWORD: ${HERMES_DASHBOARD_PASSWORD}
|
|
HERMES_DASHBOARD_BASIC_AUTH_SECRET: ${HERMES_DASHBOARD_SECRET}
|
|
volumes:
|
|
- /home/william/hermes-data:/opt/data
|
|
networks:
|
|
- web
|
|
# Without this the image's default command launches the interactive CLI, which
|
|
# immediately exits ("Input is not a terminal") since a detached container has no
|
|
# stdin — the container then just sits there having done nothing, every restart.
|
|
command: ["gateway", "run"]
|
|
labels:
|
|
- "traefik.enable=true"
|
|
- "traefik.http.routers.hermes-dashboard.rule=Host(`${HERMES_DASHBOARD_HOSTNAME}`)"
|
|
- "traefik.http.routers.hermes-dashboard.entrypoints=websecure"
|
|
- "traefik.http.routers.hermes-dashboard.tls.certresolver=letsencrypt"
|
|
# Just TLS termination + routing — no Traefik-level auth middleware. Hermes's own
|
|
# login gate is not optional here anyway: it fails closed at startup once its bind
|
|
# isn't loopback-only (required for Traefik, a separate container, to reach it at
|
|
# all), so a second gate in front of it would only add friction, not remove Hermes's
|
|
# own one. One password, at Hermes's own login page.
|
|
- "traefik.http.services.hermes-dashboard.loadbalancer.server.port=9119"
|
|
|
|
claude-agent:
|
|
# No Matrix presence (see hermes above; only one agent is meant to be in Matrix).
|
|
# Two things call this now: Gitea's pull_request webhook (PR review), and Hermes,
|
|
# over MCP (POST /mcp), to delegate a question to the real `claude` CLI when it
|
|
# specifically wants the Claude subscription instead of whatever LiteLLM routed it to.
|
|
image: ${GITEA_REGISTRY_IMAGE}
|
|
container_name: claude-agent
|
|
restart: unless-stopped
|
|
# Explicit vars, not env_file: .env — Portainer's git-based stack deploy clones the
|
|
# repo fresh (no .env present, it's gitignored) and only performs ${VAR} substitution
|
|
# from the stack's own Env list, so env_file here would silently fail to load anything.
|
|
environment:
|
|
GITEA_URL: ${GITEA_URL}
|
|
GITEA_TOKEN: ${GITEA_TOKEN}
|
|
GITEA_WEBHOOK_SECRET: ${GITEA_WEBHOOK_SECRET}
|
|
# Claude subscription (Pro/Max) auth via `claude setup-token`, not API billing —
|
|
# Claude Code reads this in preference to ANTHROPIC_API_KEY when both could apply.
|
|
CLAUDE_CODE_OAUTH_TOKEN: ${CLAUDE_CODE_OAUTH_TOKEN}
|
|
# Shared secret for the /mcp bridge endpoint (internal network only either way, but
|
|
# this keeps it from being callable by anything that merely reaches the container).
|
|
MCP_BRIDGE_KEY: ${MCP_BRIDGE_KEY}
|
|
volumes:
|
|
- agent_workspace:/workspace
|
|
networks:
|
|
- web
|
|
labels:
|
|
- "traefik.enable=true"
|
|
- "traefik.http.routers.agent.rule=Host(`${AGENT_HOSTNAME}`)"
|
|
- "traefik.http.routers.agent.entrypoints=websecure"
|
|
- "traefik.http.routers.agent.tls.certresolver=letsencrypt"
|
|
- "traefik.http.services.agent.loadbalancer.server.port=3001"
|
|
|
|
act_runner:
|
|
image: gitea/act_runner:latest
|
|
container_name: act_runner
|
|
restart: unless-stopped
|
|
environment:
|
|
GITEA_INSTANCE_URL: ${GITEA_URL}
|
|
GITEA_RUNNER_REGISTRATION_TOKEN: ${ACT_RUNNER_REGISTRATION_TOKEN}
|
|
GITEA_RUNNER_NAME: gitops-vps-runner
|
|
# catthehacker/ubuntu:act-latest is the standard job-container image for
|
|
# act/act_runner — includes git + docker CLI, which job steps need.
|
|
GITEA_RUNNER_LABELS: docker:docker://catthehacker/ubuntu:act-latest
|
|
# No custom config.yaml: act_runner already auto-detects its own bind-mounted
|
|
# docker.sock and passes it through to job containers with no extra config needed.
|
|
# (A relative-path bind-mounted config file here would also be unreliable under
|
|
# Portainer's git-stack deploy — its cloned checkout doesn't persist for the
|
|
# container's runtime, so the mount source can silently resolve to nothing.)
|
|
volumes:
|
|
- /var/run/docker.sock:/var/run/docker.sock
|
|
- act_runner_data:/data
|
|
networks:
|
|
- web
|
|
|
|
networks:
|
|
web:
|
|
external: true
|
|
|
|
volumes:
|
|
matrix_data:
|
|
agent_workspace:
|
|
act_runner_data:
|