Skip to content

Configuration

Settings live in .env (Docker; mirrored into the data volume on every start) or ~/.reviewstage/.env (from source; chmod 600). config.example lists the file’s full shape, .env.example the Docker subset. The dashboard reads the file once at startup; restart after any change. The poller and the review runner re-read it on every run. The rows marked process environment only are not written to .env by either installer and are not read from it by the server.

A handful of operational knobs can also be changed live from the dashboard’s Settings page; those are stored in settings.json and take precedence over .env — see Runtime settings below.

SettingDefaultWhat it does
REPOSrequired (or REPO)The GitHub repositories this instance reviews, as owner/name, comma-separated (quote the value if you separate with spaces — the file is sourced by bash). One install, many repositories. Each entry is validated as owner/name at startup.
REPOemptySingle-entry alias for REPOS, kept for existing installs. If both are set the lists are unioned.
REPO_ALLOW_ORGemptyAn org (or user) whose repositories are accepted on demand in addition to REPOS: the poller discovers them by searching each signed-in user’s open review requests under that owner, and the base clone is made on the first review. The service token must be able to see the org.
GITHUB_PATrequiredThe service token: reads PR metadata and diffs, clones the repos, runs the poller’s searches. Never posts; comments and approvals use each signed-in reviewer’s own token. Fine-grained PAT scoped to the repos (or all repos under the owner when using REPO_ALLOW_ORG): Pull requests read/write, Contents read, Metadata read.
PUBLIC_URLrequired (Docker: http://localhost:8899)Where browsers reach the dashboard. Every Slack button and the OAuth callback are built from it. No trailing slash.
REVIEWERDocker: derived from GITHUB_PATThe GitHub login the service token belongs to. From source, set it yourself; the Docker entrypoint fills it in by asking GitHub who the token is.
RS_SECRETgenerated on first startSigns every dashboard link and session, and derives the key that encrypts stored tokens. Rotating it signs everyone out and invalidates outstanding Slack links and stored tokens.
DRY_RUN11: the dashboard renders and the buttons work, but nothing is ever written to GitHub. Flip to 0 only after a dry run you have compared by hand, then restart.
SKIP_BOT_PRS01 skips PRs opened by bots. Default off: AI-written PRs are where a skeptical review pays off most. Overridable in Settings.
RS_MAX_PR_AGE_DAYS45The poller ignores review requests on PRs older than this many days. 0 disables the cutoff. Overridable in Settings.
MIN_FREE_MB800Refuse to start a review below this much available RAM, in MB (read from /proc/meminfo; the check is skipped where that does not exist). The default means a 1 GB host cannot start a single review — give the machine 2 GB rather than lowering this.
MIN_FREE_DISK_MB500Refuse to start a review, a QA guide or a profiling run below this much free disk on ROOT, in MB. A full volume used to look like a finished review with an empty result. The default is defined once, in bin/lib-limits.sh, and read by both the job scripts and bin/doctor.sh — a passing disk check in the doctor means the jobs will start.
RS_RETENTION_DAYS30How long per-run .log files and whole archived run snapshots (history/<ts>/) are kept before the poller’s once-a-day sweep deletes them. The newest five runs per PR per reviewer survive regardless of age. The sweep also truncates watch.log and clone.log once either passes 8 MiB, and never touches the live review.json, posted.json or approved. See Retention.
RS_QA_TIMEOUTderived from the diffThe QA-guide agent’s wall-clock budget, as a timeout duration (40m). Unset, it is sized from the PR: 25m at most 5 changed files and 200 additions, 40m at most 40 files and 2,000 additions, 60m above that. A run killed by the budget fails loudly rather than publishing a half-written guide.
RS_QA_SKILLskills/pr-qa-guide/SKILL.md next to bin/Path to the QA-guide skill whose body is inlined into the QA prompt. The job refuses to start if the file is missing or empty.
RS_WEBHOOK_WORKERS4Threads that process inbound GitHub webhook deliveries. A saturated pool sheds with 503 (GitHub retries) instead of spawning an unbounded thread per delivery.
RS_SEARCH_LIMIT200Maximum rows the poller accepts from one repository’s review-request search. A result that equals the limit is logged as possibly truncated.
RS_ORG_SEARCH_LIMIT300The same ceiling for the org-wide search REPO_ALLOW_ORG turns on.
RS_MODELemptyOverrides the model for a review started outside the dashboard, and for bin/profile-repo.sh (which defaults to sonnet). The dashboard sets the model per run from the run form, so this is a from-the-shell knob.
RS_HOST_PORT8899Docker only. The host port Compose publishes the dashboard on, as 127.0.0.1:${RS_HOST_PORT}:8899. Safe to set in .env — it is read only for the publish and is never passed into the container.
RS_PORT8899The port the server binds. On Docker the container’s port is pinned to 8899 in docker-compose.yml because it is part of the image contract (EXPOSE, the healthcheck, the publish target), so an RS_PORT in .env does not move it — use RS_HOST_PORT to change where it is published. From source there is no second reader and .env is the right place.
SLACK_WEBHOOKemptyA Slack incoming webhook for review-request cards and “review ready” pings. Send-only: a fresh message each time. Point it at a private channel; cards name PR titles and authors.
SLACK_BOT_TOKENemptyWith SLACK_CHANNEL, posts via chat.postMessage so the review-ready message threads under the review-request card. Takes precedence over the webhook.
SLACK_CHANNELemptyChannel ID for the bot-token path.
DISCORD_WEBHOOKemptyA Discord channel webhook. Cards arrive as an embed and mention the reviewer’s saved Discord user ID.
WEBHOOK_URLemptyAny JSON endpoint (Teams, Zapier, n8n, your own). Every event is one POST of the raw payload; see Notifications.
WEBHOOK_SECRETemptyWith WEBHOOK_URL, signs each body: X-ReviewStage-Signature: sha256=HMAC-SHA256(secret, body).
GITHUB_WEBHOOK_SECRETemptyEnables POST /webhooks/github: every delivery’s X-Hub-Signature-256 is verified against it. Unset, the endpoint answers 503 and polling does all the work. See GitHub webhooks.
NOTIFY_BACKENDSderivedComma list of slack, discord, generic, none. Empty = whichever of the URLs above are set. Overridable in Settings.
GH_DEVICE_FLOW1Sign in with GitHub via GitHub’s device flow, on every install with nothing to register: the login page shows a short code to enter at github.com/login/device. 0 turns it off. See GitHub sign-in.
RS_PERSONAL0 (the desktop app writes 1)Personal mode, for one person on their own laptop: the server boots with no repository configured, the repositories chosen in the first-run wizard (settings.json) are unioned with REPOS, an in-process poller replaces the pr-watch service and runs every search with the signed-in user’s own token, and the job scripts receive that token in their environment only — GITHUB_PAT is not needed. Team mode is untouched when it is unset. See Install → Desktop app.
GH_DEVICE_CLIENT_IDOv23liHjtjxcPNwXC6Y5The OAuth App the device flow uses. The default is the project’s shared public client ID (device flow has no secret and no callback; the token goes from GitHub straight to your server). Set your own app’s ID to sign in under your identity — tick Enable Device Flow on it.
GH_CLIENT_IDemptyClient ID of an OAuth App or GitHub App whose callback URL is <PUBLIC_URL>/oauth/callback. With it set, Sign in with GitHub goes through GitHub’s redirect flow (one click, no code) instead of the device flow; empty, the device flow serves the same button. Restart after changing. See GitHub sign-in.
GH_CLIENT_SECRETemptyThe matching client secret. Both must be set for the redirect flow to be enabled.
GH_OAUTH_SCOPESemptyOAuth App: repo — the smallest classic scope that can comment on and approve a PR in a private repository (public_repo if every repo is public). OAuth Apps cannot request fine-grained permissions. GitHub App: leave empty; permissions come from the app. Tick Expire user access tokens on either and tokens last 8 hours, refreshed here automatically.
RISK_PATHSemptyComma-separated label:pattern rules; a rule matches when a changed file path equals the glob or contains the substring, and the review then shows a “Touches label paths” banner. Context only, never a gate. Example: billing:src/billing/,auth:*/auth/*.
RISK_PATHS__<OWNER>__<NAME>unsetPer-repository override of RISK_PATHS. The key is the repo upper-cased with / → __ and any other character outside A-Z0-9_ → _: acme/widgets-web → RISK_PATHS__ACME__WIDGETS_WEB. When set (even empty) it replaces the global list for that repo.
RULE_SUGGEST_MIN3How many dropped (or reworded) findings of the same complaint, from at least two different PRs, before it is offered as a proposed Team rule on the Skills page. Minimum 2. Raise it on a noisy repository; nothing is ever added to a skill without someone clicking Accept. See Skills and learnings.
RS_SIGNATURE_GRACE_DAYS7Signed links cover action:owner/name#pr:expiry. Links minted before the repository dimension existed (action:pr:expiry) keep verifying for this many days after the first start of the repo-aware server, so Slack cards already sent keep working. 0 rejects them at once.
RS_HOST_ALIASESemptyComma-separated extra hostnames that point at this instance. A visit on one hostname without a session bounces through another to pick up an existing login.
RS_DOMAINemptyA parent domain to scope the session cookie to, so one login covers every alias. Empty = host-only cookies.
RS_ENVemptyLegacy. With RS_DOMAIN, an .env without PUBLIC_URL derives it as https://reviewstage-<RS_ENV>.<RS_DOMAIN>. New installs set PUBLIC_URL and leave this empty.
RS_HOSTemptyLegacy. Overrides the hostname derived from RS_ENV + RS_DOMAIN.
RS_USERthe invoking userbin/bootstrap.sh only (from source). The account the systemd unit runs as.
SETUP_APACHE0bin/bootstrap.sh only. 1 makes it write and enable an Apache vhost for PUBLIC_URL’s hostname; otherwise it prints what to configure and you bring your own proxy.
RS_SECRET minimum length32 charactersNot a setting you can lower. The server prints a FATAL line and exits 1 when RS_SECRET is empty or shorter than 32 characters, because HMAC(b"", …) is a signature anyone can compute. doctor FAILs on the same rule.
Container log size3 × 10 MB per serviceNot an environment variable. docker-compose.yml caps the json-file driver for every service, so a container left running for months can no longer fill the disk with its own stdout.
CLAUDE_CODE_VERSIONlatestDocker build arg, not a runtime setting. Pins @anthropic-ai/claude-code in the image: docker compose build --build-arg CLAUDE_CODE_VERSION=1.2.3.
POLL_INTERVAL180Seconds between poller passes when Settings has not set poll_interval_seconds. Read from the process environment by poller-loop.sh (the Docker poller’s loop, which does not source .env) and from .env by pr-watch.sh; the dashboard shows the .env value on the Settings page. Prefer the Settings page — it applies without a restart and governs both.
RS_BIND127.0.0.1Process environment only (the image sets 0.0.0.0). Address the server binds. 0.0.0.0 inside a container; keep loopback with a reverse proxy in front otherwise.
RS_COOKIE_SECURE1Process environment only. 0 drops the Secure flag from the session cookie for a plain-http install. The Docker entrypoint sets it to 0 for any http:// PUBLIC_URL, but the server honours it only when PUBLIC_URL is unset or a genuine loopback host — a real hostname served over plain http keeps Secure cookies and gets a log line saying so.
ROOT~/.reviewstageProcess environment only. Base directory for .env, the base clones (repos/<owner>__<name>), worktrees, per-PR state (state/<owner>__<name>/<pr>), users.json, learnings and skills. Docker mounts the data volume here.

Sign in with GitHub works out of the box through GitHub’s device flow (GH_DEVICE_FLOW=1, the default) with the project’s shared public client ID: a short code at github.com/login/device, nothing to register. Teams that want one-click redirect sign-in, or their own app identity on the consent screen, set the three GH_* keys below; when they are set the redirect flow is preferred. Either way the token GitHub returns is stored encrypted exactly like a pasted PAT and is the token used for that person’s comments and approvals; a GitHub-sign-in user never needs a PAT. Set-up is below; what the token can do, and why the scope is repo, is in Security → Signing in.

Two kinds of app work:

OAuth AppGitHub App
Needs an org ownerNo (unless the org restricts third-party apps)Yes — installed on the org
GH_OAUTH_SCOPESrepoempty
PermissionsClassic scope: every repo the user can write toPull requests: write, Contents: read on the installed repos only
RevocationThe user, at github.com/settings/applicationsThe user, or the org owner centrally
StatusSupported todaySupported today; the roadmap default

Device tokens for phones and the CLI have no .env knob: they are per-user, created and revoked in Settings → Devices, expire 180 days after last use, and are pruned by the poller nightly.

One-click sign-in under your own OAuth App

Section titled “One-click sign-in under your own OAuth App”

The login page’s primary button uses GitHub’s OAuth device flow with a shared public client ID (Ov23liHjtjxcPNwXC6Y5; public by design — device flow has no client secret and no callback URL). Click it, enter the short code at github.com/login/device, authorise, and the page signs you in on its own. The token GitHub issues goes straight from GitHub to your server — the ReviewStage project never sees it — and is stored encrypted and used exactly like a pasted PAT, for the comments and approvals that person clicks, under their own name. Scope is repo because OAuth Apps cannot request fine-grained permissions (Security → Signing in).

  • GH_DEVICE_FLOW=0 turns the device flow off (token sign-in only, or your own app below).
  • GH_DEVICE_CLIENT_ID=<client id> uses your own OAuth App for the device flow instead (tick Enable Device Flow on it); still no secret.

Use a personal access token instead stays on the login page for air-gapped or policy-restricted organisations: Create a fine-grained token opens GitHub’s token page — pick the repositories you review and grant Pull requests: Read and write, Contents: Read and Metadata: Read (a classic token with the repo scope also works). Pick an expiry; 90 days is a good default.

Teams that prefer GitHub’s Authorize screen with a redirect back (no code to type), or want the consent screen to carry their own app’s name, register an OAuth App and set GH_CLIENT_ID / GH_CLIENT_SECRET; when they are set the redirect flow takes priority over the device flow.

  1. On github.com go to Settings → Developer settings → OAuth Apps → New OAuth App (or under your organisation’s settings if the app should belong to the org).

  2. Fill in:

    FieldValue
    Application nameReviewStage (or your team’s name for it)
    Homepage URLyour PUBLIC_URL, e.g. https://reviews.example.com
    Authorization callback URL<PUBLIC_URL>/oauth/callback — exact
    Enable Device Flowoff
  3. Register, then Generate a new client secret and copy both the Client ID and the secret (the secret is shown once).

  4. Optional but recommended: in the app’s Optional features, turn on Expire user access tokens. Tokens then last eight hours and ReviewStage refreshes them itself.

  5. In .env:

    Terminal window
    GH_CLIENT_ID=<client id>
    GH_CLIENT_SECRET=<client secret>
    GH_OAUTH_SCOPES=repo

    repo is the smallest classic scope that can comment on and approve a pull request in a private repository. Public repositories only? public_repo is enough.

  6. Restart (docker compose up -d again, or systemctl restart reviewstage). Sign in with GitHub now goes through GitHub’s Authorize screen instead of a code.

If a sign-in comes back with “the token cannot see owner/name”, the organisation restricts third-party OAuth apps. An org owner approves the app once under Organization settings → Third-party access, and it works for everyone from then on. The login page demotes the GitHub button and opens the token form while that is pending.

A GitHub App works with the same two .env keys (leave GH_OAUTH_SCOPES empty) and gives narrower, per-repository permissions in exchange for an org owner installing it.

The dashboard’s Settings page (Setup group; admin only — everyone else sees it read-only) writes ROOT/settings.json. The poller re-reads it every cycle and pr-watch.sh / notify.sh read it on every run, so a change applies within seconds and never needs a restart or an .env edit.

Precedence for every key it carries: settings.json > .env > default. Only keys present in the file override, so an install that never opened Settings behaves exactly as its .env says. Each row on the page shows where the current value comes from.

{
"poller_enabled": true,
"poll_interval_seconds": 180,
"notify_backends": ["slack", "discord"],
"max_pr_age_days": 45,
"skip_bot_prs": false,
"auto_profile": { "acme__widgets": true },
"updated_at": 1789265317
}
KeyType / rangeDefault.env fallbackEffect
poller_enabledbooltrue—false pauses pr-watch.sh (the poller service keeps running and logs that it is paused; a cron-driven pr-watch.sh exits at once).
poll_interval_secondsint, 60–3,600180POLL_INTERVALSeconds between polls; the loop notices a new value within 15 s.
notify_backendslist of slack / discord / generic / nonederived from configured URLsNOTIFY_BACKENDSWhich backends notify_card posts to. none cannot be combined with others.
max_pr_age_daysint, 0–3,65045RS_MAX_PR_AGE_DAYSSuppress cards for PRs opened more than this many days ago; 0 = no cutoff.
skip_bot_prsboolfalseSKIP_BOT_PRSSkip bot-authored PRs entirely.
auto_profileobject, repo slug (owner__name) → bool{}—Re-profile that repository automatically when its file tree changes materially (checked by pr-watch.sh at most once a day; runs as the admin on the admin’s connected Claude account, skipped with a log line otherwise). Set from the Skills page’s Repository profile card, admin only. See Repository profile.

DRY_RUN is deliberately not a runtime setting: flipping GitHub writes on stays an .env edit plus a restart.

The admin is the REVIEWER login from .env; if that is empty, the user flagged "admin": true in users.json; if nobody is flagged, the first user who signed in (flagged automatically at that point so the choice is stable). /api/me reports is_admin. The API is GET /api/settings (anyone signed in) and PUT /api/settings (admin, with the signed token from the GET); the file is written atomically. poller.last next to it holds the epoch of the last completed poll, shown on the page.

Polling finds a review request up to one interval late; a webhook delivers it within a second. Both write the same queue and share the same dedup key, so turning webhooks on changes latency, not behaviour.

  1. openssl rand -hex 32 → GITHUB_WEBHOOK_SECRET=… in .env, restart the dashboard.
  2. GitHub → repository or organization → Settings → Webhooks → Add webhook: payload URL <PUBLIC_URL>/webhooks/github, content type application/json, the same secret, events Pull requests + Pull request reviews.
  3. Watch Settings → Webhooks on the dashboard: Last ping fills in on save, the light turns from amber polling only to green webhooks active once a verified event has arrived within two poll intervals.

What the receiver does with each event is listed in Notifications → From GitHub webhooks. It ignores repositories outside REPOS / REPO_ALLOW_ORG, never starts a review, and responds 202 before doing any work. State lives in ROOT/webhooks.json (last_event_at, last_event, last_ping, count, last_error), which /api/settings exposes.

Keep the poller on. When a webhook event arrived within 2 × poll_interval_seconds, pr-watch.sh logs webhooks active; poll is a safety net and otherwise runs unchanged — it is the recovery path for a missed delivery. Once the light is green you can lower the interval or pause polling from the Poller card; nothing does that for you. GitHub must be able to reach the one path — deploy/README.md covers the Tailscale and Cloudflare Access cases.

The short version is on Install → Team. The rest of what the Docker install needs and offers:

  • Memory. A review refuses to start below MIN_FREE_MB — 800 MB available, not total — so a 1 GB VPS cannot run a single review, and a 2 GB box should not be running much else. Inside a container the figure is read from the cgroup budget rather than the host’s, so a small container on a large machine is measured honestly. Raise MIN_FREE_MB only if you know the host can take it.
  • Disk. About 3 GB for the image, plus room for one blobless clone per repository. A review, a QA guide or a profiling run refuses to start below MIN_FREE_DISK_MB — 500 MB free on the data volume. The doctor reads the same floor, so a passing disk check means the jobs will start.
  • Network during the build. The image fetches Debian packages, the GitHub CLI apt repository, Node 24 and the Claude Code CLI from npm. Behind a corporate proxy, export HTTP_PROXY / HTTPS_PROXY / NO_PROXY and pass them to the build (docker compose build --build-arg HTTP_PROXY=$HTTP_PROXY --build-arg HTTPS_PROXY=$HTTPS_PROXY), and configure the Docker daemon’s own proxy so the base images can be pulled. At run time the container needs to reach api.github.com, github.com and Anthropic.
  • Host port. Set RS_HOST_PORT=9000 in .env and docker compose up -d. That key is read only to interpolate the publish and is never passed into the container. RS_PORT is not the knob here: the container’s listen port is pinned to 8899 in docker-compose.yml, because EXPOSE, the healthcheck and the publish target all name it.
  • Profiles. docker compose --profile team up -d adds the review-request poller and Slack/Discord cards (Team mode). docker compose up -d demo seeds sample runs so you can explore the UI before wiring anything.
  • REPO=owner/name still works as a single-entry alias for REPOS.

docker compose exec app doctor prints one PASS / WARN / FAIL line per check: the .env it actually reads, the repositories in REPOS, whether the service token can see each of them, whether git, gh, claude, jq, flock, openssl and curl are on PATH, free disk and free RAM, whether /health answers, the installed skills, and who has signed in and connected Claude. It writes nothing and is safe to run at any time.

Everything it checks lives in the container, so that is where it has to run. bin/doctor.sh from the repository gets there by itself: it spots the Compose project, re-execs inside the running app container and says so on its first line. If app is not up it warns that it is checking the host instead, which on a Docker install will FAIL no matter how healthy the install is — use docker compose run --rm app doctor then. It does not check Docker itself; docker compose ps is that check.

The dashboard is a progressive web app. Once it is reachable over HTTPS (or on localhost), install it from the browser and it opens standalone, in the app’s own colours, from your home screen or dock:

  • iPhone / iPad (Safari): Share → Add to Home Screen.
  • Android (Chrome): the Install app prompt, or ⋮ → Add to Home screen.
  • Desktop (Chrome / Edge): the install icon in the address bar.

It is the same app as the tab; the sign-in and Claude connection carry over. Offline, it shows a “needs a connection to your server” page rather than stale data, because everything lives on your server. Push notifications per device are in Notifications; the desktop app’s phone pairing over a QR code is on Install → Desktop.

  • Poll frequency is the poll_interval_seconds runtime setting above (or POLL_INTERVAL as a fallback); there is no minutes-based .env key.
  • Review timeouts come from the effort level chosen when starting a run: Quick 12 minutes, Standard 25, Deep 40. They are fixed in bin/run-review.sh. A QA guide is sized from the diff instead, and is the one job with an override (RS_QA_TIMEOUT, above).
  • Request changes is a checkbox on the post form, per review. The default review event is COMMENT; the agent never sets it.
  • Claude credentials are per user: each reviewer connects their own Claude account in Integrations, and their runs use that token. There is no server-wide API key setting; a user who has not connected Claude cannot run reviews.
  • Per-user data (encrypted GitHub and Claude tokens, Slack member ID, Discord user ID, the admin flag) lives in ROOT/users.json, written by the dashboard on sign-in. To remove a user, delete their key.
  • ANTHROPIC_API_KEY is not read anywhere. There is no server-wide API key and no fallback: a review runs on the clicking user’s connected Claude account or it does not run.
  • RS_ACTOR, RS_EFFORT, RS_FOCUS, RS_DEPTH, RS_SKILL_CHOICE, RS_STACK, RS_CACHE_KEY, RS_RUN_AS are set by the server when it spawns run-review.sh, per run. They are not configuration; setting them in .env does nothing useful.

Every key above was re-checked against the code for this release. If you add a setting, add the row in the same change — a key that is read by bin/ and named nowhere here, or named here and read nowhere, is a bug in this page.

MIT licensed · Built on Claude Code