Reviewed-on: #1
semprini-maintainer — autonomous stack maintenance agent
A standalone systemd service that keeps the container stack healthy and current, and talks to the operator over Matrix (chat.semprini.me).
It is the unattended driver for the canonical stack-support agent that lives
in the semprini-core repo (agents/stack-support.agent.md): the daemon
gathers state and hands the judgement calls (diagnosis, remediation, upgrade
planning) to that agent, run headless via Claude Code.
Relationship to semprini-core
This is a separate project from the stack it maintains. It does not bundle
its own copy of the stack logic — at runtime it reads, from the semprini-core
checkout pointed to by repo_dir in config.json:
agents/stack-support.agent.md— injected as the agent's system promptdocs/stack-support-runbook.md— the remediation runbookcore_stack/compose*.yml— the compose files it operates on
So a semprini-core checkout and the running core stack must be present on the
host (they are what this agent maintains). The canonical stack-support agent
definition stays in semprini-core to avoid drift. Other projects that register
Uptime Kuma monitors (e.g. semprini-blog) are also maintained — their repos
are made available to the agent via extra_dirs.
Prerequisites
- Docker (host user in the
dockergroup) - Python 3 (stdlib only — no venv needed)
- Claude Code CLI on
PATH - A semprini-core checkout + the core stack running (incl. Uptime Kuma)
What it does
| Cadence | Action |
|---|---|
Every 5 min (health_interval_seconds) |
Read Uptime Kuma to learn which services are tracked right now and their up/down status. If a service is confirmed down across down_confirmations cycles, invoke the stack-support agent to diagnose + remediate, then report to Matrix. |
Every 7 days (upgrade_interval_seconds) |
Resolve the containers behind tracked services, find the owning compose file, and have the agent research + apply upgrades (test, pin, rollback on failure). Breaking/major upgrades are escalated to Matrix for a decision. |
Continuous (matrix_poll_seconds) |
Poll the Matrix room for the admin's replies — to answer escalations, or to take ad-hoc instructions. |
Daily (heartbeat_interval_seconds) |
Quiet "N/M services up" heartbeat when all is well. |
Scope is dynamic
Scope is whatever Uptime Kuma is tracking at the moment the check runs — the
monitor list is re-read every cycle, nothing is hard-coded. This deliberately
covers services from other projects that register Kuma monitors (e.g.
semprini-blog), not just semprini-core. Other project repos are made
available to the agent via extra_dirs.
Kuma's own heartbeat result is the health signal, so the daemon never re-implements probes — it trusts the monitoring system already in the stack.
Architecture
┌─────────────────────────────────────────┐
│ maintainer.py (daemon) │
Uptime Kuma ──┤ read monitors+status (sqlite in volume) │
(kuma.db) │ │
│ on down / on schedule: │
│ claude -p --append-system-prompt │──▶ stack-support
│ (agents/stack-support.agent.md) │ agent does the
│ │ diagnosis/fix
│ parse JSON result envelope │◀── + returns JSON
│ │
Matrix ◀─────┤ send summaries / ask questions │
(@paul) ─────┤ poll replies → resume escalations │
└─────────────────────────────────────────┘
The agent returns a small JSON envelope the daemon relays:
{"status":"ok|action_taken|escalate|blocked","summary":"…","question":"…","options":["…"]}
escalate posts the question (numbered options) to Matrix and pauses upgrades
until the admin replies; the reply is fed back to the agent to carry out.
Autonomy
Set autonomy in config.json:
full(default) — remediate and apply safe upgrades autonomously (test + auto-rollback); escalate only major/breaking upgrades.fix-only— remediate health issues; report upgrades but never apply them.notify— never change anything; only report to Matrix.
Why it runs as paul, not root
- Claude Code refuses
--permission-mode bypassPermissionsunder root. paulis in thedockergroup, so the agent can drive Docker/Compose.- Kuma's root-owned
kuma.dbis read via a throwawaykeinos/sqlite3container mounting the named volume read-only — no host file access needed.
Install
cd /home/paul/Dev/semprini-maintainer
# 1. Config
cp config.example.json config.json
# set repo_dir to the semprini-core checkout; edit paths/intervals if needed
# (defaults match this host)
# 2. Provision the Matrix bot + private room with the admin
./register-matrix-bot.sh
# then, one-time: accept the room invite in Element as @paul:semprini.me
# 3. Provision the Gitea token (lets the agent push + open PRs as claude-code)
./register-gitea-bot.sh
# 4. Install + start the service
./install.sh
config.json and .bot-secrets hold the bot token/password and are gitignored.
Pushing code & pull requests
When the agent changes tracked files in a repo (compose files, configs, scripts,
docs) it does not commit to main — it branches, commits, pushes, and opens
a pull request on git.semprini.me (Gitea) for the operator to review, then
reports the PR URL to Matrix. Live remediation that must restore service now
(e.g. docker compose up) still happens immediately; only the git change is
gated behind the PR.
It authenticates as the claude-code user (an OIDC account in
auth.semprini.me that is already a push-capable collaborator). register-gitea-bot.sh
mints a Gitea personal access token via the gitea admin CLI inside the
user-gitea container — git.semprini.me has password login and HTTP basic auth
disabled, but a PAT still works for git-over-HTTPS and the API, so no SSH
exposure or core-stack change is needed. The daemon hands the headless agent
claude-code's git identity + credential (via GIT_CONFIG_*, scoped to
git.semprini.me) and the Gitea API coordinates ($GITEA_TOKEN, $GITEA_API,
$GITEA_REPO) so it can open the PR itself. Leaving gitea.token empty disables
the workflow — the agent then edits/redeploys live without committing.
Operate
journalctl -u semprini-maintainer -f # logs
systemctl status semprini-maintainer
sudo systemctl restart semprini-maintainer
# One-off cycles without the service (for testing):
python3 maintainer.py --config config.json --once health
python3 maintainer.py --config config.json --once upgrade
python3 maintainer.py --config config.json --once replies
Files
| File | Purpose |
|---|---|
maintainer.py |
The daemon. Stdlib only. |
config.example.json |
Template — copy to config.json. |
register-matrix-bot.sh |
Creates the @maintainer account + room, writes token to config. |
register-gitea-bot.sh |
Mints the claude-code Gitea token for push + PRs, writes it to config. |
semprini-maintainer.service |
systemd unit (runs as paul). |
install.sh |
Syntax-check, install, enable, start. |
See docs/architecture.md for the architecture
decision record.