Self-hosted · LangGraph · Sandboxed
Argus.
The hundred-eyed reviewer for your pull requests. It clones the head commit, runs your real checks inside a sandbox, reads the surrounding code through tools, and publishes one honest review: findings anchored to file and line, a verdict, and a GitHub check run.
- Node ≥ 22.12
- Postgres + Redis
- Providers OpenAI · OpenRouter · nan.builders · openai-compatible
- Container docker compose
What it does
A review is not a diff summary. It is a claim about code that someone has to verify, so every finding carries the evidence that produced it.
Ground truth first
Your own test, lint and typecheck scripts run in a sandbox. A failing script is stronger evidence than an opinion.
Tools, not guessing
The model reads files, searches the repository and asks for more commands, bounded by a token, time and tool-call budget.
Evidence required
Critical, high and security findings must quote the line the model read, or the submission is rejected. A claim nobody can check is not published.
It remembers the PR
A re-review is a delta: it reports what is new and names what earlier runs found and this one did not raise again.
Quickstart
Local development runs the three processes directly and keeps Postgres and Redis in containers. Five commands, then a review.
# 1 — dependencies
npm install
# 2 — your settings: AUTH_SECRET is the only one with no default
cp .env.example .env
# 3 — infrastructure only (Postgres + Redis)
npm run dev:infra
# 4 — schema, then the three processes
npm run db:generate && npm run db:migrate
npm run dev # api :4000 · worker · web :3000
Open http://localhost:3000 and sign in. The API creates the first account from BOOTSTRAP_ADMIN_EMAIL / BOOTSTRAP_ADMIN_PASSWORD the first time it boots with an empty users table.
Optional: demo data
npm run db:seed # seeded repositories, runs and findings + admin@example.com / change-me-please
The seed adopts any account that already owns the admin email instead of failing, and never overwrites a password — reseeding cannot invalidate a real account.
Review pipeline
One review, step by step. Every step is idempotent and streams events, so a crashed worker can be resumed.
- Trigger — webhook (verified, deduplicated by delivery id), API call, CLI, or retry.
- Enqueue — a ReviewRun row with an idempotency key from (repository, pull request, head sha, trigger). Three webhooks from one push produce one review.
- Lock — a Redis lock on
review:<pullRequestId>stops two workers reviewing the same pull request at once. - Prepare — shallow clone of the head commit into a workspace keyed by the run, plus PR metadata, commits and file list from GitHub.
- Classify — changed files are bucketed (source, test, docs, generated, …) and the plan decides which checks are worth running.
- Run checks — planned scripts execute in the sandbox; output is parsed into findings with real file and line anchors.
- Agent loop — the model reads files, searches the repository, requests commands and drafts findings, bounded by budget and permissions.
- Validate — each draft is schema-checked, deduplicated, calibrated against
REVIEW_MIN_PUBLISH_CONFIDENCEand compared with the previous run. A model critique may only soften or discard — never strengthen. - Finalize — a verdict (
passed/neutral/failed), a narrative and counts. - Publish — summary comment found and updated in place via an embedded marker, plus a check run with annotations — or a pending approval when the gate is on.
| Severity | What it means for the check run |
|---|---|
| critical | Blocks by default. The check fails and the finding is published inline. |
| high | Blocks by default, same as critical. Both are configurable per repository. |
| medium | Published, does not block unless you add it to the blocking set. |
| low · info | Notes. Kept and shown, never blocking. |
Architecture
An npm-workspaces monorepo. The API, the worker and the CLI share one container: same dependency set, different entrypoint.
Packages
| Package | Responsibility |
|---|---|
| @acr/config | Environment schema, typed configuration, logger, workspace paths |
| @acr/shared | Domain types, ports, finding validation, budgets, prompt security |
| @acr/database | Prisma schema, review store, queries, the seeder |
| @acr/github | GitHub App auth, read and publish clients, webhook verification |
| @acr/sandbox | Docker and process command runners, workspace confinement |
| @acr/queue | BullMQ client, job schemas, Redis locks, worker pool |
| @acr/ai | LangGraph review graph, provider abstraction, tools, prompts |
| @acr/pipeline | The review use-case, publishing, container wiring |
Apps
| App | Responsibility |
|---|---|
| apps/api | Fastify HTTP API, sessions, SSE event stream, webhook receiver |
| apps/worker | BullMQ consumers, review reconciliation, health endpoint on :9100 |
| apps/web | Next.js dashboard: repositories, pull requests, runs, live events |
| apps/cli | One-shot review from the terminal, no dashboard needed |
Configuration
Every variable is validated in packages/config/src/env.ts. Startup fails with the list of what is wrong instead of booting half-configured, and .env.example documents all of them.
| Variable | Default | Effect |
|---|---|---|
| AUTH_SECRET | — | Session signing key. Required, ≥ 32 chars |
| DATABASE_URL | — | Postgres connection string |
| REDIS_URL | — | Optional. Without it the system runs inline: in-process locks, in-memory events |
| SANDBOX_MODE | docker | docker | process | off |
| ALLOW_PROCESS_SANDBOX | false | Must be true for process mode: an explicit opt-in to the weaker sandbox |
| SANDBOX_NETWORK | none | none blocks package installs during checks |
| REVIEW_COMMAND_EXECUTION | inline | inline runs checks in the reviewing process; queued dispatches them as worker jobs (needs Redis, and compose sets queued) |
| LLM_PROVIDER | openai-compatible | openai | openrouter | nan-builders | openai-compatible. The named ones carry a default base URL |
| LLM_BASE_URL | — | Explicit endpoint; overrides the provider preset. Required for openai-compatible |
| LLM_MODEL | gpt-4o-mini | Model id. OpenRouter uses vendor/model, nan.builders uses ids like glm5.3-flash |
| REVIEW_MAX_TOKENS | 200000 | Per-review token budget. Hitting it stops the run, it never silently truncates |
| REVIEW_MAX_DURATION_MS | 900000 | Per-review wall clock |
| REVIEW_MAX_TOOL_CALLS | 60 | Agent loop cap |
| REVIEW_MIN_PUBLISH_CONFIDENCE | 0.6 | Below it, findings are kept and shown but not published |
| REVIEW_REQUIRE_APPROVAL_FOR_PUBLISH | false | Adds the human gate before anything reaches GitHub |
| API_PUBLIC_URL | http://localhost:4000 | Used for the dashboard link inside published comments |
Provider presets
All four speak OpenAI Chat Completions, so tool calls and JSON mode behave identically. An explicit base URL always wins; openai-compatible has no default and needs one.
# OpenRouter
LLM_PROVIDER=openrouter
LLM_API_KEY=sk-or-...
LLM_MODEL=openai/gpt-4o
LLM_HTTP_REFERER=https://your-app.example # optional app attribution
LLM_APP_TITLE=Argus # optional app attribution
# nan.builders
LLM_PROVIDER=nan-builders
LLM_API_KEY=sk-...
LLM_MODEL=glm5.3-flash
# Any other gateway, local or hosted
LLM_PROVIDER=openai-compatible
LLM_BASE_URL=https://gateway.internal/v1
LLM_MODEL=my-model
Per-repository policy
Stored in the database and editable in the dashboard or via PATCH /repositories/:id/settings. It can narrow or widen the deployment defaults inside the bounds the environment allows: which checks run, deep review, inline findings, summary comment, check run, ignored paths, minimum confidence, blocking severities, the approval gate and the agent's permission set.
Connecting GitHub
A GitHub App is the full path: webhooks and installation tokens. A personal access token is the quick path: reviews without webhooks.
GitHub App (recommended)
| Where | What to set |
|---|---|
| github.com/settings/apps/new | Homepage URL, and a Webhook URL pointing at your deployment |
| Permissions | Contents: read · Issues: read & write · Pull requests: read & write · Checks: read & write · Metadata: read |
| Events | Pull request |
| Values into .env | GITHUB_APP_ID · GITHUB_APP_SLUG · GITHUB_PRIVATE_KEY_PATH · GITHUB_WEBHOOK_SECRET |
GITHUB_TOKEN and fails without one. Sync imports the installation's list and keeps the installation id, which is what mints tokens.
Personal access token
A classic token with the repo scope, or a fine-grained token with Contents: read, Issues: read & write, Pull requests: read & write, Checks: read & write. Put it in GITHUB_TOKEN. It cannot receive webhooks, so reviews are triggered from the dashboard, the CLI or the API.
Local webhook testing
# expose the API (port 4000, not the web) and point the App's Webhook URL at:
# https://<your-tunnel>/github/webhooks
cloudflared tunnel --url http://localhost:4000 # or ngrok http 4000
Saving the Webhook URL makes GitHub send a ping. A bad signature answers 401, a repeated delivery answers duplicate — that is the idempotency working, not an error. Redeliver from the App’s Advanced tab to see it.
Using it
Three doors to the same pipeline: the dashboard, the CLI, and HTTP.
Dashboard
- Repositories — every repository the platform can review, with its counts, plus Add and Sync.
- Repository detail — Trigger a review by number, the review policy, and access management.
- Pull requests — review history per PR, Sync from GitHub, Review now.
- Reviews — status, plan, usage, cost, node trace, findings, check runs, live events, approvals, SARIF export.
CLI
npm run review -- --repo owner/name --pr 42 # full review, no dashboard, no Redis
npm run review -- --repo owner/name --pr 42 --no-checks # fastest: AI pipeline only
npm run review -- --repo owner/name --pr 42 --publish # write the comment and check run
| Flag | Effect |
|---|---|
| --repo, --repository | Repository as owner/name (required) |
| --pr, --number | Pull request number (required) |
| --publish | Post the summary, inline findings and check run. Off by default: a local trial must not surprise the author |
| --no-checks | Skip test/lint/typecheck execution |
| --model <name> | Override LLM_MODEL for this run |
| --fresh | Force a new run for the same head instead of reusing it |
| --json · --quiet | Machine-readable result · only the final result |
The first run reads the repository and the pull request straight from GitHub and records both, so a PR shows up in the dashboard without waiting for a webhook. Exit code is non-zero when the run fails, so it works as a CI step.
HTTP API
Everything the dashboard uses, in plain HTTP with a session cookie. Routes are not prefixed: the API is either served on its own host or routed by your ingress.
| Method | Path | Notes |
|---|---|---|
| GET | /health | Never fails, never rate limited: version, uptime, dependency probes |
| GET | /ready | 200 only when the database answers; also lists configuration issues |
| POST | /auth/login | Rate limited, sets the acr_session cookie |
| GET | /auth/me | Current user, or 401 |
| GET POST | /repositories | List what you can see; add a repository (admin) |
| POST | /repositories/sync | Import the App installation's repository list |
| PATCH | /repositories/:id/settings | Review policy for one repository |
| GET PUT DELETE | /repositories/:id/access | Grant, replace or revoke a user's access |
| GET | /pull-requests | Filterable by repository, author, state |
| POST | /pull-requests/:id/review | Trigger a review; returns the run id immediately. Needs triage |
| POST | /reviews | Create a run by repository + number. Same triage rule as above |
| GET | /reviews/:id | Status, plan, usage, cost, node trace |
| GET | /reviews/:id/findings | Paginated, filterable by severity and category |
| POST | /reviews/:id/publish · /reject | Decide the pending approval and publish the stored snapshot, or cancel |
| POST | /reviews/:id/retry | New run for the same head with a fresh idempotency key. Needs triage |
| GET | /reviews/:id/events | Server-Sent Events: replay from Last-Event-ID, then tail live |
| POST | /github/webhooks | Signature-verified, delivery-deduplicated |
Errors are one envelope — { code, message, details, requestId } — with codes mirroring the status: validation_error, unauthorized, forbidden, not_found, conflict, internal_error. Every response carries x-request-id, which is also the key in the logs.
Sandbox, permissions and cost
The model never decides what is safe. Budgets, permissions and publishing are computed from configuration, and the agent operates inside those bounds.
Sandbox modes
| Mode | What it gives you |
|---|---|
| docker | Default. Container with CPU, memory and PID limits, a writable per-run workspace, --network none by default, --cap-drop ALL, no-new-privileges and a command allow-list |
| process | Runs on the host. Requires ALLOW_PROCESS_SANDBOX=true, an explicit opt-in, and is meant for environments that cannot run containers |
| off | No command execution at all. The review reasons from the diff and the files it can read |
Workspace confinement is enforced with real paths, so a symlink cannot escape the run's directory, and the command allow-list is derived from the repository manifest — the model can request a script, not invent a command.
Prompt injection
Pull request text is untrusted input from strangers. It is wrapped in a per-request random boundary, injection signals are detected and surfaced, and the instruction hints field is the repository owner's channel, not the author's.
Cost
- Every run records tokens in, tokens out and an estimated cost, visible per run and per repository.
- Budget limits (tokens, wall clock, tool calls) stop a run instead of letting it drift; the run records that it hit the limit.
- A single surviving finding skips the adversarial critique — a deliberate cost decision, not an omission.
Deployment
docker-compose.yml defines postgres, redis, migrate, api, worker and web. Four commands and the stack is up.
docker compose build
docker compose up -d
docker compose logs -f api worker
# health: GET /health (never fails) · GET /ready (gates on the database)
- One backend image with
apiandworkertargets: same dependency set, different entrypoint. - The
migrateservice runsprisma migrate deployand gates the API withservice_completed_successfully, so nothing serves traffic on an old schema. apiandworkershare theworkspacesvolume — that is what makesREVIEW_COMMAND_EXECUTION=queuedwork: the worker opens the exact path the API created.- Scale by adding workers; BullMQ plus the per-pull-request lock makes that safe.
- The worker image carries the Docker CLI. Mount
/var/run/docker.sockonly if you accept that a container can then control the host daemon; it is commented out for that reason.
Before you call it production
- Set
NODE_ENV=productionexplicitly. Compose substitutes it from your.env, so a container run inheritsdevelopment— and the production invariant checks only run when it isproduction. - Mount the App private key and point
GITHUB_PRIVATE_KEY_PATHat the in-container path, or passGITHUB_PRIVATE_KEYinline. - Set
COOKIE_SECURE=truebehind TLS, and keep real secrets in the environment rather than a committed file. The API already honorsX-Forwarded-*throughtrustProxy. - Back up Postgres: the volumes hold every run, finding and approval. Redis needs its AOF, already enabled in compose, so accepted reviews survive a restart.
Testing
One command runs everything that is expected to pass.
npm run verify # typecheck + typecheck:tests + lint + all test projects
npm run test:unit # no database needed
npm run test:integration
npm run test:e2e
npm run test:e2e:web # Playwright against the dashboard
skipped / already_running. Stop the app containers first, or give the tests their own Redis.
The database-backed suites read the seeded fixtures, so run npm run db:seed once after a fresh volume. A review is a real HTTP and database flow in e2e, not a mocked one.
Troubleshooting
Real failures, and what they actually mean.
| Symptom | Cause and fix |
|---|---|
| table does not exist | Migrations never ran. npm run db:migrate before booting. |
| Environment variable not found: DATABASE_URL | The Prisma CLI reads the .env from its working directory. The root scripts run it from the repo root with an explicit schema for exactly this reason; run them from the root. |
| Cannot find module '@acr/…' on build | Stale *.tsbuildinfo next to a missing dist: tsc -b believes the projects are current and emits nothing. Delete the tsbuildinfo, or use the package’s clean script, which now removes both. |
| Repository has no GitHub App installation | The row came from Add repository (no installation) and GITHUB_TOKEN is empty. Sync the repository, or set a token. |
api exits with pino-pretty error | LOG_PRETTY=true in an image without dev dependencies. The logger falls back to JSON and warns; set LOG_PRETTY=false. |
Web build fails on /_global-error | next build ran with NODE_ENV=development. The image sets production for the build step; if you build by hand, do the same. |
Review stuck in QUEUED | Its job is not in the queue. The worker reconciles on start: it re-dispatches QUEUED runs past the threshold that have no live job, so restart the worker once. |
| db:seed fails on user email | Older builds upserted the admin by a fixed id and collided with the bootstrap account. Current code adopts the id the email already has. |
| 401 on the webhook | Signature mismatch: GITHUB_WEBHOOK_SECRET must be exactly the App’s secret. Also point the tunnel at the API (4000), not the web (3000). |
Known limits
What this does not do yet, stated plainly.
- A review is a model’s reading of your code. Evidence is required for blocking findings and the checks give ground truth, but a finding is a claim to verify, not a verdict you delegate.
- A re-review reports a delta: it never claims a previous finding was fixed, only that this run did not raise it again.
- Checks are npm-shaped. A repository whose scripts live elsewhere gets the AI review without the sandboxed checks.
- SARIF export is generated by the dashboard from a run’s findings; uploading it to code scanning is your pipeline’s job, not automatic.
- TLS, ingress, secret management and database backups are deployment concerns this repository does not ship.