Skip to content

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
pr-review-agent config generate          # writes ./config.yaml
pr-review-agent config validate          # loads it and reports what it found

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
unknown keys in 'github': ['agent_user_id']

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
curl -s https://api.github.com/users/<login> | grep '"id"'

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
pr-review-agent daemon start --log-level DEBUG --log-format json   # highest
PR_REVIEW_AGENT_LOG_LEVEL=DEBUG                  # what a systemd unit uses
PR_REVIEW_AGENT_LOG_FORMAT=json
# then logging.level and logging.format in config.yaml, then INFO and auto

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
github:
  repo: owner/name

triggers:
  allowlist: []

budget:
  session_tokens: 88k
  weekly_tokens: 1.5m
  max_run_tokens: 60k

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.