Configuration reference¶
One file, config.yaml, next to the daemon. config.yaml is gitignored: it
names real accounts and will later sit beside the agent's credentials.
Two templates ship inside the installed package, and config generate
writes one out. Both are parsed by the test suite, so neither can drift out
of step with the loader:
| Template | What it is | Written by |
|---|---|---|
config.minimal.example.yaml |
The smallest file that loads โ every required key and nothing else. Start here. | config generate |
config.example.yaml |
Every key the loader accepts, with the reasoning behind each. Values shown for optional keys are the defaults. | config generate --full |
1 2 | |
config generate refuses to overwrite an existing file unless --force is
given: a config.yaml names real accounts, is gitignored, and has no copy
anywhere. config validate needs no GITHUB_TOKEN โ it answers a question
about a file.
The two copies at the repository root are the same bytes, kept for anyone working from a clone; a test asserts they match the packaged ones.
๐งพ The rule the loader follows¶
Unknown keys are rejected, not ignored. A typo in a safety setting must fail at startup, not silently fall back to a default that spends tokens. That applies to unknown top-level sections and to unknown keys inside a section.
Only the sections backed by implemented components are accepted today. The
publish section arrived with the component that reads it rather than
before it โ accepting settings that do nothing is the failure this rule
exists to prevent. The same rule is why budget carries max_run_tokens
but no turn cap: the governor reserves against the first, while the second
is not a setting at all โ queue.DEFAULT_MAX_ATTEMPTS bounds how often one
trigger may reach an engine, and the CLI bounds the turns inside a single
run.
๐ What is not in this file¶
Credentials. The GitHub token is read from the GITHUB_TOKEN environment
variable, so it can come from a systemd EnvironmentFile, a secret manager or
the shell without ever being a file the repository could swallow. config.yaml
is gitignored regardless, because it names real accounts.
See TOKENS.md for the scope the token needs, and why several repositories sharing one budget must not share one token.
๐ Sections¶
github¶
| Key | Type | Required | Meaning |
|---|---|---|---|
repo |
owner/name |
yes | The repository to poll. Reviews are posted here. |
repo must contain exactly one /, with both halves non-empty.
agent_user_id was removed โ delete the line¶
This is a breaking change to config.yaml. The key named the account the
agent posts as, so the classifier could reject its own events as
self_author / self_commenter. Both rejections are gone: the loop they
guarded is closed in the publisher, which neutralises the handle in every
body it posts โ see TRIGGERS.md.
As an identity check it also had a failure mode that could not be
configured around. On a single-maintainer repository the agent posts as the
maintainer, so self_* rejected every event from the only human who used it
โ before freshness, draft state, mention detection or the allowlist were ever
consulted โ and the allowlist entry for that account was dead. That is
issue #36.
A file that still sets it is refused by name, because github accepts no
unknown keys:
1 | |
Delete the line. That id belongs in triggers.allowlist, where it now
decides something. SIGHUP is unaffected โ a reload that cannot parse the
file logs the error and keeps the running configuration โ but a restart will
not come up until the line is gone.
triggers¶
| Key | Type | Required | Meaning |
|---|---|---|---|
allowlist |
list of integers | yes | Numeric GitHub user ids whose events may start a review. |
handle |
string | no (default claude) |
The handle that summons a review. A leading @ is stripped. |
allowlist entries must be numeric user ids, never logins. A login can be
renamed and the freed name registered by somebody else, which would hand
eligibility to a stranger. A login in this list raises AllowlistConfigError
at startup rather than never matching โ so does a non-positive number, and so
does a string like "--5" that only looks numeric.
Look an id up with:
1 | |
An empty allowlist is valid and allows nobody. It is the safe starting state.
handle is what makes the pipeline reusable for a different agent: set it to
aider and @aider becomes the trigger.
budget¶
Required, and one of the two sections SIGHUP reloads (the other is
publish). The full specification is
BUDGET.md; this is the key list.
| Key | Type | Required | Meaning |
|---|---|---|---|
session_tokens |
token count | yes | Your estimate of the plan's rolling 5-hour allowance. |
weekly_tokens |
token count | yes | Your estimate of the plan's rolling 7-day allowance. |
max_run_tokens |
token count | yes | Reserved up front for one review, released down to actual usage when it settles. |
enabled |
boolean | no (default true) |
false stops reviewing. It does not turn the budget checks off. |
authority |
boolean | no (default true) |
This file publishes the pool arithmetic every daemon sharing the store governs by. Exactly one config per store may have it. Settled at startup: a change needs a restart, not a SIGHUP. |
comply |
boolean | no (default true) |
Adopt what the authority published rather than this file's own limits. Consulted only when authority is false. Settled at startup, like authority. |
reviewer_share_pct |
integer 1โ100 | no (default 40) |
The share of each plan window the agent may use, never the whole allowance. |
per_contributor_pct |
integer 1โ100 | no (default: no cap) | The share of the agent's weekly allowance any one contributor may spend, over the same rolling week. |
max_changed_files |
integer | no (default 100) |
A pull request touching more reviewable files is refused before a worktree exists. |
max_changed_lines |
integer | no (default 5000) |
The same, for additions plus deletions. |
default_exclusions |
boolean | no (default true) |
Apply the built-in exclusions: lockfiles, vendored trees, generated code, minified bundles. false is for a repository that genuinely reviews them. |
excluded_paths |
list of glob patterns | no (default []) |
The operator's own exclusions, added to the built-ins. Counted against neither cap and not shown to the reviewer. |
min_review_interval_seconds |
integer โฅ 0 | no (default 900) |
How long one pull request waits between reviews. A trigger arriving sooner is deferred, not dropped. 0 disables it. |
mention_min_review_interval_seconds |
integer โฅ 0 | no (default 300) |
The same, for a @claude: a person is waiting, so it is shorter. May not exceed min_review_interval_seconds. |
max_reviews_per_pull_request |
integer โฅ 1 | no (default: no cap) | The most reviews one pull request may have in a trailing 24 hours. |
incremental_min_commits |
integer โฅ 0 | no (default 0) |
A later round is shown only what changed since the last completed one. Below this many new commits it is full instead. 0 disables it. |
incremental_min_seconds |
integer โฅ 0 | no (default 0) |
The same, for a previous round more recent than this. At or below min_review_interval_seconds it never fires, because the pacer defers such a trigger first. |
A token count is a positive integer, written however reads best:
1500000, 1_500_000, 1500k and 1.5m are the same number. The suffixes
are k and m, either case. A suffixed value must come out whole, so
1.2345k is refused rather than rounded.
The two intervals ship enabled, unlike the caps above them, because what they bound is not a guess: a pull request reviewed twice in a minute was reviewed twice for one question, whatever the plan's allowance turns out to be. They bound a rate where the windows bound a total, and neither replaces the other โ a window cannot tell one pull request consuming the day from thirty sharing it. BUDGET.md has the reasoning, including why this is not the contributor window again.
A deferred trigger keeps its queue row, spends no attempt and is claimed once
the interval passes, so nothing is lost; what changes is when, and which
head gets reviewed. Nothing is posted to say the agent is waiting โ a
deferral is a WARNING log line.
The three token counts have no defaults, deliberately. A subscription
publishes no quota, so every one of them is a guess the operator has to
make, and a guess that shipped as a default would be a spending ceiling
nobody chose. They are required even when enabled is false, so that
flipping the kill switch back on over SIGHUP cannot fail on a key that was
never supplied.
Set them conservatively low anyway. The circuit breaker now catches an over-estimate and decays the effective limits toward the real one, but it only learns by hitting the wall: every trip is a lockout the operator could have avoided by guessing lower to begin with.
Several repositories, one allowance¶
Running a daemon per repository against one store.path is how they come
to share one token budget: the ledger and the circuit breaker are global to
the file, so the pool is shared by construction.
Whose numbers govern that pool is not. Each process would otherwise police it using its own file, and two files that disagree do not split the allowance โ every window is measured against one usage total, so the most permissive file keeps admitting runs after the others have correctly stopped.
So exactly one config keeps authority: true and publishes session_tokens,
weekly_tokens, max_run_tokens, reviewer_share_pct and
per_contributor_pct; every other config sets authority: false and adopts
them. Forgetting one is loud rather than silent: a second authority on the
same store refuses to start and names the first.
Everything else in budget stays local. enabled does, because a brake that
could only be pulled fleet-wide could not stop one misbehaving repository;
max_changed_files, max_changed_lines and excluded_paths do, because they
describe a repository rather than the allowance.
Every config still carries a complete budget section, valid on its own. A
complier's token counts are inert while an authority governs, and the daemon
says so at startup rather than leaving the file to imply otherwise.
Start order does not matter. A complier that finds no published policy
exits 3, and the shipped unit restarts it, so it simply waits for its
authority. comply: false is the escape hatch โ it governs with this file's
own limits, and is logged at WARNING when another config is the authority,
because it is the one remaining way to overspend a shared pool.
A single daemon needs none of this. Both keys default to true, and
comply is consulted only when authority is false, so a lone daemon
governs its own store with nothing added to the file.
The role needs a restart. SIGHUP reloads the limits but pins authority
and comply to what the process started with, logging a WARNING when the
file disagrees. Who governs the pool is decided once, against the whole store:
promoting a complier on a reload would republish over a live authority without
the second-authority refusal, and demoting an authority would leave its
governor complying with nobody while the log reported success.
max_run_tokens must fit inside the daily allowance โ a seventh of the
weekly limit, after reviewer_share_pct โ or the file is refused. A run
that cannot fit in the tightest window could never be admitted at all, which
is a configuration that reviews nothing, arrived at by arithmetic nobody did
by hand.
per_contributor_pct is meaningless on a one-person allowlist. The one
account able to trigger anything would simply meet its own cap, so leaving it
unset โ the default โ is right until several people can trigger reviews and
one of them monopolising the week becomes a real outcome rather than a
hypothetical. When it is set, a contributor past 85 % of their own share
stops being auto-reviewed but can still summon a review with @claude, and
is refused entirely at 100 %; everyone else's headroom is measured
separately. The same fit rule as the daily window applies: if the resulting
allowance is below max_run_tokens the file is refused.
The two diff-size caps are layer 2 of the budget, enforced by the
workspace but configured here so that every spending cap
lives in one section. Unlike the token counts they do have defaults: a
plan's allowance is unpublished, whereas a diff-size cap is an ordinary
engineering choice. tests/test_config_sections.py pins both by value, so widening
one is a visible diff.
Be clear about what they bound: the reviewer's input, not the disk. A fetch pulls every object reachable from the head, so a commit that adds a large blob and a later one that removes it still downloads it while reporting no changed lines.
Exclusions are subtracted from both caps and from the diff the reviewer is shown โ one list, one mechanism, so the two cannot disagree. Without them a vendored-dependency bump is refused on a size cap for thousands of lines nobody would have read.
The built-in list ships in the package (config/excluded_paths.py) rather
than in the config file, so a file names only what is special about its
repository. It covers lockfiles, vendor/, node_modules/, third_party/,
generated code and minified output (*.min.js, *.min.css, *.map). The
generated-code globs are per generator โ Protocol Buffers, OpenAPI/Swagger
stubs, GraphQL codegen, gRPC stubs, Go generators โ copied from pr-agent's
generated_code_ignore.toml at commit 10bbd9a (MIT), plus a local
*.generated.*. The full list is pinned by value in
tests/test_config_budget.py.
excluded_paths adds to that list; default_exclusions: false drops it,
leaving only excluded_paths in force. Patterns are globs matched at any
depth via **/, and one may not begin with : โ the pathspec magic is the
agent's to supply.
Changed in 1.8.0: before it, excluded_paths replaced the built-ins and
excluded_paths: [] excluded nothing. A file that set [] to review
everything now needs default_exclusions: false as well.
Because the caps are measured after exclusion, they are checked once the diff exists rather than before the fetch: three aggregate integers from the API have no per-path breakdown to subtract a lockfile from. See BUDGET.md.
max_turns and wall_clock_seconds were once promised here and are not
coming. Layer 3's wall clock is engine.timeout_seconds, where the code that
enforces it lives; its turn cap is not a setting at all. See
BUDGET.md.
store¶
| Key | Type | Required | Meaning |
|---|---|---|---|
path |
string | no (default state.db) |
The SQLite file holding watermarks, ETags and the review queue. |
The whole section is optional, which is one of the two exceptions to "only
the sections backed by implemented components are accepted" being paired with
a required section. The exception is affordable because the default cannot
spend anything: a database that does not exist yet has its watermarks
seeded to the moment the daemon started,
so a fresh file reviews nothing from the backlog. Unknown keys inside store
are still rejected.
A relative path is resolved against the working directory the daemon is
started in, so the daemon logs the absolute path it settled on at INFO.
Prefer an absolute path under a service account's data directory in
production โ pointing at the wrong file costs the queue's memory of what has
already been reviewed.
Several daemons sharing this file share a token budget, which is the whole mechanism behind the section above. Make the path absolute and identical in every one of them: the default is relative, so two units started from different working directories would silently get two stores and two independent budgets.
workspace¶
| Key | Type | Required | Meaning |
|---|---|---|---|
cache_dir |
string | no (default .cache/repos) |
Where the bare mirror and the per-run checkouts live. Created 0700. A relative path is resolved against the daemon's working directory once, at startup, and the absolute result is logged; it is not re-read on SIGHUP. |
git |
string | no (default git) |
Which git the hardened checkout runs. A plain name resolves through the PATH the checkout passes through to the child โ so a writable directory early on the daemon user's PATH shadows it and defeats gitcmd's -c hardening flags and its built environment together, without touching either. An absolute path settles it. |
The second optional section, and the argument differs from store's. It
is affordable because the section holds two paths and nothing else: the
setting that bounds what a checkout may cost lives in budget, with every
other spending cap. Unknown keys inside workspace are still rejected.
git and engine.binary are the only two programs this agent
executes, and both take the same answer. pr-review-agent host check prints
where each name resolved to โ the absolute path, not just "found" โ because
which file runs is the whole question; and startup logs a WARNING while
either is a plain name.
A relative path is resolved against the working directory the daemon starts
in, and logged absolute at INFO, exactly as store.path is.
Never share a cache_dir between daemons
Where store.path must be shared to share a budget, this must not be.
The daemon deletes <cache_dir>/runs at startup to clear what a crash
left behind โ safe only because no checkout of its own is live at that
moment. Pointed at a cache another daemon is using, a restart would
delete that daemon's running review out from under it.
worker¶
| Key | Type | Required | Meaning |
|---|---|---|---|
count |
integer 1โ4 | no (default 1) |
How many reviews may run at once. |
The third optional section, and the only one whose value is a spending
control. Every concurrent run reserves budget.max_run_tokens up front, so
count multiplies the floor below which the governor refuses everything โ
which is why it is capped, and why tests/test_config_sections.py pins both the
default and the cap by value.
Raising it parallelises across pull requests only. One pull request is never reviewed by two workers whatever this is: the per-pull-request lease holds that.
It needs a restart to take effect โ SIGHUP swaps only budget, and the
workers are built once at startup. See WORKER.md.
engine¶
| Key | Type | Required | Meaning |
|---|---|---|---|
model |
string | yes | Passed to the CLI's --model. |
expected_version |
string | yes | What the adapter was written against. A mismatch warns; it does not refuse. |
timeout_seconds |
number | yes | Wall clock for one review. The process is killed past it. Must be below the 30-minute queue lease, or the daemon refuses to start. |
binary |
string | no (default claude) |
The executable to run, found on PATH. |
standards_paths |
list of strings | no (default none) | Files in the reviewed repository holding its review standards. |
Required. It was optional only while nothing drained the queue, because an engine nothing calls cannot spend; the worker calls it now, so a daemon configured without this section would claim work, reserve allowance and then have nothing to run. This is the section that makes the agent able to spend real money.
Inside the section nothing is softened. model and expected_version have
no defaults for the same reason the plan token counts have none โ a default
model is a cost nobody chose, and a default version pin is a claim about
output nobody checked. timeout_seconds is a spending setting and not merely
a liveness one: a killed run leaves tokens spent with no envelope to measure
them.
timeout_seconds is bounded above by the queue lease, and a value at or
above it is a startup error rather than a warning. The lease carries an
expiry rather than a heartbeat because a run cannot outlive its clock; an
hour-long timeout breaks that, and the failure is silent โ the lease lapses
under a live worker, a second worker claims the same pull request and
reserves against the same windows, and the first worker's settlement is
rejected, discarding a review that was already paid for. Strictly below, not
equal: at exactly the lease the two expire together and which one wins is a
scheduling race. See QUEUE.md.
standards_paths are read at the merge base, never at the pull request
head. The engine is generic and the standards are per-repository, so they
have to come from the repository under review โ but a pull request that could
rewrite the reviewer's instructions has talked its way past every other
control in the system. Reading at the merge base narrows the trusted set to
people who can merge to the base branch. That set is smaller, not empty, and
DESIGN.md says so. A configured
path that does not exist in the repository is skipped.
See ENGINE.md for the argv these keys produce.
publish¶
| Key | Type | Required | Meaning |
|---|---|---|---|
dry_run |
boolean | no (default false) |
Run the whole pipeline and post nothing, logging the comment that would have been written. |
post_superseded |
boolean | no (default true) |
Post a review whose commit stopped being the head while it ran, marked as describing that commit. false records it and posts nothing. |
max_publish_attempts |
integer โฅ 1 | no (default 10) |
How many posts one recorded review may cost before the agent gives up on it and stamps runs.publish_failed_at. |
Optional, and the default is to post. Unlike the plan token counts this is not a guess an operator has to make: a dry run spends exactly what a real review spends, so defaulting to one would burn the allowance and show nobody the result.
dry_run is validated as strictly true or false rather than cast. Every
non-empty string is truthy in Python, so dry_run: "no" would read as "post
for real" under a cast and as "post nothing" under YAML's own boolean rules;
refusing both is the only answer that cannot surprise an operator.
post_superseded defaults to posting because the tokens are spent before
the head is re-read: discarding the review saves nothing and shows nobody
anything, and most of a review survives a fixup commit. What the reader needs
is to know which commit the text describes, which the header says. See
PUBLISHER.md.
max_publish_attempts is deliberately well above the queue's max_attempts
(queue.DEFAULT_MAX_ATTEMPTS, 3). That
bound measures the allowance one poison trigger may drain and a post reaches
no engine, so a failed post is handed back unattempted and counted here
instead. Ten rather than three because the findings are already paid for: the
cost of one more attempt is a single HTTP request, and the cost of stopping
too early is a review nobody ever sees. What it bounds is the case no retry
fixes โ a locked pull request, a repository whose issues were turned off โ
which was otherwise repeated on every claim forever. Clearing the stamp
offers the review again; see
STORAGE.md.
Reloadable on SIGHUP โ see Reload โ and described in full in
PUBLISHER.md.
logging¶
| Key | Type | Required | Meaning |
|---|---|---|---|
level |
DEBUG | INFO | WARNING | ERROR | CRITICAL |
no (default INFO) |
How much the daemon says. Case-insensitive; an unrecognised name is refused at startup. |
format |
auto | text | json |
no (default auto) |
What a record looks like. auto is text when stderr is a terminal and JSON when it is anything else. |
Optional, and the whole section. Two scalars: there are no destinations and no per-logger map โ the daemon writes one stream to stderr and the supervisor owns where it goes. LOGGING.md has the reasoning for both omissions.
Each is the lowest of three layers. Precedence is flag > environment > this file:
1 2 3 4 | |
The environment layer is the one deployment uses. GITHUB_TOKEN already
arrives that way, so a unit already has an Environment= block and both
settings land beside it without touching ExecStart=.
format decides the shape, not the destination. json emits one object
per record, with the values the message interpolates promoted to real fields
โ so jq -r 'select(.reason) | [.pr, .reason] | @tsv' answers why wasn't
this reviewed without re-parsing a string. Under systemd the daemon also
prefixes each record with journald's <N> priority, which journald strips
before storing; that is detected from JOURNAL_STREAM, not configured, and
it is what makes journalctl -u pr-review-agent -p warning work.
The level is applied to the pr_review_agent logger, not to the root one. That is what stops --log-level DEBUG turning on the HTTP transport:
level |
pr_review_agent |
httpx |
httpcore |
asyncio |
|---|---|---|---|---|
DEBUG |
DEBUG |
WARNING |
INFO |
DEBUG |
INFO |
INFO |
WARNING |
INFO |
INFO |
WARNING |
WARNING |
WARNING |
WARNING |
WARNING |
ERROR |
ERROR |
ERROR |
ERROR |
ERROR |
CRITICAL |
CRITICAL |
CRITICAL |
CRITICAL |
CRITICAL |
Each third-party logger follows the configured level downwards without limit, and upwards only as far as its own floor. One rule sets all three floors โ the lowest level at which that library is quiet โ and it gives three different answers, because the three get loud at three different levels:
| Library | What it emits | Floor |
|---|---|---|
httpx |
Nothing at DEBUG; one line per request at INFO (HTTP Request: GET https://โฆ "HTTP/1.1 200 OK"), and the poller makes several every cycle |
WARNING |
httpcore |
Nothing at INFO; fourteen records per request at DEBUG, including the response header list verbatim โ and it is the half of the pair the credential travels through |
INFO |
asyncio |
One record for the whole process (Using selector: EpollSelector), nothing per request, no credential near it |
DEBUG โ it simply follows |
So at the default INFO all three are silent, which is the noise this
replaces: basicConfig(level=INFO) configured the root logger, so httpx
put a line into the log on every poll request. And --log-level ERROR
silences their warnings too, which one shared pin at WARNING would not
have done.
tests/test_logs.py pins the whole table.
What each level shows¶
The six events docs/LOGGING.md names are split by how often they fire:
| Level | What becomes visible |
|---|---|
ERROR |
Every caught exception: the engine failing to start, a review or a publish failing, the account's usage limit, a crashed worker |
WARNING |
โฆplus every budget refusal, lapsed lease and SIGHUP that needs a restart, and the banner saying the agent is about to spend |
INFO (default) |
โฆplus each review starting, its outcome, it being posted and what it left in the budget; startup, cold-start watermarks, SIGHUP reloads and resumed runs |
DEBUG |
โฆplus every poll cycle and every trigger decision, the engine's argv and the dry_run review body |
Events 1 and 2 โ the poll cycle and the trigger decisions โ fire for every
pull request and every comment on every cycle, which is what made the v0.12.0
log unreadable, so they are DEBUG. Events 3 to 6 fire once per review and
are all visible by default, so the default level is the review lifecycle and
nothing else.
The ERROR row is a rule, not a list: a record emitted from inside an
except block is ERROR, and the test suite fails if one is not. That is
what makes journalctl -p err mean something went wrong rather than
something was written.
Not reloadable on SIGHUP: only budget and publish are, and both are
brakes. Changing the level needs a restart.
๐ A minimal file¶
Every key below is required; everything else has a default. This is
config.minimal.example.yaml verbatim, and
tests/test_config_loading.py loads it, so it cannot drift. The shipped file carries
no comments: it is meant to be copied and edited, and the reasoning belongs
on this page rather than in a file that becomes somebody's config.yaml.
Both values are placeholders, and neither runs as shipped. owner/name
is refused at startup by name, so a file that was generated and never edited
cannot poll and post against somebody else's repository under your token; an
empty allowlist is valid and allows nobody, and startup says so at WARNING
because "the agent never answers" and "nobody is allowlisted" look identical
from the outside. Put the numeric user ids of the people who may summon a
review in the list โ never their logins, for the reason
triggers gives.
1 2 3 4 5 6 7 8 9 10 | |
An earlier version of this page showed a minimal file with no budget
section at all. It would not have loaded โ budget is required, and its
three token counts have no defaults on purpose.
๐ Reload¶
SIGHUP re-reads config.yaml and adopts its budget section, so stopping
the agent never requires a restart.
Only budget and publish are hot-swapped. A changed github,
triggers or store section is logged as needing a restart rather than
half-applied; a changed workspace or worker section is also left alone
until a restart, though nothing warns about it. For the first three, the
daemon's watermarks describe the repository it started against, and
swapping that mid-flight would make them meaningless. workspace.cache_dir
is in that list for a neighbouring reason โ moving the cache under a running
daemon would orphan the mirror it is fetching into.
The checkout's two caps are reloaded, because they are budget keys. That
is why the workspace is handed them per checkout rather than reading them
once at startup: a copy taken at construction would ignore the reload
silently.
A broken file leaves the previous configuration in force, logged at
ERROR. Crashing on a bad reload would turn the emergency brake into a way to
take the service down with a typo.
publish.dry_run is the other reloadable key, and it took this mechanism
without change: SIGHUP swaps the budget and publish sections together,
and a broken file leaves both as they were.
It is the quieter brake. budget.enabled: false stops the agent spending;
publish.dry_run: true lets it spend exactly as before and posts nothing,
logging the comment it would have written instead. Use it to watch what the
agent would say before letting it say it โ not to save money. See
PUBLISHER.md.