Running as a service¶
daemon start is built to run unattended: it polls on an interval, it holds
leases across restarts, and it reloads its budget on SIGHUP. What it has
never had is a supported way to stay running. This page is that way — a
systemd user unit, shipped inside the package and installed by
pr-review-agent service install.
DOCKER.md rules the container out explicitly ("This is not a deployment
image"), so this is the deployment story.
🙋 Why a user unit and not a system one¶
The review engine spawns the claude binary, and claude finds its login
under $HOME. A system unit would therefore need a dedicated service
account, a second claude login performed as that account, and a
ProtectHome= setting carefully drilled through so the daemon can still
reach it. Every one of those is a step that can be got wrong silently: the
daemon starts, polls, classifies, and only fails when it first tries to
review.
A user unit runs as the human who already ran claude login. There is no
second credential, nothing to chown, and no root anywhere in the procedure.
The cost is one command, and it is not optional:
1 | |
Without lingering, a user manager starts at your first login and stops at your last logout. With it, the manager — and so the daemon — starts at boot and survives you logging out. If you forget this, the symptom is a daemon that works perfectly until you close your SSH session.
If you need a system unit anyway — a shared host where no human account
should own the agent — the unit here is a reasonable starting point, but
state.db and the checkout cache must move to StateDirectory= and
CacheDirectory= under /var/lib, and the claude login problem above is
yours to solve.
🗂 What goes where¶
Config and state are deliberately not in one directory: config.yaml is
read-only to the service and reloaded on SIGHUP, while state.db is
written on every poll cycle. /etc is the wrong home for the second of
those even on a system install — and ProtectSystem=strict, which this unit
sets, mounts /etc read-only for the service, so a state database there
could not be opened at all.
| What | Path | Written by |
|---|---|---|
| the unit | ~/.config/systemd/user/pr-review-agent.service |
service install |
config.yaml |
~/.config/pr-review-agent/config.yaml |
you, via config generate |
GITHUB_TOKEN |
~/.config/pr-review-agent/token.env, mode 0600 |
you |
state.db |
~/.local/state/pr-review-agent/state.db |
the daemon |
| checkout cache | ~/.cache/pr-review-agent/repos |
the daemon |
| logs | the journal | journald |
XDG_CONFIG_HOME, XDG_STATE_HOME and XDG_CACHE_HOME are honoured if
set; the paths above are what they default to.
Why not StateDirectory= and friends
systemd would create these directories itself, but the base it resolves
them against for a user unit changed in systemd 256 — state moved from
$XDG_DATA_HOME to $XDG_STATE_HOME. service install resolves the
paths once and writes them absolutely into the unit and into what it
tells you to put in config.yaml, so the layout does not depend on the
host's systemd version.
🚀 Install¶
Install the package somewhere permanent first. A throwaway venv is fine for
a trial, but ExecStart= will name whatever interpreter's bin directory
the console script sits in, so deleting it later breaks the unit.
1 2 | |
That writes the unit, creates the three directories, and creates an empty
token.env at mode 0600. It does not write config.yaml — config
generate is the only thing that writes that file, and this keeps it so. It
does not run systemctl either; it prints the commands, because starting a
service is your decision and not an install step.
1. Write the config¶
1 | |
Then edit it. Beyond the usual github.repo, triggers.allowlist and
budget keys — see the configuration reference — two keys
must change for a service install, because both default to paths relative
to the working directory, and a unit has no meaningful one:
1 2 3 4 5 | |
service install prints both lines with your paths already filled in.
A third key often needs changing too:
1 2 | |
The user manager's PATH is not your shell's. It does not read .bashrc,
.zshrc or .profile, so an nvm shim or an ~/.npm-global/bin entry
that works interactively will not be found. command -v claude in your
shell gives the absolute path to paste here. Getting this wrong fails at the
first review, not at startup.
Then check it:
1 | |
2. Supply the token¶
1 2 | |
One line, no quotes, no export:
1 | |
A fine-grained PAT scoped to the one repository in config.yaml, with
Pull requests: read and write, Issues: read and write and
Metadata: read only — see TOKENS.md for why
each of the three. It is passed to the daemon by EnvironmentFile= and never
by ExecStart=, so it does not appear in ps, in systemctl show or in
systemctl cat.
service install never overwrites this file, not even under --force: by
the second install it holds a real credential.
3. Check the host, then start¶
1 2 3 4 5 6 7 8 | |
host check is worth running by hand before the first start, because it
names which of the four things is unreachable — api.github.com,
github.com, api.anthropic.com, or a git too old for
GIT_CONFIG_GLOBAL. From inside the unit the same failure is just a
non-zero exit and a restart.
👥 Several repositories, one budget¶
A daemon watches one repository. To watch several — each with its own GitHub
token and its own allowlist — run one instance per repository from a systemd
template unit, and point them all at one state.db. That shared file is
what makes them share a token budget: the ledger carries no repository
column, so every window already sums across whoever wrote it. How the limits
themselves are agreed is
BUDGET.md.
1 2 | |
That writes ~/.config/systemd/user/pr-review-agent@.service once — one file
serves every instance — plus a directory per instance:
| What | Path | Shared? |
|---|---|---|
| the unit | ~/.config/systemd/user/pr-review-agent@.service |
one file, all instances |
config.yaml |
~/.config/pr-review-agent/<instance>/config.yaml |
never |
GITHUB_TOKEN |
~/.config/pr-review-agent/<instance>/token.env |
never |
state.db |
~/.local/state/pr-review-agent/state.db |
always |
| checkout cache | ~/.cache/pr-review-agent/<instance>/repos |
never |
Three rules, each of which fails silently if broken:
store.pathmust name the same file in every instance. One store is one ledger, and one ledger is one budget. Two files means two independent budgets that each believe they own the whole plan.workspace.cache_dirmust differ in every instance. The daemon clears<cache_dir>/runsat startup to remove what a crash left behind, which is safe only while no checkout of its own is live. Pointed at a shared cache, a restarting instance would delete another instance's running review.budget.authority: trueon exactly one instance,falseon the rest. This one is not silent — a second authority refuses to start and names the first — but it is the one to get right first.
Start order does not matter. An instance that is not the authority exits 3
while no policy has been published, and Restart=on-failure brings it back
until one has.
1 2 3 4 5 6 | |
The token is the trust boundary
Each instance reads only its own token.env, and that is the reason to
run a process per repository rather than one process holding every
token. No bug in the instance watching web can post as the account
that watches api, because it never holds that credential. Keep the
files one-per-instance at mode 0600; do not point two units at one
EnvironmentFile.
🎛 Operating it¶
1 2 3 | |
reload and restart are not interchangeable. ExecReload= sends
SIGHUP, which re-reads config.yaml and applies only the budget and
publish sections — see the daemon loop. That is the one way
to turn the kill switch on, or tighten a cap, without dropping the queue and
re-seeding the poll watermarks. A change to any other section needs a
restart, and the daemon says so in the log when it sees one.
To change anything in the [Service] section, do not edit the installed
file — the next service install --force would overwrite it:
1 2 | |
Logs:
1 2 3 | |
The priority is the daemon's own level, not SyslogLevel=: under a unit it
writes each record behind a <N> prefix that journald reads and strips, so
-p warning returns the warnings and errors and nothing else. journald
cannot read an application's level by itself, which is why the daemon has to
say so. LOGGING.md has the detail.
🔧 What the unit says, and why¶
systemctl --user cat pr-review-agent shows the installed file. Five
choices in it are load-bearing and would not be obvious from the outside.
StandardOutput= and StandardError= are not set. This looks like an
omission and is not. The defaults are journal and inherit, which makes
both descriptors the same journal socket — the only arrangement in which
JOURNAL_STREAM names it unambiguously. Setting StandardError=journal
explicitly opens a second stream with a different inode, and
systemd#6800 then leaves
JOURNAL_STREAM naming only one of the two. The daemon's journald
detection, and the priority prefix that depends on it, fail silently. Leave
both unset.
There is no LogsDirectory=. The daemon never opens a log file. It
writes one stream to stderr and journald owns the destination; a file copy,
if you want one, is rsyslog's job.
ProtectHome= is absent, on purpose. It would hide the state database,
the checkout cache and the claude login in a single directive. The
service would start and then fail at its first review.
ProtectSystem=strict makes /usr, /boot, /efi and /etc
read-only for the service; $HOME, where everything the daemon writes
lives, is untouched. On a host with unprivileged user namespaces disabled
this directive can stop a user unit from starting at all, with a
namespace error in systemctl --user status. If you hit that, comment it
out in an override and report it — the rest of the hardening does not
depend on it.
Environment=PR_REVIEW_AGENT_LOG_LEVEL=INFO is the verbosity knob. It
is what systemctl --user edit is meant to change, because changing
verbosity should never mean editing ExecStart=:
1 2 | |
A restart, not a reload: only the budget and publish sections are
re-read on SIGHUP. --log-level on ExecStart= would beat the
environment, and the shipped unit sets none, so this line is the effective
level. logging.level in config.yaml is the lowest layer of the three.
LOGGING.md has what each level shows.
The format needs no knob under systemd. PR_REVIEW_AGENT_LOG_FORMAT
exists and takes the same three layers, but the default auto already
resolves to JSON here, because stderr is the journal rather than a terminal
— and the daemon detects the journal socket and prefixes each record with
its <N> priority, so journalctl -u pr-review-agent -p warning returns
the warnings and nothing else. Set PR_REVIEW_AGENT_LOG_FORMAT=text only if
you would rather read the journal than query it:
1 2 | |
⬆️ Upgrading¶
1 2 | |
Re-run service install --force only if the unit itself changed in the
release. It rewrites the unit and leaves config.yaml, token.env,
state.db and the checkout cache alone — but it also discards anything you
edited into the unit directly, which is why systemctl --user edit is the
right place for local changes.
If the venv or pipx path moved, the unit's ExecStart= is now stale and
service install --force is how it is refreshed.
🩺 Troubleshooting¶
| Symptom | Cause |
|---|---|
| dies when you log out | loginctl enable-linger $USER was not run |
Failed to connect to bus over SSH |
no user manager in that session; XDG_RUNTIME_DIR is unset — log in properly or use machinectl shell |
| starts, then exits 3 | unusable config or missing token; journalctl --user -u pr-review-agent -n 20 names which |
| reviews fail but polling works | engine.binary is not an absolute path, or claude is not logged in as this user |
state.db keeps reappearing empty |
store.path is still the relative default, so it is being written to the manager's working directory |
| restart loop reporting success | an ExecStart= with no verb; a bare pr-review-agent exits 2 by design, precisely so this is visible |
Verify the unit parses before blaming it:
1 | |
🍎 macOS and Windows¶
There is no unit for either, and service install is a Linux procedure.
The daemon itself runs on both — daemon.py skips SIGHUP on Windows
rather than faking it — so a launchd plist or an NSSM service works; they
are just not shipped or tested here.
See also: configuration reference, the daemon loop, budget governor.