By request: one agent in Matrix, not several. Removes matrixBot.js, router.js (chat-vs-code-task classifier), litellm.js (claude-agent's own LiteLLM client), the matrix-bot-sdk dependency, runChatTask() and its gitea.js branch/PR helpers (createBranch/createPullRequest — only ever called from the now-removed chat flow), and every Matrix/LiteLLM env var from claude-agent's compose service. claude-agent already left the control room manually before this merge. It keeps its Gitea-webhook-triggered PR review, which never touched Matrix or LiteLLM to begin with. Makes PR #12 (the claude-bot/Hermes cross-reply cascade fix) moot — the bug can't happen once claude-agent has no Matrix client at all. Close #12 without merging once this lands.
gitops-automation
Claude Code + Hermes automation wired into Gitea + Portainer + Matrix on this VPS. See
~/.claude/plans/cozy-honking-lantern.md on the host for the original design rationale
(some of it — the Matrix chat bot on claude-agent — has since been superseded, see below).
What this gives you today:
- PR review: opening/updating a PR in a watched Gitea repo gets a Claude-authored
review comment (
claude-agent, triggered by a Gitea webhook — nothing to do with Matrix). - GitOps redeploy: pushing to
mainon this repo rebuilds theclaude-agentimage (Gitea Actions) and redeploys the stack, chained as the last step of that same workflow. - Matrix: Hermes is the only agent present in Matrix.
claude-agentused to also run a Matrix bot (!claude/!aicommands, then no-prefix auto-routing) — that's been removed entirely (by request: one agent in Matrix, not several). Hermes has its own native Matrix connection, responds to@hermes <message>in shared rooms (no mention needed in DMs), and has its own tools (terminal, code execution, web search, etc.) — see its docs at https://hermes-agent.nousresearch.com for what it can do. It does not (yet) have the old branch/PR-opening workflow the Matrix bot used to have; that logic still exists in git history if it's worth reviving as a Hermes tool/skill later.
Nothing here auto-merges PRs. Every path stops at a comment or an open PR — a human clicks
merge (enforced by branch protection on main, not just by convention — see below).
Prerequisites (one-time, on the VPS)
sudo usermod -aG docker william # then start a new shell/session
docker network create web
Bring-up order
-
DNS — confirm these resolve to
217.160.66.143:gitea.apps.williamturner.eu,portainer.apps.williamturner.eu,matrix.apps.williamturner.eu,agent.apps.williamturner.eu. -
Front Gitea with Traefik: edit
~/gitea/docker-compose.yml— add it to thewebnetwork and Traefik labels (mirror thematrix-homeserverblock in this repo'sdocker-compose.yml, using port3000as the service port and ruleHost(`gitea.apps.williamturner.eu`)). Update~/gitea/data/gitea/conf/app.ini:DOMAINandROOT_URL→https://gitea.apps.williamturner.eu/. Recreate the container, confirm the HTTPS URL works, then remove3000:3000from the port mapping. -
Front Portainer with Traefik: same pattern on
~/portainer-compose.yaml, service port9443(Portainer serves TLS itself on that port — either terminate TLS at Traefik withtraefik.http.services.portainer.loadbalancer.server.scheme=httpsandserversTransportwith insecure skip-verify, or simplest: also expose Portainer's plain HTTP port internally and point Traefik at that instead). Confirmhttps://portainer.apps.williamturner.euworks before removing9443:9443. -
Create the
gitops-automationrepo in Gitea (viahttp://217.160.66.143:3000if step 2 isn't done yet, otherwise the HTTPS URL), push this directory to it. -
Gitea API token: user Settings → Applications → generate a token with repo + webhook scopes. Put it in
.envasGITEA_TOKEN. -
Gitea Actions runner: admin Settings → Actions → Runners → create registration token →
.envasACT_RUNNER_REGISTRATION_TOKEN. Also set repo-level Actions variablesGITEA_HOST(e.g.gitea.apps.williamturner.eu) and secretGITEA_TOKEN(Settings → Actions → Variables/Secrets on the repo) — the workflow in.gitea/workflows/build.ymlreads those. -
First image build (registry is empty until Actions runs once):
cp .env.example .env # fill in values as you go docker build -t "$(grep GITEA_REGISTRY_IMAGE .env | cut -d= -f2)" ./agent docker login <gitea-host> -u <your-username> docker push "$(grep GITEA_REGISTRY_IMAGE .env | cut -d= -f2)" -
Bring up the stack, ideally as a Portainer "Repository" stack pointed at this repo (so it's also the GitOps redeploy target) — or directly:
docker compose up -d -
First boot: Matrix bot account — with
MATRIX_ALLOW_REGISTRATION=truein.env, redeploymatrix-homeserver, then register the bot:curl -s https://matrix.apps.williamturner.eu/_matrix/client/v3/register \ -H 'Content-Type: application/json' \ -d '{"username":"claude-bot","password":"<pick one>","auth":{"type":"m.login.dummy"}}'This returns an
access_token— put it in.envasMATRIX_BOT_TOKEN. Then setMATRIX_ALLOW_REGISTRATION=falseand redeploymatrix-homeserveragain. -
Control room: from any Matrix client logged in as yourself on this homeserver, create a room, invite
@claude-bot:matrix.apps.williamturner.eu, copy the room ID into.envasMATRIX_CONTROL_ROOM_ID. Redeployclaude-agent. -
Portainer stack webhook: in the stack's settings, enable the webhook, copy the URL into
.env/repo secrets asPORTAINER_STACK_WEBHOOK_URL. -
Gitea webhooks on each repo you want automation for:
- push →
main→PORTAINER_STACK_WEBHOOK_URL(only needed on this repo, for GitOps redeploy of the automation stack itself) - pull request (opened, synchronized) →
https://agent.apps.williamturner.eu/webhooks/gitea, secret =GITEA_WEBHOOK_SECRET, on every repo you want auto-reviewed.
- push →
-
Firewall:
sudo ufw allow 80/tcp 443/tcp; once the HTTPS routes above are all confirmed working,sudo ufw delete allow 3000/tcpandsudo ufw delete allow 9443/tcp. -
Branch protection on
main, on every repo you want the bot working on (so it's structurally limited to opening PRs, never merging or pushing directly) — via the repo's Settings → Branches → Add Rule, or the API:curl -X POST -H "Authorization: token $GITEA_TOKEN" -H "Content-Type: application/json" \ "$GITEA_URL/api/v1/repos/<owner>/<repo>/branch_protections" \ -d '{ "rule_name": "main", "enable_push": false, "enable_merge_whitelist": true, "merge_whitelist_usernames": ["<your-human-username>"] }'This blocks all direct pushes to
main(everyone goes through a PR) and restricts merging to the human usernames listed —claude-bot(see below) is never in that list, so it structurally cannot merge, only open PRs, regardless of what its token can do. -
Dedicated bot account (recommended over using your own account/token for the agent): create a separate Gitea user (e.g.
claude-bot) via Site Administration → User Accounts, generate its own token (scopes: repository, issue, package — noadmin,organization, oruserneeded), add it as a repo Collaborator with Write permission on each automated repo, then use its token asGITEA_TOKEN(agent) andREGISTRY_TOKEN(CI secret) instead of your own. This makes every PR comment, branch, and PR clearly attributed to the bot instead of you, and its access is easy to revoke independently.
Smoke test
- Open a throwaway PR on a repo with the PR webhook set → expect a Claude review comment.
- In the Matrix control room:
@hermes hello→ expect a reply from Hermes. git pushtomainon this repo (touchingagent/**) → expect a Gitea Actions run, then a chained Portainer redeploy at the end of that same workflow.
Notes
agent/src/runner.jsonly ever runs read-onlygit clone/fetch/checkoutfor the PR diff it reviews — Claude Code itself is deniedEdit/Write/commit/push tools (--disallowedTools), so this path can never modify a repo, only comment on it..gitea/workflows/build.ymlassumes the act_runner labeldocker— checkGITEA_RUNNER_LABELSindocker-compose.ymlmatches what you actually registered.- Hermes's own config/memory/skills live in
/home/william/hermes-dataon the host (bind-mounted, not in this repo) — back that up separately if it accumulates anything worth keeping.