# lgtmaybe — full documentation

> AI code review for GitHub, GitLab, Gitea, Azure DevOps, and local Git diffs. It posts inline findings and a summary on pull and merge requests, or prints findings locally. Choose from seven hosted providers, ollama, or any OpenAI-compatible endpoint. Bedrock, Vertex, and Azure support keyless cloud auth.

Source: https://lgtmaybe.coles.codes/ — generated from the docs/ tree.

---

<!-- Source: https://lgtmaybe.coles.codes/ -->

<div class="hero" markdown>

![lgtmaybe logo: a shrugging face with curly-brace arms](assets/logo.svg){ width="128" }

# lgtmaybe

</div>

lgtmaybe reviews pull and merge requests on **GitHub, GitLab, Gitea, and Azure
DevOps**. You can also review a local Git diff before pushing. Choose a hosted
model, ollama, or another OpenAI-compatible endpoint. Reviews on a code host post inline
comments and one summary; local reviews print findings in your terminal.

It reads the diff and a little surrounding code, but comments only on changed
lines. It never checks out or runs the change. Generated files and binaries are
skipped, secrets are redacted, and all author-supplied text is treated as
untrusted.

It checks for:

- **Correctness and security:** logic errors, missed `await`s, injection, auth mistakes, and leaked secrets.
- **Code health:** performance problems, needless complexity, deprecations, and risky dependencies.
- **Tests and documentation:** missing or weak tests, stale comments, and undocumented APIs.
- **Intent:** whether the change does what its title, description, and commits say it does.
- **Ponytail:** code you don't need, standard-library opportunities, and simpler ways to get the job done.

Findings are graded from `info` to `critical` and land on the changed line
they refer to. A clean change gets a 👍 **LGTM!**.

![An inline lgtmaybe review comment flagging a [CRITICAL] SQL injection vulnerability, with an explanation and a committable parameterised-query suggestion](assets/marketplace/marketplace-screenshot-1.png){ width="720" }

Slash commands can refresh a review (`/review`, `/improve`), answer a question
(`/ask`), post a structured description (`/describe`), or post
[a change overview](how-to/generate-a-change-diagram.md) (`/diagram`). The
overview rates the risk of change, lists high-impact areas, and draws a
flowchart, plus a sequence diagram when the change alters a runtime flow. Run
`lgtmaybe diagram` to print a text version locally.

```mermaid
flowchart LR
    web["Storefront<br/>React<br/>places orders"]
    api["Order API<br/>Python<br/>creates orders (changed)"]
    db["Order database<br/>PostgreSQL<br/>stores orders"]
    queue["Order events<br/>SQS<br/>buffers events (new)"]
    worker["Notification worker<br/>Python<br/>sends confirmations (new)"]
    email["Email provider<br/>delivers messages"]
    web -->|calls| api
    api -->|stores in| db
    api -->|publishes to| queue
    queue -->|delivers to| worker
    worker -->|sends via| email
```

## Start here

<div class="grid cards" markdown>

- **Tutorial:** [Getting started](tutorial/getting-started.md): your first review with ollama, locally and free.
- **How-to:** [install the CLI](how-to/install-the-cli.md), [choose a review model](how-to/choose-a-review-model.md), [run locally](how-to/run-locally-with-ollama.md), [Bedrock OIDC](how-to/review-with-bedrock-oidc.md), [Vertex WIF](how-to/review-with-vertex-wif.md), [Azure OpenAI](how-to/review-with-azure.md), [GitHub Action](how-to/use-as-github-action.md), [GitLab](how-to/review-on-gitlab.md), [Gitea](how-to/review-on-gitea.md), [Azure DevOps](how-to/review-on-azure-devops.md).
- **Reference:** [Configuration](reference/config.md): every config field and schema.
- **Explanation:** [What gets reviewed](explanation/what-gets-reviewed.md), [Architecture](explanation/architecture.md), [Auth model](explanation/auth-model.md), [Data & privacy](explanation/data-and-privacy.md).

</div>

## Providers

| Provider | Auth |
|---|---|
| `openai` | `OPENAI_API_KEY` |
| `anthropic` | `ANTHROPIC_API_KEY` |
| `openrouter` | `OPENROUTER_API_KEY` |
| `zai` | `ZAI_API_KEY` for GLM / Zhipu AI; optional `--api-base` for the China / coding-plan endpoint |
| `bedrock` | Ambient AWS creds via GitHub OIDC, no static key |
| `vertex` | Ambient GCP creds via Workload Identity Federation, no key |
| `azure` | Resource endpoint, plus `AZURE_API_KEY` or keyless Entra (Azure AD) creds via GitHub OIDC |
| `ollama` | None: local only, zero cost |
| `openai-compatible` | `--api-base` to any OpenAI `/v1` endpoint; key optional (placeholder for keyless local servers) |

## Where It Posts

The model provider and the code host are independent: any provider above works
on any host below.

| Host | How it runs | Token | Guide |
|---|---|---|---|
| GitHub | GitHub Action | `GITHUB_TOKEN` | [GitHub Action](how-to/use-as-github-action.md) |
| GitLab | GitLab CI job | `GITLAB_TOKEN` | [Review on GitLab](how-to/review-on-gitlab.md) |
| Gitea | Gitea Actions | `GITEA_TOKEN` | [Review on Gitea](how-to/review-on-gitea.md) |
| Azure DevOps | Azure Pipelines job | `SYSTEM_ACCESSTOKEN` or `AZURE_DEVOPS_TOKEN` | [Review on Azure DevOps](how-to/review-on-azure-devops.md) |
| None | `lgtmaybe review` locally | n/a | [Install the CLI](how-to/install-the-cli.md) |

The same review runs on every host. They differ in thread resolution,
incremental reviews, and keyless cloud auth. See
[Architecture](explanation/architecture.md#code-hosts-forges) for the details.

## For AI Agents

The site root publishes a curated [`llms.txt`](llms.txt) index of these docs,
plus a whole-corpus [`llms-full.txt`](llms-full.txt), for LLM crawlers and
coding agents.

---

<!-- Source: https://lgtmaybe.coles.codes/tutorial/getting-started/ -->

# Getting Started with lgtmaybe

This tutorial walks you through your first review using **ollama**, which runs
the model on your own machine, costs nothing, and needs no API keys. By the end
you will have reviewed a branch and seen the findings in your terminal, with no
GitHub token and no pull request.

## What You Need

- Python 3.11 or later
- [ollama](https://ollama.com) running locally
- A local git repository with some changes on a branch to review

## Step 1: Install lgtmaybe

```bash
pip install lgtmaybe
```

On macOS you can install from the Homebrew tap instead:

```bash
brew tap MattJColes/tap
brew trust MattJColes/tap   # current Homebrew requires trusting third-party taps
brew install lgtmaybe
```

[Install the CLI](../how-to/install-the-cli.md) explains the `brew trust` step,
the Windows (WinGet) install, and which providers the base install covers.
API-key and local providers work out of the box; keyless cloud providers need a
`pip` extra.

Verify the install:

```bash
lgtmaybe --help
```

## Step 2: Start ollama and Pull a Model

```bash
ollama serve          # starts the local server on http://localhost:11434
ollama pull qwen3.6:27b   # or any model you prefer
```

Leave `ollama serve` running in a separate terminal.

## Step 3: Point lgtmaybe at ollama

lgtmaybe has no built-in model, so tell it once which provider and model to
use:

```bash
lgtmaybe config init
```

It finds the running ollama and the models you have pulled, so you only
confirm:

```console
Found ollama running at http://localhost:11434 (1 model pulled) - use ollama? [Y/n]:
Pulled models: qwen3.6:27b
Model [qwen3.6:27b]:
Checking ollama / qwen3.6:27b with one short request...
OK: the model answered.
Wrote /home/you/.config/lgtmaybe/config.yml
Next: lgtmaybe review
```

Press Enter twice. Before it writes anything, it sends the model one tiny
request, so a stopped server or a model you have not pulled fails here, not
halfway through a review. The file holds only the provider and model; if you
later use a hosted provider, its API key stays in your environment.

If ollama is on another machine, answer `n`, choose `ollama`, and enter its
address. You can also skip `config init` and pass `--provider ollama --model
qwen3.6:27b` to each command. Without either, `lgtmaybe review` stops straight
away and tells you to run `config init`.

## Step 4: Review Your Changes

From inside a git repo, on a branch with some changes, run:

```bash
lgtmaybe review
```

lgtmaybe diffs your current branch against the remote primary branch
(`origin/HEAD`, falling back to `origin/main` / `origin/master`, then a local
`main` / `master`). It sends the diff, with some surrounding lines for context,
to your local qwen3.6:27b model and prints the findings to your terminal:

```console
src/app.py:14  [MEDIUM] Missing None check before use (confidence 8/10)
  find_user() returns None for an unknown id, so user.email raises AttributeError.

1 finding · risk low (no risk signals) · provider ollama · model qwen3.6:27b · lgtmaybe 2.11.1
```

Each finding shows its file and line, severity, and title, then the
explanation. The last line counts the findings, rates the risk of the change,
and names the model and lgtmaybe version.

To review the whole worktree (your branch's commits plus uncommitted edits),
add `--working`. To review only the uncommitted edits, add `--uncommitted`. To
diff against a different base, pass `--base main`.

`lgtmaybe review --help` lists the everyday options; `--help-all` adds the
advanced tuning ones.

## Step 5: Change the Output Format

`--format` controls what `review` prints. `--json` (shorthand for
`--format json`) emits a JSON array ready to pipe into other tooling:

```bash
lgtmaybe review --json
```

`--format agent` prints the findings as correction instructions an AI coding
agent can read and apply, for a local review-and-fix loop. See
[Fix findings with an AI agent](../how-to/fix-findings-with-an-ai-agent.md).

`--format sarif` prints a SARIF 2.1.0 log you can upload to GitHub code
scanning or open in an IDE. See
[Upload findings as SARIF](../how-to/upload-findings-as-sarif.md).

## Step 6: See the Whole Change

`lgtmaybe diagram` sends the same local diff to three concurrent model calls and
prints the **change overview**:

- a description of what you changed
- a risk-of-change rating (computed locally, no model call)
- a **High Impact Areas** section calling out anything that could bite, such as
  infrastructure, security posture, outage risk, migrations, or backups
- a diagram of the components your change touches, plus a sequence diagram
  when it alters a run-time flow

```bash
lgtmaybe diagram
```

It takes the same `--base` / `--working` / `--uncommitted` flags as `review`.
Run `review` then `diagram` before you open a pull request: the first tells you
what's wrong with the change, the second what the change is and what it could
break. The diagrams print as Mermaid source plus a text rendering for the
terminal. Paste the Mermaid into a GitHub comment or
[mermaid.live](https://mermaid.live) to see it drawn. See
[Generate a change overview](../how-to/generate-a-change-diagram.md).

## Step 7: Post Reviews on Real Pull Requests

The CLI reviews local changes. To post inline comments and a summary on real
pull requests, wire lgtmaybe into your code host. The review is the same on
every host; only the setup differs:

- **GitHub:** add the Action to your repo.
  See [Use as a GitHub Action](../how-to/use-as-github-action.md).
- **GitLab:** add a CI job.
  See [Review on GitLab](../how-to/review-on-gitlab.md).
- **Gitea:** add a Gitea Actions workflow.
  See [Review on Gitea](../how-to/review-on-gitea.md).
- **Azure DevOps:** add an Azure Pipelines build validation policy.
  See [Review on Azure DevOps](../how-to/review-on-azure-devops.md).

## What Happened Under the Hood

lgtmaybe ran its pipeline over your local diff:

1. **Fetch:** read the diff from your local repo with `git`.
2. **Filter:** skipped generated files, binaries, and lockfiles, and redacted
   anything that looked like a secret.
3. **Review:** sent four concurrent model calls (security, correctness, code
   health, and artefacts for tests and documentation), each asking for findings
   as JSON.
4. **Parse and anchor:** validated each reply against the findings schema and
   pinned every finding to the changed line it quotes.
5. **Reflect:** asked the model to audit its own findings and dropped the ones
   it could not stand behind.
6. **Filter findings:** dropped defect findings with no concrete failure
   scenario and anything below the severity floor (`low` by default).
7. **Render:** printed the findings. The GitHub Action posts them to the pull
   request instead.

## Next Steps

- To configure severity thresholds, path filters, and token caps, see
  [Configure .lgtmaybe.yml](../how-to/configure-lgtmaybe-yml.md).
- To use a cloud provider with no API keys, see
  [Review with Bedrock OIDC](../how-to/review-with-bedrock-oidc.md) or
  [Review with Vertex WIF](../how-to/review-with-vertex-wif.md).
- To use lgtmaybe in a GitHub Actions workflow, see
  [Use as a GitHub Action](../how-to/use-as-github-action.md).

---

<!-- Source: https://lgtmaybe.coles.codes/how-to/install-the-cli/ -->

# Install the CLI

Install the `lgtmaybe` command-line tool once, then point it at any provider.
This page covers getting the CLI onto your machine. Choosing a model backend (a
hosted API, keyless cloud, or a local model) comes after: see the
[provider how-tos](../index.md#providers) and
[Choose a review model](choose-a-review-model.md).

## Install (pip)

Works on any OS with Python 3.11+:

```bash
pip install lgtmaybe
```

Verify it:

```bash
lgtmaybe --help
```

That prints the command list with usage examples. `<command> --help` lists a
command's options. For `lgtmaybe review` it shows only the everyday ones and
ends with a line pointing at `lgtmaybe review --help-all`, which lists the
advanced tuning options too.

To check which build you are running, for a bug report or a benchmark record:

```bash
lgtmaybe --version
```

It prints one line, `lgtmaybe <version>`, for every install method (pip,
Homebrew, or the Windows executable). A source checkout that was never
installed has no version metadata and prints `lgtmaybe unknown`.

Upgrade later with `pip install --upgrade lgtmaybe`.

## Install on Windows (WinGet)

The WinGet package is a portable executable for Windows x86_64 (64-bit).
Install it by its exact package identifier:

```powershell
winget install --id MattJColes.lgtmaybe --exact
```

Verify the command alias:

```powershell
lgtmaybe --help
```

Upgrade or uninstall it later with the same package identifier:

```powershell
winget upgrade --id MattJColes.lgtmaybe --exact
winget uninstall --id MattJColes.lgtmaybe --exact
```

If WinGet cannot find a newly published version, refresh its package sources
and retry:

```powershell
winget source update
```

The executable bundles ast-grep for cross-file symbol resolution. It does not
bundle the optional cloud SDKs for keyless Bedrock, Vertex, or Azure, so use
pip with the matching extra for those providers (see
[below](#cloud-providers-need-extras)). `pip install lgtmaybe` also works on
Windows.

## Install on macOS (Homebrew)

On macOS (and Linuxbrew) you can install from the project's
[Homebrew tap](https://github.com/MattJColes/homebrew-tap) instead of `pip`:

```bash
brew tap MattJColes/tap
brew trust MattJColes/tap
brew install lgtmaybe
```

The middle step is required. Current Homebrew refuses to load a formula from a
third-party tap until you explicitly **trust** it (`Error: Refusing to load
formula … from untrusted tap`). You decide this once per machine, for every tap
outside Homebrew's core, and a tap author cannot waive it for you. To trust
only this formula instead of the whole tap, use
`brew trust --formula MattJColes/tap/lgtmaybe`.

`brew install MattJColes/tap/lgtmaybe` taps and installs in one line, but still
needs the `brew trust` step first or it stops at the untrusted-tap error.

Upgrade later with `brew upgrade lgtmaybe`.

If you tapped the earlier `MattJColes/lgtmaybe` name, switch over once with
`brew untap MattJColes/lgtmaybe && brew tap MattJColes/tap`, then
`brew trust MattJColes/tap`. Upgrades keep working on the old tap because GitHub
redirects the renamed repo, so switching is optional tidying.

Homebrew installs lgtmaybe into its own virtualenv, so it never touches your
system or project Python. The formula installs lgtmaybe and its dependencies
from prebuilt **PyPI wheels** (no compiling), so a first install takes about a
minute, mostly download time. There is no prebuilt bottle, so it works on any
architecture and macOS version. It also installs the `ast-grep` formula, which
lgtmaybe uses for cross-file symbol resolution.

## Cloud Providers Need Extras

The base pip install, Homebrew formula, and Windows executable cover every
API-key and local provider:

| Providers | Covered by base install? |
|---|---|
| `openai`, `anthropic`, `openrouter`, `zai`: set the provider's API key in your environment | ✅ Yes |
| `ollama`, `openai-compatible`: point `--api-base` at the server (local, or a hosted OpenAI-compatible API) | ✅ Yes |
| `bedrock`, `vertex`, `azure`: keyless cloud (OIDC/WIF) | ❌ Needs an extra |

The keyless cloud providers need cloud SDKs that the Homebrew formula and
Windows executable do not bundle. Install the CLI from PyPI with the matching
extra:

```bash
pip install 'lgtmaybe[bedrock]'   # or [vertex] / [azure]
```

Or run them through the [GitHub Action](use-as-github-action.md), whose image
bundles all three SDKs and handles the OIDC/WIF exchange for you.

To use `--static-analysis` locally, install the tools it drives with
`pip install 'lgtmaybe[static-analysis]'` (ruff, bandit, mypy, semgrep, and
zizmor). gitleaks and osv-scanner are separate binaries you install yourself;
any tool that is not installed is skipped. The Action image bundles all of
them.

To serve reviews to a coding agent with `lgtmaybe mcp`, install the MCP SDK
with `pip install 'lgtmaybe[mcp]'`. The Homebrew formula and Windows executable
do not include it. See
[Use from a coding agent over MCP](use-from-a-coding-agent-over-mcp.md).

## Configure a Provider

lgtmaybe has no built-in model, so tell it which provider and model to use
once:

```bash
lgtmaybe config init
```

It looks for a provider API key in your environment (`ANTHROPIC_API_KEY`,
`OPENAI_API_KEY`, `OPENROUTER_API_KEY`, `ZAI_API_KEY`), Azure, AWS or Google
Cloud credentials, and a running ollama with its pulled models. It offers the
first one it finds, suggests a current model, then checks the pair with one
tiny request so a wrong key or model fails now rather than mid-review. It
writes the provider and model to `~/.config/lgtmaybe/config.yml`; API keys stay
in your environment and are never written. Pass `--no-verify` to skip the
check, for example when you are offline.

Until a provider is configured (by `config init`, a `.lgtmaybe.yml`, or
`--provider`/`--model`), `lgtmaybe review` stops at once with a pointer to
`config init` instead of guessing a model.

## Next Steps

- Run your first local review: [Getting started](../tutorial/getting-started.md).
- Diagram what a change touches: [Generate a change overview](generate-a-change-diagram.md).
- Post reviews on real pull requests: [Use as a GitHub Action](use-as-github-action.md).

---

<!-- Source: https://lgtmaybe.coles.codes/how-to/choose-a-review-model/ -->

# Choose a Review Model

**Start with `google/gemini-3.8-flash` on OpenRouter.** In the 24 September
2026 benchmark on lgtmaybe 2.8.5, it led the 11 tested models on both small,
varied changes (60.9%) and large Python diffs (71.4%), and its breadth result
is fully adjudicated. It still flagged findings on seven of nine clean changes,
so check its output before acting on it.

```bash
export OPENROUTER_API_KEY=...
lgtmaybe review --provider openrouter --model google/gemini-3.8-flash
```

If large diffs matter more than small changes, consider `openai/gpt-6-luna`. It
was second on long horizon (64.6%) and finished those five cases in 440 seconds
of total review time, against Gemini's 798. Its breadth recall was much lower
(42.9% against 62.9%), so it is not the everyday default.

These recommendations come from a synthetic corpus, not your repository. Try
the candidates on recent pull requests before choosing a team default. See [Review with OpenRouter](review-with-openrouter.md) for setup.

## What the Suites Measure

- **Breadth:** 32 small changes across seven programming languages, GitHub
  Actions, and Terraform, with 72 planted findings and nine verified-clean
  changes. Each model reviews the suite three times; the table reports the
  median result.
- **Long horizon:** Four increasingly large Python changes with eight planted
  bugs each, plus one large clean change. Each model reviews the suite once.

The suites use different cases, so **compare scores within a suite, never
across suites.** Score is closed-world F0.5, which weights precision above
recall, multiplied by completeness: the share of review calls that returned
parseable findings. An unmatched finding counts as a false positive even if it
might be useful on real code. A provisional breadth score still has a few
findings awaiting human adjudication.

## Cloud Models Tested on lgtmaybe 2.8.5

### Small Changes: Breadth

| Model | Score | Recall | Precision | False positives | Clean pass |
|---|---:|---:|---:|---:|---:|
| `google/gemini-3.8-flash` | **60.9%** | 62.9% | 79.3% | 12 | 22.2% |
| `x-ai/grok-4.7` † | 55.7% | 62.9% | 70.1% | 20 | 11.1% |
| `z-ai/glm-5.3-flash` † | 55.1% | 62.9% | 70.0% | 21 | 11.1% |
| `moonshotai/kimi-k3` † | 52.0% | 65.7% | 63.3% | 27 | 0.0% |
| `openai/gpt-6-sol` | 50.0% | 60.0% | 62.9% | 26 | 11.1% |
| `openai/gpt-6-luna` † | 50.0% | 42.9% | 70.5% | 13 | 22.2% |
| `z-ai/glm-5.3` † | 49.9% | 64.3% | 67.6% | 22 | 11.1% |
| `xiaomi/mimo-v2.6-flash` † | 46.2% | 62.9% | 61.3% | 29 | 0.0% |
| `qwen/qwen3.8-flash` † | 45.4% | 62.9% | 59.5% | 32 | 11.1% |
| `openai/gpt-6-astra` | 27.7% | 44.3% | 66.7% | 16 | 44.4% |
| `anthropic/claude-opus-5.5` | 6.0% | 1.4% | 100.0% | 0 | 100.0% |

† Provisional: some borderline findings remain unadjudicated. Clean pass is
the share of nine clean changes on which the model returned no findings. Opus's
perfect precision and clean pass came from finding almost none of the planted
bugs. Astra's score was limited in part by 45.3% completeness.

### Large Diffs: Long Horizon

| Model | Score | Recall | Precision | False positives |
|---|---:|---:|---:|---:|
| `google/gemini-3.8-flash` | **71.4%** | 87.5% | 75.7% | 9 |
| `openai/gpt-6-luna` | 64.6% | 84.4% | 69.2% | 12 |
| `openai/gpt-6-sol` | 30.6% | 78.1% | 29.8% | 59 |
| `moonshotai/kimi-k3` | 30.4% | 100.0% | 37.6% | 53 |
| `qwen/qwen3.8-flash` | 28.3% | 78.1% | 58.1% | 18 |
| `openai/gpt-6-astra` | 25.5% | 56.2% | 40.0% | 27 |
| `z-ai/glm-5.3-flash` | 24.3% | 59.4% | 48.7% | 20 |
| `x-ai/grok-4.7` | 21.9% | 65.6% | 36.2% | 37 |
| `xiaomi/mimo-v2.6-flash` | 21.5% | 71.9% | 44.2% | 29 |
| `z-ai/glm-5.3` | 8.1% | 81.2% | 12.4% | 183 |
| `anthropic/claude-opus-5.5` | 0.0% | 0.0% | 100.0% | 0 |

Kimi found all 32 planted bugs but added 53 false positives. The low scores
for Qwen Flash and Xiaomi also reflect completeness below 50%: many of their
review calls produced no parseable findings. The [benchmark results][results] show
per-case recall, token use, truncation, and wall time.

## Local Models

The published local runs used older lgtmaybe versions and have not been
repeated on 2.8.5. Among local models with canonical runs on both suites,
`nvidia/Qwen3.6-35B-A3B-NVFP4` is the strongest: 48.2% breadth on 2.2.0 and
43.8% long horizon on 2.1.4. Treat those numbers as a starting point for a
local trial, not a comparison with the cloud tables above. The example below
reaches it through a local OpenAI-compatible server such as vLLM:

```bash
lgtmaybe review \
  --provider openai-compatible \
  --model nvidia/Qwen3.6-35B-A3B-NVFP4 \
  --api-base http://127.0.0.1:8000/v1
```

See [Run locally with ollama](run-locally-with-ollama.md) for hardware guidance
and [Other OpenAI-compatible servers](use-a-custom-openai-compatible-endpoint.md)
for vLLM, llama.cpp, and LM Studio setup.

## Source and Limits

The [benchmark repository][bench] holds the live leaderboard, [complete
results][results], reproducible commands, and append-only raw results. Older
runs stay there for historical comparison, but their model versions, lgtmaybe
versions, and sometimes review profiles differ. The cloud recommendations on
this page use the 22 canonical runs from 24 September 2026 (11 models, two
suites).

The corpus is synthetic and does not measure provider price, availability,
data handling, or performance on your codebase. No run in this cohort has an
immutable audit trace; the live leaderboard reports audit availability.

[bench]: https://github.com/MattJColes/lgtmaybe-benchmarks
[results]: https://github.com/MattJColes/lgtmaybe-benchmarks/blob/main/RESULTS.md

---

<!-- Source: https://lgtmaybe.coles.codes/how-to/use-as-github-action/ -->

# Use lgtmaybe as a GitHub Action

Use this guide to add lgtmaybe to a repository as a GitHub Actions workflow
that reviews pull requests automatically.

!!! tip "Not on GitHub?"

    lgtmaybe runs the same review on GitLab and Gitea. See
    [Review on GitLab](./review-on-gitlab.md) or
    [Review on Gitea](./review-on-gitea.md).

lgtmaybe is listed on the
[GitHub Marketplace](https://github.com/marketplace/actions/lgtmaybe). It is a
**GitHub Action**: the reviewer runs in your workflow, and its provider, model,
and provider credentials go in the step's `with:` block. Reviews post as
`github-actions[bot]` using the workflow's own token. To post as
`lgtmaybe[bot]` instead, install the public lgtmaybe App and set
`github_identity: lgtmaybe`; the App changes only the comment author. The
[minimal OpenAI workflow](#minimal-workflow-openai) below shows the complete
default setup, and
[`examples/workflows/`](https://github.com/MattJColes/lgtmaybe/tree/main/examples/workflows)
has a ready-to-copy workflow for every cloud and API-key provider.

Alongside the review, lgtmaybe posts a change overview comment on opened,
reopened, and pushed-to pull requests (`auto_diagram`, on by default). It
holds a description, a risk-of-change rating, a **High Impact Areas** section,
and a Mermaid flowchart, plus a sequence diagram when the change alters a
run-time flow. Each push refreshes it. Set `auto_diagram: false` for no
overview, or drop one section with `auto_describe: false`, `high_impact: false`,
or `risk: false`. If the PR description, or a comment lgtmaybe didn't post,
already has a Mermaid diagram, the automatic overview is skipped and the review
summary says so. `/diagram` still posts one on request.

ollama runs the model on your own machine, so use it through the
[CLI](run-locally-with-ollama.md) rather than a posting workflow.

## Contents

- [Security requirement: pull_request_target](#security-requirement-pull_request_target)
- [Who can trigger a review](#who-can-trigger-a-review)
- [Minimal workflow: openai](#minimal-workflow-openai)
- [Choose the GitHub author](#choose-the-github-author)
- [Other key-based providers](#other-key-based-providers)
- [Keyless cloud workflows](#keyless-cloud-workflows)
- [Action inputs](#action-inputs)
- [Gate merges on findings](#gate-merges-on-findings)
- [Adding a config file](#adding-a-config-file)
- [Pin to a specific version](#pin-to-a-specific-version)
- [On another code host](#on-another-code-host)

## Security Requirement: pull_request_target

All lgtmaybe workflows use the `pull_request_target` trigger, not
`pull_request`. This is non-negotiable:

- `pull_request_target` runs in the context of the **base branch**, so it can
  read secrets and write to the PR.
- lgtmaybe **never checks out or runs PR code**. It fetches the diff through
  the GitHub API, so the PR author cannot get code to run in the reviewer's
  environment.

The action reads the PR from the triggering event, so there is no PR URL input
to set. On an `issue_comment` event it runs the slash command (`/review`,
`/improve`, `/ask`, `/describe`, `/diagram`) through the same engine. On a
`synchronize` push the review is **incremental** by default: only the commits
added since the last completed review are reviewed, and earlier findings stay
open until fixed. Comment `/review full` for a full re-review, or fix the
behaviour with the `incremental` input or config key.

Replies inside finding threads do not trigger lgtmaybe, and a reply is not
evidence that a finding is fixed. Push the fix instead: when the next review no
longer reports the finding and GitHub marks the thread outdated, lgtmaybe
resolves the thread and replies `✅ Looks resolved.` Use `/ask` when you want
the model to answer a question.

> **Upgrading:** the `answer_replies` option has been removed. Delete it from
> `.lgtmaybe.yml` (configuration is strict, so a leftover key is an error) and
> from your Action inputs, and remove the `pull_request_review_comment` trigger
> from custom workflows.

> **Note on cost.** With ollama the model runs on your own hardware, so reviews
> are free. On a hosted provider each run uses tokens you pay for, so decide
> who can trigger one (next section). The default limits that to people you
> trust, and `max_files` (in `.lgtmaybe.yml`) and `max_input_tokens` keep any
> single run modest.

## Who Can Trigger a Review

You choose who reviews run for. The example workflows gate the `review` job on
the triggering user's
[author association](https://docs.github.com/en/graphql/reference/enums#commentauthorassociation)
and default to **trusted contributors**: `OWNER`, `MEMBER`, and `COLLABORATOR`.
A maintainer can still review an outside contributor's PR at any time by
commenting `/review` on it, because their own association passes the gate.

The same `if:` also requires a comment to carry a slash command before any job
starts. `issue_comment` fires on every comment on every pull request, so
without that clause an ordinary "lgtm, merging" would claim a runner, pull the
container, and start Python only to find no command and exit. That spends no
tokens, but it queues a job ahead of reviews that have work to do. The check is
substring-based and case-insensitive, so `/REVIEW` and `/review full` both pass.

To change the policy, edit the `if:` on the `review` job:

- **Everyone:** drop the author-association checks so any PR or slash command
  runs a review (keep the slash-command clause, or every comment starts a job).
  This suits an open project, but on a hosted provider it means anyone can
  start a paid run.
- **Returning contributors too:** add `CONTRIBUTOR` to also auto-review anyone
  who has committed to the repository before.
- **Admins only:** keep just `OWNER` (plus `MEMBER` for your organisation).

For extra guardrails, require approval for fork-PR workflow runs in
**Settings → Actions → General → Fork pull request workflows**, or move the
provider key behind a protected `environment`. See
[Trust and Cost](../explanation/trust-and-cost.md) for the reasoning behind
these options.

## Minimal Workflow: openai

```yaml
name: lgtmaybe

on:
  pull_request_target:
  issue_comment:
    types: [created]

permissions:
  contents: read
  pull-requests: write

# One run per PR and event type: a new push cancels an in-flight review, but
# the overview's own issue_comment event cannot cancel the review that posted it.
concurrency:
  group: lgtmaybe-${{ github.event.pull_request.number || github.event.issue.number }}-${{ github.event_name }}
  cancel-in-progress: true

jobs:
  review:
    # Only trusted authors (owner / member / collaborator) can trigger a review,
    # and a comment only starts a job when it carries a slash command.
    if: >-
      (github.event_name == 'pull_request_target' &&
       contains(fromJson('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.pull_request.author_association)) ||
      (github.event.issue.pull_request &&
       contains(fromJson('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association) &&
       (contains(github.event.comment.body, '/review') ||
        contains(github.event.comment.body, '/improve') ||
        contains(github.event.comment.body, '/ask') ||
        contains(github.event.comment.body, '/describe') ||
        contains(github.event.comment.body, '/diagram')))
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7 # base repo only, for .lgtmaybe.yml
      - uses: MattJColes/lgtmaybe@v2
        with:
          provider: openai
          model: gpt-5.5
          api_key: ${{ secrets.OPENAI_API_KEY }}
```

## Choose the GitHub Author

The minimal workflow uses GitHub's built-in workflow token and posts as
`github-actions[bot]`. This is the default and needs no App installation.

To post as `lgtmaybe[bot]`, install the public lgtmaybe App on selected
repositories, add `id-token: write` to the workflow, and set
`github_identity: lgtmaybe`. The provider, model, and provider key stay where
they are. [Post as lgtmaybe[bot]](./post-as-a-github-app.md) has the complete
workflow, the permission boundary, and uninstall instructions.

If your organisation runs its own GitHub App, the same guide covers the
advanced `app_id` / `app_private_key` inputs.

## Other Key-Based Providers

Swap the `provider`, `model`, and `api_key` inputs:

```yaml
# anthropic
- uses: MattJColes/lgtmaybe@v2
  with:
    provider: anthropic
    model: claude-sonnet-4-6
    api_key: ${{ secrets.ANTHROPIC_API_KEY }}

# openrouter
- uses: MattJColes/lgtmaybe@v2
  with:
    provider: openrouter
    model: anthropic/claude-sonnet-4.6
    api_key: ${{ secrets.OPENROUTER_API_KEY }}

# zai (GLM / Zhipu AI)
- uses: MattJColes/lgtmaybe@v2
  with:
    provider: zai
    model: glm-4.6
    api_key: ${{ secrets.ZAI_API_KEY }}

# openai-compatible (here DeepSeek's hosted API)
- uses: MattJColes/lgtmaybe@v2
  with:
    provider: openai-compatible
    model: deepseek-chat
    api_base: https://api.deepseek.com/v1
    api_key: ${{ secrets.OPENAI_COMPATIBLE_API_KEY }}
```

For each one, create an API key in the provider's console, add it as a
repository secret (**Settings → Secrets and variables → Actions**), and
reference it as `api_key`.

## Keyless Cloud Workflows

Bedrock (AWS OIDC), Vertex (GCP WIF), and Azure (Entra OIDC) need **no API keys
in secrets**. Pass `aws_role_arn`, `gcp_wif_provider`, or `azure_client_id` and
the action performs the keyless token exchange for you. All three need the
`id-token: write` permission. See:

- [Review with Bedrock OIDC](./review-with-bedrock-oidc.md)
- [Review with Vertex WIF](./review-with-vertex-wif.md)
- [Review with Azure OpenAI](./review-with-azure.md)

## Action Inputs

Every input is optional. Leave one empty to use the default shown. Most review
settings can also go in `.lgtmaybe.yml`; an input you set overrides the file.

### Provider and Model

| Input | Default | Description |
|---|---|---|
| `provider` | none | One of `openai`, `openrouter`, `anthropic`, `zai`, `bedrock`, `vertex`, `azure`, `ollama`, `openai-compatible` |
| `model` | none | Model identifier for the chosen provider |
| `api_key` | none | API key for key-based providers, including hosted `openai-compatible` endpoints. Leave empty for bedrock, vertex, ollama, keyless azure, and keyless local servers |
| `api_base` | none | Resource endpoint for azure (`https://<resource>.openai.azure.com`), the `/v1` base for `openai-compatible` (for example `https://api.deepseek.com/v1`), or a custom base URL for another provider |
| `fallback_model` | none | Model to re-run a call on when `model` cannot answer it. For a truncated reply it is the *last* remedy, tried only after a smaller payload, a lower `reasoning_effort`, and reasoning switched off have failed on `model`; for any other failure lgtmaybe switches to it straight away. Overrides `fallback_model` in `.lgtmaybe.yml`. See [Configure fallback_model](configure-lgtmaybe-yml.md#fallback_model) for provider scope, cost, and failure behaviour |
| `triage_model` | none (no triage) | Cheap model that runs first to skip plainly non-substantive files and rank the rest by risk. Security-relevant files always escalate past triage |
| `reflect_model` | `model` | Model for the self-reflection (false-positive audit) pass. Point it at a stronger model to audit a weaker reviewer's findings |
| `language` | English | Human language for finding titles and bodies and the describe/diagram text (for example `Japanese`). Paths, severities, and suggested code are unchanged |

### What Runs

| Input | Default | Description |
|---|---|---|
| `preset` | `fast` | `fast` runs four calls (security, correctness, code health, and artefacts for tests and documentation) on every provider; `full` runs one call per lens. An explicit `categories` list in `.lgtmaybe.yml` overrides it |
| `incremental` | auto | Review only the commits added since the last completed review on `synchronize` pushes, and the whole PR elsewhere. `true`/`false` forces it. Falls back to a full review after a force-push or when there is no earlier review. `/review full` forces a full re-review on demand |
| `recursive` | `true` | Review a file whose diff exceeds `max_input_tokens` hunk by hunk instead of sending it whole. Set `false` to disable |
| `spec_review` | `true` | Check the diff against a committed spec (OpenSpec, GitHub Spec Kit, Kiro). Runs only when a spec is detected and matches the PR, so a repository without specs pays nothing |
| `static_analysis` | `false` | Run deterministic tools (ruff, bandit, mypy, gitleaks, zizmor, ast-grep, osv-scanner, semgrep) in a sandbox over the changed files. Linters feed the model untrusted hints to confirm or discard; gitleaks, zizmor, ast-grep, and osv-scanner post findings directly with no model call. The image bundles every tool and an offline vulnerability database |
| `mid_review_retrieval` | `false` | Let a lens ask once for read-only files outside the diff, then re-run with them, instead of hedging a cross-file finding. Costs at most one extra call per batch and lens |
| `symbol_resolution` | `true` | During reflection, use ast-grep in a read-only shallow clone of the base branch to find the definition of a symbol a finding depends on, so cross-file findings are re-judged against the real code |
| `learn_feedback` | `true` | On a re-run, suppress a finding a human reacted 👎 to on its inline comment. Resolving a thread is not a suppress signal (it means fixed) |
| `resolve_fixed` | `true` | Resolve a finding's conversation once a re-run no longer reports it and the thread is outdated. Set `false` to resolve by hand |

### What Gets Posted

| Input | Default | Description |
|---|---|---|
| `auto_diagram` | `true` | On an opened, reopened, or pushed-to PR, post or refresh the change overview comment. Set `false` to opt out of the whole comment |
| `auto_describe` | `true` | Head the change overview with a structured description (title, change type, summary, per-file walkthrough, intent check). Set `false` to drop the section; `/describe` still works |
| `high_impact` | `true` | Include the High Impact Areas section: infrastructure, security posture, outage risk, data migrations, backups and recovery, compatibility, observability, dependencies, cost, and compliance. Set `false` to drop the section and its model call |
| `risk` | `true` | Rate the PR's risk of change (low/medium/high/critical) from importer counts, sensitive paths, size, and untested code. Deterministic, no model call. Shown on the summary line, in the overview, as a hidden `lgtmaybe-risk` marker, and (with `pr_labels`) as a `risk/<level>` label |
| `pr_labels` | `false` | Attach derived labels: `review-effort/1-5`, `possible-security-issue`, `consider-splitting`, and `risk/<level>`. Best-effort, no extra model calls |
| `fail_on` | off | Merge-gate threshold (`info`/`low`/`medium`/`high`/`critical`). Creates a `lgtmaybe` Check Run that **fails** when any finding is at or above this severity. See [Gate merges on findings](#gate-merges-on-findings) |
| `profile` | `false` | Print a timing profile in the Action log: per-stage and per-call tables with token and prompt-cache usage |

### Limits and Model Tuning

| Input | Default | Description |
|---|---|---|
| `timeout` | provider default (ollama/openai-compatible/openrouter 1800s, cloud 600s) | Enforced wall-clock timeout for each model call. For ollama and openai-compatible, which may be a single-slot server that queues calls, the default is multiplied by the concurrency, then capped at `max_review_seconds` but never below 1800s. Transient failures (capacity 429s, connection blips, 5xx) are retried with exponential backoff; permanent ones (bad key, quota or billing 429, unknown model) and a call that uses up this whole timeout fail fast, because re-sending the same request against the same budget would only burn it twice |
| `max_review_seconds` | `3600` | Soft wall-clock ceiling for the whole review. Once it passes, queued calls are skipped and partial results post with a notice. A cancelled or timed-out job does the same (on SIGINT/SIGTERM). `0` disables |
| `max_review_tokens` | `0` (off) | Soft ceiling on billable tokens (input plus output) across every model call. Once it passes, no further calls start and the review posts what it has with a notice. Run once with `profile: true`, read the total, then set this above it |
| `max_concurrency` | `6` | Concurrent model calls across the whole review. Set `1` for a serial local run |
| `max_input_tokens` | `100000` | Token budget per model call before the diff is split into batches. Left unset, it is lowered to fit the model's context window when that is known |
| `max_tokens` | 16384 on ollama, openai-compatible and openrouter; the model's own ceiling elsewhere | Cap on the tokens each call may generate. `0` sends no cap. OpenRouter reserves prompt plus `max_tokens` against a prepaid balance, so the finite default keeps that bounded; raise it when a long answer is being cut off |
| `max_findings_per_lens` | `50` | Ceiling on findings from one (batch, lens) call, a backstop against a degenerate reply. When it fires, the highest-severity findings are kept and the review names the lens and the count dropped. `0` disables |
| `reasoning_effort` | route default | Bound how much a reasoning model thinks per call: `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, or `default`. Use it when lens calls truncate or run long |
| `temperature` | `0.0` | Sampling temperature (`0.0` is deterministic) |
| `num_ctx` | `32768` | ollama context window (ignored for hosted providers) |
| `structured_output` | `true` | Constrain output to the findings JSON schema via `response_format` (JSON mode). Set `false` for an `openai-compatible` gateway that rejects it |
| `repair_unparseable` | `true` | When a reply cannot be parsed, send it back once (with the schema, without the diff) to be reformatted. Costs nothing on a healthy run |
| `retry_without_schema` | `true` | If the reformat also fails, re-run that lens once without `response_format`. Only applies to calls that sent the schema |

### Cloud Auth

| Input | Default | Description |
|---|---|---|
| `aws_role_arn` | none | IAM role ARN to assume via OIDC for bedrock (keyless) |
| `aws_region` | `us-east-1` | AWS region for bedrock |
| `gcp_wif_provider` | none | Workload Identity Federation provider resource name for vertex |
| `gcp_service_account` | none | GCP service account email to impersonate via WIF |
| `azure_client_id` | none | Entra (Azure AD) client ID with a federated credential, for keyless azure via OIDC |
| `azure_tenant_id` | none | Entra (Azure AD) tenant ID for keyless azure |

### GitHub Identity and Runtime

| Input | Default | Description |
|---|---|---|
| `config_path` | `.lgtmaybe.yml` | Path to the config file, relative to the repo root. With the default, a missing file is fine; a path you set must exist |
| `github_token` | `${{ github.token }}` | Token for reading the PR and posting the review |
| `github_identity` | `actions` | Comment author: `actions` posts as `github-actions[bot]`; `lgtmaybe` posts as `lgtmaybe[bot]` once the public App is installed and `id-token: write` is granted |
| `identity_broker_url` | managed endpoint | Advanced override for the public App identity exchange |
| `app_id` | none | Advanced: ID of your own GitHub App, used with `app_private_key`. Do not combine with `github_identity: lgtmaybe` |
| `app_private_key` | none | Advanced: private key of your own App named by `app_id` |
| `app_owner` | current repo's owner | Owner for a cross-repo App token |
| `app_repositories` | current repo | Repositories the App token may access, newline- or comma-separated. Use with `app_owner` |
| `image` | `ghcr.io/mattjcoles/lgtmaybe:v2` | Override the container image (advanced) |

The action passes `GITHUB_TOKEN` and the provider credentials into the
container itself, so you do not set them as `env`.

## Gate Merges on Findings

Set `fail_on` to a severity to turn the review into a merge gate. After posting
the review, lgtmaybe creates a **Check Run** named `lgtmaybe`. Its conclusion is
`failure` when any surviving finding is at or above that severity, and
`success` otherwise. lgtmaybe never sets PR approval state, so a clean review
stays comment-only.

```yaml
permissions:
  contents: read
  pull-requests: write
  checks: write # needed to create the Check Run

# ...

- uses: MattJColes/lgtmaybe@v2
  with:
    provider: openai
    model: gpt-5.5
    api_key: ${{ secrets.OPENAI_API_KEY }}
    fail_on: high # block merge on any high/critical finding
```

To make it block merges, mark the check as required in **branch protection**:

1. Open **Settings → Branches → Branch protection rules** (or a ruleset) for the
   target branch.
2. Enable **Require status checks to pass before merging**.
3. Search for and add the **`lgtmaybe`** check. It appears in the list once the
   Action has run with `fail_on` set on a PR against that branch.

A PR with a finding at or above the threshold then shows a failing `lgtmaybe`
check and cannot merge until the finding is resolved or `fail_on` is set to a higher
severity (for example `high` to `critical`).
Leave `fail_on` unset (the default) to keep reviews advisory; no check run is
created.

Creating the Check Run requires `checks: write`, as in the snippet above. The
public `lgtmaybe[bot]` App intentionally does not hold that permission, so
`github_identity: lgtmaybe` cannot be combined with `fail_on`. Use the default
Actions identity or a self-managed App granted `Checks: write`.

### What the Public App Cannot Do

The public `lgtmaybe[bot]` App holds a deliberately minimal permission set, so
two features are unavailable under `github_identity: lgtmaybe`:

| Feature | Behaviour under the public App |
|---|---|
| `fail_on` (merge-gate Check Run) | Refused up front with a setup error, because it needs `checks: write` |
| `resolve_fixed` (auto-resolve fixed conversations) | GitHub refuses `resolveReviewThread`, so threads stay open. The review is unaffected: nothing is posted to the thread, the refusal is logged once with what to do, and the run continues |

Both work under the default `actions` identity (with `pull-requests: write` in
the workflow `permissions:` block) or a self-managed App holding the matching
permission. Set `resolve_fixed: false` to stop attempting it.

## Adding a Config File

Place a `.lgtmaybe.yml` at the repo root to control severity thresholds, path
filters, file caps, and more. Set `config_path` if it lives elsewhere. See
[Configure .lgtmaybe.yml](./configure-lgtmaybe-yml.md) for all options.

## Pin to a Specific Version

`@v2` is a floating tag that tracks the latest `v2.x.x` release. To pin the
action exactly, use a full version tag:

```yaml
uses: MattJColes/lgtmaybe@v2.11.1
```

The action's default `image` is the floating `ghcr.io/mattjcoles/lgtmaybe:v2`,
so pinning the tag alone still runs the newest v2 container. To pin the
reviewer too, set the image to the matching version tag (no `v` prefix):

```yaml
uses: MattJColes/lgtmaybe@v2.11.1
with:
  image: ghcr.io/mattjcoles/lgtmaybe:2.11.1
  # ...provider, model, api_key as usual
```

## On Another Code Host

The review is identical on GitLab and Gitea; only the wiring and a few
host-capability differences change:

- [Review on GitLab](./review-on-gitlab.md): a CI job running
  `lgtmaybe gitlab-ci` on merge request pipelines.
- [Review on Gitea](./review-on-gitea.md): Gitea Actions, running the same
  container as GitHub.

Keyless cloud auth (Bedrock, Vertex, Azure) relies on GitHub Actions' OIDC
token, so it is available only on GitHub. On the other hosts use an API-key
provider or `ollama`.

---

<!-- Source: https://lgtmaybe.coles.codes/how-to/post-as-a-github-app/ -->

# Post as lgtmaybe[bot]

lgtmaybe is a GitHub Action. The Action runs the reviewer, and the provider,
model, and provider credentials stay in the workflow's `with:` block. A GitHub
App is optional: it only changes the author of lgtmaybe's GitHub comments from
`github-actions[bot]` to a branded bot.

You have three choices:

- **Do nothing:** the Action uses GitHub's workflow token and posts as
  `github-actions[bot]`.
- **Install the public lgtmaybe App:** the Action posts as `lgtmaybe[bot]`. You
  never receive or manage an App private key.
- **Use your own GitHub App:** an advanced, self-managed setup where you supply
  its App ID and private key.

## Install the public lgtmaybe App

1. [Install the lgtmaybe App](https://github.com/apps/lgtmaybe/installations/new)
   and select only the repositories where it should review pull requests.
2. Add `id-token: write` to the workflow permissions.
3. Set `github_identity: lgtmaybe` on the Action step.

Here is a complete OpenAI workflow. It gates reviews on trusted authors and
slash commands, as explained in
[Who can trigger a review](use-as-github-action.md#who-can-trigger-a-review):

```yaml
name: lgtmaybe

on:
  pull_request_target:
  issue_comment:
    types: [created]

permissions:
  contents: read
  id-token: write

jobs:
  review:
    if: >-
      (github.event_name == 'pull_request_target' &&
       contains(fromJson('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.pull_request.author_association)) ||
      (github.event.issue.pull_request &&
       contains(fromJson('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association) &&
       (contains(github.event.comment.body, '/review') ||
        contains(github.event.comment.body, '/improve') ||
        contains(github.event.comment.body, '/ask') ||
        contains(github.event.comment.body, '/describe') ||
        contains(github.event.comment.body, '/diagram')))
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7 # base repository only
      - uses: MattJColes/lgtmaybe@v2
        with:
          github_identity: lgtmaybe
          provider: openai
          model: gpt-5.5
          api_key: ${{ secrets.OPENAI_API_KEY }}
```

The provider configuration is unchanged: `github_identity` chooses who posts
on GitHub, not which model runs or where.

The public App has only:

- `contents: read`, to fetch repository and pull-request content
- `pull requests: write`, to post reviews, comments, and labels
- `issues: write`, to answer pull-request issue comments

The Action exchanges GitHub's signed workflow identity token for a short-lived
App token limited to the triggering repository and exactly those permissions.
The identity broker checks the repository, the event, and that the workflow
runs from the default branch. It never receives your diff, provider key,
prompt, model response, or review findings.

`github_identity: lgtmaybe` is an explicit choice. If `id-token: write` is
missing, the App is not installed on the repository, the workflow is not
trusted, or the identity service is unavailable, the job fails with setup
guidance. A misconfiguration is never papered over by quietly posting as
`github-actions[bot]`.

Reviews and the `/review`, `/improve`, `/ask`, `/describe`, and `/diagram`
commands arrive on `pull_request_target` or `issue_comment`, which run from the
default branch, so they post as `lgtmaybe[bot]`. Replies inside review threads
do not start a model run; the next review checks a pushed fix.

To remove access, uninstall the App or remove the repository from the
installation. To return to `github-actions[bot]`, remove
`github_identity: lgtmaybe` and `id-token: write` from the workflow (and add
back `pull-requests: write`). The Action revokes its temporary App token after
each run, and GitHub expires it anyway if that cleanup cannot run.

The public App intentionally has no `Checks: write` permission, so
`github_identity: lgtmaybe` cannot be combined with `fail_on`. When lgtmaybe
must create a merge gate, keep the default `github_identity: actions` and grant
the workflow `checks: write`, or use a self-managed App with `Checks: write`.
The public App also cannot resolve fixed conversations: GitHub refuses
`resolveReviewThread` for it, so those threads stay open. See
[What the public App cannot do](use-as-github-action.md#what-the-public-app-cannot-do).

## Use your own GitHub App

Use this path only when your organisation already runs a GitHub App and wants
reviews attributed to it.

Give the App the repository permissions `contents: read`,
`pull requests: write`, and `issues: write`, plus `checks: write` if you use
`fail_on`. Install it on the repositories it may review. Store its ID as the
`LGTMAYBE_APP_ID` repository variable and its PEM private key as the
`LGTMAYBE_APP_PRIVATE_KEY` Actions secret:

```yaml
permissions:
  contents: read

steps:
  - uses: actions/checkout@v7
  - uses: MattJColes/lgtmaybe@v2
    with:
      provider: openai
      model: gpt-5.5
      api_key: ${{ secrets.OPENAI_API_KEY }}
      app_id: ${{ vars.LGTMAYBE_APP_ID }}
      app_private_key: ${{ secrets.LGTMAYBE_APP_PRIVATE_KEY }}
```

The Action mints a short-lived token for the current repository from these and
revokes it at the end of the job. To review across repositories, set
`app_owner` and `app_repositories` (see the
[Action inputs](use-as-github-action.md#github-identity-and-runtime)).

Do not combine `app_id` / `app_private_key` with
`github_identity: lgtmaybe`. The Action checks this before minting a token.

---

<!-- Source: https://lgtmaybe.coles.codes/how-to/review-on-azure-devops/ -->

# Review Pull Requests on Azure DevOps

lgtmaybe posts reviews to Azure DevOps Repos as well as GitHub, GitLab and
Gitea. The review itself is identical (same lenses, same reflection pass, same
findings) because none of it depends on the host. Only the adapter that reads
the change and posts the comments differs.

Azure DevOps Services is supported: `dev.azure.com/{organisation}` and the
legacy `{organisation}.visualstudio.com`. Azure DevOps Server (on-premises) is
not supported yet.

## Set It Up

Azure Pipelines runs a pull request build from the YAML in the pull request
itself, so the job that holds the model key must not live in the repository
being reviewed. The setup below keeps it in a template in a separate, protected
repository, and the reviewed repository only extends it. Read
[Security: who can change the pipeline](#security-who-can-change-the-pipeline)
before choosing anything simpler.

1. **Create a protected template repository.** For example
   `Platform/pipeline-templates`. Add branch policies to its `main` branch so
   nobody can change it without a review from someone trusted with the secrets.
   Commit
   [`examples/azure-devops/lgtmaybe-review.yml`](https://github.com/MattJColes/lgtmaybe/blob/main/examples/azure-devops/lgtmaybe-review.yml)
   to it.

2. **Store the model key in a variable group.** In *Pipelines → Library*,
   create a variable group named `lgtmaybe-secrets` holding the provider key as
   a secret, for example `ANTHROPIC_API_KEY`. Use a key with a spend limit.

3. **Require the template.** On that variable group, open *Approvals and
   checks*, add a **Required template** check, and name the template
   repository, `refs/heads/main` and `lgtmaybe-review.yml`. A pipeline that does
   not extend exactly that template is refused the key, whatever its YAML says.

4. **Let the pipeline comment.** lgtmaybe posts with the job's own token,
   `System.AccessToken`. In *Project settings → Repositories → Security*,
   select the project's build service identity
   (`<Project> Build Service (<Organisation>)`) on the reviewed repository and
   allow **Contribute to pull requests**, and nothing broader.

5. **Add the pipeline to the reviewed repository.** Copy
   [`examples/azure-devops/azure-pipelines.yml`](https://github.com/MattJColes/lgtmaybe/blob/main/examples/azure-devops/azure-pipelines.yml),
   point its `resources.repositories` entry at your template repository, and
   create a pipeline from it. Approve its access to the template repository and
   the variable group on the first run.

6. **Run it on pull requests.** Azure Repos starts pull request builds from a
   branch policy, not from a `pr:` trigger. In *Project settings →
   Repositories → Policies*, open the target branch and add the pipeline under
   **Build validation**.

The reviewed repository's pipeline holds no steps and no secrets:

```yaml
trigger: none

resources:
  repositories:
    - repository: trusted
      type: git
      name: Platform/pipeline-templates
      ref: refs/heads/main

extends:
  template: lgtmaybe-review.yml@trusted
```

The template in the protected repository runs the GHCR image with the
`azure-pipelines` command:

```yaml
jobs:
  - job: lgtmaybe
    pool:
      vmImage: ubuntu-latest
    variables:
      - group: lgtmaybe-secrets
    steps:
      - checkout: none

      - script: |
          docker run --rm \
            -e SYSTEM_COLLECTIONURI -e SYSTEM_TEAMPROJECT \
            -e BUILD_REPOSITORY_NAME -e BUILD_REPOSITORY_PROVIDER \
            -e SYSTEM_PULLREQUEST_PULLREQUESTID \
            -e SYSTEM_ACCESSTOKEN -e ANTHROPIC_API_KEY \
            ghcr.io/mattjcoles/lgtmaybe:v2 \
            azure-pipelines --provider anthropic --model claude-sonnet-4-6
        displayName: lgtmaybe review
        env:
          SYSTEM_ACCESSTOKEN: $(System.AccessToken)
          ANTHROPIC_API_KEY: $(ANTHROPIC_API_KEY)
```

Secret variables, `System.AccessToken` included, reach a script only when they
are mapped under `env:`, which is why both are listed there. The template takes
no parameters on purpose: a parameter would let the pull request's YAML pass
steps into the job that holds the secrets.

`azure-pipelines` finds the pull request from the predefined variables
`SYSTEM_COLLECTIONURI`, `SYSTEM_TEAMPROJECT`, `BUILD_REPOSITORY_NAME` and
`SYSTEM_PULLREQUEST_PULLREQUESTID`. On a build that is not validating a pull
request there is no id, and lgtmaybe fails with an error naming the missing
variable rather than guessing. A pipeline building a GitHub repository is
refused too: those pull requests live on GitHub, so use the
[GitHub Action](use-as-github-action.md). Azure Pipelines always sets
`BUILD_REPOSITORY_PROVIDER`; when it is unset (running the command by hand),
the repository is treated as Azure Repos.

Configure the review with the command's flags: `--provider`, `--model`,
`--fallback-model`, `--api-key`, `--api-base` and `--config`.

### Without Docker

On a self-hosted agent without Docker, install the CLI instead. These steps
replace the `steps:` of the protected template, never the reviewed
repository's pipeline:

```yaml
    steps:
      - checkout: none
      - task: UsePythonVersion@0
        inputs:
          versionSpec: "3.12"
      - script: |
          pip install lgtmaybe
          lgtmaybe azure-pipelines --provider anthropic --model claude-sonnet-4-6
        displayName: lgtmaybe review
        env:
          SYSTEM_ACCESSTOKEN: $(System.AccessToken)
          ANTHROPIC_API_KEY: $(ANTHROPIC_API_KEY)
```

## Tokens

lgtmaybe reads the Azure DevOps token from one of two variables:

| Variable | What it is | Sent as |
|---|---|---|
| `AZURE_DEVOPS_TOKEN` | A personal access token with **Code (Read & write)** scope | Basic auth, empty user name |
| `SYSTEM_ACCESSTOKEN` | The job's own token, mapped from `$(System.AccessToken)` | Bearer token |

When both are set, `AZURE_DEVOPS_TOKEN` wins. Use a personal access token when
the comments should come from a named account rather than the build service.
Either way the token travels only in the `Authorization` header and is never
logged.

## Try It Locally First

Reviewing a pull request by URL happens in the pipeline, where the token and
the pull request live. Locally, `lgtmaybe review` reviews the branch you have
checked out against the remote primary branch, and prints the findings instead
of posting them:

```bash
git switch my-feature-branch
export ANTHROPIC_API_KEY=...
lgtmaybe review --provider anthropic --model claude-sonnet-4-6
```

## How It Differs from GitHub

Azure DevOps serves no unified diff. lgtmaybe reads the pull request's latest
iteration, lists the files it changes against the merge base, fetches each
file's text before and after, and builds the diff itself. Lockfiles, vendored
and binary files are listed as changed but never fetched, and a file side over
1 MB is treated like a binary. A pull request changing more than 300 files is
not reviewed; lgtmaybe says so instead of reviewing part of it. The same goes
when Azure DevOps reports no merge base for the pull request. A file Azure
DevOps keeps throttling or failing to return is retried, then left out on its
own and named under **Files not reviewed** in the summary; a 401 or 403 fails
the review, since that means the token is wrong.

Each finding is its own comment thread on the changed line, left **active** so
a "resolve all comments" branch policy can hold the merge until someone deals
with it. The summary is a general thread that is edited in place on each run.
It is created **closed**, so it never holds a merge on its own. A finding whose
thread is already open, or marked *Won't fix* or *Closed*, is not posted again;
a finding whose thread was marked *Resolved* and that comes back is.

On a later run, a finding thread lgtmaybe opened is marked *Resolved*, with a
`✅ Looks resolved.` reply, when the finding is no longer reported **and** the
line it was posted on has changed since. Azure DevOps has no "this code moved"
flag, so lgtmaybe records the iteration each thread was posted at and compares
the file then with the file now. Threads anyone else opened are never touched,
and nothing is resolved after a run that failed or finished only partly. The
token needs the same *Contribute to pull requests* permission it already uses
to post. Set `resolve_fixed: false` in `.lgtmaybe.yml` to resolve threads by
hand. A thread posted by an earlier lgtmaybe version may carry no iteration,
and one that doesn't stays open for a person to resolve.

These are not available yet:

| Not available | Why | What happens instead |
|---|---|---|
| Incremental review | Not built yet | Every run is a full review |
| Slash commands (`/review`, `/ask`, …) | `azure-pipelines` reviews the pull request; nothing routes comments to it | Queue the build again to review again |
| Change overview comment | `azure-pipelines` runs the review only | No description or diagram thread is posted |
| PR labels and the `fail_on` merge gate | Not built yet in the Azure DevOps adapter | No labels or pull request status are set |
| 👎 feedback (`learn_feedback`) | Not built yet | A downvoted finding can be posted again on a later run |
| Symbol resolution | No base-branch clone in the Azure DevOps adapter | Reflection judges cross-file findings without the symbol's definition |
| One-click suggestions | Azure DevOps has no suggestion blocks | A suggested fix shows as a code block |
| Azure DevOps Server | Only Azure DevOps Services URLs are recognised | The URL is refused with an error naming Azure DevOps Server |

Keyless model auth (Bedrock, Vertex, Azure OpenAI through GitHub OIDC) is built
into the GitHub composite action, not the `azure-pipelines` command. Use an API
key, or `ollama` against a model on the agent.

## Security: Who Can Change the Pipeline

A pull request build in Azure Pipelines runs the YAML from the pull request's
own merge result. This is the opposite of GitHub's `pull_request_target`, where
the base branch's workflow runs. Anyone who can push a branch and open a pull
request can therefore rewrite a pipeline file in that repository, keep its
variable mappings, and send every secret those steps can read to a server of
their choosing. `checkout: none` does not help: the YAML itself is the part the
pull request controls.

So a pipeline file in the reviewed repository that maps a secret is only safe
when everyone who can open a pull request there is already trusted with that
secret. Otherwise use the setup above:

- The secret-bearing job lives in a template in a separate repository with
  branch policies, and the reviewed repository only `extends` it.
- The model key sits in a variable group guarded by a **Required template**
  check, so a pipeline that drops the `extends`, or points it at another branch
  or file, is refused the key.
- **Builds of pull requests from forks get no secrets by default. Keep that
  setting off.**
- Grant the build service only **Contribute to pull requests** on the reviewed
  repository, and keep *Limit job authorization scope to current project*
  turned on. Any pipeline can map its own `System.AccessToken`, so its
  permissions are what a rewritten pipeline could use.
- Prefer `System.AccessToken` over a personal access token. If you must use a
  personal access token, give it only **Code (Read & write)** on this
  organisation, a short expiry, and keep it in the protected variable group.
- Use a model key with a spend limit, so a leaked key has a ceiling.

lgtmaybe itself never checks out or executes pull request code. It reads the
change through the API and treats every byte of it as untrusted input.

---

<!-- Source: https://lgtmaybe.coles.codes/how-to/review-on-gitea/ -->

# Review Pull Requests on Gitea

lgtmaybe posts reviews to Gitea as well as GitHub. The review itself is
identical (same lenses, same reflection pass, same findings) because none of it
depends on the host. Only the adapter that fetches the diff and posts the
comments differs.

## Set It Up

The example workflow needs Gitea 1.21 or later, for the `pull_request_target`
trigger. Read
[Security: Who Can Change the Workflow](#security-who-can-change-the-workflow)
for why it matters.

1. **Create a token.** Sign in as a dedicated account with write access to the
   reviewed repository only. In Gitea, go to *Settings → Applications → Access
   Tokens* and create a token with the `write:repository`, `write:issue` and
   `read:user` scopes (`read:user` lets lgtmaybe tell its own comments apart). Add it to your repository as a secret named `LGTMAYBE_GITEA_TOKEN`.
   Gitea refuses secret names that start with `GITEA_` or `GITHUB_`, and the
   example maps this secret to the `GITEA_TOKEN` variable lgtmaybe reads.

    You need all three. `write:repository` also covers reading, and Gitea files the
    review endpoint lgtmaybe posts inline comments through
    (`POST /pulls/{index}/reviews`) and the commit-status endpoint under the
    *repository* category, not *issue*, so a token with only `read:repository`
    is refused on the one call the setup exists to make. `write:issue` covers
    the summary comment and the PR labels. `read:user` lets lgtmaybe ask Gitea
    who the token is (`GET /user`): only comments that identity wrote count as
    lgtmaybe's, so nobody else can edit the summary through us or paste a hidden
    id that hides a finding. Without it every re-run posts a fresh summary and
    re-posts its findings rather than trusting comments it cannot attribute.

2. **Add a provider key.** Add the API key for whichever model you want as a
   second secret, for example `ANTHROPIC_API_KEY`. Use a key with a spend
   limit.

3. **Add the workflow.** Copy
   [`examples/gitea/lgtmaybe.yml`](https://github.com/MattJColes/lgtmaybe/blob/main/examples/gitea/lgtmaybe.yml)
   into your repository at `.gitea/workflows/lgtmaybe.yml`.

That is the whole setup. Gitea Actions reimplements the GitHub Actions runtime,
so the example runs lgtmaybe's container image with the same `action` command
and `INPUT_*` variables. lgtmaybe reads `GITHUB_SERVER_URL`, which Gitea points
at your own instance, to know which host to post to.

## Try It Locally First

Reviewing a pull request by URL happens in the Actions run, where the token and
the PR context live. Locally, `lgtmaybe review` reviews the branch you have
checked out against the remote primary branch, and prints the findings instead
of posting them:

```bash
git switch my-feature-branch
export ANTHROPIC_API_KEY=...
lgtmaybe review --provider anthropic --model claude-sonnet-4-6
```

It runs the same review, so it is a quick way to see what a model will say about
your change before you wire up the workflow.

## What Works, and What Does Not

The review itself works in full: every lens, the self-reflection pass, secret
redaction, prompt-injection hardening, static-analysis fusion, path filters,
finding rules, the change overview comment, PR labels (only labels that already
exist in the repository; lgtmaybe does not create them), the `fail_on` merge gate
(as a commit status), and the `/review`, `/improve`, `/ask`, `/describe` and
`/diagram` slash commands.

A slash command runs only for a commenter with write access to the repository.
Gitea's workflow `if:` cannot read the commenter's role the way GitHub's
`author_association` does, so lgtmaybe checks it itself through the collaborator
permission API. Anyone else's command is ignored with a log line, exits
successfully, and makes no model call.

These are unavailable on Gitea, and lgtmaybe skips them rather than failing:

| Not available | Why | What happens instead |
|---|---|---|
| Incremental review | Gitea's compare endpoint returns commit metadata, not a unified diff | Every run is a full review |
| Resolve-on-fix | Gitea has no thread-resolution API | Fixed findings' comments stay open |
| 👎 feedback (`learn_feedback`) | Not implemented in the Gitea adapter | A downvoted finding can be posted again on a later run |
| Symbol resolution | No base-branch clone in the Gitea adapter | Reflection judges cross-file findings without the symbol's definition |
| Keyless cloud auth | The OIDC/WIF exchange is built into the GitHub composite action | Use an API key, or `ollama` for a local model |

A submitted Gitea review cannot be edited, so lgtmaybe reads the hidden finding
IDs already on the pull request and does not post the same finding twice. The
summary lives in an ordinary comment, which is editable, so it updates in place
on each run.

## Security: Who Can Change the Workflow

A `pull_request` run in Gitea Actions takes its workflow file from the pull
request's own head commit. A pull request from a branch in the same repository
runs whatever workflow it contains, with the repository's secrets, so anyone who
can push a branch could rewrite the review job and send the model key and the
token to a server of their choosing. A pull request from a fork also runs its
own workflow, but Gitea passes it no repository or organisation secrets, only
the automatic job token, so lgtmaybe cannot review it.

`pull_request_target`, available from Gitea 1.21, takes the workflow from the
base branch instead, as on GitHub, and gives it the repository's secrets for
pull requests from forks too. That is why the example uses it, and why it is the
same trade-off as on GitHub: lgtmaybe never checks out or executes pull-request
code. It reads the diff through the API and treats every byte of it as
untrusted input. The `issue_comment` arm always runs the workflow from the
default branch, so a pull request cannot change that either. Gitea still treats
a comment on a fork's pull request as a fork run and passes it no secrets, so
slash commands work only on pull requests from branches in the repository.

What `pull_request_target` does not cover:

- **Anyone with write access can still read the secrets.** Repository and
  organisation secrets reach every workflow run in the repository, including a
  `push` workflow on a branch someone has just pushed, with no pull request
  involved. Give write access only to people trusted with the secrets, and take
  other contributions as pull requests from forks.
- **Gitea before 1.21 has no `pull_request_target`.** There, switch the example
  to `pull_request` only if everyone who can push a branch is trusted with the
  secrets. Pull requests from forks are not reviewed, because their runs get no
  secrets.
- **A fork's pull request starts a paid review without approval.** On an
  instance with open registration, anyone can open one. Use a model key with a
  spend limit.

Keep the token narrow too. A Gitea access token works on every repository its
owner can reach, which is why step 1 creates it on a dedicated account with
write access to the reviewed repository only, and gives it only the two scopes
lgtmaybe needs. Do not use `secrets.GITEA_TOKEN` in its place: that name always
resolves to the automatic job token, because Gitea refuses a secret of your own
with that name.

---

<!-- Source: https://lgtmaybe.coles.codes/how-to/review-on-gitlab/ -->

# Review Merge Requests on GitLab

lgtmaybe posts reviews to GitLab as well as GitHub. The review itself is
identical (same lenses, same reflection pass, same findings) because none of it
depends on the host. Only the adapter that fetches the diff and posts the
comments differs.

## Set It Up

A merge request pipeline runs the CI configuration from the merge request's own
source branch, so where the job lives decides who can read its secrets. Read
[Security: Who Can Change the Pipeline](#security-who-can-change-the-pipeline)
before choosing where it goes.

1. **Create a token.** In your project, go to *Settings → Access Tokens* and
   create a project access token with the `api` scope, the Developer role and an
   expiry date. Add it under *Settings → CI/CD → Variables* as `GITLAB_TOKEN`,
   masked, with **Environment scope** set to `lgtmaybe`.

2. **Add a provider key.** Add the API key for whichever model you want as a
   second masked variable scoped to the `lgtmaybe` environment, for example
   `ANTHROPIC_API_KEY`. Use a key with a spend limit.

3. **Add the job.** Copy
   [`examples/gitlab/.gitlab-ci.yml`](https://github.com/MattJColes/lgtmaybe/blob/main/examples/gitlab/.gitlab-ci.yml)
   into your repository, or merge its `lgtmaybe` job into your existing
   pipeline. If not everyone who can push a branch is trusted with the secrets,
   put it in a separate configuration project instead, as described under
   [Keep the Pipeline Configuration Outside the Project](#keep-the-pipeline-configuration-outside-the-project).

The job names the environment `lgtmaybe` with `action: access`. That is what
lets it read the variables scoped to that environment, without recording a
deployment, while the pipeline's other jobs never receive them.

The job runs `python -m lgtmaybe gitlab-ci`, which finds the merge request from
GitLab's predefined variables (`CI_SERVER_URL` or `CI_SERVER_HOST`,
`CI_MERGE_REQUEST_PROJECT_PATH` or `CI_PROJECT_PATH`, and
`CI_MERGE_REQUEST_IID`). It must therefore run on merge request pipelines:

```yaml
rules:
  - if: $CI_PIPELINE_SOURCE == "merge_request_event"
```

On a branch pipeline there is no merge request to review, and lgtmaybe fails
with an error naming the missing `CI_MERGE_REQUEST_IID` rather than guessing.

The example job does not clone the repository (`GIT_STRATEGY: none`), so there
is no `.lgtmaybe.yml` to read. Configure the review with the command's flags:
`--provider`, `--model`, `--fallback-model`, `--api-key`, `--api-base`,
`--config` and `--no-incremental`.

## Try It Locally First

Reviewing a merge request by URL happens in CI, where the token and the MR
context live. Locally, `lgtmaybe review` reviews the branch you have checked out
against the remote primary branch, and prints the findings instead of posting
them:

```bash
git switch my-feature-branch
export ANTHROPIC_API_KEY=...
lgtmaybe review --provider anthropic --model claude-sonnet-4-6
```

It runs the same review, so it is a quick way to see what a model will say about
your change before you wire up the pipeline.

## How It Differs from GitHub

GitLab has no batched review object, so each finding is posted as its own
discussion, anchored by the merge request's diff refs and the old or new file
line. The summary is a note that updates in place on each run.

Resolve-on-fix works here: GitLab discussions resolve over plain REST, where
GitHub needs GraphQL. A finding's discussion is resolved only once the follow-up
validation pass on an incremental run has confirmed it is fixed, never merely
because the finding stopped being reported. GitHub also requires the thread to
be marked outdated (the code under it changed). GitLab's API has no such flag,
so lgtmaybe works it out: a discussion counts as outdated when the diff since
the commit it was posted on touches the hunk around its line.

The GitLab adapter also supports PR labels and the `fail_on` merge gate
(reported as a commit status). Both are config-file settings, so they need a job
that provides a file for `--config`.

### Incremental Re-review

The first pipeline on a merge request reviews the whole change. Every later
pipeline reviews only the commits pushed since the last completed review, read
from a hidden watermark in lgtmaybe's summary note. lgtmaybe trusts that
watermark only in a note written by its own token's user, so another user's
note cannot skip a review.

It falls back to a full review whenever the increment can't be trusted:

- the branch was rebased or force-pushed (the new commits don't build on the
  last reviewed one);
- the new commits include a merge;
- GitLab no longer knows the last reviewed commit, timed out, or left part of
  the diff out because it is too large;
- any API call fails.

A failed or partial run leaves the watermark where it was, so the next run
still covers the commits it missed. Running a pipeline again on a head that was
already reviewed completely posts nothing.

GitLab has no `/review full`. To force a full re-review, run the pipeline with
the CI/CD variable `LGTMAYBE_INCREMENTAL` set to `false`, pass
`--no-incremental`, or set `incremental: false` in the config file.

### 👎 Feedback

Award a 👎 (`thumbsdown`) emoji to the first note of one of lgtmaybe's finding
discussions and later runs stop raising that finding on the merge request. Only
a 👎 from a member with the Developer role or above counts, because anyone can
award an emoji on a public project. A high or critical security finding is
never hidden this way. Set `learn_feedback: false` to turn it off.

These are not available:

| Not available | Why | What happens instead |
|---|---|---|
| Slash commands (`/review`, `/ask`, …) | `gitlab-ci` reviews the merge request; nothing routes MR comments to it | Re-run the pipeline to review again |
| Change overview comment | `gitlab-ci` runs the review only | No description or diagram comment is posted |
| Symbol resolution | No base-branch clone in the GitLab adapter | Reflection judges cross-file findings without the symbol's definition |
| Keyless cloud auth | The OIDC/WIF exchange is built into the GitHub composite action | Use an API key, or `ollama` |

## Security: Who Can Change the Pipeline

A merge request pipeline runs the `.gitlab-ci.yml` from the merge request's
source branch, not from the target branch. This is the opposite of GitHub's
`pull_request_target`, where the base branch's workflow runs, and GitLab has no
equivalent. Anyone who can push a branch to the project (the Developer role and
above) can therefore edit the `lgtmaybe` job, keep its variables, and send the
model key and `GITLAB_TOKEN` to a server of their choosing. A branch pipeline
gives them the same access without opening a merge request at all.
`GIT_STRATEGY: none` does not help: the YAML itself is the part they control.

So a job in the reviewed project's own CI configuration is only safe when
everyone who can push a branch there is already trusted with both secrets. For a
team where that holds, the setup above is enough. Otherwise, choose one of the
setups below. Each has a cost, and none of them is as simple as
`pull_request_target`.

Scoping the variables to the `lgtmaybe` environment is worth doing either way,
but it is not a defence against this. It keeps the secrets away from jobs that
never ask for them, such as a compromised test tool, and a job the merge request
controls can still name the environment.

### Merge Requests from Forks

A merge request from a fork is safe by default. Its pipeline runs in the fork,
with the fork's CI configuration and the fork's variables, and never receives
the parent project's variables. Keep it that way:

- Do not use **Run pipeline** on a fork's merge request in the parent project.
  That runs the fork's CI configuration in the parent project with all of the
  parent's variables, which hands the fork's author your secrets. GitLab shows a
  warning before it does this.
- To remove the option, disable `ci_allow_fork_pipelines_to_run_in_parent_project`
  with the [projects API](https://docs.gitlab.com/api/projects/#update-a-project).

The cost is that lgtmaybe does not review fork merge requests: their pipelines
have no key.

### Protected Variables

Marking `GITLAB_TOKEN` and the model key **Protected** keeps them out of every
pipeline on an unprotected branch, so pushing a branch no longer reveals them.
A merge request pipeline receives protected variables only when all of these
hold (the setting below arrived in GitLab 18.1):

- the project allows it, under *Settings → CI/CD → Variables → Access protected
  resources in merge request pipelines*;
- both the source and target branches are protected, and in the same project;
- the user who starts the pipeline can push or merge to the target branch.

A merge request pipeline from a fork never receives them.

The cost: lgtmaybe then reviews only merge requests between protected branches,
opened by people who could already change the pipeline there. A merge request
from an ordinary feature branch gets no token and the job fails. To skip the job
there instead, extend its rule:

```yaml
rules:
  - if: $CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_SOURCE_BRANCH_PROTECTED == "true"
```

### Keep the Pipeline Configuration Outside the Project

This works on every GitLab tier. In the reviewed project, set *Settings → CI/CD →
General pipelines → CI/CD configuration file* to a file in a separate project,
for example `.gitlab-ci.yml@my-group/ci-config` (append `:branch-name` to pin a
branch). Every pipeline in the reviewed project, merge request and branch
pipelines alike, then runs that file, so no commit to the reviewed project can
change a job. Changing the setting needs the Maintainer role. Protect the
configuration project's default branch and give write access only to people
trusted with the secrets.

The variables stay unprotected, so merge requests from feature branches are
reviewed. What it costs:

- That file is now the project's whole pipeline, so the project's build and test
  jobs must live there too. Do not `include` the reviewed project's own CI files
  from it: a file read at the merge request's commit is the merge request's to
  edit, and its jobs could name the `lgtmaybe` environment.
- Those build and test jobs still run the merge request's code. Keep both
  variables scoped to the `lgtmaybe` environment so those jobs never receive
  them, and keep that environment out of every job but the review.
- Do not let a job that runs merge request code share a shell-executor runner
  with the review job. There, jobs run as the same user on the same host, and
  one can read another's environment.
- Keep *Settings → CI/CD → Variables → Minimum role to use pipeline variables*
  at Maintainer or above (the self-managed default), or `no_one_allowed` (the
  default for new projects on GitLab.com). A pipeline variable outranks a
  project variable, so anyone who can set one can change the review job's
  environment. `AZURE_API_BASE`, for example, tells lgtmaybe where to send the
  key when the job passes no `--api-base`.

### Pipeline Execution Policies

A [pipeline execution policy](https://docs.gitlab.com/user/application_security/policies/pipeline_execution_policies/)
(GitLab Ultimate) can add the `lgtmaybe` job to every pipeline from a separate
security policy project, so a merge request cannot change that job. On its own
it does not keep the secrets safe. Unless the policy replaces the project's
configuration entirely (`override_project_ci`), the project's own
`.gitlab-ci.yml` still runs from the merge request's branch, and its jobs
receive the same project and group variables, or can name the `lgtmaybe`
environment to get them. A policy that does replace the configuration has the
same costs as the previous section.

### Tokens and Keys

Whichever setup you choose, assume a secret can leak and limit what it is worth:

- Make `GITLAB_TOKEN` a project access token on this project only, with the
  `api` scope (posting discussions needs it; `read_api` cannot), the Developer
  role and nothing higher, and a short expiry. On GitLab.com, project access
  tokens need Premium or Ultimate. On the Free tier, use a personal access token
  of a dedicated account that is a Developer of this project alone. A leaked
  token can post as lgtmaybe, including a summary note whose watermark makes
  the next incremental run skip the commits before it.
- Use a model key with a spend limit, so a leaked key has a ceiling.

lgtmaybe itself never checks out or executes merge request code. It reads the
diff through the API and treats every byte of it as untrusted input, which is
why the example job sets `GIT_STRATEGY: none` and does not clone the repository
at all.

---

<!-- Source: https://lgtmaybe.coles.codes/how-to/review-with-openai/ -->

# Review with OpenAI

OpenAI is a key-based provider: add an `OPENAI_API_KEY` and pick a model. There
is no OIDC and no endpoint to configure, which makes it the simplest path to a
hosted review.

## Contents

- [Get an API key](#get-an-api-key)
- [GitHub Action](#github-action)
- [Run locally](#run-locally)
- [Choosing the model](#choosing-the-model)
- [Persist non-secret defaults](#persist-non-secret-defaults)

## Get an API Key

Create a key at <https://platform.openai.com/api-keys>. In your repository, add
it as an Actions secret named `OPENAI_API_KEY`
(**Settings → Secrets and variables → Actions → New repository secret**).

## GitHub Action

Copy [`examples/workflows/review-openai.yml`][wf] to
`.github/workflows/lgtmaybe.yml`. The core step is:

```yaml
- uses: MattJColes/lgtmaybe@v2
  with:
    provider: openai
    model: gpt-5.5
    api_key: ${{ secrets.OPENAI_API_KEY }}
```

The workflow runs on `pull_request_target`, so the secret is available, but PR
code is never checked out: lgtmaybe reads the diff through the API. See
[Use as a GitHub Action](use-as-github-action.md) for the full workflow,
including [who can trigger a review](use-as-github-action.md#who-can-trigger-a-review).

## Run Locally

```bash
export OPENAI_API_KEY=sk-...

lgtmaybe review --provider openai --model gpt-5.5
```

You can pass the key with `--api-key sk-...` instead of the env var. Either way,
the key is never persisted to config.

## Choosing the Model

Use any chat model your key can access, named as you would send it to the
OpenAI API (litellm's native form): `gpt-5.5` or one of the newer GPT-6 models
(`gpt-6-sol`, `gpt-6-luna`, `gpt-6-astra`), or a smaller model to cut cost.
[Choose a review model](choose-a-review-model.md) has measured results for the
GPT-6 family.

Under the default `fast` preset, a `gpt-5*` review model has its reflection
(false-positive audit) pass run on `gpt-5-mini`, so your key needs access to
that model too. Set `reflect_model` to choose a different auditor.

## Persist Non-Secret Defaults

Provider and model are not secrets, so they can live in `.lgtmaybe.yml`. The key
stays in the environment.

```yaml
provider: openai
model: gpt-5.5
```

With that file in place, `lgtmaybe review` needs no flags. See
[Configure .lgtmaybe.yml](configure-lgtmaybe-yml.md) for every setting.

[wf]: https://github.com/MattJColes/lgtmaybe/blob/main/examples/workflows/review-openai.yml

---

<!-- Source: https://lgtmaybe.coles.codes/how-to/review-with-anthropic/ -->

# Review with Claude (Anthropic)

Anthropic is a key-based provider: add an `ANTHROPIC_API_KEY` and pick a Claude
model. There is no OIDC and no endpoint to configure.

## Contents

- [Get an API key](#get-an-api-key)
- [GitHub Action](#github-action)
- [Run locally](#run-locally)
- [Choosing the model](#choosing-the-model)
- [Want it through a gateway instead?](#want-it-through-a-gateway-instead)
- [Persist non-secret defaults](#persist-non-secret-defaults)

## Get an API Key

Create a key in the [Anthropic Console](https://console.anthropic.com/). In your
repository, add it as an Actions secret named `ANTHROPIC_API_KEY`
(**Settings → Secrets and variables → Actions → New repository secret**).

## GitHub Action

Copy [`examples/workflows/review-anthropic.yml`][wf] to
`.github/workflows/lgtmaybe.yml`. The core step is:

```yaml
- uses: MattJColes/lgtmaybe@v2
  with:
    provider: anthropic
    model: claude-sonnet-4-6
    api_key: ${{ secrets.ANTHROPIC_API_KEY }}
```

The workflow runs on `pull_request_target`, so the secret is available, but PR
code is never checked out: lgtmaybe reads the diff through the API. See
[Use as a GitHub Action](use-as-github-action.md) for the full workflow,
including [who can trigger a review](use-as-github-action.md#who-can-trigger-a-review).

## Run Locally

```bash
export ANTHROPIC_API_KEY=sk-ant-...

lgtmaybe review --provider anthropic --model claude-sonnet-4-6
```

You can pass the key with `--api-key sk-ant-...` instead of the env var. Either
way, the key is never persisted to config.

## Choosing the Model

Pass litellm's native Anthropic name as the model. `claude-sonnet-4-6` is a
strong default. Use an Opus-class model (such as `claude-opus-5-5`) for the
deepest review at a higher price, or a Haiku-class model (such as
`claude-haiku-4-5`) to cut cost on smaller PRs.

Under the default `fast` preset, a Sonnet or Opus review model has its
reflection (false-positive audit) pass run on `claude-haiku-4-5`, so your key
needs access to that model too. Set `reflect_model` to choose a different
auditor.

## Want It Through a Gateway Instead?

If you reach Claude through OpenRouter, Bedrock or Vertex rather than the
Anthropic API, use that provider instead:

- [OpenRouter](review-with-openrouter.md): `anthropic/claude-sonnet-4.6`
- [Bedrock (keyless OIDC)](review-with-bedrock-oidc.md)
- [Vertex (keyless WIF)](review-with-vertex-wif.md)

## Persist Non-Secret Defaults

Provider and model are not secrets, so they can live in `.lgtmaybe.yml`. The key
stays in the environment.

```yaml
provider: anthropic
model: claude-sonnet-4-6
```

With that file in place, `lgtmaybe review` needs no flags. See
[Configure .lgtmaybe.yml](configure-lgtmaybe-yml.md) for every setting.

[wf]: https://github.com/MattJColes/lgtmaybe/blob/main/examples/workflows/review-anthropic.yml

---

<!-- Source: https://lgtmaybe.coles.codes/how-to/review-with-openrouter/ -->

# Review with OpenRouter

[OpenRouter](https://openrouter.ai/) is a key-based gateway to many model
vendors behind one API. Add an `OPENROUTER_API_KEY`, then pick any model
OpenRouter offers by its `vendor/model` name.

## Contents

- [Get an API key](#get-an-api-key)
- [GitHub Action](#github-action)
- [Run locally](#run-locally)
- [Choosing the model](#choosing-the-model)
- [Credit reservations](#credit-reservations)
- [Rate limits](#rate-limits)
- [Persist non-secret defaults](#persist-non-secret-defaults)

## Get an API Key

Create a key at <https://openrouter.ai/keys>. In your repository, add it as an
Actions secret named `OPENROUTER_API_KEY`
(**Settings → Secrets and variables → Actions → New repository secret**).

## GitHub Action

Copy [`examples/workflows/review-openrouter.yml`][wf] to
`.github/workflows/lgtmaybe.yml`. The core step is:

```yaml
- uses: MattJColes/lgtmaybe@v2
  with:
    provider: openrouter
    model: anthropic/claude-sonnet-4.6
    api_key: ${{ secrets.OPENROUTER_API_KEY }}
```

The workflow runs on `pull_request_target`, so the secret is available, but PR
code is never checked out: lgtmaybe reads the diff through the API. See
[Use as a GitHub Action](use-as-github-action.md) for the full workflow,
including [who can trigger a review](use-as-github-action.md#who-can-trigger-a-review).

## Run Locally

```bash
export OPENROUTER_API_KEY=sk-or-...

lgtmaybe review --provider openrouter --model anthropic/claude-sonnet-4.6
```

You can pass the key with `--api-key sk-or-...` instead of the env var. Either
way, the key is never persisted to config.

## Choosing the Model

OpenRouter models are named `vendor/model`. Pick whichever fits your budget and
quality bar, for example:

- `anthropic/claude-sonnet-4.6`
- `openai/gpt-5.5`
- `z-ai/glm-4.6`

Browse the full catalogue and per-model pricing at
<https://openrouter.ai/models>.

One catalogue quirk: a model can accept JSON-schema mode through OpenRouter yet
return replies that parse as empty, so the review comes back with zero findings
and no error (seen with `anthropic/claude-fable-5.1`). If a model is silent on
everything, try one diff with `--no-structured-output` (see
[Configure .lgtmaybe.yml](configure-lgtmaybe-yml.md#structured_output)) before
writing the model off.

## Credit Reservations

OpenRouter checks your balance before it generates anything, and it costs the
worst case: the prompt plus the most tokens the reply could use. A request with
no cap is assumed to want the model's full output ceiling, so a review can be
refused for credit it was never going to spend:

```
This request requires more credits, or fewer max_tokens.
You requested up to 65536 tokens, but can only afford 25905.
```

To keep that reservation realistic, OpenRouter reviews send a default cap of
16384 tokens per call. If a refusal names a larger number, something has
overridden the default. Check the `per-call budget resolved` log line, which
names the ceiling and where it came from:

```
per-call budget resolved  timeout_s=1800  max_tokens=16384  max_tokens_source="provider default"
```

Top up, or lower the cap so the reservation fits your balance:

```yaml
max_tokens: 8192
```

`--max-tokens 8192` does the same for one run, and `max_tokens` is also a GitHub
Action input. Lower it with care: a cap that is too low truncates the findings
JSON mid-object, and reasoning models spend this same budget on thinking tokens,
so they need more headroom than a plain model. `max_tokens: 0` removes the cap
and puts the reservation back to the model's full ceiling.

lgtmaybe treats this refusal as permanent and stops after one attempt. Your
balance cannot grow mid-review, so retrying every lens would only waste runner
time before reporting the same failure.

## Rate Limits

A capacity 429 is different: it is temporary, and lgtmaybe handles it for you.
Rate-limited calls back off on a 20s-60s ladder, long enough to reach a fresh
per-minute window, and honour OpenRouter's `Retry-After` header when it sends
one (clamped at 120s). A lens that is still failing on the provider when the
fan-out drains is re-run once more.

That rescue covers provider-side failures only. It leaves alone unparseable
output, a blown `max_tokens` ceiling, a batch the oversized-diff split already
retried, an unrecoverable failure such as a spent quota, and any limit you set
(`max_review_seconds`, `max_review_tokens`, a cancelled job), because a second
attempt would buy the same answer at full price. Usually you will see none of
this. A review that could not recover says so in its summary and names the lens
it lost.

If you are seeing rate limits, check the review's own burst before blaming the
model. Every `(batch, lens)` call shares one concurrency pool and one API key, so
a wide fan-out can throttle itself:

```yaml
max_concurrency: 3
```

Beyond that, it is a question about your OpenRouter account. Limits on free model
variants (the `:free` suffix) are account-wide rather than per-model, and the
daily allowance depends on how much credit the account has bought, so a review
is far more likely to be throttled on a free variant than a paid one. Paid
models carry no OpenRouter platform request cap, though the upstream provider
behind a model can still rate-limit you. Neither is the same as a per-key credit
spending cap, which limits what a key may spend, not how often it may call.

## Persist Non-Secret Defaults

Provider and model are not secrets, so they can live in `.lgtmaybe.yml`. The key
stays in the environment.

```yaml
provider: openrouter
model: anthropic/claude-sonnet-4.6
```

With that file in place, `lgtmaybe review` needs no flags. See
[Configure .lgtmaybe.yml](configure-lgtmaybe-yml.md) for every setting.

[wf]: https://github.com/MattJColes/lgtmaybe/blob/main/examples/workflows/review-openrouter.yml

---

<!-- Source: https://lgtmaybe.coles.codes/how-to/review-with-zai/ -->

# Review with z.ai (GLM)

[z.ai](https://z.ai/)'s GLM models (from Zhipu AI) are a first-class provider:
add a `ZAI_API_KEY` and pick a model. lgtmaybe reaches them through litellm's
native `zai/` route, so the international endpoint needs no `api_base`.

## Contents

- [Get an API key](#get-an-api-key)
- [GitHub Action](#github-action)
- [Run locally](#run-locally)
- [Choosing the model](#choosing-the-model)
- [China / coding-plan endpoint](#china-coding-plan-endpoint)
- [Persist non-secret defaults](#persist-non-secret-defaults)

## Get an API Key

Create a key in the [z.ai developer console](https://z.ai/) (see the
[API docs](https://docs.z.ai/)). In your repository, add it as an Actions secret
named `ZAI_API_KEY`
(**Settings → Secrets and variables → Actions → New repository secret**).

## GitHub Action

Copy [`examples/workflows/review-zai.yml`][wf] to
`.github/workflows/lgtmaybe.yml`. The core step is:

```yaml
- uses: MattJColes/lgtmaybe@v2
  with:
    provider: zai
    model: glm-4.6
    api_key: ${{ secrets.ZAI_API_KEY }}
```

The workflow runs on `pull_request_target`, so the secret is available, but PR
code is never checked out: lgtmaybe reads the diff through the API. See
[Use as a GitHub Action](use-as-github-action.md) for the full workflow,
including [who can trigger a review](use-as-github-action.md#who-can-trigger-a-review).

## Run Locally

```bash
export ZAI_API_KEY=...

lgtmaybe review --provider zai --model glm-4.6
```

You can pass the key with `--api-key ...` instead of the env var. Either way, the
key is never persisted to config.

## Choosing the Model

Pass any GLM chat model your key can access, such as `glm-4.6`, `glm-4.7`,
`glm-4.5-air`, or a newer `glm-5.x`. Use the name z.ai's API expects; lgtmaybe
adds the `zai/` route prefix for you.

## China / Coding-Plan Endpoint

The native route targets z.ai's international endpoint. To use the China or
coding-plan endpoint instead, point `--api-base` (or `ZAI_API_BASE`, or the
Action's `api_base` input) at it:

```bash
lgtmaybe review \
  --provider zai \
  --model glm-4.6 \
  --api-base https://open.bigmodel.cn/api/paas/v4
```

## Persist Non-Secret Defaults

Provider and model are not secrets, so they can live in `.lgtmaybe.yml`. The key
stays in the environment.

```yaml
provider: zai
model: glm-4.6
```

With that file in place, `lgtmaybe review` needs no flags. See
[Configure .lgtmaybe.yml](configure-lgtmaybe-yml.md) for every setting.

[wf]: https://github.com/MattJColes/lgtmaybe/blob/main/examples/workflows/review-zai.yml

---

<!-- Source: https://lgtmaybe.coles.codes/how-to/review-with-bedrock-oidc/ -->

# Review with AWS Bedrock (Keyless OIDC)

Use this guide to run lgtmaybe with AWS Bedrock using GitHub's OIDC token, so no
static AWS credentials are stored in secrets.

## Contents

- [How it works](#how-it-works)
- [One-time AWS setup](#one-time-aws-setup)
- [Workflow example](#workflow-example)
- [Choosing a Bedrock model ID](#choosing-a-bedrock-model-id)
- [Running locally with ambient AWS credentials](#running-locally-with-ambient-aws-credentials)
- [Troubleshooting](#troubleshooting)

## How It Works

GitHub Actions issues a short-lived OIDC token. AWS STS exchanges it for
temporary credentials for an IAM role you control. Pass `aws_role_arn` and the
Action performs that exchange for you; lgtmaybe then picks up the credentials
through the standard AWS credential chain. There is no `AWS_ACCESS_KEY_ID` or
`AWS_SECRET_ACCESS_KEY` in your secrets.

## One-Time AWS Setup

Do this once in your AWS account:

1. Create an IAM OIDC identity provider for `token.actions.githubusercontent.com`.
2. Create an IAM role with a trust policy scoped to your repository
   (`repo:<org>/<repo>:*`).
3. Attach the least-privilege policy below.
4. Confirm the models you want are enabled in the target region (model access
   in the Bedrock console).
5. Note the role ARN (e.g. `arn:aws:iam::123456789012:role/lgtmaybe-bedrock`).
   It becomes the `aws_role_arn` Action input.

The role needs only:

```json
{
  "Effect": "Allow",
  "Action": [
    "bedrock:InvokeModel",
    "bedrock:InvokeModelWithResponseStream"
  ],
  "Resource": [
    "arn:aws:bedrock:*:<account-id>:inference-profile/*",
    "arn:aws:bedrock:*::foundation-model/*"
  ]
}
```

Both ARNs are required for the recommended inference-profile model IDs (the
`us.`/`eu.`/`apac.`-prefixed IDs below). A cross-region inference profile routes
the call to the foundation model in several regions, so the role needs
`bedrock:InvokeModel*` on both the `inference-profile/*` ARN and the underlying
`foundation-model/*` ARN. Granting only one still fails with
`AccessDeniedException`. A bare `anthropic.…` model ID needs only the
`foundation-model/*` ARN, but most current Claude models can only be invoked
through an inference profile (see the model table below).

Once it works, scope each `Resource` to specific model and inference-profile
ARNs for tighter least privilege, e.g.
`arn:aws:bedrock:*:<account-id>:inference-profile/us.anthropic.claude-opus-5-5*`
and `arn:aws:bedrock:*::foundation-model/anthropic.claude-opus-5-5*`.

## Workflow Example

Copy [`examples/workflows/review-bedrock.yml`][wf] to
`.github/workflows/lgtmaybe.yml` and store the role ARN in an `AWS_ROLE_ARN`
secret. The Action assumes the role itself, so you need no separate
`configure-aws-credentials` step, but the workflow must grant `id-token: write`
so GitHub will mint the OIDC token:

```yaml
permissions:
  contents: read
  pull-requests: write
  id-token: write # required for the OIDC token exchange (keyless auth)
```

The review step is:

```yaml
- uses: MattJColes/lgtmaybe@v2
  with:
    provider: bedrock
    model: us.anthropic.claude-sonnet-4-6
    aws_role_arn: ${{ secrets.AWS_ROLE_ARN }}
    aws_region: us-east-1
```

`aws_region` defaults to `us-east-1`. The example also carries the
trusted-author guard described in
[who can trigger a review](use-as-github-action.md#who-can-trigger-a-review).

## Choosing a Bedrock Model ID

The `model` must be a Bedrock model identifier. Bedrock hosts Anthropic Claude,
Amazon Nova, Meta Llama, Mistral, Cohere, OpenAI GPT and others, each under
Bedrock's own ID: `openai.gpt-6-sol`, not OpenAI's `gpt-6-sol`. An ID written
for another provider's API is rejected with
`The provided model identifier is invalid`. Use one of the Claude IDs below:

| Model | Inference-profile ID (recommended) | Base model ID |
|---|---|---|
| Claude Opus 5.5 | `us.anthropic.claude-opus-5-5` | `anthropic.claude-opus-5-5` |
| Claude Sonnet 4.6 | `us.anthropic.claude-sonnet-4-6` | `anthropic.claude-sonnet-4-6` |
| Claude Haiku 4.5 | `us.anthropic.claude-haiku-4-5-20251001-v1:0` | `anthropic.claude-haiku-4-5-20251001-v1:0` |

Haiku 4.5 keeps its dated `-20251001-v1:0` suffix on Bedrock; unlike Opus 5.5
and Sonnet 4.6, it has no undated `anthropic.claude-haiku-4-5` ID.

On Bedrock, Claude Opus 4.7 and later (Opus 5.5 included) take lgtmaybe's
findings schema as a forced tool call rather than native structured output.
litellm picks that route from its model map, so there is nothing to configure.

Prefer the inference-profile form. Most current Claude models on Bedrock can
only be invoked through a cross-region inference profile, not with on-demand
throughput on the bare model ID, so a bare ID often fails with the same
`invalid model identifier` (or `on-demand throughput isn't supported`) error.
Prefix the ID with the geography that matches `aws_region`: `us.` (US), `eu.`
(Europe) or `apac.` (Asia Pacific), or the narrower `au.` / `jp.`. A `global.`
prefix lets Bedrock route the call to any commercial region. Use the bare
`anthropic.…` ID only where on-demand access to that model is enabled in your
region.

## Running Locally with Ambient AWS Credentials

If your shell has AWS credentials (from `~/.aws`, SSO, or an assumed role), you
can review your current branch with Bedrock. Bedrock request signing needs
`boto3`, so install the `bedrock` extra (the Action image already bundles it):

```bash
pip install 'lgtmaybe[bedrock]'

lgtmaybe review \
  --provider bedrock \
  --model us.anthropic.claude-sonnet-4-6
```

Bedrock always uses the AWS credential chain; lgtmaybe ignores `--api-key` for
it.

> **`No matching distribution found for lgtmaybe[bedrock]`** (`from versions:
> none`): this is not a packaging problem, because the `bedrock` extra is
> published on PyPI. `pip` found no version compatible with your environment.
> The usual cause is a Python older than the required 3.11 (check with
> `python --version`), so install under 3.11 or later (e.g. with `pipx` or `uv`).
> If your Python is fine, `pip` can't reach PyPI: check your network, proxy or
> `--index-url`.

## Troubleshooting

**`ExpiredTokenException`**: the OIDC exchange failed or the role session
expired. Check that the workflow grants `id-token: write` and that the IAM trust
policy names the correct GitHub repository.

**`AccessDeniedException`**: the role lacks `bedrock:InvokeModel` for the
selected model, or the model is not enabled in the Bedrock console for your
account and region. For an inference-profile ID (`us.`/`eu.`/`apac.`-prefixed),
the policy must allow the `inference-profile/*` ARN as well as
`foundation-model/*`.

**`The provided model identifier is invalid`**: the `model` is not a Bedrock
model ID. Either it is another provider's name (such as `gpt-6-sol` or
`claude-sonnet-4-6`; Bedrock's are `openai.gpt-6-sol` and
`us.anthropic.claude-sonnet-4-6`), so pick an ID from the table above, or the
model needs a cross-region inference profile, so prefix it with
`us.`/`eu.`/`apac.` (e.g. `us.anthropic.claude-sonnet-4-6`) instead of using the
bare `anthropic.…` ID.

**`output_config.format: Extra inputs are not permitted`**, **`outputConfig`**,
or a `toolConfig` / "doesn't support tool choice" refusal: the model's Converse
route doesn't accept the structured-output shape lgtmaybe sent, and Bedrock
rejected the request with a 400. No action is needed. lgtmaybe re-sends the same
schema as a forced tool call (which Converse does implement) and the rest of the
lens fan-out uses that shape straight away. Only a route that refuses the tool
call too falls back to prompt-instructed JSON, which the lenient parser still
reads; that fallback is logged as a warning naming the model and the reason. If
every lens fails at once with `every review call failed` on an older lgtmaybe,
upgrade, or pass `--no-structured-output` (`structured_output: false`) as a
stopgap.

**Structured output stepped down after an odd reply**: an empty reply, or one
that parsed to something other than findings, moves the model one rung down to
the forced tool call and keeps the schema. Only a second such reply gives the
schema up. The provider log says which happened:
`structured output stepped down to a forced tool call` is the middle rung and
costs nothing, while `structured output disabled for this model` is the floor
and carries a `reason` (such as `rejected`, `empty-response`,
`unparseable-output`, `tool-rejected` or `internal-tool-rejected`) worth
reading.

[wf]: https://github.com/MattJColes/lgtmaybe/blob/main/examples/workflows/review-bedrock.yml

---

<!-- Source: https://lgtmaybe.coles.codes/how-to/review-with-vertex-wif/ -->

# Review with Google Vertex AI (Keyless WIF)

Use this guide to run lgtmaybe with Google Vertex AI using Workload Identity
Federation (WIF), so no service-account JSON or static key is stored in secrets.

## Contents

- [How it works](#how-it-works)
- [One-time GCP setup](#one-time-gcp-setup)
- [Workflow example](#workflow-example)
- [Environment variables](#environment-variables)
- [Available Vertex AI models](#available-vertex-ai-models)
- [Running locally with ADC](#running-locally-with-adc)
- [Troubleshooting](#troubleshooting)

## How It Works

GitHub Actions issues an OIDC token. WIF exchanges it for a short-lived GCP
access token that impersonates a service account holding only the Vertex AI
permissions it needs. lgtmaybe picks up those credentials through Application
Default Credentials (ADC).

## One-Time GCP Setup

Do this once in your GCP project:

1. Enable the Vertex AI API on the project.
2. Create a workload identity pool and a GitHub provider in it.
3. Create a service account with `roles/aiplatform.user`, or something narrower.
   That role grants `aiplatform.endpoints.predict`; do not assign broader
   project-level roles.
4. Grant the GitHub principal permission to impersonate that service account,
   scoped to your repository.
5. Note the WIF provider resource name (for `gcp_wif_provider`) and the service
   account email (for `gcp_service_account`).

## Workflow Example

Copy [`examples/workflows/review-vertex.yml`][wf] to
`.github/workflows/lgtmaybe.yml` and store the provider resource name and service
account email in `GCP_WIF_PROVIDER` and `GCP_SERVICE_ACCOUNT` secrets. The Action
authenticates to GCP itself, so you need no separate
`google-github-actions/auth` step, but the workflow must grant `id-token: write`:

```yaml
permissions:
  contents: read
  pull-requests: write
  id-token: write # required for the WIF token exchange (keyless auth)
```

The review step is:

```yaml
- uses: MattJColes/lgtmaybe@v2
  with:
    provider: vertex
    model: gemini-3.8-flash
    gcp_wif_provider: ${{ secrets.GCP_WIF_PROVIDER }}
    gcp_service_account: ${{ secrets.GCP_SERVICE_ACCOUNT }}
```

The example also carries the trusted-author guard described in
[who can trigger a review](use-as-github-action.md#who-can-trigger-a-review).

## Environment Variables

litellm reads the project and region from these variables:

| Variable | Required | Description |
|---|---|---|
| `VERTEXAI_PROJECT` | No | GCP project ID. Defaults to the project your ADC resolves (`GOOGLE_CLOUD_PROJECT`, or your gcloud config). |
| `VERTEXAI_LOCATION` | No | Region (default: `us-central1`) |

In the Action, the authentication step sets the project for you. The Action
forwards only `GOOGLE_*` and `CLOUDSDK_*` variables (plus `AWS_*`, `AZURE_*` and
its own `INPUT_*`) into its container, so `VERTEXAI_*` variables set in your
workflow do not reach lgtmaybe there. The Action therefore always uses
litellm's default region, `us-central1`; to use another region, run the CLI in
a workflow step with `VERTEXAI_LOCATION` set instead.

## Available Vertex AI Models

| Model | Vertex model ID |
|---|---|
| Gemini 3.8 Flash | `gemini-3.8-flash` |
| Gemini 3.5 Flash | `gemini-3.5-flash` |
| Gemini 3.1 Pro (preview) | `gemini-3.1-pro-preview` |
| Gemini 3 Pro (preview) | `gemini-3-pro-preview` |
| Gemini 2.5 Pro | `gemini-2.5-pro` |
| Claude Opus 5.5 | `claude-opus-5-5` |

Use the Gemini 3.x Pro `-preview` IDs as written: litellm's model map has no
bare `gemini-3-pro` or `gemini-3.1-pro` entry, so with those lgtmaybe can't fit
its input budget to the model's window. Claude on Vertex must be enabled in
Model Garden first, and `VERTEXAI_LOCATION` must be a region that serves it.

## Running Locally with ADC

Sign in with `gcloud auth application-default login` so your shell has ADC.
Vertex token minting needs `google-auth`, so install the `vertex` extra (the
Action image already bundles it):

```bash
pip install 'lgtmaybe[vertex]'

export VERTEXAI_PROJECT=my-project
export VERTEXAI_LOCATION=us-central1

lgtmaybe review \
  --provider vertex \
  --model gemini-3.8-flash
```

This reviews your current branch with Vertex. Vertex always uses ambient
credentials; lgtmaybe ignores `--api-key` for it.

## Troubleshooting

**`vertex requires ambient GCP credentials`**: lgtmaybe found no sign of GCP
credentials (`GOOGLE_APPLICATION_CREDENTIALS`, a project variable such as
`GOOGLE_CLOUD_PROJECT` or `VERTEXAI_PROJECT`, the ADC file gcloud writes, or
metadata-server credentials, which need the `google-auth` package). Run `gcloud auth application-default login`, set
`GOOGLE_APPLICATION_CREDENTIALS`, or (in CI) check the WIF inputs.

**`UNAUTHENTICATED`**: ADC credentials are missing or expired. Run
`gcloud auth application-default login` locally, or check the WIF provider and
the service account impersonation binding in CI.

**`PERMISSION_DENIED`**: the service account lacks `roles/aiplatform.user`, or
the Vertex AI API is not enabled in the project. Enable it with:

```bash
gcloud services enable aiplatform.googleapis.com --project=my-project
```

**`Model not found`**: the model ID may not be available in your selected
region. Check the [Vertex AI Model Garden](https://console.cloud.google.com/vertex-ai/model-garden).

[wf]: https://github.com/MattJColes/lgtmaybe/blob/main/examples/workflows/review-vertex.yml

---

<!-- Source: https://lgtmaybe.coles.codes/how-to/review-with-azure/ -->

# Review with Azure OpenAI (Keyless OIDC)

Use this guide to run lgtmaybe against Azure OpenAI with GitHub's OIDC token
federated to Entra (Azure AD), so no static Azure key is stored in secrets. A
key-based alternative is covered at the end.

## Contents

- [How it works](#how-it-works)
- [One-time Azure setup](#one-time-azure-setup)
- [Workflow example](#workflow-example)
- [Choosing the model name](#choosing-the-model-name)
- [Running locally with ambient Azure credentials](#running-locally-with-ambient-azure-credentials)
- [Key-based alternative](#key-based-alternative)
- [Troubleshooting](#troubleshooting)

## How It Works

GitHub Actions issues a short-lived OIDC token. Entra exchanges it for an access
token for your Azure OpenAI resource, through a federated credential on an app
registration or managed identity. Pass `azure_client_id` and `azure_tenant_id`
and the Action sets up that exchange for you; lgtmaybe then picks up the token
through `azure-identity`'s `DefaultAzureCredential`. There is no
`AZURE_API_KEY` in your secrets.

Azure still needs two non-secret values: the deployment name (`model`) and the
resource endpoint (`api_base`, `https://<resource>.openai.azure.com`).

## One-Time Azure Setup

Do this once in your Azure tenant:

1. Register an Entra app (or use a user-assigned managed identity). Note its
   client ID (`azure_client_id`) and your tenant ID (`azure_tenant_id`).
2. Add a federated credential to it for GitHub Actions:
   - Issuer: `https://token.actions.githubusercontent.com`
   - Subject: scoped to your repo, e.g. `repo:<org>/<repo>:pull_request`
     (or `:ref:refs/heads/main`, `:environment:…`)
   - Audience: `api://AzureADTokenExchange`
3. On the Azure OpenAI resource, grant the app the
   **Cognitive Services OpenAI User** role. It is least privilege: it can call
   models but not manage the resource.
4. Deploy the model you want and note the deployment name. That is what you
   pass as `model`, not the upstream OpenAI model ID.
5. Note the resource endpoint (`https://<resource>.openai.azure.com`). It
   becomes `api_base`.

## Workflow Example

Copy [`examples/workflows/review-azure.yml`][wf] to
`.github/workflows/lgtmaybe.yml` and set the `AZURE_CLIENT_ID`,
`AZURE_TENANT_ID` and `AZURE_API_BASE` secrets. The Action performs the OIDC
exchange itself, so you need no separate `azure/login` step, but the workflow
must grant `id-token: write` so GitHub will mint the token:

```yaml
permissions:
  id-token: write          # required for the OIDC token exchange (keyless)
  contents: read
  pull-requests: write
```

The review step is:

```yaml
- uses: MattJColes/lgtmaybe@v2
  with:
    provider: azure
    model: my-gpt-4o-deployment      # your Azure deployment name
    api_base: ${{ secrets.AZURE_API_BASE }}  # https://<resource>.openai.azure.com
    azure_client_id: ${{ secrets.AZURE_CLIENT_ID }}
    azure_tenant_id: ${{ secrets.AZURE_TENANT_ID }}
```

The example also carries the trusted-author guard described in
[who can trigger a review](use-as-github-action.md#who-can-trigger-a-review).

## Choosing the Model Name

For Azure, `model` is the deployment name you chose in the portal, not the
underlying OpenAI model ID. A deployment of `gpt-4o` named `my-gpt-4o-deployment`
is referenced as `model: my-gpt-4o-deployment`, which litellm routes as
`azure/my-gpt-4o-deployment`.

## Running Locally with Ambient Azure Credentials

Keyless works locally too. Install the `azure` extra, sign in (or use a managed
identity), set the endpoint, and review your current branch:

```bash
pip install 'lgtmaybe[azure]'
az login

export AZURE_API_BASE="https://<resource>.openai.azure.com"

lgtmaybe review \
  --provider azure \
  --model my-gpt-4o-deployment
```

`DefaultAzureCredential` finds your `az login` session (or a managed identity,
or `AZURE_*` env vars), and no static key is stored.

## Key-Based Alternative

To use a resource key instead, pass it as `api_key` with the endpoint as
`api_base`. Drop `azure_client_id`, `azure_tenant_id` and the `id-token`
permission. A supplied key always wins over ambient credentials, and the
`azure-identity` package is not needed in this mode.

```yaml
- uses: MattJColes/lgtmaybe@v2
  with:
    provider: azure
    model: my-gpt-4o-deployment
    api_key: ${{ secrets.AZURE_API_KEY }}
    api_base: ${{ secrets.AZURE_API_BASE }}
```

Locally, `export AZURE_API_KEY=… AZURE_API_BASE=…` (or pass `--api-key` and
`--api-base`).

## Troubleshooting

**`azure requires credentials`**: lgtmaybe found neither a key nor an ambient
Entra credential. For keyless, check that `id-token: write` is present and the
federated credential's subject matches the triggering ref or event. For key
mode, set `AZURE_API_KEY` (or the `api_key` input).

**`azure requires the resource endpoint`**: set `api_base` (or `AZURE_API_BASE`)
to `https://<resource>.openai.azure.com`.

**`keyless azure needs the azure-identity package`**: you are running keyless on
the CLI without the extra. Install `lgtmaybe[azure]`. The Action image already
bundles it.

**`AADSTS70021` / no matching federated identity**: the federated credential's
issuer, subject or audience don't match this workflow. The audience must be
`api://AzureADTokenExchange` and the subject must match the repo and event.

**`DeploymentNotFound` / 404**: `model` must be the deployment name on the
resource that `api_base` points at, not the upstream OpenAI model ID.

**`Unsupported API version`**: pin one by setting `AZURE_API_VERSION` (e.g.
`2024-08-01-preview`) in the environment. Otherwise litellm uses its default.

[wf]: https://github.com/MattJColes/lgtmaybe/blob/main/examples/workflows/review-azure.yml

---

<!-- Source: https://lgtmaybe.coles.codes/how-to/run-locally-with-ollama/ -->

# Run Locally with ollama

Use this guide to review your local changes with a local ollama model: no API
cost, no egress, no keys. The CLI reviews your `git` diff and prints the
findings. To post reviews on pull requests, use the
[GitHub Action](use-as-github-action.md).

> **ollama is the easiest local start.** Running a different local server
> (vLLM, llama.cpp or LM Studio) or a hosted OpenAI-compatible API like
> DeepSeek? Use the `openai-compatible` provider instead:
> [Other OpenAI-compatible servers](use-a-custom-openai-compatible-endpoint.md).
> The [model-choice and hardware guidance below](#which-model-and-will-it-fit)
> applies to any local runtime.

## Contents

- [Prerequisites](#prerequisites)
- [Pull the model you want](#pull-the-model-you-want)
- [Which model, and will it fit?](#which-model-and-will-it-fit)
- [Run the review](#run-the-review)
- [Reviewing large files (recursive walk)](#reviewing-large-files-recursive-walk)
- [Use a remote ollama instance](#use-a-remote-ollama-instance)
- [Inside the GitHub Action's container](#inside-the-github-actions-container)
- [Get findings as JSON](#get-findings-as-json)
- [Let an AI agent apply the fixes](#let-an-ai-agent-apply-the-fixes)
- [Concurrency on a local server](#concurrency-on-a-local-server)
- [The output ceiling (why a review can't run forever)](#the-output-ceiling-why-a-review-cant-run-forever)
- [Slow models and timeouts](#slow-models-and-timeouts)
- [Troubleshooting](#troubleshooting)

## Prerequisites

- lgtmaybe installed (`pip install lgtmaybe`)
- [ollama](https://ollama.com) installed and running
- A local git repository with changes to review

## Pull the Model You Want

```bash
ollama pull qwen3.6:27b        # strong all-round coding model
ollama pull gemma4:e4b         # smaller, for devices with limited RAM
```

List the models you have:

```bash
ollama list
```

## Which Model, and Will It Fit?

Two rules:

1. **Pick a coding model.** Reviewing a PR is a coding task, so use a model
   built for code (e.g. the Qwen3 coder line), not a general chat model.
2. **Bigger and newer is more accurate.** Use the largest, most recent coding
   model your hardware can run. Our accuracy numbers are for a small model: we
   benchmarked `qwen3.5:4b`, and it did well, but only with recursive review on
   (88% vs 61% recall). A larger, newer model catches more and relies on that
   less.

A solid mid-2026 default is Qwen3.6-27B (`qwen3.6:27b`). It is close to frontier
API models on coding benchmarks (SWE-bench Verified ~77%) yet small enough for a
workstation or a well-specced laptop, so it clears lgtmaybe's bar across all the
review lenses without a data-centre GPU. Smaller models work too, but accuracy
falls off: you will miss subtler findings and may need `--no-reflect`, because
the reflection pass over-prunes on a weak model.

**Hardware, quantised (the usual way to run it locally):**

| You have | What to expect |
|---|---|
| **< 32 GB** RAM/VRAM | Drop to a smaller model (`gemma4:e4b`) or use a hosted provider. 27B at a usable quant won't leave room for the diff. |
| **32 GB** RAM/VRAM | The practical floor. Run `qwen3.6:27b` at a 4-bit quant (≈16-18 GB of weights) with a modest context window. Keep `num_ctx` conservative so the model plus the diff and findings fit. |
| **48 GB+** RAM/VRAM (preferred) | Comfortable. Room for the weights plus the default `--num-ctx` (32k), or a raised 64k window for very large diffs, with headroom for the KV cache. |

This applies to discrete VRAM and Apple Silicon unified memory alike. A bigger
context window costs memory on top of the weights, so if you raise `--num-ctx`
for a large diff (see [Slow models and timeouts](#slow-models-and-timeouts)),
size it to the table above. On a hosted provider none of this matters: the model
runs on the provider's hardware.

## Run the Review

From inside the repository, on the branch you want reviewed:

```bash
lgtmaybe review \
  --provider ollama \
  --model qwen3.6:27b \
  --api-base http://localhost:11434
```

`http://localhost:11434` is the default, so `--api-base` is optional here.

This diffs your current branch against the remote primary branch and prints the
findings. Add `--working` to review the whole worktree (branch commits plus
uncommitted edits) against that same base, `--uncommitted` to review only your
uncommitted edits against HEAD, or `--base <ref>` to diff against a different
base.

## Reviewing Large Files (Recursive Walk)

When a single file's diff is larger than the per-call token budget
(`--max-input-tokens`), lgtmaybe walks it hunk by hunk, reviewing each hunk in
its own focused call, instead of sending the whole file and letting the tail drop
out of the model's attention. The findings are merged, and inline-comment
positions still bind to the real diff. This recursive (RLM-style) review is on
by default (`recursive: true`). Files that fit the budget are still reviewed
whole, so nothing changes for small diffs.

It helps small local models most, because a smaller prompt is easier to review
thoroughly. In our A/B benchmark a local `qwen3.5:4b` caught all 6 planted bugs
reviewing recursively, versus 4 of 6 reviewing each file whole. The two it
missed were both in the file's tail, even though the diff fit the context window,
so the gain is focus, not just avoiding truncation. It is a single
non-deterministic run on one fixture, so treat it as directional; the harness is
described in
[DEVELOPMENT.md](https://github.com/MattJColes/lgtmaybe/blob/main/DEVELOPMENT.md#benchmarking-the-recursive-rlm-walk).

To review each file whole instead (one call per file, which keeps all of a
file's hunks in view together but tends to miss more on big files with small
models), pass `--no-recursive`:

```bash
lgtmaybe review --provider ollama --model qwen3.5:4b \
  --api-base http://localhost:11434 --no-recursive
```

```yaml
# or in .lgtmaybe.yml (also how the GitHub Action picks it up):
recursive: false
```

## Use a Remote ollama Instance

If ollama runs on another machine (e.g. a Tailscale peer):

```bash
lgtmaybe review \
  --provider ollama \
  --model qwen3.6:27b \
  --api-base http://100.x.x.x:11434
```

lgtmaybe adds no authentication, and ollama has none built in, so restrict
network access at the host or firewall.

## Inside the GitHub Action's Container

The Action runs lgtmaybe in a container, so an ollama server on the runner host
is reached at `host.docker.internal`, not `localhost`. Set it with the Action's
`provider`, `model` and `api_base` inputs, or in `.lgtmaybe.yml`:

```yaml
provider: ollama
model: qwen3.6:27b
api_base: http://host.docker.internal:11434
```

## Get Findings as JSON

The CLI prints a readable listing by default and never posts anywhere. Add
`--json` for a machine-readable array you can pipe into other tools:

```bash
lgtmaybe review \
  --provider ollama \
  --model qwen3.6:27b \
  --api-base http://localhost:11434 \
  --json
```

## Let an AI Agent Apply the Fixes

`--format agent` prints the findings as correction instructions an AI coding
agent (such as Claude Code) can read and apply, so you can review and fix a
branch locally before opening a PR. See
[Fix findings with an AI agent](fix-findings-with-an-ai-agent.md).

## Concurrency on a Local Server

lgtmaybe fans its review calls out across a pool sized by `max_concurrency`,
6 by default on every provider, local included. That is a ceiling on what
lgtmaybe will have in flight, not a promise that your server runs them at once:

- **A default ollama runs one request at a time.** `OLLAMA_NUM_PARALLEL` is `1`
  unless you set it, so five of six calls queue (up to `OLLAMA_MAX_QUEUE`, 512).
  Nothing is lost and nothing fails; the wall clock is your server's throughput
  either way. Raising `max_concurrency` alone changes nothing.
- **The setting that matters is on the server.** Start ollama with
  `OLLAMA_NUM_PARALLEL=4` and four calls really do run together.
- **It costs memory in proportion.** ollama allocates the context window per
  parallel slot: four slots at `num_ctx` 32768 need 128k of context, not 32k.
  This is the usual reason a machine that reviewed happily with one slot falls
  over with four.

So the two settings should match. If you have raised the server, tell lgtmaybe:

```bash
lgtmaybe review --max-concurrency 4
```

**How to tell which you got.** `--profile` answers it: compare the per-call
`elapsed` column with the `review` stage total. If the calls' elapsed times sum
to roughly the stage time, they ran one after another; if they sum to well over
it, they overlapped.

```
review             95.32s        <- stage total
correctness         52.02s   |
spec                45.64s   |   sum is ~3x the stage,
artefacts           43.67s   |   so these overlapped
security            43.30s   |
```

**Queue time is already covered.** A queued call's timeout clock starts when the
request is sent, not when your server gets to it, so on a single-slot server the
last call in a six-wide fan-out would otherwise have to finish within the same
budget as the first. lgtmaybe scales the local default timeout by the fan-out
width to cover that (see [below](#slow-models-and-timeouts)), so you should not
need to intervene. If you would rather the calls didn't queue at all:

```yaml
# .lgtmaybe.yml
max_concurrency: 1
```

## The Output Ceiling (Why a Review Can't Run Forever)

A local model under structured output sometimes fails to stop and keeps
decoding. With no ceiling, only the per-call timeout ends it: half an hour of
sustained GPU for a single lens on a one-file diff, and hours for a whole review.

So a review runs with a default output ceiling of 16384 tokens per call, half
the default 32768 `num_ctx`. Measured findings payloads are hundreds of tokens,
so the rest is headroom for a thinking model, which draws its reasoning from the
same budget.

The same ceiling applies to `openai-compatible` and `openrouter`, because the
failure is not specific to local models: benchmarking found a model behind
`openai-compatible` emitting 223,558 tokens on a single lens call, and a hosted
model on `openrouter` returning a 393k-token response that parsed into 699 false
positives on a diff with nothing wrong in it. The first-party APIs (openai,
anthropic, zai, bedrock, vertex, azure) get no default ceiling, because they have
not shown this failure and a cap could truncate long findings payloads.

The ceiling is a fixed number, not a fraction of your window: raising `num_ctx`
for a big diff adds room for the prompt, not for a longer answer. Use
`max_tokens` when you want a longer answer.

Every run logs the ceiling it resolved and where it came from:

```
per-call budget resolved  timeout_s=3600  max_tokens=16384  max_tokens_source="provider default"
```

If a lens hits it, you get a truncation notice, not a silent clean review:

```
response hit the 16384-token `max_tokens` ceiling before finishing — the batch
is re-reviewed in smaller pieces automatically, so a lens that keeps doing it is
usually generation instability in the model, which a higher ceiling makes more
expensive rather than prevents
```

**One truncation is not a reason to raise the cap.** The batch is re-reviewed in
smaller pieces on its own, so a lens that trips it occasionally has still been
reviewed. A model that runs away repeatedly is generating past the ceiling, not
being cut short by it. A bigger ceiling only makes a longer wasted call; changing
the model is what fixes it.

Raising it is right when a genuinely long answer is being cut off: a large diff
with many real findings, where most are salvaged each time. Then raise it, or
remove the cap with `0`, which leaves the timeout as the run's only stop:

```bash
lgtmaybe review --provider ollama --model qwen3.5:4b --max-tokens 32768
lgtmaybe review --provider ollama --model qwen3.5:4b --max-tokens 0  # uncapped
```

```yaml
# or in .lgtmaybe.yml (also how the GitHub Action picks it up):
max_tokens: 32768
```

Lowering the ceiling is the quickest way to get a result out of a slow model:
`--max-tokens 512` turns a stuck review into one that finishes in minutes and
reports what it truncated.

## Slow Models and Timeouts

Local models are slow, especially large ones on CPU, so ollama gets a long
default per-call timeout of 1800 seconds. You don't need to set anything for a
normal run. (Direct cloud providers default to 600 seconds.)

**That default scales with the fan-out.** Because the calls queue, the budget
has to cover the wait as well as the work. At the default width of six the
per-call timeout is `1800 × 6`, bounded by `max_review_seconds` (3600 seconds by
default; `0` disables the deadline and with it the bound). It is never trimmed
below ollama's own 1800 seconds, so a deadline set lower than that does not
shrink it. The bound applies to the budget, not the wall clock: the deadline
decides when a call may start, so a call that begins just inside it still runs
its full budget. At `max_concurrency: 1` there is no queue and the budget is the
plain 1800 seconds. Every run logs the number it resolved, and why:

```
per-call budget resolved  timeout_s=3600  timeout_source="provider default"  concurrency=6
```

An explicit `timeout` is never scaled: `timeout: 600` means 600 at any width.

If a big model still times out (you'll see
`litellm.Timeout: Connection timed out after 3600.0 seconds`), raise it
explicitly:

```bash
# CLI flag (seconds):
lgtmaybe review --provider ollama --model qwen3.6:35b \
  --api-base http://localhost:11434 --timeout 3600
```

```yaml
# or in .lgtmaybe.yml (also how the GitHub Action picks it up):
provider: ollama
model: qwen3.6:35b
timeout: 1800
```

The review makes four lens calls under the default `fast` preset (nine under
`--preset full`). On a default ollama those queue rather than overlap, so a slow
model takes roughly `lens calls × per-call time`. That is why `fast` is the
default: four calls instead of nine is the biggest single local speed-up.

To go faster, narrow the lenses with `categories:` in `.lgtmaybe.yml` (e.g. just
`security` and `correctness`), use a smaller model, or give ollama more GPU. If
you have the VRAM to serve requests in parallel, raise `OLLAMA_NUM_PARALLEL` on
the ollama server as described [above](#concurrency-on-a-local-server): the same
four calls then overlap instead of queueing, close to a 4× wall-clock win. Add
`--profile` to any run to see the per-call breakdown.

## Troubleshooting

**`Connection refused` on port 11434**: make sure `ollama serve` is running and
the `--api-base` URL is reachable.

**Model not found**: run `ollama pull <model>` before using it.

**`review incomplete — every review call failed`**: every lens call timed out or
returned output that wasn't valid JSON. Raise `--timeout`, try a model that
follows instructions more reliably, or check the `LITELLM_LOG=DEBUG` output for
the underlying error. lgtmaybe reports this and exits non-zero rather than
pretending the PR is clean.

For a large diff this can mean the prompt plus the findings don't fit in
ollama's context window, so the output is truncated. lgtmaybe runs ollama with a
32768-token context (`num_ctx`) and structured JSON output, which covers most
reviews.

lgtmaybe does not send ollama's `think` flag. ollama already turns thinking on
for a model that supports it and rejects the flag for one that does not, so
sending nothing is right for both.

For a big multi-file change, raise the context window with `--num-ctx` so the
whole diff and the findings fit. This is ollama-only; hosted providers manage
their context window server-side and ignore it:

```bash
# A large multi-file diff on a local model: more time and more context
# (32768 is already the default; go above it for very large diffs):
lgtmaybe review --provider ollama --model qwen3.6:35b \
  --api-base http://localhost:11434 --timeout 1800 --num-ctx 65536
```

```yaml
# or in .lgtmaybe.yml (also how the GitHub Action picks it up):
provider: ollama
model: qwen3.6:35b
timeout: 1800
num_ctx: 65536
```

`--num-ctx` needs enough RAM/VRAM on the ollama host, so size it to your
machine. The budget that decides when lgtmaybe splits a diff into separate model
calls is `--max-input-tokens`, which applies to any provider. Its default
(100000) is lowered automatically to fit the model's window; on ollama that is
`num_ctx` minus the output ceiling, so 16384 tokens at the defaults. Set it
explicitly to send a large diff in fewer calls, or lower it for a small-context
model. If a very large diff still truncates, narrow it with `include_paths` /
`exclude_paths` or a lower `max_files` in `.lgtmaybe.yml`, or run a model with a
bigger context window.

> **If you set `--max-input-tokens` yourself, keep it under `--num-ctx`.** The
> two are independent: `--max-input-tokens` caps each batch lgtmaybe sends,
> while `--num-ctx` is the window ollama allocates. An explicit value is used as
> given, not fitted. lgtmaybe also estimates tokens with a generic tokeniser and
> local models tokenise differently, so leave headroom: a batch budget well
> below the window (e.g. `--max-input-tokens 16000` with `--num-ctx 32768`)
> avoids ollama silently truncating the findings JSON, which otherwise shows up
> only as an unhelpful "review failed".

**Review is empty or truncated**: the diff may exceed the model's context
window. Add a path filter in `.lgtmaybe.yml` to shrink the diff, or lower
`max_files`.

---

<!-- Source: https://lgtmaybe.coles.codes/how-to/use-a-custom-openai-compatible-endpoint/ -->

# Local Models and Other OpenAI-Compatible Providers

Many model servers speak the OpenAI `/v1` wire format: local and self-hosted
runtimes like [vLLM][vllm], [llama.cpp][llamacpp] and [LM Studio][lmstudio],
plus hosted APIs like [DeepSeek][deepseek] and many proxies. The
`openai-compatible` provider points lgtmaybe at any of them: you supply the base
URL and, if the server wants one, a key.

Use this provider when the model you want is not in the built-in provider list.

> Some endpoints that could run through here have a first-class provider
> instead, which needs less setup: [z.ai / GLM](review-with-zai.md) (`zai`) and
> [ollama](run-locally-with-ollama.md) (`ollama`).

## Contents

- [Local models at a glance](#local-models-at-a-glance)
- [How it works](#how-it-works)
- [DeepSeek (hosted, keyed)](#deepseek-hosted-keyed)
- [llama.cpp (local, keyless)](#llamacpp-local-keyless)
- [LM Studio (local, keyless)](#lm-studio-local-keyless)
- [vLLM (local or self-hosted, keyless)](#vllm-local-or-self-hosted-keyless)
- [Concurrency: what each server can actually take](#concurrency-what-each-server-can-actually-take)
- [Persist it in `.lgtmaybe.yml`](#persist-it-in-lgtmaybeyml)
- [Gateways that don't support JSON mode (`response_format`)](#gateways-that-dont-support-json-mode-response_format)

## Local Models at a Glance

Run a model on your own hardware, with no cost, no key, and nothing leaving the
machine:

| Runtime | Provider | Key needed? | See |
|---|---|---|---|
| [ollama][ollama] | `ollama` (native) | No | [Run locally with ollama](run-locally-with-ollama.md) |
| [vLLM][vllm] | `openai-compatible` | No | [below](#vllm-local-or-self-hosted-keyless) |
| [llama.cpp][llamacpp] | `openai-compatible` | No | [below](#llamacpp-local-keyless) |
| [LM Studio][lmstudio] | `openai-compatible` | No | [below](#lm-studio-local-keyless) |

ollama has its own `--provider ollama` and [its own guide](run-locally-with-ollama.md).
vLLM, llama.cpp and LM Studio are reached through `openai-compatible` and their
local server's `--api-base`, as shown below.

**Which model, and will it fit?** The ollama guide's model-choice and hardware
advice applies to any local runtime: pick a coding model, prefer bigger and
newer, and size it to your RAM/VRAM. See
[Which model, and will it fit?](run-locally-with-ollama.md#which-model-and-will-it-fit).

## How It Works

`--provider openai-compatible` routes through litellm's OpenAI client but sends
requests to the `--api-base` you give instead of `api.openai.com`. The base URL
is required (`--api-base` or `OPENAI_COMPATIBLE_API_BASE`); the API key is
optional:

- **Hosted endpoints** (DeepSeek, a paid proxy) need a key. Pass `--api-key` or
  set `OPENAI_COMPATIBLE_API_KEY`.
- **Local servers** (llama.cpp, LM Studio, vLLM) usually need none. lgtmaybe
  then sends a harmless placeholder key, because the OpenAI client rejects an
  empty one.

A key you supply is read from the environment or `--api-key` and is never
persisted to config.

Because the endpoint might be a slow local model, `openai-compatible` gets the
same generous 1800-second per-call timeout as ollama (scaled for queueing, see
[Concurrency](#concurrency-what-each-server-can-actually-take)), and the same
default output ceiling of 16384 tokens per call. For a fast hosted endpoint like
DeepSeek you can lower the timeout with `--timeout` (or `timeout:` in config).

## DeepSeek (Hosted, Keyed)

```bash
export OPENAI_COMPATIBLE_API_KEY=sk-...        # your DeepSeek key

lgtmaybe review \
  --provider openai-compatible \
  --model deepseek-chat \
  --api-base https://api.deepseek.com/v1
```

You can pass the key with `--api-key sk-...` instead of the env var.

## llama.cpp (Local, Keyless)

Start the server:

```bash
llama-server -m ./model.gguf --port 8000        # serves the OpenAI API at /v1
```

Then review against it, with no key:

```bash
lgtmaybe review \
  --provider openai-compatible \
  --model local-model \
  --api-base http://localhost:8000/v1
```

## LM Studio (Local, Keyless)

Enable the local server in LM Studio (it serves the OpenAI API on port `1234` by
default), then:

```bash
lgtmaybe review \
  --provider openai-compatible \
  --model your-loaded-model \
  --api-base http://localhost:1234/v1
```

## vLLM (Local or Self-Hosted, Keyless)

```bash
vllm serve meta-llama/Llama-3.1-8B-Instruct --port 8000
```

```bash
lgtmaybe review \
  --provider openai-compatible \
  --model meta-llama/Llama-3.1-8B-Instruct \
  --api-base http://localhost:8000/v1
```

## Concurrency: What Each Server Can Actually Take

lgtmaybe fans out across a pool sized by `max_concurrency`, 6 by default on
every provider. That is a ceiling on what it will have in flight; whether your
server runs the calls together is up to the server, and the three above differ
sharply:

| Server | Concurrent by default? | Raising it |
|---|---|---|
| **vLLM** | Yes, continuous batching | Close to free; each request keeps the full `--max-model-len` |
| **llama.cpp** | No, one slot | `-np N` adds slots, but **splits one KV cache between them** |
| **LM Studio** | No, single slot | Not exposed; set `max_concurrency` to 1 |

The llama.cpp trap fails as a quality problem rather than an error. `-np`
divides the context you asked for: `-c 32768 -np 4` leaves each slot 8k, and a
lgtmaybe review prompt is comfortably larger than that, so the diff is silently
truncated and the model reviews something it was never fully shown. Size `-c` as
`slots × per-slot context`:

```bash
llama-server -m ./model.gguf --port 8000 -np 4 -c 131072   # 4 slots × 32k each
```

vLLM allocates KV blocks dynamically rather than up front, so concurrent
requests each keep the full `--max-model-len`. That makes it the local server to
choose when review latency matters:

```bash
vllm serve <model> --port 8000 --max-model-len 32768
lgtmaybe review --provider openai-compatible --model <model> \
  --api-base http://localhost:8000/v1 --max-concurrency 6
```

**Queueing does not cost you a timeout.** A queued request's clock starts when it
is sent, not when a slot frees, so lgtmaybe scales the `openai-compatible`
default timeout (1800 seconds) by the fan-out width: `1800 × 6` at the default,
bounded by `max_review_seconds` (3600 seconds; `0` disables the deadline and the
bound with it). It is never trimmed below the 1800-second provider default, so a
deadline set lower than that does not shrink it. The bound applies to the budget,
not the wall clock: the deadline gates when a call may start, and does not cut a
running call short. An explicit `timeout` is used exactly as written, at any
width. Each run logs the number it resolved and the width it assumed:

```
per-call budget resolved  timeout_s=3600  timeout_source="provider default"  concurrency=6
```

For a single-slot server, you can say so and have lgtmaybe queue nothing:

```yaml
# .lgtmaybe.yml
max_concurrency: 1
```

## Persist It in `.lgtmaybe.yml`

The provider, model and base URL are not secrets, so they can live in config.
The key stays in the environment.

```yaml
provider: openai-compatible
model: deepseek-chat
api_base: https://api.deepseek.com/v1
```

With that file in place, `lgtmaybe review` needs no flags. In the GitHub Action,
set the same values as inputs (or in `.lgtmaybe.yml`) and pass `api_key` from a
secret for hosted endpoints; see
[`examples/workflows/review-openai-compatible.yml`][wf]. Leave `api_key` empty for
a keyless server on the runner host, reached from the Action's container at
`http://host.docker.internal:<port>/v1`.

## Gateways That Don't Support JSON Mode (`response_format`)

To keep models returning clean findings instead of prose, lgtmaybe asks for
structured output through the OpenAI `response_format` parameter (JSON mode).
Most endpoints honour it. Some enterprise gateways and custom proxies don't:
they either ignore it, so the model answers with the JSON in a ```` ```json ````
fence or surrounded by prose, or reject the request with a `400 Bad Request`.

lgtmaybe handles the first case for you: the parser strips fences and pulls the
JSON out of surrounding prose, so a gateway that ignores `response_format` still
produces a normal review.

A third case is common with LM Studio serving a "thinking" model (e.g.
qwen3.x): the server accepts `response_format`, but the schema-constrained
decoder returns empty content. lgtmaybe handles this too. It retries an empty
reply once with the schema sent as a forced tool call instead; if the model
answers empty under that as well, lgtmaybe stops sending a schema to that model
and the parser reads the findings from plain text. No flag needed.

When a gateway rejects `response_format` with a `400`, lgtmaybe recognises some
refusals that name the parameter and steps down by itself. If yours still fails,
turn structured output off so the request never carries the parameter. The
prompt still asks for JSON and the lenient parser still does its job:

```bash
lgtmaybe review \
  --provider openai-compatible \
  --model gemini-3.5-flash \
  --api-base https://api.myllm.com/v1 \
  --no-structured-output
```

Persist it as `structured_output: false` in `.lgtmaybe.yml`, or set the
Action's `structured_output` input to `false`.

[deepseek]: https://api-docs.deepseek.com/
[llamacpp]: https://github.com/ggml-org/llama.cpp
[lmstudio]: https://lmstudio.ai/
[vllm]: https://docs.vllm.ai/
[ollama]: https://ollama.com/
[wf]: https://github.com/MattJColes/lgtmaybe/blob/main/examples/workflows/review-openai-compatible.yml

---

<!-- Source: https://lgtmaybe.coles.codes/how-to/configure-lgtmaybe-yml/ -->

# Configure .lgtmaybe.yml

Put a `.lgtmaybe.yml` file at the root of your repository to set how lgtmaybe
reviews it. lgtmaybe reads the file from the directory it runs in, which is the
repo root for both the CLI and the GitHub Action. Pass `--config PATH` (or the
Action's `config_path` input) to read a different file; an explicit path must
exist.

The file holds non-secret settings only. API keys stay in the environment or
your CI secrets.

## Contents

- [Full Example](#full-example)
- [Field Reference](#field-reference)
  - [provider](#provider)
  - [model](#model)
  - [fallback_model](#fallback_model)
  - [min_severity](#min_severity)
  - [include_paths / exclude_paths](#include_paths-exclude_paths)
  - [max_files](#max_files)
  - [max_input_tokens](#max_input_tokens)
  - [preset](#preset)
  - [categories](#categories)
  - [context_lines](#context_lines)
  - [function_context](#function_context)
  - [timeout](#timeout)
  - [structured_output](#structured_output)
  - [reflect](#reflect)
  - [min_confidence](#min_confidence)
  - [mid_review_retrieval](#mid_review_retrieval)
  - [incremental](#incremental)
  - [static_analysis](#static_analysis)
  - [triage_model](#triage_model)
  - [auto_diagram](#auto_diagram)
  - [auto_describe](#auto_describe)
  - [high_impact](#high_impact)
  - [risk](#risk)
  - [pr_labels](#pr_labels)
  - [finding_rules](#finding_rules)
  - [summary_template](#summary_template)
  - [resolve_fixed](#resolve_fixed)
  - [extra_lenses](#extra_lenses)
  - [lens_paths](#lens_paths)
  - [directory_rules](#directory_rules)
  - [project](#project)
- [Precedence and CLI Overrides](#precedence-and-cli-overrides)
- [Upgrading from 1.14](#upgrading-from-114)

## Full Example

```yaml
provider: openai
model: gpt-5.5
min_severity: low
include_paths:
  - "src/**"
  - "lib/**"
exclude_paths:
  - "**/__pycache__/**"
  - "**/*.min.js"
max_files: 30
max_input_tokens: 80000
categories:
  - security
  - correctness
  - tests
```

## Field Reference

This page covers the fields most teams set. [Reference: Config](../reference/config.md)
lists every field with its type and default, including the tuning knobs not
described here (`max_tokens`, `reasoning_effort`, `temperature`, `language`,
`fail_on`, `max_review_seconds`, `max_review_tokens` and others).

### provider

Which LLM backend to use. One of `openai`, `openrouter`, `anthropic`, `zai`,
`bedrock`, `vertex`, `azure`, `ollama`, `openai-compatible`.

```yaml
provider: anthropic
```

There is no built-in provider or model. If no flag, no `.lgtmaybe.yml` and no
user config (written by `lgtmaybe config init`) sets both, lgtmaybe stops before
doing any work and tells you how to configure one. In the GitHub Action, set the
`provider` and `model` inputs instead.

### model

The model identifier for the chosen provider. The format varies by provider:

| Provider | Example model IDs |
|---|---|
| openai | `gpt-5.5` |
| anthropic | `claude-sonnet-4-6`, `claude-haiku-4-5` |
| openrouter | `anthropic/claude-sonnet-4.6` |
| zai | `glm-4.6`, `glm-4.7`, `glm-4.5-air` (GLM / Zhipu AI; newer `glm-5.x` IDs pass through too) |
| bedrock | `us.anthropic.claude-sonnet-4-6`, `us.anthropic.claude-haiku-4-5-20251001-v1:0` (prefer the cross-region inference profile; another provider's name like `gpt-6-sol` is invalid, Bedrock's is `openai.gpt-6-sol` - see [Review with Bedrock](review-with-bedrock-oidc.md)) |
| vertex | `gemini-3.8-flash`, `gemini-3.1-pro-preview` |
| azure | your deployment name, e.g. `my-gpt-4o-deployment`, not the upstream model ID - see [Review with Azure](review-with-azure.md) |
| ollama | `qwen3.6:27b`, `gemma4:e4b` |
| openai-compatible | the served model name, e.g. `deepseek-chat` or `meta-llama/Llama-3.1-8B-Instruct` (also set `api_base` - see [Use a custom OpenAI-compatible endpoint](use-a-custom-openai-compatible-endpoint.md)) |

Write the bare ID. lgtmaybe adds the provider's litellm prefix itself, so
`bedrock/us.anthropic.claude-sonnet-4-6` would end up double-prefixed.

### fallback_model

A second model for a review call the primary model cannot finish. It is off by
default and adds no calls to a healthy review.

Both models go through the same `provider`, `api_base` and credentials. An
OpenRouter review can fall back from a Google model to a Qwen model because
OpenRouter serves both; an `openai` review cannot fall back to an Anthropic
account. Cross-provider failover needs a separate review run.

```yaml
provider: openrouter
model: google/gemini-3.8-flash
fallback_model: qwen/qwen3.8-max
```

When the fallback runs depends on the failure:

| Primary model result | What lgtmaybe does |
|---|---|
| Transient provider failure, such as a connection error, capacity rate limit, or 5xx | Exhausts the primary model's bounded retries, then tries the fallback. |
| Permanent provider failure, such as an unknown model, rejected request, or exhausted quota | Skips further primary attempts and tries the fallback. A shared credential, quota or endpoint failure usually rejects both. |
| Output hits the `max_tokens` ceiling, or the reply stops mid-JSON | First retries on the primary with a smaller payload or lower `reasoning_effort`, as the token counts suggest. Tries the fallback only if that also fails. |

The fallback does not judge the primary's answer. A clean review, a weak
finding or zero findings never trigger it. Unparseable output has its own
bounded repair path.

The fallback gets a fresh request and retry budget, so its time and tokens add
to what the primary already spent, and its usage counts towards
`max_review_tokens` once the call returns. The review summary names every lens
the fallback answered. `--profile` prints a `models:` line, and
`--profile-json` records the model on each call (see
[Profile Reference](../reference/profile.md#the-models-line)).

If the fallback also fails, lgtmaybe marks the lens incomplete. It does not
walk a list of models or go back to the primary, and a review with no usable
results fails rather than posting a clean result. If a lens keeps needing the
second model, make that model the primary.

The `--fallback-model` flag and the Action's `fallback_model` input override
the file:

```bash
lgtmaybe review --fallback-model qwen/qwen3.8-max
```

See [Choose a Review Model](choose-a-review-model.md) for current model results.

### min_severity

The lowest severity to report. Findings below it are dropped. From low to high:
`info`, `low`, `medium`, `high`, `critical`.

```yaml
min_severity: medium   # drops info and low findings
```

Default: `low`, which drops only `info` findings. A `project.stage` of
`prototype` raises the default to `medium` (see [project](#project)).

### include_paths / exclude_paths

Glob patterns that restrict which changed files are reviewed. `include_paths`
is an allowlist and `exclude_paths` a denylist applied after it, so an exclude
always wins. Both default to empty, which reviews every file.

Patterns match the full repo-relative path, and a `**/` prefix also matches at
the repo root, so `**/*.lock` covers a root-level lockfile. The built-in skip
filter for generated, vendored and binary files runs first, so an
`include_paths` entry can't bring back a lockfile.

```yaml
include_paths:
  - "src/**"
exclude_paths:
  - "src/generated/**"
  - "**/*.lock"
```

### max_files

The maximum number of changed files to review. Files past the cap are skipped
and the summary says `Reviewed the top N of M changed files`. With
[`triage_model`](#triage_model) set, the cap drops the lowest-risk files;
otherwise files keep their diff order.

```yaml
max_files: 30
```

Default: `50`. A separate per-file cap, `max_file_diff_lines` (default `2000`,
`0` disables), skips any single file whose diff is longer than that and names it
in the summary. It catches generated data files that the name-based skip filter
can't recognise.

### max_input_tokens

The token budget for each model call. When the diff exceeds it, lgtmaybe splits
the review across batched calls. With `recursive` on (the default), a single
file larger than the budget is walked hunk by hunk instead of being cut short.

```yaml
max_input_tokens: 80000
```

Default: `100000`. When you leave it unset, lgtmaybe lowers it to fit a model
whose context window is smaller. A value you set is used as is.

### preset

How many model calls the review spends. `fast` (the default) covers the nine
everyday categories in four calls, one per concern:

| Call | Covers |
|---|---|
| security | security |
| correctness | correctness, plus stated intent when the PR states one |
| code health | performance, complexity, ponytail, deprecation |
| artefacts | tests, documentation |

The same four run on every provider. Worker count only changes whether they
overlap. A fifth `spec` call runs when the repository commits a specification
that matches the PR (see [categories](#categories)).

`full` runs one focused call per category, for release branches and deep
audits.

```yaml
preset: full
```

Default: `fast`. CLI: `--preset fast|full` (`--full` is shorthand); Action
input: `preset`. An explicit `categories` list overrides the grouping.

### categories

Which review lenses to run. Narrowing the list turns off the preset grouping:
each listed category runs as its own concurrent call, and the findings are
merged. Fewer categories means fewer calls and fewer tokens, at the cost of
coverage.

Choose from `security`, `correctness`, `deprecation`, `tests`, `documentation`,
`performance`, `complexity`, `intent`, `ponytail` and `spec`.

```yaml
categories:
  - security
  - correctness
```

Default: all ten. Two of them only run when there is something to check:

- `intent` checks the diff against the PR's stated intent: title, description
  and commit names on GitHub, or your `git log` commit names on the CLI. With
  no stated intent (for example, no commits beyond the base branch yet) it is
  skipped and costs nothing. Under `fast` it shares the correctness call.
- `spec` checks the diff against a committed specification (OpenSpec, GitHub
  Spec Kit or Kiro). With no matching spec it is skipped. Turn it off with
  `spec_review: false`, `--no-spec`, or the Action input `spec_review`.

The PR title, description and commit names reach the provider only through the
`intent` lens and the change overview (`auto_diagram`). To keep that text from
being sent at all, drop `intent` from `categories` and set `auto_diagram: false`.

`ponytail` is the "lazy senior dev" check: *the best code is the code you never
wrote*. It flags code that needn't exist (YAGNI, reinventing the standard
library, code that could be far shorter). See
[What gets reviewed](../explanation/what-gets-reviewed.md#ponytail-the-laziest-senior-dev-in-the-room).

### context_lines

The most unchanged lines added around each changed hunk, read from the head
revision of the file, so the model sees a change in the context of its
surrounding code. The full budget goes before the hunk, where the enclosing
signature and setup are, and a quarter of it (at least one line) goes after.
lgtmaybe uses the smaller of this value and what the token budget allows, so
the padding shrinks on large PRs. Set it to `0` to review the bare diff.

```yaml
context_lines: 10   # up to 10 lines before each hunk and 2 after; 0 disables
```

Default: `20`. CLI: `--context-lines`.

### function_context

Extends each hunk's leading padding up to the enclosing function or class
signature when that sits above the `context_lines` window. lgtmaybe finds the
boundary with ast-grep (parsing only, never running code) for Python,
JavaScript, TypeScript/TSX, Go, Rust, Java and Ruby, with a bounded reach so a
distant definition can't swamp the diff. Other languages, and any ast-grep
failure, fall back to the fixed-line padding.

```yaml
function_context: false   # fixed-line padding only
```

Default: `true`.

### timeout

The per-request timeout, in seconds, for each model call. Left unset, lgtmaybe
picks a default by provider:

| Provider | Default |
|---|---|
| ollama, openai-compatible | 1800 s, multiplied by `max_concurrency` (calls queue on a single-slot local server) and capped at `max_review_seconds`, but never below 1800 s. With the defaults that is 3600 s. |
| openrouter | 1800 s (it can route to slow reasoning models) |
| other cloud providers | 600 s |

An explicit value is used as is and never scaled. Raise it for a large local
model:

```yaml
timeout: 3600   # an hour per call, e.g. for a big model on CPU
```

CLI: `--timeout`; Action input: `timeout`. See
[Run locally with ollama](run-locally-with-ollama.md#slow-models-and-timeouts).

### structured_output

Asks the provider to return the findings JSON schema through its native JSON
mode (litellm `response_format`). This stops models, especially local ones, from
replying with prose instead of findings. Leave it on unless you hit one of two
failures:

1. The gateway **rejects** `response_format` outright (some
   `openai-compatible` gateways return a `400`). The review fails loudly; turn
   this off.
2. The gateway **accepts** JSON-schema mode but returns replies the parser reads
   as empty. This fails silently: `--profile` shows
   `findings: 0 parsed / 0 returned`, the run exits clean, and a capable model
   looks like one that finds nothing. This was seen with
   `anthropic/claude-fable-5.1` behind OpenRouter on lgtmaybe 2.3.0 and 2.7.0:
   the same diff returned 11 findings with this off. If a model is unexpectedly
   silent, re-run one diff with `--no-structured-output` before concluding it
   finds nothing.

Either way, the lenient parser still strips code fences and pulls JSON out of
surrounding prose.

```yaml
structured_output: false   # gateway rejects JSON-schema mode, or a model returns nothing under it
```

Default: `true`. CLI: `--no-structured-output`; Action input:
`structured_output`. See
[Use a custom OpenAI-compatible endpoint](use-a-custom-openai-compatible-endpoint.md#gateways-that-dont-support-json-mode-response_format).

### reflect

Runs the self-reflection pass, which audits the merged findings and drops the
ones the model marks low-confidence before anything is posted. It trims false
positives, so leave it on for most models. Turn it off for a weaker or local
model that drops valid findings during the audit.

```yaml
reflect: false   # keep every finding; skip the false-positive audit
```

Default: `true`. CLI: `--no-reflect`.

To audit a weak reviewer with a stronger model instead of turning the pass off,
set `reflect_model` (same provider and credentials as `model`). Under the
`fast` preset, unless you set `reflect_model`, an `anthropic` Sonnet or Opus
review audits with `claude-haiku-4-5`, and an `openai` GPT-5 review (other than
a mini or nano model) audits with `gpt-5-mini`. Every other combination audits
with the review model.

### min_confidence

During reflection, the auditor scores each kept finding from 0 (certainly a
false positive) to 10 (certainly real) by trying to disprove it against the diff
and the file text. Findings scored below `min_confidence` are dropped.

The score appears in the CLI output, the JSON output and, as a percentage, in
the posted comment's title next to the lens that raised it:
`**[HIGH · security · 80%] Title**`. A kept finding with no score always passes
the threshold, and its comment leaves out the percentage (as every comment does
with `reflect: false`).

```yaml
min_confidence: 5   # drop findings the auditor scores 0-4
```

Default: `0`, no numeric filtering; reflection prunes only through its keep/drop
verdicts. CLI: `--min-confidence`.

### mid_review_retrieval

Lets a review lens ask once for files it can't see.

By default every lens is told the diff is only a slice of the codebase. If a
finding depends on unseen code (a guard, a base class, a helper), the lens must
hedge, lower the severity, or say nothing. That keeps out confident cross-file
mistakes, but it also hides a real bug whose evidence is one file away.

With this on, a lens can name the files or symbols it needs alongside its
findings. lgtmaybe fetches them read-only (the same API or worktree read the
reflection auditor uses, never a checkout of PR code), redacts them, and re-runs
that one lens with them. Findings from both calls are kept and de-duplicated,
so a deferral can only add findings.

```yaml
mid_review_retrieval: true   # trade tokens for cross-file recall
```

Default: `false`, because it can cost up to one extra model call per batch and
lens, carrying the fetched text too. The other bounds are fixed: one hop (the
re-run can't ask again), at most five files within a quarter of
`max_input_tokens`, and a deferral that arrives past `max_review_seconds` or
`max_review_tokens` is skipped and reported in the summary. Read
[Reduce Review Cost](reduce-review-cost.md#what-costs-more-on-purpose) first,
and set `max_review_tokens` if the worst case matters. CLI:
`--mid-review-retrieval`; Action input: `mid_review_retrieval`.

### incremental

Commit-scoped review on GitHub. On a re-run, lgtmaybe reads a hidden watermark
from its own summary comment (the head SHA its last completed review covered)
and reviews only the commits pushed since, instead of the whole PR. New findings
post as inline comments. Findings on files outside the increment stay open, and
only a run that re-reviewed their file can auto-resolve them.

It falls back to a full review when there is no watermark yet, after a
force-push or rebase, or if the compare call fails. A failed review never moves
the watermark, so no commit is skipped. Comment `/review full` on the PR to
force a full re-review.

```yaml
incremental: false   # every run reviews the whole PR
```

Default: auto. Incremental on a `synchronize` push (new commits on a PR that was
already reviewed); full everywhere else, including open/reopen, slash commands
and the local CLI. Action input: `incremental`.

### static_analysis

Runs fast, deterministic tools over the changed files. Each tool feeds the
review in one of two modes:

- **`hint`**: the tool's findings become hints the model confirms, puts in
  context, or discards. This catches mechanical bugs models miss without posting
  raw linter noise; only findings the model confirms are reported. Default for
  ruff, bandit, mypy and semgrep.
- **`finding`**: the tool's findings are posted directly, with no model call.
  Free and identical run to run. Default for gitleaks, zizmor, ast-grep and
  osv-scanner.

The split follows the tool. A committed credential is either there or not, so
asking a model to "confirm or discard" a regex match only adds latency and a
chance of talking itself out of a real hit. A lint or SAST heuristic is often
technically true and beside the point, which is what a model is good at
filtering. Override either way with `tool_mode`.

| Tool | Checks |
|---|---|
| ruff, bandit, mypy | Python lint, security and type checks |
| gitleaks | secrets, any language |
| zizmor | GitHub Actions workflow security (template injection, unpinned `uses`, broad permissions); runs only when the PR changes a workflow |
| ast-grep | your own structural rules, set with `ast_grep_rules` (skipped when unset) - the deterministic sibling of `extra_lenses` |
| osv-scanner | known vulnerabilities in your dependencies |
| semgrep | multi-language SAST |

```yaml
static_analysis:
  enabled: true
  tools: [ruff, bandit, mypy, gitleaks, zizmor]  # default: all supported tools
  min_severity: low            # floor on mapped tool severity (default info)
  tool_min_severity:           # per-tool overrides of the floor
    ruff: medium               # only medium+ from ruff; bandit keeps low
  tool_mode:                   # per-tool overrides of hint vs finding
    gitleaks: hint             # route secrets through the model instead
  # semgrep_rules: .semgrep.yml  # replace the bundled MIT rule pack
  # ast_grep_rules: .ast-grep/   # your own structural rules
```

Default: `enabled: false`, so no tool ever runs. CLI:
`--static-analysis/--no-static-analysis`; Action input: `static_analysis`.

**Installing the tools.** A tool that isn't installed is skipped silently.
`pip install lgtmaybe[static-analysis]` installs ruff, bandit, mypy, semgrep and
zizmor, and ast-grep ships with lgtmaybe. gitleaks and osv-scanner are Go
binaries you install separately. The GitHub Action image includes all eight.

**Sandboxing.** The tools run on the already-fetched file texts in a throwaway
directory (never a checkout, never running PR code), in a subprocess with no
network, a scrubbed environment and a hard timeout. Their output is treated as
untrusted: redacted and wrapped before it reaches the model.

**Direct findings.** Tools read whole files, but only the diff is under review,
so a finding on a line the PR didn't change is dropped and counted in the
summary. Otherwise a fake credential in a test fixture would post on every PR
that touches the file. Direct findings are also capped per review, most severe
first. They carry a `scan:<tool>` category, so `finding_rules` can drop or
re-grade one scanner without turning it off:

```yaml
finding_rules:
  - match: {category: "scan:gitleaks", path: "tests/fixtures/**"}
    action: {drop: true}
```

**osv-scanner** answers a question a model can't: whether a pinned dependency
has a published advisory depends on what was disclosed this week, past any
model's knowledge cutoff. It reads the manifests and lockfiles the PR changes
through a scan-only channel that never enters the diff, a prompt or reflection.
Its findings are about a dependency rather than a line, so they render in the
review body, and only `high` and `critical` advisories show by default
(`unanchored_min_severity`).

It needs a local vulnerability database, since the sandbox has no network. The
Action image bakes one in at build time. On the CLI, point
`OSV_SCANNER_LOCAL_DB_CACHE_DIRECTORY` at a directory and seed it once from a
project that has your manifests:

```bash
export OSV_SCANNER_LOCAL_DB_CACHE_DIRECTORY="$HOME/.cache/osv-db"
osv-scanner scan source --offline-vulnerabilities --download-offline-databases .
```

Keep the variable set when you run lgtmaybe; it is the one variable passed into
the sandbox. Without a database the scanner reports nothing, and the review says
so rather than calling it clean.

**What the model stops being asked.** When osv-scanner posts findings, the
review prompt no longer asks the model whether a version has a known advisory
or a package is abandoned, since a knowledge cutoff can't answer either. The
model keeps deprecated APIs, end-of-life runtimes, typosquats and licence
conflicts. Likewise, when gitleaks posts findings the model is no longer asked
for hardcoded secrets: lgtmaybe redacts matched secrets before the diff leaves
for the provider, so the model couldn't see them anyway, while gitleaks reads
the unredacted text. Secrets written to a log are a different defect that no
secret scanner reports, so the model keeps that one. Set either tool to `hint`
and the model takes those checks back.

**semgrep** runs out of the box with a small, high-precision MIT rule pack that
ships with lgtmaybe; `semgrep_rules` replaces it. Rules are always read from a
local path, because semgrep's registry configs need the network. lgtmaybe
doesn't bundle the well-known semgrep-rules or opengrep-rules collections:
they are LGPL-2.1 plus a Commons Clause, which an MIT package can't
redistribute. Point `semgrep_rules` at a fuller pack you have obtained yourself.

**mypy** catches unguarded `Optional` bugs, such as a `dict.get()` result
dereferenced without a `None` check, which a review lens reads straight past. It
runs with `--ignore-missing-imports --follow-imports=skip` because the sandbox
holds only the changed files, so it reports what it can prove from one file and
stays quiet about the rest.

### triage_model

Two-stage routing, so routine PRs don't pay frontier prices while risky ones
still get the strong model. When set, this cheap model runs first over the
per-file diffs. It skips files that plainly need no review (pure formatting,
trivial renames, generated churn) and scores the rest 0-10 by risk. The strong
`model` then reviews only the survivors, riskiest first. Skipped files are
listed in the review summary, and `/review full` reviews everything.

Some files always go to the strong model, whatever triage says:
security-relevant paths (auth, crypto, session code, migrations, IaC, CI
workflows, dependency manifests), patches containing security-relevant tokens,
files with static-analysis hits, and large hunks. Any triage failure, such as an
unparseable verdict or a provider error, reviews everything.

`triage_model`, `model` and `reflect_model` share one provider and set of
credentials, so pointing them all at one ollama model costs nothing. The
trade-off is cheaper, faster reviews against the risk of triage under-rating a
subtle change. The always-review rules bound that risk; for maximum recall,
leave triage off.

```yaml
triage_model: claude-haiku-4-5   # cheap gatekeeper; unset = no triage
```

Default: unset (every file gets the full review). CLI: `--triage-model`; Action
input: `triage_model`.

### auto_diagram

Posts the change overview as a comment and refreshes it on every push: a
description of the change, a Risk of Change section, High Impact Areas, a
Mermaid flowchart of the components the PR touches and, when the change alters
a run-time flow, a Mermaid sequence diagram. It is one comment, updated in
place. `/diagram` posts the same overview on demand.

This is the switch for the whole comment. With it off, only the review itself
is posted automatically. It also stands down when the PR description or another
comment already has a diagram; the review summary says so, and `/diagram` still
works.

```yaml
auto_diagram: false
```

Default: `true`. Action input: `auto_diagram`. See
[Generate a change overview](generate-a-change-diagram.md).

### auto_describe

Heads the change overview with a structured description: a suggested title, the
change type, a short summary, a per-file walkthrough table and, when the PR
states an intent, a "does it do what it says" check. It is its own model call
and best-effort: a failure leaves a short note in its place and never blocks the
rest of the comment.

Because it rides the overview comment, it refreshes on every push. `/describe`
posts a description as a separate comment whether or not this is on.

```yaml
auto_describe: false
```

Default: `true`. Action input: `auto_describe`.

### high_impact

Includes the High Impact Areas section in the change overview: changes a
reviewer must not miss, across ten areas - infrastructure, security posture,
availability (anything that could cause an outage), data migrations, backups
and recovery, compatibility, observability, dependencies, cost and compliance.

Path patterns ground the model call as untrusted hints and also set a floor on
its output, so a sensitive file (a `*.tf`, a workflow, a migration, a backup
policy, a lockfile) is named even when the model says nothing about it. A failed
call falls back to that floor instead of dropping the section, and an empty
result says what was checked.

```yaml
high_impact: false
```

Default: `true`. Action input: `high_impact`. See
[Generate a change overview](generate-a-change-diagram.md).

### risk

Rates each PR's risk of change as `low`, `medium`, `high` or `critical`, with
the reasons. It is deterministic and makes no model call. It combines how many
workspace files import each changed file (Python and TS/JS, via ast-grep), the
High Impact path signals, the size of the change, and code changed without any
test changing.

The level and its top reason appear on the review summary line, the change
overview explains every reason, and a hidden `<!-- lgtmaybe-risk:<level> -->`
marker lets a workflow read it. With `pr_labels` on, the PR also gets a
`risk/<level>` label.

`core_paths` names modules the importer count can't see as central, such as a
config loader or a plugin registry. A changed file matching one is at least
`high`.

```yaml
risk:
  core_paths:
    - src/shared/**
    - "**/settings.py"
```

Default: on, with no `core_paths`. Turn it off with `risk: {enabled: false}`,
`--no-risk`, or the Action input `risk: false`. See
[Risk of Change](../explanation/risk-of-change.md) for how the level is decided
and how to gate on it.

### pr_labels

Adds labels derived from the finished review, with no extra model calls:

- `review-effort/1` to `review-effort/5`: a size estimate from the changed lines.
- `possible-security-issue`: a high or critical finding was posted by the
  security lens or by a security scanner (gitleaks, bandit, semgrep, zizmor,
  osv-scanner).
- `consider-splitting`: the diff spans many unrelated top-level directories.
- `risk/<level>`: the [risk of change](../explanation/risk-of-change.md)
  verdict, when `risk` is on. Only one level is applied at a time.

Labels are reconciled on each run (a stale `review-effort/2` is removed when the
score changes), and only lgtmaybe's own label families are touched. A labelling
failure never fails the review.

```yaml
pr_labels: true
```

Default: `false`. Action input: `pr_labels`.

### finding_rules

Declarative rules applied to findings just before posting. Rules can only drop
or re-grade findings; no user code runs. Each rule has a `match` (every field
given must match) and an `action`, and rules apply in order.

Match fields:

- `path`: a glob; a `**/` prefix also matches at the repo root.
- `category`: the lens that produced the finding (`security`, `correctness`, a
  custom lens ID, or `scan:<tool>`).
- `title_contains`: a case-insensitive substring.
- `min_severity`: matches findings at or above this severity.

Actions: `drop: true` or `set_severity: <level>`.

```yaml
finding_rules:
  # complexity nits in tests aren't worth a comment
  - match: {path: "tests/**", category: complexity}
    action: {drop: true}
  # documentation findings are informational for this repo
  - match: {category: documentation}
    action: {set_severity: info}
```

Default: no rules.

### summary_template

A custom template for the review summary line. Placeholders: `{count}`
(findings posted), `{provider}`, `{model}`, `{version}` (the lgtmaybe release
that produced the review), `{risk}` (the risk segment, empty when `risk` is
off) and `{temperature}` (`temperature not sent` when the model's route dropped
the configured temperature, so the review may not be reproducible; empty
otherwise). A template that fails to format falls back to the built-in line.

```yaml
summary_template: "🤖 {count} finding(s) · {model} · lgtmaybe {version}"
```

Keep `{version}` if you can. The same model on the same provider reviews
differently across releases, so the version is what makes a surprising review
traceable.

Default: unset, which uses the built-in
`N findings · risk … · provider X · model Y · lgtmaybe Z` line.

### resolve_fixed

Resolves a review conversation once its finding is fixed. On a re-run, when a
finding lgtmaybe raised is no longer produced and GitHub marks its thread
outdated (the code under it changed), lgtmaybe replies `✅ Looks resolved.` and
resolves the conversation. Both conditions must hold, so a thread is never
closed just because nearby lines moved. Set it to `false` to resolve
conversations by hand.

GitHub resolves threads through its GraphQL API; the default `GITHUB_TOKEN` with
`pull-requests: write`, which posting already needs, is enough. The public
`lgtmaybe[bot]` App cannot resolve threads, so under `github_identity: lgtmaybe`
they stay open (see
[What the public App cannot do](use-as-github-action.md#what-the-public-app-cannot-do)).
GitLab has no
"outdated" signal, so there lgtmaybe resolves only threads its re-run validated
as fixed. Azure DevOps has no "outdated" signal either, so lgtmaybe compares
the file at the iteration it posted the thread against the file now. It marks
the thread *Resolved* only when the finding is no longer reported **and** the
flagged line changed, and never after a run that failed or finished only
partly. Gitea's API can't resolve
threads, and the local CLI has no conversations, so both ignore this.

```yaml
resolve_fixed: false   # leave fixed conversations open for manual resolution
```

Default: `true`. Action input: `resolve_fixed`.

### extra_lenses

Your own review lenses, run alongside the built-in categories. Each one runs as
its own focused model call, and its findings merge into the same review. A lens
needs an `id` (unique, not a built-in category name, and not starting with
`scan:`) and `instructions`. A `title` and a worked example (`example_diff` and
`example_finding`, supplied together) are optional, but the example noticeably
improves a small model's output.

```yaml
extra_lenses:
  - id: simplify
    title: Simplify or delete
    instructions: |
      Flag code that should not exist at all. The best code is the code you never
      wrote: prefer the standard library, an existing dependency, or one line over
      a new abstraction. Call out needless wrappers, premature generality, and
      "just in case" code with no caller.
    example_diff: |
      --- a/util.py
      +++ b/util.py
      @@ -4,1 +4,3 @@
       def get_name(user):
      +    name = user.name
      +    return name
    example_finding:
      path: util.py
      line: 5
      severity: low
      title: Needless local variable
      body: The temporary adds nothing; return user.name directly.
      suggestion: "    return user.name"
```

Lens text reaches the model as trusted instructions, so define lenses only in
files you control (the committed `.lgtmaybe.yml` or repo skill files), never
from PR-author content. See [Add a custom review lens](add-a-custom-lens.md)
for a walkthrough.

Default: none.

### lens_paths

Loads `extra_lenses` from separate skill files instead of inlining them, which
helps when sharing a lens across repos or wiring lgtmaybe into an agent harness.
Each entry is a YAML file (one lens or a list of lenses), a directory of `*.yml`
/ `*.yaml` lens files, or `pack:<name>` for a lens pack bundled with lgtmaybe.
Paths resolve relative to where lgtmaybe runs (your repo root). Loaded lenses
are appended to any inline `extra_lenses`.

```yaml
lens_paths:
  - .lgtmaybe/skills            # a directory of one-lens-per-file skill files
  - team-lenses/house-style.yml # or a single file
  - pack:robustness             # a bundled pack
```

Default: none. See
[Bundled lens packs](add-a-custom-lens.md#bundled-lens-packs-packname).

### directory_rules

Scopes extra review instructions and reference files to part of the repo, for
when `payments/**` and `tests/**` deserve different treatment. Each rule has
`paths` (globs; a `**/` prefix also matches at the repo root, and an empty or
missing list applies the rule everywhere), free-text `instructions`, and
`context_files` read from the checked-out workspace. A rule reaches every lens
call whose batch contains a file it matches.

```yaml
directory_rules:
  - paths: ["payments/**", "billing/**"]
    instructions: |
      Money-handling code. Treat rounding, currency-conversion and retry
      changes as high severity, and flag any write path that is not idempotent.

  - paths: ["tests/**"]
    instructions: |
      Test code. Do not flag duplication, long functions, or missing
      docstrings here.

  - paths: ["src/**"]
    instructions: Check changes against the architecture described below.
    context_files: [ARCHITECTURE.md]
```

Context files are redacted like the diff, share an eighth of
`max_input_tokens`, and are capped at five files per review. A missing or
over-budget file is skipped silently. Instructions and context are trusted
config: on `pull_request_target` they come from the base branch, never the PR
head. See
[Scope review instructions to a directory](scope-review-instructions-to-a-directory.md).

Default: no rules.

### project

Tells the reviewer what kind of project this is, so a weekend prototype and a
regulated payments service don't get the same review. See
[Calibrate reviews to your project](calibrate-reviews-to-your-project.md) for
the walkthrough.

```yaml
project:
  stage: regulated        # prototype | production | regulated
  domain: payments        # a short label, up to 80 characters
  notes: |                # up to 1,000 characters
    PCI-scoped card flows live under payments/. Most traffic is batch.
```

`stage` changes some defaults. A value you set yourself always wins.

| Stage | Default it changes | Review guidance |
|---|---|---|
| `prototype` | `min_severity` defaults to `medium` instead of `low` | Report correctness and security; skip hardening, observability, scalability and documentation polish |
| `production` | none | The standard bar |
| `regulated` | Any PR that isn't only docs and tests is at least `medium` [risk](#risk), with a `regulated workload` reason | Rate data handling, auditability, access control and personal-data exposure one severity step higher where the domain makes them material |

All three fields also add one short paragraph to the system prompt every lens
shares, so with prompt caching it is paid for once per run rather than per lens.
Leave the block out and the prompt is unchanged. Like `directory_rules`, this is
trusted config, read from the base branch on `pull_request_target`.

lgtmaybe doesn't read `AGENTS.md`, `CLAUDE.md` or `SOUL.md` for this. Those
files are written for coding agents, most of their text is noise to a reviewer,
and the larger ones don't fit the context-file budget. To put one in front of
the reviewer anyway, name it in a `directory_rules` entry's `context_files`.

Default: no profile.

## Precedence and CLI Overrides

Settings are merged from four layers, highest first:

1. CLI flags, or the GitHub Action's inputs.
2. `.lgtmaybe.yml` (or the file named by `--config` / `config_path`).
3. The user config at `~/.config/lgtmaybe/config.yml`, managed with
   `lgtmaybe config` (local `review` and `diagram` commands only).
4. Built-in defaults.

```bash
lgtmaybe review \
  --provider anthropic \
  --model claude-sonnet-4-6 \
  --min-severity high
```

Most single-value settings have a flag; run `lgtmaybe review --help-all` for
the list (`--help` alone shows only the everyday ones). List and nested settings are YAML-only: `include_paths`, `exclude_paths`,
`categories`, `finding_rules`, `extra_lenses`, `lens_paths`, `directory_rules`,
`project`, `spec_paths`, `risk.core_paths` and the `static_analysis` sub-keys.
The `--static-analysis` and `--risk` flags switch those features on or off and
keep the rest of their YAML settings.

## Upgrading from 1.14

Remove `prompt_cache` from `.lgtmaybe.yml` and `--prompt-cache` /
`--no-prompt-cache` from scripts. lgtmaybe 2.0 and later reject them. Prompt
caching is now automatic wherever the provider supports it.

---

<!-- Source: https://lgtmaybe.coles.codes/how-to/add-a-custom-lens/ -->

# Add a Custom Review Lens (BYO Skills)

lgtmaybe ships ten built-in review lenses: security, correctness, deprecation,
tests, documentation, performance, complexity, intent, spec, and `ponytail`
(the "lazy senior dev", write-less-code lens). A **custom lens** adds your own.
It runs alongside the built-ins as its own focused model call, and its findings
merge into the same review. Use one to enforce a house style or team convention
the built-in lenses don't cover.

## Contents

- [How a Lens Works](#how-a-lens-works)
- [Inline in `.lgtmaybe.yml`](#inline-in-lgtmaybeyml)
- [As Reusable Skill Files](#as-reusable-skill-files)
- [Bundled Lens Packs (`pack:<name>`)](#bundled-lens-packs-packname)
- [Run It](#run-it)
- [Security: Lenses Are Trusted Input](#security-lenses-are-trusted-input)
- [See Also](#see-also)

## How a Lens Works

Every lens, built-in or custom, has the same shape: a focused set of
instructions and one worked example, sent as its own model call so the model
concentrates on a single concern. A custom lens has these fields:

| Field | Required | What it is |
|---|---|---|
| `id` | yes | A unique short name. It can't be a built-in category name or start with `scan:`. |
| `instructions` | yes | What to look for, in plain language. This is the lens. |
| `title` | no | A human-readable heading (defaults to `id`). |
| `example_diff` + `example_finding` | no (together) | A worked example: a hunk and the finding the model should return for it. Without one, the lens gets a generic example. Supplying your own noticeably improves smaller models. |

Findings from the lens carry its `id` as their category, so
[`finding_rules`](configure-lgtmaybe-yml.md#finding_rules) can drop or re-grade
them like any built-in lens.

## Inline in `.lgtmaybe.yml`

The quickest way is to define the lens in your repo config:

```yaml
provider: ollama
model: qwen3.6:27b
extra_lenses:
  - id: house-style
    title: House style
    instructions: |
      Enforce our team conventions on changed code. Flag new public functions
      that return bare dicts instead of a typed dataclass, and any logging at
      WARNING or above that doesn't include a correlation id.
    example_diff: |
      --- a/api.py
      +++ b/api.py
      @@ -10,1 +10,2 @@
       def make_user(name):
      +    return {"name": name, "active": True}
    example_finding:
      path: api.py
      line: 11
      severity: low
      title: Public function returns a bare dict
      body: House style returns a typed dataclass from public functions, not a dict.
      suggestion: "    return User(name=name, active=True)"
```

!!! note "The Ponytail lens is built in"
    The "lazy senior dev / write less code" instinct from
    [Ponytail](https://github.com/DietrichGebert/ponytail) ships as the built-in
    `ponytail` lens, so you don't need a custom lens for it. Use `extra_lenses`
    for something the built-ins don't cover, like the house-style rule above.

## As Reusable Skill Files

To share lenses across repos, or let an agent harness add its own, put each
lens in its own file and point `lens_paths` at the file or its directory:

```yaml
# .lgtmaybe.yml
provider: ollama
model: qwen3.6:27b
lens_paths:
  - .lgtmaybe/skills
```

```yaml
# .lgtmaybe/skills/house-style.yml
id: house-style
title: House style
instructions: |
  Flag new public functions that return bare dicts instead of a typed dataclass,
  and WARNING+ logging without a correlation id.
```

A skill file holds one lens (a mapping) or several (a list). A directory entry
loads every `*.yml` and `*.yaml` file in it. Paths resolve relative to where
lgtmaybe runs, normally the repo root. Lenses from `lens_paths` are appended to
any inline `extra_lenses`, and every `id` must be unique across the whole set.

## Bundled Lens Packs (`pack:<name>`)

lgtmaybe ships an opt-in library of extra lenses, distilled from the wider
engineering-review ecosystem (Ousterhout, Metz, Carmack, the NASA Power of Ten /
TigerStyle, Google's eng-practices, and several skill and rule collections; see
[ATTRIBUTION.md](https://github.com/MattJColes/lgtmaybe/blob/main/ATTRIBUTION.md)).
They are off by default because they are more opinionated than the built-ins,
and every lens you enable adds a model call per batch. Enable a pack by name
with the `pack:` scheme in `lens_paths`, which works for a `pip install` with no
repo-relative path to point at:

```yaml
# .lgtmaybe.yml
provider: ollama
model: qwen3.6:27b
lens_paths:
  - pack:design       # combine as many as you like
  - pack:robustness
```

| Pack | Lenses | Use it when |
|---|---|---|
| `pack:design` | `wrong-abstraction`, `shallow-module`, `information-leakage`, `errors-out-of-existence`, `hidden-state`, `naming` | You want review of structure and taste: the right abstraction, deep modules, explicit state. |
| `pack:robustness` | `assertions`, `bounded`, `idempotency`, `migrations`, `portability`, `observability` | Operational safety: defensive invariants, bounded work, retry-safe side effects, safe migrations. |
| `pack:interface` | `api-design`, `type-safety`, `magic-values`, `comment-why` | Contracts and clarity: backward compatibility, tight types, named constants, comments that explain why. |
| `pack:frontend` | `accessibility`, `i18n` | The diff touches UI or user-facing copy. |

Each pack is a directory of skill files inside the package, so you can browse or
copy any single lens from
[`src/lgtmaybe/lenses/`](https://github.com/MattJColes/lgtmaybe/tree/main/src/lgtmaybe/lenses).
An unknown `pack:` name fails with a list of the packs that exist.

## Run It

Custom lenses run on every review, CLI or GitHub Action, once they are in your
config. On the CLI, their findings appear alongside the built-ins:

```bash
lgtmaybe review --provider ollama --model qwen3.6:27b --api-base http://localhost:11434
```

`categories` controls only the built-in lenses, so you can narrow it and your
custom lenses still run.

## Security: Lenses Are Trusted Input

A lens's instructions and example reach the model as trusted reviewer
instructions, so treat a lens like code you run: define lenses only in files you
control, such as your committed `.lgtmaybe.yml` or repo skill files, and never
source them from a pull request. On `pull_request_target` the Action reads
config from the base branch, not the PR head, so a fork PR can't add or change a
lens. The diff itself is always treated as untrusted data, separately from your
lenses.

## See Also

- [Configure .lgtmaybe.yml](configure-lgtmaybe-yml.md#extra_lenses) - the field reference.
- [What gets reviewed](../explanation/what-gets-reviewed.md) - the built-in lenses.

---

<!-- Source: https://lgtmaybe.coles.codes/how-to/scope-review-instructions-to-a-directory/ -->

# Scope Review Instructions to a Directory

A monorepo is not uniform. `payments/**` deserves a strictness that would be
pure noise on `tests/**`, and reviewing `src/**` well may need a design document
the diff never shows. `directory_rules` scopes both **extra instructions** and
**reference files** to path globs, so one repo config can say different things
about different directories.

## Contents

- [The Shape of a Rule](#the-shape-of-a-rule)
- [Scope Instructions to a Directory](#scope-instructions-to-a-directory)
- [Give the Reviewer Background Reading](#give-the-reviewer-background-reading)
- [How Rules Reach the Model](#how-rules-reach-the-model)
- [Limits](#limits)
- [Security: Rules Are Trusted Configuration](#security-rules-are-trusted-configuration)
- [See Also](#see-also)

## The Shape of a Rule

| Field | Required | What it is |
|---|---|---|
| `paths` | no | fnmatch globs against the repo-relative path. A `**/` prefix also matches at the repo root. **Omit it** (or leave it empty) and the rule applies everywhere. |
| `instructions` | no | Free text handed to every lens call whose batch contains a file the rule matches. |
| `context_files` | no | Repo-relative files whose text is read from the checked-out workspace and included alongside the instructions. |

A rule needs at least one of `instructions` and `context_files` to do anything.

## Scope Instructions to a Directory

```yaml
provider: bedrock
model: us.anthropic.claude-sonnet-4-6

directory_rules:
  - paths: ["payments/**", "billing/**"]
    instructions: |
      This is money-handling code. Treat any rounding, currency-conversion or
      retry change as high severity, and flag any write path that is not
      idempotent under a duplicated webhook.

  - paths: ["tests/**"]
    instructions: |
      Test code. Do not flag duplication, long functions, or missing
      docstrings here. Explicit and repetitive is the house style for tests.
      Do still flag assertions that can never fail.

  - paths: ["infra/**/*.tf"]
    instructions: |
      Terraform. Flag any IAM policy with a `*` action or resource, and any
      storage bucket or database that becomes publicly reachable.
```

Rules are independent, not exclusive. lgtmaybe reviews files in batches, and a
rule applies to a whole batch when any file in it matches. A batch touching both
`payments/charge.py` and `tests/test_charge.py` gets both rules, in the order
you wrote them.

## Give the Reviewer Background Reading

`context_files` puts a document in front of the model that the diff would never
show it, such as the architecture the code should follow, a data-model
invariant, or a deprecation plan:

```yaml
directory_rules:
  - paths: ["src/**"]
    instructions: |
      Check changes against the architecture described below. Flag a new
      import that crosses a boundary the document forbids.
    context_files:
      - ARCHITECTURE.md
      - docs/data-model.md
```

Paths are repo-relative. The files are read from the workspace lgtmaybe runs
in, the same place your `.lgtmaybe.yml` comes from, and are redacted for secrets
before they leave the process, exactly like the diff. A path that resolves
outside the workspace is skipped.

## How Rules Reach the Model

The rules matched for a batch are rendered into one block placed ahead of the
diff in each review call, labelled as trusted configuration so the model never
confuses it with the untrusted diff after it. The block is part of the same
cacheable prefix as the diff, so on providers with prompt caching you pay for it
roughly once per batch rather than once per lens.

Instructions and context text both go in as written, except that any of
lgtmaybe's own prompt delimiters are neutralised, so a document that happens to
quote one can't break out of its block.

## Limits

- **Instructions and context only.** A rule cannot change `min_severity`,
  `categories`, or any other setting for a directory. If you want a lens that
  runs everywhere with its own worked example, that is
  [a custom lens](add-a-custom-lens.md).
- **The context budget is small.** Context files share `max_input_tokens / 8`
  (12,500 tokens at the default) and are capped at five files per review, so the
  diff always dominates the call. A file that doesn't fit is skipped whole.
- **A missing path is skipped silently.** A context file is an aid, and a stale
  path must never fail a review. lgtmaybe doesn't log which files it skipped, so
  if a document doesn't seem to reach the model, check that the path is
  repo-relative and that the file fits the budget.
- **YAML only.** Like `finding_rules` and `extra_lenses`, `directory_rules` is a
  list of objects, so it has no CLI flag and no Action input. Put it in
  `.lgtmaybe.yml`.

## Security: Rules Are Trusted Configuration

Both the instructions and the context text enter the prompt as **trusted**
content, so they must never come from a pull request's author.

On the recommended `pull_request_target` trigger, the workflow checks out the
**base** branch (the code already merged), never the PR head. `directory_rules`
and the files it names are read from that checkout, the same source as
`.lgtmaybe.yml` and `lens_paths`. lgtmaybe never fetches context files through
the host's API, which would resolve them at the untrusted PR head.

On every host, context comes only from the job's workspace, so it is as trusted
as whatever your job checks out. The [GitLab example job](review-on-gitlab.md)
doesn't clone the repository at all, so it has no `.lgtmaybe.yml` or context
files to read.

The practical rule: a fork PR can change `ARCHITECTURE.md` in its own branch and
lgtmaybe will still read the base branch's copy. Review changes to your config
and context files the way you review any other change to CI.

## See Also

- [Configure `.lgtmaybe.yml`](configure-lgtmaybe-yml.md) - every field
- [Add a custom review lens](add-a-custom-lens.md) - a whole extra lens, repo-wide
- [Calibrate reviews to your project](calibrate-reviews-to-your-project.md) - stage, domain and notes for the whole repo
- [Configuration reference](../reference/config.md) - generated schema

---

<!-- Source: https://lgtmaybe.coles.codes/how-to/calibrate-reviews-to-your-project/ -->

# Calibrate Reviews to Your Project

A weekend prototype and a regulated payments service shouldn't get the same
review. The prototype doesn't need to hear about missing metrics, and the
payments service shouldn't have an access-control slip rated as a minor issue.
The `project` block tells the reviewer which kind of repository it is looking
at, and lgtmaybe adjusts both its defaults and the model's guidance to match.

## Contents

- [The Shape of a Profile](#the-shape-of-a-profile)
- [Pick a Stage](#pick-a-stage)
- [Add Domain and Notes](#add-domain-and-notes)
- [What Your Settings Still Control](#what-your-settings-still-control)
- [How the Profile Reaches the Model](#how-the-profile-reaches-the-model)
- [Why Not Read AGENTS.md?](#why-not-read-agentsmd)
- [Security: the Profile Is Trusted Configuration](#security-the-profile-is-trusted-configuration)
- [See Also](#see-also)

## The Shape of a Profile

```yaml
provider: bedrock
model: us.anthropic.claude-sonnet-4-6

project:
  stage: regulated
  domain: payments
  notes: |
    PCI-scoped card flows live under payments/. Most traffic is nightly batch,
    so latency findings on the batch path matter less than correctness.
```

| Field | Required | What it is |
|---|---|---|
| `stage` | no | `prototype`, `production` or `regulated`. Changes defaults and adds fixed guidance. |
| `domain` | no | A short label, up to 80 characters: `payments`, `healthcare`, `internal tooling`. |
| `notes` | no | Free text the reviewer should know, up to 1,000 characters. A longer value fails validation instead of being cut short. |

Every field is optional. Leave the block out and the review is unchanged.

## Pick a Stage

| Stage | Default it changes | Guidance the model gets |
|---|---|---|
| `prototype` | [`min_severity`](configure-lgtmaybe-yml.md#min_severity) defaults to `medium` instead of `low` | Report correctness and security problems, and skip hardening, observability, scalability and documentation polish it doesn't need yet. |
| `production` | none | Hold the change to the standard review bar. |
| `regulated` | Any PR that isn't only docs and tests is at least `medium` [risk](../explanation/risk-of-change.md), with a "regulated workload" reason | Where the domain makes them material, rate data handling, auditability, access control and personal-data exposure one severity step higher. |

`production` changes no settings. It lets a team state the stage explicitly,
and gives the guidance paragraph a stage to name alongside `domain` and
`notes`.

The `regulated` risk floor stops at `medium` on purpose. Two distinct `high`
reasons make a change `critical`, so a `high` floor would turn every change to a
widely imported module into `critical`. A PR that only touches documentation
and tests stays `low` in any stage.

## Add Domain and Notes

`domain` and `notes` give the model context it can't infer from a diff. Keep
`notes` to what changes how a finding should be judged: where the sensitive
code lives, which paths are hot, what the team has decided not to care about.
For instructions that only apply to part of the repo, use
[`directory_rules`](scope-review-instructions-to-a-directory.md) instead, which
reaches only the lenses reviewing matching files.

## What Your Settings Still Control

A stage only moves defaults. A value you set yourself always wins, whether it
comes from `.lgtmaybe.yml`, the user config or a CLI flag:

```yaml
project:
  stage: prototype
min_severity: low   # stays low: you set it, so the stage leaves it alone
```

```bash
lgtmaybe review --min-severity info   # beats the prototype default too
```

## How the Profile Reaches the Model

The profile renders one short paragraph at the end of the system prompt that
every review lens shares:

```text
## Project context
From the reviewer configuration (trusted). Calibrate what you report and how
severe you rate it to this project:
This is a regulated workload: where the domain makes them material, rate data
handling, auditability, access control and personal-data exposure one severity
step higher than you otherwise would.
Domain: payments.
Notes from the maintainers: PCI-scoped card flows live under payments/. ...
```

Because it sits in the shared prompt, providers with prompt caching pay for it
once per run rather than once per lens. Only the review lenses see it: the
reflection pass and the change overview (description, High Impact Areas and
diagrams) do not.

The stage's settings changes are deterministic. Whether the guidance paragraph
shifts the severities a given model reports depends on the model, and isn't
measured by the eval harness yet.

## Why Not Read AGENTS.md?

Many repositories already have an `AGENTS.md`, `CLAUDE.md` or `SOUL.md`, and
reading one automatically looks like a shortcut. lgtmaybe deliberately doesn't,
for three reasons:

- Those files are written for coding agents: build commands, test runners,
  style rules, personas. Little of it tells a reviewer how strict to be, and
  all of it costs tokens on every call.
- Context files share an eighth of `max_input_tokens` (12,500 tokens at the
  default of 100,000), and a file over budget is skipped whole. The repositories
  with the most thorough agent docs are the ones whose file wouldn't fit.
- A stage maps to settings changes that behave the same way every run. Free
  prose can't do that.

If you want one of those files in front of the reviewer anyway, name it in a
`directory_rules` entry with no `paths`, which applies everywhere:

```yaml
directory_rules:
  - context_files: [AGENTS.md]
```

## Security: the Profile Is Trusted Configuration

The paragraph enters the system prompt as trusted content, so it must never come
from a pull request's author. On the recommended `pull_request_target` trigger,
`.lgtmaybe.yml` is read from the checked-out base branch, never the PR head.
lgtmaybe still neutralises prompt delimiters in `notes` and `domain`, so nothing
in them can close a prompt block early.

## See Also

- [Configure `.lgtmaybe.yml`](configure-lgtmaybe-yml.md#project), every field
- [Scope review instructions to a directory](scope-review-instructions-to-a-directory.md), for per-path instructions
- [Risk of change](../explanation/risk-of-change.md), how the verdict is built
- [Configuration reference](../reference/config.md), the generated schema

---

<!-- Source: https://lgtmaybe.coles.codes/how-to/generate-a-change-diagram/ -->

# Generate a Change Overview

lgtmaybe can post a **change overview** on a pull request: what the change is,
what is risky about it, and what it touches. It is one comment, updated in
place, for a reviewer to read before the diff. It is separate from the review:
the review finds bugs in the lines it reads, and the overview describes the
change as a whole.

## Contents

- [What You Get](#what-you-get)
- [High Impact Areas](#high-impact-areas)
- [Structure and Sequence](#structure-and-sequence)
- [On GitHub: `/diagram` and `auto_diagram`](#on-github-diagram-and-auto_diagram)
- [Locally: `lgtmaybe diagram`](#locally-lgtmaybe-diagram)
- [Why Mermaid (and What the ASCII Is For)](#why-mermaid-and-what-the-ascii-is-for)
- [See Also](#see-also)

## What You Get

Three model calls, run concurrently, plus one deterministic section make up one
comment, in reading order:

1. **The description head** (`auto_describe`): a suggested title, the change
   type, and a short summary.
2. **Risk of Change** (`risk`): the change's `low` to `critical` verdict and
   every reason behind it, computed without a model call. See
   [Risk of Change](../explanation/risk-of-change.md).
3. **[High Impact Areas](#high-impact-areas)** (`high_impact`): the changes a
   reviewer must not miss.
4. **The description detail** (`auto_describe`): a per-file walkthrough, and a
   "does it do what it says?" check when the PR states an intent.
5. **Up to two diagrams**: a Mermaid flowchart of the structure and, when the
   change alters one, a Mermaid sequence diagram of the run-time flow. Each
   comes with a plain-text rendering, which is what a terminal shows and the
   fallback if the Mermaid can't be rendered.

The description, High Impact Areas and diagrams are three calls rather than one
because a single prompt doing three jobs does all three worse. They run at the
same time, so the overview takes about as long as its slowest call. Each
section has its own switch. The description and High Impact Areas are
best-effort: if one of those calls fails, its slot says so and the rest of the
comment still posts.

GitHub renders both Mermaid diagrams natively inside the comment, with no image
and no external hosting.

The flowchart is deliberately sparse: at most six components, short
relationship labels, and Mermaid's automatic layout. Changed components are
marked `(new)` or `(changed)` in their labels. GitHub's renderer offers zoom
buttons but no full screen, so each diagram is followed by an **⛶ Open full
screen** link that opens the same diagram alone on
[mermaid.live](https://mermaid.live), with pan and zoom. The diagram source
travels compressed in the URL fragment and is decoded in your browser, so
nothing is sent anywhere until you click. Relationships the model inferred
rather than saw in the diff are called out in a note under the diagrams,
because the diff is only a slice of the codebase.

Here is the posted comment for a PR that puts a Redis cache in front of a user
service. The Mermaid renders in place, with each text rendering tucked in a
collapsible "Text version" underneath:

> ## Cache user lookups in Redis
>
> **Change type:** feature
>
> User reads now check Redis before PostgreSQL, and successful database reads
> populate the cache for later requests.

> ### **Risk of Change: Critical**
>
> - touches infrastructure: `infra/redis.tf`
> - touches backup and recovery: `ops/backup_retention.yaml`

> ### **High Impact Areas**
>
> - **Infrastructure** — New ElastiCache cluster (`infra/redis.tf`). Adds a
>   managed dependency to the production VPC. _Check:_ security group and
>   failover settings.
> - **Availability** — Cache on the user read path (`api/users.py`). User reads
>   now depend on Redis, so a Redis outage takes reads down with it. _Check:_
>   confirm a Redis error falls back to the database.
> - **Backup and recovery** — touched: `ops/backup_retention.yaml` (not assessed
>   by the model)

> ### Walkthrough
>
> | File | Change |
> |---|---|
> | `api/users.py` | Reads through the Redis cache before querying PostgreSQL |
> | `infra/redis.tf` | Adds an ElastiCache Redis cluster |
> | `ops/backup_retention.yaml` | Excludes the cache from nightly backups |

> ### Structure

```mermaid
flowchart LR
    n0["Client"]
    n1["User API<br/>Python<br/>serves user reads<br/>(changed)"]
    n2["Redis cache<br/>Redis<br/>caches user rows<br/>(new)"]
    n3["User DB<br/>PostgreSQL"]
    n0 -->|"requests"| n1
    n1 -->|"checks"| n2
    n1 -->|"on miss"| n3
```

> [⛶ Open full screen](https://mermaid.live)&nbsp; *(the real link carries the
> diagram source compressed into the URL)*

<details>
<summary>Text version</summary>

```
[Client] --requests--> [User API Python (changed)]
[User API Python (changed)] --checks--> [Redis cache Redis (new)]
[User API Python (changed)] --on miss--> [User DB PostgreSQL]
```

</details>

> ### Sequence

```mermaid
sequenceDiagram
    participant n0 as Client
    participant n1 as User API (changed)
    participant n2 as Redis cache (new)
    participant n3 as User DB
    n0->>n1: GET /users/{id}
    n1->>n2: read cached row
    n2-->>n1: miss
    n1->>n3: SELECT user
    n1->>n2: store row, 60s TTL
    n1-->>n0: user JSON
```

<details>
<summary>Text version</summary>

```
1. [Client] -> [User API (changed)]: GET /users/{id}
2. [User API (changed)] -> [Redis cache (new)]: read cached row
3. [Redis cache (new)] --> [User API (changed)]: miss
4. [User API (changed)] -> [User DB]: SELECT user
5. [User API (changed)] -> [Redis cache (new)]: store row, 60s TTL
6. [User API (changed)] --> [Client]: user JSON
```

</details>

> The User DB link is inferred from an import, not shown in the diff.

## High Impact Areas

A review reads the changed lines and tells you what is wrong in them. It cannot
tell you that halving a node count removes your peak headroom, or that the PR
quietly shortened a backup retention policy. **High Impact Areas** asks a
different question, *what could this change break beyond itself?*, and answers
it in one section near the top of the overview.

Ten areas are checked:

| Area | What it covers |
|---|---|
| **Infrastructure** | IaC, Kubernetes/Helm, container images, CI/CD pipelines, deploy scripts, networking, DNS, load balancers, resource sizing |
| **Security** | Authentication, authorisation, IAM or permission scope, cryptography, secret handling, removed input validation, CORS/CSP/TLS, CI workflow permissions |
| **Availability** | Anything that could cause a production outage: startup and config defaults, timeouts, retries, circuit breakers, connection pools, rate limits, health checks, removed error handling, concurrency and locking, runtime or major dependency upgrades, hot-path performance, rollback and deploy ordering |
| **Data migration** | Schema or data migrations, destructive or irreversible data operations, backfills |
| **Backup and recovery** | Backup jobs, retention and lifecycle policies, snapshots, restore paths, disaster recovery, failover, replication |
| **Compatibility** | Breaking an external contract: HTTP APIs, event or message schemas, CLI flags, SDK surface, wire formats, feature-flag removal |
| **Observability** | Logs, metrics, alerts, dashboards, tracing, audit trails or runbooks being removed, silenced or changed |
| **Dependencies** | Supply chain: new dependencies, major bumps, loosened pins, lockfiles, base images, build toolchain |
| **Cost** | Autoscaling limits, instance types, provisioned capacity, storage classes, quotas |
| **Compliance** | PII or privacy handling, audit trails, data residency, licence changes |

Each call-out names the area, a short headline, the files involved, the blast
radius, and the one thing a reviewer should verify. A call-out the model rates
`medium` or `critical` shows that severity next to the area name; `high` is the
unmarked default. Files the model names that the PR never changed are dropped.

### The Path Floor

The model is not the only source. Before the call, lgtmaybe matches the PR's
changed paths against fixed patterns per area: `*.tf`, `.github/workflows/`,
`alembic/`, anything named for backups or retention, lockfiles, and so on.
Those matches do two jobs:

- They are sent to the model as untrusted hints, saying where to look (never
  that anything is wrong).
- They floor the output. A matched file the model said nothing about under
  that area is still listed, marked `(not assessed by the model)`, so a weak
  model cannot make your Terraform change invisible.

The floor is also the fallback. If the call fails or returns nothing usable,
the section shows the path matches alone under a "Model assessment unavailable"
note. It degrades; it never disappears.

When nothing qualifies, the section says so and names what it checked:

> ### **High Impact Areas**
>
> None detected — checked: infrastructure, security posture, availability, data
> migrations, backups and recovery, compatibility, observability, dependencies,
> cost, compliance.

### Turning It Off

The section is on by default. To drop it and its model call:

```yaml
high_impact: false
```

The description has its own switch, `auto_describe: false`, and Risk of Change
has `risk: {enabled: false}`. With all three off, the comment is just the
diagrams and costs one call.

## Structure and Sequence

The two diagrams answer different questions, which is why lgtmaybe draws both:

| | Answers | Best on |
|---|---|---|
| **Flowchart** (structure) | What does this change touch, and what talks to what? | Structural PRs: a component added, a dependency introduced, a service split |
| **Sequence** | What happens at run time, in what order, and where did that change? | Behavioural PRs: a retry added, an ordering fixed, a signal now handled |

Most pull requests are behavioural, and structure alone under-describes them. A
PR that fixes terminal restoration on `SIGTERM` might touch two files and draw
three boxes, while its sequence diagram (signal, flag set, loop exit, guard
dropped, terminal restored) *is* the explanation of the fix. A PR that puts a
cache in front of a service is the opposite: the new box is the headline, and
the flow shows how a request now reaches it.

The sequence view is omitted when the change has no meaningful run-time flow
(documentation, configuration, formatting) rather than invented. Both diagrams
come from one model call and share the same six-component budget. The sequence
view adds at most eight ordered steps.

## On GitHub: `/diagram` and `auto_diagram`

Comment **`/diagram`** on a pull request to post the change overview, or update
it in place. Re-running edits the same comment instead of stacking new ones.
(`/describe` still posts the description alone as its own comment, for when
that is all you want.)

`auto_diagram` is on by default, with no workflow input or `.lgtmaybe.yml`
needed. The overview posts when a PR is opened or reopened and refreshes after
each push, so every section tracks the head commit. To opt out of the whole
comment, set the input in your workflow:

```yaml
      - uses: MattJColes/lgtmaybe@v2
        with:
          provider: anthropic
          model: claude-sonnet-4-6
          api_key: ${{ secrets.ANTHROPIC_API_KEY }}
          auto_diagram: "false"
```

or in `.lgtmaybe.yml`:

```yaml
auto_diagram: false
```

The automatic overview stands back when the pull request already diagrams the
change: a fenced `mermaid` block in the PR description, or in any comment that
is not this setup's own overview (yours, a teammate's, another review bot's
walkthrough, or another lgtmaybe setup's overview). The review still runs, and
its summary says where the existing diagram lives. Once lgtmaybe has posted its
own overview on a PR, it keeps refreshing that one so it never goes stale.
Comment `/diagram` to post the overview anyway; slash commands skip the check.
Gitea runs the same check through the shared Action entrypoint. The
`gitlab-ci` command posts no automatic overview yet.

The description and High Impact Areas are best-effort: a failure is logged,
leaves a visible note in its slot, and never blocks the rest. The diagram is a
required completion step, so a failed diagram leaves the commit unfinished and
the next run redoes it rather than skipping it.

## Locally: `lgtmaybe diagram`

`lgtmaybe diagram` prints the change overview of your local changes, with no
GitHub involved:

```console
$ lgtmaybe diagram --provider ollama --model llama3
```

It diffs your branch against the base using the same base resolution as
`lgtmaybe review`: `--base` overrides it, `--working` includes uncommitted
edits, and `--uncommitted` uses only the working-tree edits. The output is the
same body the `/diagram` comment carries (description, Risk of Change, High
Impact Areas, walkthrough, then each diagram's Mermaid source, full-screen link
and text rendering), with one change: a terminal renders no HTML, so the
collapsible "Text version" wrapper becomes a plain labelled section. An excerpt:

````console
$ lgtmaybe diagram --provider ollama --model llama3
...
### Sequence

```mermaid
sequenceDiagram
    participant n0 as Order API (changed)
    participant n1 as Payments
    n0->>n1: POST /charge
    n1-->>n0: 503 Unavailable
    n0->>n0: backs off 200ms
```

[⛶ Open full screen](https://mermaid.live/view#pako:…)

Text version:

```
1. [Order API (changed)] -> [Payments]: POST /charge
2. [Payments] --> [Order API (changed)]: 503 Unavailable
3. [Order API (changed)] -> [Order API (changed)]: backs off 200ms
```
````

The Mermaid source stays in the output on purpose. Your terminal can't draw
it, but you can paste it into a GitHub comment,
[mermaid.live](https://mermaid.live), or a Markdown file to render it.

## Why Mermaid (and What the ASCII Is For)

GitHub renders **Mermaid** natively in comments and Markdown, both flowcharts
and sequence diagrams, so a `mermaid` fence renders in the comment with no
image to generate or host. That matters for a `pull_request_target` reviewer:
hosting an image would mean committing a file or calling an external service,
and neither fits a fork-safe comment that is updated in place.

A terminal can't render Mermaid, which is why each diagram also comes as plain
text. In the GitHub comment that text sits in a collapsible "Text version"
block, out of the way of the rendered diagram. In the terminal the same block
becomes a labelled section, because HTML tags are noise in a shell. Either way
the text is what you read without a renderer, and it is the fallback if a
diagram fails to render.

The model never writes Mermaid. It returns typed graph data (components,
relationships, ordered steps), and lgtmaybe renders the syntax itself, escaping
every label. A diff that tries to smuggle diagram source or markup into a label
can't reach the fence.

D2 isn't used because GitHub doesn't render it in Markdown, so it would show as
source.

## See Also

- [Configure `.lgtmaybe.yml`](configure-lgtmaybe-yml.md)
- [Use as a GitHub Action](use-as-github-action.md)
- [Configuration reference](../reference/config.md)

---

<!-- Source: https://lgtmaybe.coles.codes/how-to/fix-findings-with-an-ai-agent/ -->

# Fix Findings with an AI Agent

`lgtmaybe review` runs locally and prints findings; it never posts anywhere. The
`--format agent` output turns those findings into plain correction instructions
an AI coding agent (such as Claude Code) can read and apply, so you get a
review-and-fix loop on your own machine before you ever open a pull request.

It works with any provider. ollama keeps it local and free; a cloud provider
gives you a stronger reviewer for a small cost.

## Contents

- [Print the Findings as Agent Instructions](#print-the-findings-as-agent-instructions)
- [Hand It to the Agent](#hand-it-to-the-agent)
- [Close the Loop](#close-the-loop)
- [See Also](#see-also)

## Print the Findings as Agent Instructions

From inside the repo, on the branch you want reviewed (this reviews the
branch's commits against the remote primary branch; add `--working` to include
uncommitted edits too):

```bash
lgtmaybe review \
  --provider ollama \
  --model qwen3.6:27b \
  --api-base http://localhost:11434 \
  --format agent
```

The output is a set of instructions rather than a bare listing:

```text
Code review findings for your local changes. Act as the developer and apply each
correction below: open the file at the given path and line, fix the issue, and
apply the suggested change where one is given.

[1] src/app.py:42  (HIGH)  possible NPE
    Issue: `user` may be None here.
    Suggested fix:
        if user is not None:
            do_thing(user)

1 finding(s) to address. After applying the fixes, re-run `lgtmaybe review` to
confirm they are resolved.
```

## Hand It to the Agent

Save the instructions and point your agent at them. Only the instructions go to
stdout; the token-usage footer goes to stderr, so it stays out of the file:

```bash
lgtmaybe review --provider ollama --model qwen3.6:27b \
  --api-base http://localhost:11434 --format agent > review.txt
```

Then ask the agent to work through `review.txt`. For example, in Claude Code:

> Apply the corrections in review.txt, then delete it.

Each finding carries a path, a line, the issue and often a suggested
replacement, so the agent can make the edit without guessing.

## Close the Loop

Once the agent has applied the fixes, run the review again to confirm the
findings are gone. By default `lgtmaybe review` reviews only your committed
branch changes, so either commit the fixes first or add `--working` to include
uncommitted edits:

```bash
lgtmaybe review --provider ollama --model qwen3.6:27b \
  --api-base http://localhost:11434 --format agent --working
```

A clean branch prints `No review findings — nothing to correct.` followed by
the summary line. Repeat until you are happy, then open your PR. To post reviews
on the PR itself, set up the [GitHub Action](use-as-github-action.md).

## See Also

- [Use from a coding agent over MCP](use-from-a-coding-agent-over-mcp.md) - let
  the agent call the review as a tool, with no files in between
- [Run locally with ollama](run-locally-with-ollama.md) - the local CLI setup
- [What gets reviewed](../explanation/what-gets-reviewed.md) - scope, caps, and output formats

---

<!-- Source: https://lgtmaybe.coles.codes/how-to/review-before-you-commit/ -->

# Review Before You Commit

lgtmaybe ships two hooks for the [pre-commit](https://pre-commit.com) framework,
so a local review runs from your Git hooks with no extra scripting:

| Hook id | Stage | Runs | Reviews |
|---|---|---|---|
| `lgtmaybe-pre-push` | `pre-push` | `lgtmaybe review` | Your branch's commits against the remote's primary branch |
| `lgtmaybe` | `pre-commit` | `lgtmaybe review --uncommitted` | The changes you are about to commit, against `HEAD` |

Both print findings exactly as `lgtmaybe review` does. Nothing is posted
anywhere.

## Contents

- [Before You Start](#before-you-start)
- [Add the Hook](#add-the-hook)
- [Block on Findings](#block-on-findings)
- [Review Every Commit Instead](#review-every-commit-instead)
- [Use a Keyless Cloud Provider](#use-a-keyless-cloud-provider)
- [Skip the Review Once](#skip-the-review-once)
- [See Also](#see-also)

## Before You Start

- **Each run calls your model.** A review is one or more model calls, so it
  takes time and, on a hosted provider, costs money every time the hook fires.
  That is why `pre-push` (one review per push) is the recommended stage rather
  than `pre-commit` (one review per commit). ollama keeps it local and free.
- **Credentials come from your environment.** The hook runs in your shell's
  environment, so export your provider's key (for example `OPENAI_API_KEY` or
  `ANTHROPIC_API_KEY`) the same way you would for a manual
  `lgtmaybe review`. lgtmaybe never saves API keys in its config.
- **Pick the provider and model once.** Run `lgtmaybe config init` to save a
  default provider and model in your user config, or put them in the repo's
  [`.lgtmaybe.yml`](configure-lgtmaybe-yml.md), or pass them as hook `args`.
- **pre-commit 3.2.0 or later.** The hooks use the `pre-commit` and `pre-push`
  stage names, which arrived in that release.

pre-commit installs lgtmaybe into its own virtual environment from the tagged
source, so you do not need lgtmaybe installed separately for the hook. The
first run takes a minute or two while pre-commit builds that environment.

## Add the Hook

Add this to `.pre-commit-config.yaml` at the root of your repository. Pin `rev`
to a release tag that includes the hooks (v2.12.0 or later);
`pre-commit autoupdate` moves it to the latest release:

```yaml
repos:
  - repo: https://github.com/MattJColes/lgtmaybe
    rev: v2.12.0
    hooks:
      - id: lgtmaybe-pre-push
        args: [--provider, anthropic, --model, claude-sonnet-4-6, --fail-on, high]
```

Then install the Git hook for the `pre-push` stage:

```bash
pre-commit install --hook-type pre-push
```

The next `git push` reviews your branch first. To try it without pushing:

```bash
pre-commit run lgtmaybe-pre-push --hook-stage pre-push
```

Any `lgtmaybe review` flag works in `args`. For example, add `--base`,
`origin/develop` when your branch targets something other than the remote's
default branch, or `--min-severity`, `medium` to print less.

## Block on Findings

Without `--fail-on` the hook is advisory: it prints the review and lets the
push through. Both hooks set `verbose: true`, so the findings show even when
the hook passes.

With `--fail-on <severity>` (as in the example above), `lgtmaybe review` exits
with exit code 3 when any finding is at or above that severity, after printing
the findings, so pre-commit stops the push. A review that could not run at all
(for example, missing credentials or an unreachable model) exits 1, which also
stops the push; a usage error exits 2.

Only the command-line flag gates a local review. A `fail_on` set in
`.lgtmaybe.yml` is the GitHub Action's merge gate and never changes the exit
code of a local run, so configuring CI does not suddenly block your pushes.

## Review Every Commit Instead

To review each commit as you make it, use the `lgtmaybe` hook and install the
default hook type:

```yaml
repos:
  - repo: https://github.com/MattJColes/lgtmaybe
    rev: v2.12.0
    hooks:
      - id: lgtmaybe
```

```bash
pre-commit install
```

While hooks run, pre-commit sets your unstaged edits aside, so the review covers
what you have staged. Untracked files in the working tree are reviewed too.
Because this calls your model on every commit, prefer a local model (see
[Run locally with ollama](run-locally-with-ollama.md)) or the `pre-push` hook.

## Use a Keyless Cloud Provider

The hook installs the base package, which covers every API-key provider plus
ollama and OpenAI-compatible servers. For keyless Bedrock, Vertex or Azure, add
the cloud SDK the matching extra would install:

```yaml
      - id: lgtmaybe-pre-push
        args: [--provider, bedrock, --model, us.anthropic.claude-sonnet-4-6]
        additional_dependencies: [boto3]   # vertex: google-auth, azure: azure-identity
```

Your ambient credentials (`~/.aws`, `gcloud auth application-default login`,
`az login`) are used as they are for a manual run.

## Skip the Review Once

pre-commit's usual escape hatches apply:

```bash
SKIP=lgtmaybe-pre-push git push   # skip only this hook
git push --no-verify              # skip every pre-push hook
```

If you use [pre-commit.ci](https://pre-commit.ci), add the hook id to its
`skip` list: it has no model credentials, so the review cannot run there. Use
the [GitHub Action](use-as-github-action.md) to review pull requests in CI.

## See Also

- [Install the CLI](install-the-cli.md) - the same `lgtmaybe review` the hooks run
- [Fix findings with an AI agent](fix-findings-with-an-ai-agent.md) - turn local findings into corrections
- [Reduce review cost](reduce-review-cost.md) - make each run cheaper

---

<!-- Source: https://lgtmaybe.coles.codes/how-to/upload-findings-as-sarif/ -->

# Upload Findings as SARIF

`lgtmaybe review --format sarif` prints the findings as a
[SARIF 2.1.0](https://docs.oasis-open.org/sarif/sarif/v2.1.0/sarif-v2.1.0.html)
log instead of a listing. SARIF is the format GitHub code scanning ingests, and
most IDEs have a SARIF viewer, so the same findings can live next to your other
static-analysis alerts rather than in pull request comments.

## Write the Log

```bash
lgtmaybe review --provider openai --model gpt-5.5 --format sarif > lgtmaybe.sarif
```

stdout carries the SARIF log and nothing else. The token footer, the `--profile`
table, and the "results may be incomplete" notice all go to stderr, so the
redirect always captures a parseable file. Every other `review` option
(`--working`, `--base`, `--min-severity` and so on) works as usual.

## What the Log Contains

| SARIF field | Comes from |
|---|---|
| `tool.driver.rules` | One rule per review lens that raised a finding (`security`, `correctness`, ...), sorted |
| `results[].ruleId` | The finding's lens; `review` when it has none |
| `results[].level` | `critical` and `high` are `error`, `medium` is `warning`, `low` and `info` are `note` |
| `results[].message.text` | The finding's title, a blank line, then its body |
| `results[].locations` | The repo-relative file, plus `region.startLine` when the line is real |
| `results[].partialFingerprints` | `lgtmaybeFingerprint/v1` and `lgtmaybeIdentity/v1`, the same ids lgtmaybe hides in posted comments |
| `results[].properties` | `severity`, plus `confidence` (0 to 10) and `suggestion` when present |
| `invocations[].executionSuccessful` | `false` when a review lens failed, so a partial log never reads as clean |

A finding whose anchor matched no changed line, or one on the old (LEFT) side of
the diff, names its file without a line. Its line number would be a guess or
would point into the old file, and a wrong-line alert misleads more than a
file-level one. The suggested fix is free text rather than an exact replacement,
so it is kept in `properties.suggestion` instead of a SARIF `fixes` entry.

The output is deterministic: the same findings give the same bytes, so a re-run
uploads an identical log.

## Upload to GitHub Code Scanning

Code scanning must be available on the repository (public repositories, or
private ones with GitHub Code Security). This workflow reviews each pull request
from a branch in the same repository and uploads the log:

```yaml
name: lgtmaybe SARIF

on:
  pull_request:

permissions:
  contents: read

jobs:
  review:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      security-events: write # upload-sarif
    steps:
      - uses: actions/checkout@v7
        with:
          fetch-depth: 0 # lgtmaybe diffs against the base branch
          persist-credentials: false
      - uses: astral-sh/setup-uv@v7
      - name: Review
        env:
          OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
          BASE_REF: ${{ github.base_ref }}
        run: |
          uvx lgtmaybe review --provider openai --model gpt-5.5 \
            --base "origin/$BASE_REF" --format sarif > lgtmaybe.sarif
      - uses: github/codeql-action/upload-sarif@v4
        with:
          sarif_file: lgtmaybe.sarif
          category: lgtmaybe
```

`BASE_REF` goes through an environment variable rather than being expanded into
the script, which keeps workflow expressions out of the shell. `category` keeps
lgtmaybe's alerts separate from CodeQL or any other SARIF upload on the same
commit.

This runs on `pull_request` and checks out the pull request, which is fine for
branches in your own repository. A pull request from a fork gets no secrets on
`pull_request`, so the review step has no key. To review fork pull requests, use
the [GitHub Action](use-as-github-action.md) instead: it runs on
`pull_request_target`, reads the diff through the API, and never checks out PR
code.

## Open It in an IDE

Any SARIF viewer can open the file directly, for example the SARIF Viewer
extension for VS Code. Result paths are relative to `%SRCROOT%`; point the
viewer at your repository root when it asks.

## See Also

- [Install the CLI](install-the-cli.md)
- [Fix findings with an AI agent](fix-findings-with-an-ai-agent.md): the
  `--format agent` output
- [What gets reviewed](../explanation/what-gets-reviewed.md): every finding field

---

<!-- Source: https://lgtmaybe.coles.codes/how-to/use-from-a-coding-agent-over-mcp/ -->

# Use from a Coding Agent over MCP

`lgtmaybe mcp` runs a [Model Context Protocol](https://modelcontextprotocol.io/)
server on stdio. A coding agent such as Claude Code or Cursor connects to it and
reviews your local changes by calling a tool, instead of running
`lgtmaybe review` in a shell and parsing what it prints. The review is the same
one `lgtmaybe review` runs: the same engine, the same config files, the same
credentials. Nothing is posted anywhere.

## Contents

- [Install the MCP Extra](#install-the-mcp-extra)
- [Add It to Claude Code](#add-it-to-claude-code)
- [Add It to Another MCP Client](#add-it-to-another-mcp-client)
- [The Tools](#the-tools)
- [Credentials and Config](#credentials-and-config)
- [Troubleshooting](#troubleshooting)
- [See Also](#see-also)

## Install the MCP Extra

The MCP SDK is an optional extra, so a plain install stays small. Install it
with pip (or `uv tool install` / `pipx install`, which put `lgtmaybe` on your
`PATH` in an isolated environment):

```bash
pip install 'lgtmaybe[mcp]'
```

The Homebrew formula and the Windows executable do not include the extra; use
pip for `lgtmaybe mcp`. Without it the command exits with a message naming the
extra to install.

## Add It to Claude Code

From the repository you want reviewed, register the server with `claude mcp add`.
Everything after `--` is the command Claude Code runs; the `lgtmaybe mcp` flags
apply to every review the agent asks for:

```bash
claude mcp add lgtmaybe -- lgtmaybe mcp \
  --provider ollama \
  --model qwen3.6:27b \
  --api-base http://localhost:11434
```

Claude Code starts the server in the project directory, so the review sees that
repository. Add `--scope project` to write the entry to the repository's
`.mcp.json` and share it with your team. The entry stores the command and every
flag exactly as you typed them, so never pass `--api-key` to `claude mcp add` or
write it into `.mcp.json`: the key would sit in a file you commit. Supply API
keys through environment variables instead (see
[Credentials and Config](#credentials-and-config)).

Then ask the agent for a review in plain words, for example:

> Review my uncommitted changes with lgtmaybe and fix anything high severity.

## Add It to Another MCP Client

Most clients (Cursor, Windsurf, Claude Desktop and others) read an
`mcpServers` block from a JSON config file. Add an entry that runs
`lgtmaybe mcp`. Pass `-C` with the repository path when the client does not
start servers inside your project:

```json
{
  "mcpServers": {
    "lgtmaybe": {
      "command": "lgtmaybe",
      "args": [
        "mcp",
        "--provider", "openai",
        "--model", "gpt-5.5",
        "-C", "/path/to/your/repo"
      ]
    }
  }
}
```

If the client cannot find `lgtmaybe`, give the full path to the executable
(`which lgtmaybe` prints it) as `command`.

## The Tools

The server offers two tools. Both take the same optional arguments and run one
full review per call.

| Tool | Returns |
| --- | --- |
| `review_changes` | The findings as JSON, in the same shape as `lgtmaybe review --format json`, plus the `summary` line and `incomplete` (true when some review calls did not finish, so the findings may be partial). |
| `review_instructions` | The findings as plain-text correction instructions, the same text as `lgtmaybe review --format agent` except for the closing line, which tells the agent to call the `review_instructions` tool again (not to run `lgtmaybe review`) to confirm its fixes. |

| Argument | Meaning |
| --- | --- |
| `mode` | `branch` (default): committed branch changes against the base. `working`: branch commits plus uncommitted edits. `uncommitted`: only the uncommitted edits against `HEAD`. |
| `base` | The base ref to diff against, like `--base`. Defaults to the remote's primary branch. |
| `min_severity` | Drop findings below `info`, `low`, `medium`, `high` or `critical`. |
| `preset` | `fast` (four model calls) or `full` (one call per review category). |

A review that cannot run (not a Git repository, a missing credential, a provider
error) comes back as a tool error with the reason, so the agent sees what went
wrong rather than an empty result. The server keeps running for the next call.

Reviews run one at a time. A review makes several model calls, so it takes as
long as `lgtmaybe review` does; ask for one per change set rather than after
every edit.

## Credentials and Config

Config loads exactly as it does for `lgtmaybe review`: your user config
(`lgtmaybe config`), then the repository's `.lgtmaybe.yml`, then the flags on
`lgtmaybe mcp`, then the tool call's own arguments. The config files are read on
every call, so an edit to `.lgtmaybe.yml` applies without restarting the server.
A config file that does not parse stops the server at startup, where the client
shows the error.

API keys come from the environment, as they do for the CLI (`OPENAI_API_KEY`,
`ANTHROPIC_API_KEY` and so on; see the provider how-tos). The server inherits
the environment the client starts it with, so export the key in the shell you
start the client from. If your client starts servers without your shell
environment, set the key in the entry's `env` block instead, keeping in mind
that this stores it in the client's config file. lgtmaybe itself never writes a
key to disk. Keyless providers need no key at all: ollama, and Bedrock, Vertex
or Azure with ambient cloud credentials.

## Troubleshooting

- **The client says the server failed to start.** Run the same command in a
  terminal. A missing extra or a broken config prints its reason and exits.
  Otherwise the command waits silently for a client, which means it works.
- **The tool error says it is not a Git repository.** The server started outside
  your project. Add `-C /path/to/your/repo` to its arguments.
- **Where are the logs?** On stderr, which the client usually records in its MCP
  log. stdout carries only the protocol, so nothing lgtmaybe logs can corrupt it.

## See Also

- [Fix findings with an AI agent](fix-findings-with-an-ai-agent.md) - the same
  review-and-fix loop through files, without MCP
- [Configure .lgtmaybe.yml](configure-lgtmaybe-yml.md) - every option a review reads
- [Install the CLI](install-the-cli.md) - pip, Homebrew and WinGet installs

---

<!-- Source: https://lgtmaybe.coles.codes/how-to/reduce-review-cost/ -->

# Reduce Review Cost

A review makes several model calls per diff batch, and every call pays for its
input even when prompt caching discounts it. Large diffs create more batches,
so cost on a hosted provider can rise quickly.

This guide shows you how to measure that cost and lower it while keeping the
findings you need.

> Deciding **who** can trigger a review is a related but separate question. See
> [Trust and Cost](../explanation/trust-and-cost.md). Every setting below is
> documented field by field in the [configuration reference](../reference/config.md).

## Contents

- [First, Measure](#first-measure)
- [Where the Tokens Go](#where-the-tokens-go)
- [The Levers, in Order of Payoff](#the-levers-in-order-of-payoff)
- [Put a Hard Ceiling on It](#put-a-hard-ceiling-on-it)
- [If a Call Runs Past `max_tokens`](#if-a-call-runs-past-max_tokens)
- [If One Lens Floods the Review](#if-one-lens-floods-the-review)
- [What Costs More, on Purpose](#what-costs-more-on-purpose)
- [What Isn't Worth Changing](#what-isnt-worth-changing)

## First, Measure

Do not tune blind. Run once with `--profile`:

```bash
lgtmaybe review --profile
```

The table ends with the meter:

```
tokens: 158,076 billable (154,200 in / 3,876 out) across 12 calls
```

Every local review prints this line to stderr even without `--profile`, so the
meter is always in view. Redirect with `2>/dev/null` if you want it gone.

When litellm's pricing map knows the model, a second line follows:

```
estimated cost: $0.5207 (litellm pricing map)
```

It is an estimate from a price list that can lag a brand-new model, so a
missing line means "unpriced", not "free". When only some calls could be
priced, the line says how many (`; 9 of 12 calls priced`) rather than passing a
partial total off as the sum. Cache reads are billed at their discount, so a
run with a warm prefix shows it.

`in` usually dwarfs `out` because every review call carries diff content. The
per-call table shows which lens and batch each call belongs to, so you can see
whether the cost comes from call count, batch count, or one large file.

On a reasoning model, read the table's `think_tok` column next to `out_tok`. It
is how much of the output budget went on thought before the model wrote a
single finding, and a summary line totals it:

```
reasoning: 41,300 of 47,900 output tokens (86%)
```

The line appears only when a route reports the breakdown. Its absence means
"not reported", not "no thinking". A high share explains a lens that ran for a
minute and returned three findings, and it is the number to check before
[raising `max_tokens`](#if-a-call-runs-past-max_tokens).

In the GitHub Action, set `profile: true` and the same breakdown lands in the
job log. Every provider call also emits a structured `provider call` log line
with its own token counts, so you can total a run without the table.

## Where the Tokens Go

For one review:

```
input tokens  ≈  batches × calls per batch × (diff + context padding + hints)
```

- **Calls per batch.** The `fast` preset (the default) makes four core calls
  per batch. `full` makes one per built-in category, up to nine. A matching
  committed spec adds its own call. This is the biggest multiplier in the
  formula.
- **Batches.** A diff larger than `max_input_tokens` is split, and each batch
  pays the full lens fan-out again. The default is 100k, fitted down to the
  model's context window when that is known (litellm's model map for hosted
  models, `num_ctx` for ollama, less the output ceiling). A value you set is
  used as-is.
- **Reflection.** One more pass over the findings.
- **Triage.** One extra cheap call when enabled.
- **The change overview** (`auto_diagram`, on by default). Up to three extra
  calls per push: the description (`auto_describe`), High Impact Areas
  (`high_impact`), and the diagrams. Each section opts out on its own;
  `auto_diagram: false` drops all three.
- **Mid-review retrieval.** Off by default. When on, it adds up to one more
  call per (batch, lens). See [What Costs More, on Purpose](#what-costs-more-on-purpose).

Two settings look expensive but are not: `max_concurrency` only changes how
many calls run at once, never how many there are, and `temperature` costs
nothing.

## The Levers, in Order of Payoff

### 1. Turn On Triage

A cheap model reads the per-file diffs first and drops files that plainly do
not need a review. The expensive model only sees the survivors, riskiest
first.

```yaml
triage_model: claude-haiku-4-5
```

This is usually the biggest single win on a repo with lots of low-substance
churn (generated files, config bumps, test fixtures). It cannot quietly hide a
problem: a deterministic security floor (auth and crypto paths, migrations,
IaC, CI workflows, dependency manifests, static-analysis hits, large hunks)
always escalates past triage, any triage failure reviews everything, and
skipped files are named in the summary. `triage_model`, `model` and
`reflect_model` share one provider and one set of credentials.

### 2. Let Deterministic Tools Do the Deterministic Work

Findings a linter can prove do not need a language model:

```bash
pip install 'lgtmaybe[static-analysis]'
```

```yaml
static_analysis:
  enabled: true
```

Installed tools run over the changed files at no token cost. The interpretive
ones (ruff, bandit, mypy, and semgrep with local rules) feed their findings to
the lenses as hints to confirm or discard, which grounds the model and tends to
cut low-value findings as well as spend. The deterministic ones (gitleaks,
zizmor, ast-grep with your own rules, osv-scanner) post their findings
directly with no model call. A tool that is not installed is skipped.

### 3. Check Prompt Caching Is Working

Prompt caching is always on. When it works, it takes most of the sting out of
the lens multiplier: lenses 2..N read the shared system preamble and diff from
cache instead of paying full input price for them.

Check the profile's cache line:

```
cache: 412000 tokens read / 38000 created across 12 calls
```

Reads climbing across a run means caching is working. Reads stuck near zero
means you are paying full price for every lens. lgtmaybe sets an explicit
cache breakpoint on the routes that take one (anthropic, bedrock Claude and
Nova, vertex, zai, and openrouter's Claude, Gemini and GLM families). OpenAI,
Azure and DeepSeek cache identical prefixes server-side with no marker. A route
with neither gets no discount, which is a strong argument for picking one that
has it.

### 4. Narrow the Lenses

An explicit `categories` list runs exactly those lenses, one call each, per
batch:

```yaml
categories: [security, correctness]
```

That is two calls per batch instead of the default four. Keep the list short:
an explicit list turns off the `fast` preset's grouping, so listing six
categories costs six calls, not four.

Otherwise keep the default `fast` preset. `preset: full` more than doubles the
call count (up to nine calls per batch instead of four) for a modest recall
gain.

### 5. Review Less

```yaml
exclude_paths: ["**/migrations/**", "**/*.generated.ts"]
max_files: 25
max_file_diff_lines: 1000
```

Generated, vendored and binary files are already skipped, and a single file
whose patch exceeds `max_file_diff_lines` (default 2000) is dropped and named
in the summary. Path filters cut the rest before any model call, so they are
pure saving.

### 6. Let Re-runs Be Incremental

On the Action's `synchronize` event, a re-run reviews only the commits since
the last reviewed SHA rather than the whole PR. That is the default, so leave
`incremental` unset. Comment `/review full` when you want everything
re-reviewed.

The `gitlab-ci` command does the same on every merge request pipeline after
the first. To re-review everything there, run the pipeline with
`LGTMAYBE_INCREMENTAL=false`.

Locally there is no watermark: every `lgtmaybe review` re-reviews the whole
branch at full price. If you are iterating, review only what you just changed:

```bash
lgtmaybe review --uncommitted    # working-tree edits vs HEAD
```

### 7. Right-Size the Model

A smaller primary model with a stronger fallback cuts expected cost when the
primary usually finishes its calls. A healthy call never reaches the fallback.
A failed call still spends the primary model's attempts before trying the
second model, so this does not reduce worst-case cost.

```yaml
provider: openrouter
model: google/gemini-3.7-flash
fallback_model: qwen/qwen3.8-max
```

Both models use the same configured provider and credentials. See
[`fallback_model`](configure-lgtmaybe-yml.md#fallback_model) for the recovery
order, failure behaviour, and reporting.

If cost matters more than latency, ollama runs locally for free:

```bash
lgtmaybe review --provider ollama --model qwen2.5-coder:14b
```

## Put a Hard Ceiling on It

Tuning changes the expected cost. A ceiling bounds the worst case.

```yaml
max_review_tokens: 300000
```

Once a run's billable tokens (input plus output across every model call) reach
the ceiling, no further calls are dispatched. In-flight calls finish, their
findings post, and the summary says that the budget stopped the run and how
many calls were skipped. It never turns a failure into a silent `LGTM`.

It is off by default on purpose. Spend scales with diff size, lens count and
batch count, so any figure that protects a small repo would silently truncate a
large one's review. Read a real run's total with `--profile` first, then set
the ceiling comfortably above it. It is a runaway guard, not a tuning knob.

## If a Call Runs Past `max_tokens`

`max_tokens` caps what one call may write. A lens that hits it comes back cut
off mid-JSON, and lowering it to save money is the usual way to get here.

On `ollama`, `openai-compatible` and `openrouter`, a default ceiling of 16384
tokens per call applies, so you can get here without setting anything. It
exists because a model under structured output can fail to terminate and keep
decoding until the timeout stops it half an hour later. The first-party APIs
get no default ceiling.

You do not have to do anything. lgtmaybe treats an over-ceiling call the same
way as one that outruns its wall clock: the batch was more than one call could
cover, so it is halved and the pieces are reviewed separately, each with a
fresh ceiling. Whatever the model finished writing before the cut is kept, so
nothing already paid for is thrown away. The summary says a batch was shrunk
and, because part of a lens is not a whole lens, that results may be
incomplete.

That recovery costs up to twice the calls for the affected batch. The pieces
run concurrently, so on the clock it costs about one extra call. It is bounded
to one split level: a piece that still runs past the ceiling is not split
again. If the pieces still fail and you set `fallback_model`, the batch's lens
is re-run once on the fallback as a last resort.

When you see splits often, the fix is usually `max_tokens`, not the diff:

```yaml
max_tokens: 32768
```

A reasoning model spends the same budget on thinking before it writes a single
finding, which is how a fifteen-line diff can truncate. The failure names the
reasoning tokens where the provider reports them. When they account for
essentially the whole ceiling, lgtmaybe does not split the batch, because
smaller pieces would each spend the same ceiling on thought. Instead it re-runs
the lens once at one lower `reasoning_effort` level, then once with reasoning
off if that also truncates, and only then on `fallback_model`.

You do not have to wait for a truncation to see this coming. `--profile`
reports the same split on calls that succeeded (`think_tok` and the
`reasoning:` total). A truncated call cannot tell you that: it spent reasoning
plus findings ≥ `max_tokens` by definition, so it says nothing about the
healthy calls beside it.

Two more numbers answer the headroom question directly:

- the **`think_%`** column: that call's thinking as a share of the
  `max_tokens` ceiling it ran under;
- a **`largest reasoning share:`** line under the table, naming the single
  call closest to the ceiling and its lens.

An 8,192-token cap whose worst lens reports 50% has real headroom. One
reporting 95% is a truncation that has not happened yet, and the aggregate
`reasoning:` line will not show it, because one runaway hides inside an
average.

A route that reports no breakdown shows `-` in both columns and prints no
`largest` line. That is not zero. The route never said, so there is nothing to
conclude either way.

### When the Cap Is Not the Lever

If `think_tok` is most of `out_tok`, raising `max_tokens` will not fix the
truncation, because the reasoning expands into the new headroom. Bound the
thinking instead:

```yaml
reasoning_effort: low   # none | minimal | low | medium | high | xhigh | default
```

This is measured. On lgtmaybe's own dogfood review, 5 of 9 lens calls spent
32,000-35,000 reasoning tokens (at or above the whole 32,768 `max_tokens`
ceiling) before writing a single finding. The one large call that completed
wrote roughly 733 tokens of findings after 28,909 tokens of thought. Raising
the cap from 16k to 32k had not helped; it only gave the model more room to
think.

Unset sends nothing, so a route without a reasoning channel is unaffected.

Some models treat effort as a switch rather than a dial. GLM 5.3 through
OpenRouter spent a 12,288-token ceiling on thought at `low` and again at
`minimal`, in 10 retries of 11; only `none` changed it. lgtmaybe's recovery
ladder takes that step for you (one level down, then reasoning off, then the
fallback model), but on such a model configuring `none` saves the two failed
calls that come first.

#### Cheaper Is Not Better Value

Bounding thinking fixes a reasoning-bound truncation. It is not a free saving,
and the direction that saves tokens is not the direction that finds bugs.
Measured on lgtmaybe's own repo, two runs per setting on the same diff:

| | `low` | `medium` |
|---|---|---|
| findings | 2, 2 | 5, 4 |
| false positives | 0, 1 | 0, 0 |
| output tokens | ~4-5k | ~14k |
| **total billable** | 180,704 | 187,191 |

`medium` roughly triples output tokens and still costs only about 4% more in
total, because a review is overwhelmingly input: the diff, the context, the
system prompt. Weighting output at 4× its input price puts it nearer 20%.
Either way, nothing like the 3× the output column suggests.

What `medium` bought was a bug in lgtmaybe's own retry backoff that `low` missed
in both runs, and `low` produced the only false positive of the four. Treat
`reasoning_effort` as a quality dial that moves cost a little, not a cost dial
that moves quality. Turn it down when reasoning is eating your `max_tokens`
ceiling, not to save money.

#### On OpenRouter, Check the Log Line

litellm forwards `reasoning_effort` to OpenRouter only for models its
capability map already flags as reasoning-capable, and the newest models are
often missing from it. For those, lgtmaybe sends the budget in OpenRouter's own
top-level `reasoning` object, so the setting is enforced either way.

More generally, any configured parameter the resolved model will not accept is
named once at startup rather than dropped in silence:

```
configured params are not supported by this model and will be ignored
```

If that line names a setting you meant to apply, the setting is not in force.
Pick a different model or drop the setting. `reasoning_effort: default` on
OpenRouter is one real case: it is in litellm's vocabulary but not in
OpenRouter's `reasoning.effort` enum (`none`, `minimal`, `low`, `medium`,
`high`, `xhigh`), so it is reported rather than quietly mapped to a nearby
level.

## If One Lens Floods the Review

A lens can stay within its token budget and still return far too many findings
by restating one claim against every line it can see. On a benchmark diff with
nothing wrong in it, a single lens returned 319 of a review's 323 findings, each
on a different line. Location dedupe does not collapse them, because no two
share a line.

So one `(batch, lens)` call contributes at most 50 findings by default. When
the bound fires, the highest-severity findings are kept and the summary names
the lens and how many were dropped:

```
⚠️ Bounded a lens to the top 50 findings by severity: `intent` (269 dropped). A lens
returning many more findings than this is usually restating one claim across many
lines. Raise max_findings_per_lens to keep them all.
```

The notice usually points to a problem with the model, not a bound set too
low. A lens returning hundreds of findings is generating badly, and a higher
bound admits more of the same. Raise it only when a genuinely large diff is
losing real findings:

```yaml
max_findings_per_lens: 100   # 0 disables the bound entirely
```

An ordinary lens returns a handful of findings and never reaches the bound.

## What Costs More, on Purpose

One setting runs the other way: `mid_review_retrieval` buys findings with
tokens. Know the price before you turn it on.

```yaml
mid_review_retrieval: true   # default false
```

Normally a lens that cannot decide without reading code outside the diff is told
to hedge the claim or drop it, so a bug whose evidence sits one file away is
never reported. With this on, the lens may instead name the files or symbols it
needs. lgtmaybe fetches them read-only and re-runs that one lens with them.

The worst case is one extra model call per (batch, lens), each also carrying
the fetched file text. On the default four-lens preset over three batches, that
is up to twelve extra calls: the multiplier in the formula above, doubled. In
practice only the lenses that defer pay it, and the fetch is capped at five
files within a quarter of `max_input_tokens`, but budget for the worst case.

To keep the bill in check:

- Pair it with `max_review_tokens`, so the worst case is bounded rather than
  merely unlikely. A deferral that arrives past the ceiling is skipped and
  reported.
- Fetched text rides the lens's own block, so a deferral never invalidates the
  shared cached prefix that sibling lenses read.
- Turn on `triage_model` first, so fewer files reach the lenses that might
  defer.

Measure rather than assume: run `python -m evals.run` with and without
`--mid-review-retrieval` against your model and compare recall to the token
total.

## What Isn't Worth Changing

**`context_lines`.** The obvious saving is not there. Measured across four real
multi-file commits in this repository, the padding around each hunk costs about
41% over sending no context at all, but only about 17% between
`context_lines: 5` and the default `20`. Hunks with overlapping windows merge,
and the reach to the enclosing definition does most of the useful widening
either way:

| `context_lines` | 0 | 5 | 10 | 20 (default) |
| --- | --- | --- | --- | --- |
| diff payload tokens | 28,316 | 34,265 | 36,072 | 40,041 |
| vs. no context | 1.00× | 1.21× | 1.27× | 1.41× |

Saving roughly a seventh of your input tokens by hiding the function a change
sits in is a bad trade. That context keeps findings anchored and cuts false
positives. Turn the multiplier down (fewer lenses, triage, caching) before you
turn the context down.

---

<!-- Source: https://lgtmaybe.coles.codes/how-to/releasing/ -->

# Releasing lgtmaybe (maintainers)

Releases are automated by **release-please**
(`.github/workflows/release-please.yml`). It reads the **conventional commits**
merged to `main` and keeps a Release PR open that bumps the version and
regenerates `CHANGELOG.md`. **Merging that PR** is the release: release-please
creates the `lgtmaybe-vX.Y.Z` tag and the GitHub release, and the same run then
publishes everywhere:

- **PyPI**, through trusted publishing (OIDC) in an inline job.
- **GHCR image and Action tags**, through the reusable
  `.github/workflows/release.yml` using the built-in `GITHUB_TOKEN`. It pushes
  a multi-arch (amd64, arm64) image tagged `X.Y.Z`, `vX` (currently `v2`), and
  `latest`, and moves the git tags `vX.Y.Z`, `vX`, and `lgtmaybe-vX` to the
  release commit so workflows can use `MattJColes/lgtmaybe@v2`.
- **Homebrew**, through `.github/workflows/homebrew.yml`, after the PyPI job
  succeeds (described below).
- **Windows**, through `.github/workflows/windows-exe.yml`, which builds and
  smoke-tests a portable executable and attaches it to the release, then
  `.github/workflows/winget.yml`, which submits that asset to winget. The
  winget job waits for the executable job. Both workflows can be dispatched by
  hand for recovery.

PyPI and GHCR need no publish tokens in secrets. Homebrew and winget each need
one PAT, set up below.

Commit messages must follow the conventional-commit format.
`.github/workflows/commitlint.yml` enforces it on PRs so release-please can
work out the next version.

## Contents

- [The Homebrew formula](#the-homebrew-formula)
- [One-time setup](#one-time-setup)
- [Each release](#each-release)
- [Rotate the public App private key](#rotate-the-public-app-private-key)
- [Before going public](#before-going-public)

## The Homebrew Formula

`homebrew.yml` regenerates the formula in the tap repo
(`MattJColes/homebrew-tap`, which is the tap `MattJColes/tap`) so
`brew install MattJColes/tap/lgtmaybe` tracks the latest version.
`scripts/update-homebrew-formula.sh` writes a small formula that creates a venv
and `pip install`s lgtmaybe and its dependencies from **PyPI wheels**, with no
per-dependency `resource` stanzas. litellm's dependency tree includes Rust
sdists (tokenizers, hf-xet) that can't build in Homebrew's sandbox, so building
from source is a dead end. The formula declares **`preserve_rpath`** so
Homebrew keeps the wheels' `@rpath` extension-dylib ids instead of failing to
rewrite them ("Failed to fix install linkage"). It is a plain source formula
with no bottle, so it installs on any architecture and macOS version.

The workflow:

- Is **called** by `release-please.yml` (`workflow_call`) once the PyPI publish
  succeeds, because GitHub does not deliver a `release: published` event for a
  release cut with the built-in `GITHUB_TOKEN` (it suppresses downstream
  triggers to prevent recursion). It also runs on `release: published` for a
  release a human cuts, on a daily `schedule`, and on a manual
  `workflow_dispatch` (with an optional `force` to re-publish the same
  version).
- **Installs the regenerated formula in CI before committing it.** A real
  `brew trust` + `brew install` gates the push, so a formula that doesn't
  install is never published. The gate trusts the tap rather than setting
  `HOMEBREW_NO_REQUIRE_TAP_TRUST`, so it exercises the same steps the install
  guide gives users.

A new version reaches the tap within minutes of release. There is no PyPI
cooldown to wait out, because `brew update-python-resources` (which imposes
one) isn't used.

## One-Time Setup

- On PyPI, add a **trusted publisher** for this repo: workflow
  **`release-please.yml`**, environment `pypi`. There is no `PYPI_TOKEN`
  secret; auth is via OIDC. The publish job is inline in that workflow on
  purpose, because PyPI trusted publishing requires the OIDC `job_workflow_ref`
  to equal the top-level workflow.
- Create the repo **environment** named `pypi` (Settings → Environments).
- After the first release, set the **GHCR package visibility to public** so
  consumers can `docker pull` the image (Packages → lgtmaybe → Package settings).
- First release only: from the GitHub release page, tick **"Publish this Action
  to the GitHub Marketplace"**, accept the terms, and pick the categories
  `code-review` and `continuous-integration`.
- **Homebrew tap:** create the repo **`MattJColes/homebrew-tap`** with a
  `Formula/` directory. It can start empty; the workflow writes the formula.
  Homebrew strips the `homebrew-` prefix, so that repo is the tap
  `MattJColes/tap` and the formula inside it is `MattJColes/tap/lgtmaybe`. Name
  the repo `homebrew-tap`, not `homebrew-lgtmaybe`, or the formula reads as
  `MattJColes/lgtmaybe/lgtmaybe`. Add a repo secret **`HOMEBREW_TAP_TOKEN`** to
  *this* repo: a fine-grained PAT with `contents: write` on the tap repo (the
  default `GITHUB_TOKEN` cannot push to another repository). To seed or verify
  the formula by hand on a Mac, run
  `scripts/update-homebrew-formula.sh <version> path/to/homebrew-tap/Formula/lgtmaybe.rb`.
- **winget:** fork [`microsoft/winget-pkgs`](https://github.com/microsoft/winget-pkgs).
  Create a classic GitHub PAT with the `public_repo` **and `workflow`** scopes
  and add it to this repo as **`WINGET_TOKEN`**. `wingetcreate` syncs the fork
  with upstream before it opens each PR, and `microsoft/winget-pkgs` often
  changes files under `.github/workflows/`. GitHub refuses a fork sync that
  brings in workflow-file changes unless the token has the `workflow` scope, so
  with `public_repo` alone the submission fails on almost every release.
- **First winget release:** build and upload the Windows asset, then run
  `wingetcreate new <asset-url>` by hand with package id `MattJColes.lgtmaybe`,
  installer type `portable`, command alias `lgtmaybe`, and MIT licence
  metadata. Microsoft moderates a new package before it is published, which can
  take days or weeks. Until that first manifest is accepted, the release job
  warns and skips the winget update rather than failing the other release
  artefacts. After that, the workflow runs `wingetcreate update` for every
  new version.

## Each Release

1. Merge feature/fix PRs to `main` using conventional-commit messages
   (`feat:`, `fix:`, `feat!:` / `BREAKING CHANGE:` for a major bump).
2. release-please opens or updates the **Release PR** automatically. Review the
   proposed version + changelog, then **merge it** to publish.
3. To (re)publish an existing tag to PyPI without a new release, run the
   `release-please` workflow via **workflow_dispatch** with the tag name (for
   example `lgtmaybe-v2.11.1`). To rebuild the GHCR image and move the Action
   tags for an existing tag, dispatch `release` the same way.
4. If Windows publication needs recovery, dispatch `windows-exe` for the tag
   (set `force` to replace an existing asset), then dispatch `winget` once the
   release asset is visible.
5. If the `winget` job fails with "The forked repository could not be synced
   with the upstream commits", sync `MattJColes/winget-pkgs` with upstream
   (**Sync fork** on its `master`, or
   `gh api -X POST repos/MattJColes/winget-pkgs/merge-upstream -f branch=master`),
   then dispatch `winget` again with the version. If it keeps recurring, check
   that `WINGET_TOKEN` still carries the `workflow` scope.

## Rotate the Public App Private Key

Rotate the public `lgtmaybe` GitHub App key quarterly, whenever maintainer access
changes, and immediately after any suspected exposure. Keep the old key active
until the replacement has passed a brokered smoke test so rotation does not
interrupt reviews.

1. In the GitHub App settings, generate a new private key. Do not delete the old
   key yet.
2. From the directory containing the downloaded PEM, replace the retained
   Secrets Manager value without printing the key:

    ```bash
    aws secretsmanager put-secret-value \
      --secret-id lgtmaybe/github-app/private-key \
      --secret-string file://lgtmaybe.private-key.pem \
      --region ap-southeast-2
    ```

3. Refresh the Lambda configuration so every execution environment drops its
   cached copy of the old key:

    ```bash
    FUNCTION_NAME="$(
      aws cloudformation describe-stack-resources \
        --stack-name LgtmaybeGithubAppIdentity \
        --region ap-southeast-2 \
        --query "StackResources[?ResourceType=='AWS::Lambda::Function'].PhysicalResourceId | [0]" \
        --output text
    )"
    aws lambda update-function-configuration \
      --function-name "$FUNCTION_NAME" \
      --description "GitHub App key rotated $(date -u +%Y-%m-%dT%H:%M:%SZ)" \
      --region ap-southeast-2
    aws lambda wait function-updated-v2 \
      --function-name "$FUNCTION_NAME" \
      --region ap-southeast-2
    ```

4. Run a brokered dogfood review and confirm GitHub attributes it to
   `lgtmaybe[bot]`.
5. Delete the old key in the GitHub App settings, then delete the downloaded PEM
   from the maintainer machine. Never paste the PEM into a command argument,
   issue, workflow log, or repository file.

If the smoke test fails, leave the old GitHub key active, put its PEM back into
the same Secrets Manager secret, refresh the Lambda configuration again, and
investigate before retrying.

## Before Going Public

- Dogfood lgtmaybe on its own PRs so the README example is real.
- Re-check the least-privilege IAM/WIF scopes (see the
  [Bedrock](./review-with-bedrock-oidc.md) and [Vertex](./review-with-vertex-wif.md)
  guides) before the repo goes public.

---

<!-- Source: https://lgtmaybe.coles.codes/reference/config/ -->

<!-- DO NOT EDIT — this file is generated by docs/generate_reference.py.
     Run `uv run python docs/generate_reference.py` to regenerate.
     CI will fail if this file is stale. -->

# Configuration Reference

This reference is generated from the pydantic schemas in `src/lgtmaybe/core/models.py`.

## ReviewConfig

The user-facing configuration model. Fields map directly to `.lgtmaybe.yml` keys and CLI flags.

| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| `api_base` | string / null | No | `null` | Api Base |
| `auto_describe` | boolean | No | `True` | Auto Describe |
| `auto_diagram` | boolean | No | `True` | Auto Diagram |
| `categories` | list[`complexity` / `correctness` / `deprecation` / `documentation` / `intent` / `performance` / `ponytail` / `security` / `spec` / `tests`] | No | `['security', 'correctness', 'deprecation', 'tests', 'documentation', 'performance', 'complexity', 'intent', 'ponytail', 'spec']` | Categories |
| `context_lines` | integer | No | `20` | Context Lines |
| `directory_rules` | list[DirectoryRule] | No | `[]` | Directory Rules |
| `exclude_paths` | list[string] | No | `[]` | Exclude Paths |
| `extra_lenses` | list[CustomLens] | No | `[]` | Extra Lenses |
| `fail_on` | `critical` / `high` / `info` / `low` / `medium` / null | No | `null` |  |
| `fallback_model` | string / null | No | `null` | Fallback Model |
| `finding_rules` | list[FindingRule] | No | `[]` | Finding Rules |
| `function_context` | boolean | No | `True` | Function Context |
| `high_impact` | boolean | No | `True` | High Impact |
| `ignore_fingerprints` | list[string] | No | `[]` | Ignore Fingerprints |
| `include_paths` | list[string] | No | `[]` | Include Paths |
| `incremental` | boolean / null | No | `null` | Incremental |
| `language` | string / null | No | `null` | Language |
| `learn_feedback` | boolean | No | `True` | Learn Feedback |
| `max_concurrency` | integer / null | No | `null` | Max Concurrency |
| `max_file_diff_lines` | integer | No | `2000` | Max File Diff Lines |
| `max_files` | integer | No | `50` | Max Files |
| `max_findings_per_lens` | integer | No | `50` | Max Findings Per Lens |
| `max_input_tokens` | integer | No | `100000` | Max Input Tokens |
| `max_review_seconds` | integer | No | `3600` | Max Review Seconds |
| `max_review_tokens` | integer | No | `0` | Max Review Tokens |
| `max_tokens` | integer / null | No | `null` | Max Tokens |
| `mid_review_retrieval` | boolean | No | `False` | Mid Review Retrieval |
| `min_confidence` | integer | No | `0` | Min Confidence |
| `min_severity` | `critical` / `high` / `info` / `low` / `medium` | No | `low` |  |
| `model` | string | Yes | — | Model |
| `num_ctx` | integer / null | No | `null` | Num Ctx |
| `pr_labels` | boolean | No | `False` | Pr Labels |
| `preset` | `fast` / `full` | No | `fast` |  |
| `project` | ProjectProfile | No | — |  |
| `provider` | `anthropic` / `azure` / `bedrock` / `ollama` / `openai` / `openai-compatible` / `openrouter` / `vertex` / `zai` | Yes | — |  |
| `reasoning_effort` | string / null | No | `null` | Reasoning Effort |
| `recursive` | boolean | No | `True` | Recursive |
| `reflect` | boolean | No | `True` | Reflect |
| `reflect_model` | string / null | No | `null` | Reflect Model |
| `repair_unparseable` | boolean | No | `True` | Repair Unparseable |
| `resolve_fixed` | boolean | No | `True` | Resolve Fixed |
| `retry_without_schema` | boolean | No | `True` | Retry Without Schema |
| `risk` | RiskConfig | No | `{'enabled': True, 'core_paths': []}` |  |
| `spec_paths` | list[string] | No | `[]` | Spec Paths |
| `spec_review` | boolean | No | `True` | Spec Review |
| `static_analysis` | StaticAnalysisConfig | No | `{'enabled': False, 'tools': ['ruff', 'bandit', 'semgrep', 'mypy', 'gitleaks', 'zizmor', 'ast-grep', 'osv-scanner'], 'min_severity': 'info', 'tool_min_severity': {}, 'tool_mode': {}, 'semgrep_rules': None, 'ast_grep_rules': None}` |  |
| `structured_output` | boolean | No | `True` | Structured Output |
| `summary_template` | string / null | No | `null` | Summary Template |
| `symbol_resolution` | boolean | No | `True` | Symbol Resolution |
| `temperature` | number | No | `0.0` | Temperature |
| `timeout` | integer / null | No | `null` | Timeout |
| `triage_model` | string / null | No | `null` | Triage Model |
| `unanchored_min_severity` | `critical` / `high` / `info` / `low` / `medium` | No | `high` |  |

## Enums

### Provider

LLM backend selected by `--provider`. Cloud providers (`bedrock`, `vertex`, `azure`) use ambient credentials — `azure` also needs the resource endpoint (`--api-base`) and accepts a key as an alternative. `openai-compatible` points at any server speaking the OpenAI `/v1` wire format (DeepSeek, llama.cpp, LM Studio, vLLM) via `--api-base`; the key is optional so keyless local servers work.

- `anthropic`
- `azure`
- `bedrock`
- `ollama`
- `openai`
- `openai-compatible`
- `openrouter`
- `vertex`
- `zai`

### Severity

Finding severity, ordered low to high. Use `min_severity` in config to suppress findings below a threshold.

- `critical`
- `high`
- `info`
- `low`
- `medium`

## ReviewFinding

The structured output the model must return for each inline comment. All fields are validated by pydantic before anything is posted.

| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| `anchor` | string / null | No | `null` | Anchor |
| `anchored` | boolean | No | `True` | Anchored |
| `body` | string | Yes | — | Body |
| `broad` | boolean | No | `False` | Broad |
| `category` | string / null | No | `null` | Category |
| `confidence` | integer / null | No | `null` | Confidence |
| `failure_scenario` | string / null | No | `null` | Failure Scenario |
| `line` | integer | Yes | — | Line |
| `path` | string | Yes | — | Path |
| `severity` | `critical` / `high` / `info` / `low` / `medium` | Yes | — |  |
| `side` | string | No | `RIGHT` | Side |
| `suggestion` | string / null | No | `null` | Suggestion |
| `title` | string | Yes | — | Title |

## ProviderResult

The normalised return value of one LLM completion, including token usage.

| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| `attempts` | integer | No | `1` | Attempts |
| `cache_creation_tokens` | integer | No | `0` | Cache Creation Tokens |
| `cache_read_tokens` | integer | No | `0` | Cache Read Tokens |
| `cost_usd` | number / null | No | `null` | Cost Usd |
| `input_tokens` | integer | Yes | — | Input Tokens |
| `model` | string / null | No | `null` | Model |
| `output_ceiling` | integer / null | No | `null` | Output Ceiling |
| `output_tokens` | integer | Yes | — | Output Tokens |
| `reasoning_tokens` | integer / null | No | `null` | Reasoning Tokens |
| `text` | string | Yes | — | Text |

## PRContext

Everything the engine needs about a pull request. Fetched via the GitHub REST API — PR code is never checked out.

| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| `base_sha` | string | Yes | — | Base Sha |
| `changed_files` | list[string] | Yes | — | Changed Files |
| `commit_messages` | list[string] | No | `[]` | Commit Messages |
| `description` | string | No | `` | Description |
| `diff` | string | Yes | — | Diff |
| `feedback_downvotes` | list[string] | No | `[]` | Feedback Downvotes |
| `file_contents` | object | No | — | File Contents |
| `head_branch` | string | No | `` | Head Branch |
| `head_sha` | string | Yes | — | Head Sha |
| `open_finding_threads` | integer | No | `0` | Open Finding Threads |
| `pr_number` | integer | Yes | — | Pr Number |
| `repo` | string | Yes | — | Repo |
| `risk` | RiskAssessment / null | No | `null` |  |
| `scan_contents` | object | No | — | Scan Contents |
| `title` | string | No | `` | Title |

## Raw JSON schemas

The canonical machine-readable schemas. These are the source of truth for provider output validation.

### ReviewConfig

```json
{
  "$defs": {
    "CustomLens": {
      "additionalProperties": false,
      "description": "A user-defined review lens \u2014 a \"skill file\" run alongside the built-ins.\n\nThe engine fans it out as its own focused LLM call (same pipeline as a\nbuilt-in ``ReviewCategory``) and merges its findings with the rest. A lens is\ndeclared in **trusted** config (``.lgtmaybe.yml`` or a file referenced by\n``lens_paths``), never from PR-author content, so its text is safe to put in\nthe system prompt. Supplying a worked example (``example_diff`` +\n``example_finding``) is optional but sharply improves a small model's output.",
      "properties": {
        "example_diff": {
          "anyOf": [
            {
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "title": "Example Diff"
        },
        "example_finding": {
          "anyOf": [
            {
              "$ref": "#/$defs/ReviewFinding"
            },
            {
              "type": "null"
            }
          ],
          "default": null
        },
        "id": {
          "title": "Id",
          "type": "string"
        },
        "instructions": {
          "title": "Instructions",
          "type": "string"
        },
        "title": {
          "default": "",
          "title": "Title",
          "type": "string"
        }
      },
      "required": [
        "id",
        "instructions"
      ],
      "title": "CustomLens",
      "type": "object"
    },
    "DirectoryRule": {
      "additionalProperties": false,
      "description": "Extra review instructions and context scoped to part of the repo.\n\nA monorepo is not uniform: ``payments/**`` wants strictness that would be\nnoise in ``tests/**``, and reviewing ``src/**`` well may need a design doc\nthe diff never shows. Each rule names the paths it applies to (fnmatch globs\nagainst the repo-relative path, a ``**/`` prefix also matching at the repo\nroot \u2014 the same matcher the path filters use; an EMPTY list applies the rule\neverywhere, mirroring ``FindingRuleMatch``), free-text ``instructions``, and\n``context_files`` read from the checked-out workspace.\n\nBoth are trusted configuration: on ``pull_request_target`` the workspace is\nthe BASE branch, so neither the instructions nor the context text is ever\nPR-author content.",
      "properties": {
        "context_files": {
          "items": {
            "type": "string"
          },
          "title": "Context Files",
          "type": "array"
        },
        "instructions": {
          "default": "",
          "title": "Instructions",
          "type": "string"
        },
        "paths": {
          "items": {
            "type": "string"
          },
          "title": "Paths",
          "type": "array"
        }
      },
      "title": "DirectoryRule",
      "type": "object"
    },
    "FindingRule": {
      "additionalProperties": false,
      "description": "One declarative post-processing rule, applied in list order.\n\nThe safe alternative to arbitrary user hooks: rules can only filter or\nre-grade findings \u2014 no user code ever executes.",
      "properties": {
        "action": {
          "$ref": "#/$defs/FindingRuleAction"
        },
        "match": {
          "$ref": "#/$defs/FindingRuleMatch",
          "default": {
            "category": null,
            "min_severity": null,
            "path": null,
            "title_contains": null
          }
        }
      },
      "required": [
        "action"
      ],
      "title": "FindingRule",
      "type": "object"
    },
    "FindingRuleAction": {
      "additionalProperties": false,
      "description": "What a matched rule does: drop the finding, or remap its severity.",
      "properties": {
        "drop": {
          "default": false,
          "title": "Drop",
          "type": "boolean"
        },
        "set_severity": {
          "anyOf": [
            {
              "$ref": "#/$defs/Severity"
            },
            {
              "type": "null"
            }
          ],
          "default": null
        }
      },
      "title": "FindingRuleAction",
      "type": "object"
    },
    "FindingRuleMatch": {
      "additionalProperties": false,
      "description": "The selector of a finding rule. Every specified field must match (AND).\n\nAn empty match selects every finding. ``path`` is an fnmatch glob against\nthe repo-relative path (a ``**/`` prefix also matches at the repo root,\nlike the path filters); ``category`` is the originating lens id;\n``title_contains`` is a case-insensitive substring; ``min_severity``\nselects findings at or above that severity.",
      "properties": {
        "category": {
          "anyOf": [
            {
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "title": "Category"
        },
        "min_severity": {
          "anyOf": [
            {
              "$ref": "#/$defs/Severity"
            },
            {
              "type": "null"
            }
          ],
          "default": null
        },
        "path": {
          "anyOf": [
            {
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "title": "Path"
        },
        "title_contains": {
          "anyOf": [
            {
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "title": "Title Contains"
        }
      },
      "title": "FindingRuleMatch",
      "type": "object"
    },
    "ProjectProfile": {
      "additionalProperties": false,
      "description": "What the repository is, so a review can be calibrated to the workload.\n\nA weekend prototype and a regulated payments service should not get the\nsame review. ``stage`` sets defaults for existing settings (a prototype\nraises the ``min_severity`` default to ``medium``; a regulated workload\nfloors the risk verdict of any change that isn't only docs and tests at\n``medium``), and an explicitly configured setting always wins. All three\nfields also render one short trusted paragraph in the shared review\npreamble. Empty (the default) is byte-identical to a review without the\nfeature.\n\nStructured on purpose: reading AGENTS.md or similar wholesale brings\ncoding-agent instructions the reviewer has no use for, and usually more\ntext than the context budget takes.",
      "properties": {
        "domain": {
          "anyOf": [
            {
              "maxLength": 80,
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "title": "Domain"
        },
        "notes": {
          "default": "",
          "maxLength": 1000,
          "title": "Notes",
          "type": "string"
        },
        "stage": {
          "anyOf": [
            {
              "enum": [
                "prototype",
                "production",
                "regulated"
              ],
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "title": "Stage"
        }
      },
      "title": "ProjectProfile",
      "type": "object"
    },
    "Provider": {
      "description": "The backend selected by the `--provider` flag.",
      "enum": [
        "openai",
        "openrouter",
        "anthropic",
        "bedrock",
        "vertex",
        "azure",
        "ollama",
        "openai-compatible",
        "zai"
      ],
      "title": "Provider",
      "type": "string"
    },
    "ReviewCategory": {
      "description": "A single review lens. The engine asks for each one in its own LLM call.\n\n``intent`` checks the diff against the PR's stated intent (title, description,\ncommit messages); it only runs when the context carries some stated intent.\n``ponytail`` is the \"lazy senior dev\" lens \u2014 the best code is the code you\nnever wrote \u2014 flagging code that needn't exist at all (YAGNI, reach for the\nstandard library, do it in fewer lines).\n``spec`` checks the diff against a specification the repository commits\n(OpenSpec, GitHub Spec Kit, Kiro); like ``intent`` it only runs when there is\nsomething to check against \u2014 here, a detected spec that matches the PR.",
      "enum": [
        "security",
        "correctness",
        "deprecation",
        "tests",
        "documentation",
        "performance",
        "complexity",
        "intent",
        "ponytail",
        "spec"
      ],
      "title": "ReviewCategory",
      "type": "string"
    },
    "ReviewFinding": {
      "additionalProperties": false,
      "description": "A single inline review comment the model wants to post.",
      "properties": {
        "anchor": {
          "anyOf": [
            {
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "title": "Anchor"
        },
        "anchored": {
          "default": true,
          "title": "Anchored",
          "type": "boolean"
        },
        "body": {
          "title": "Body",
          "type": "string"
        },
        "broad": {
          "default": false,
          "title": "Broad",
          "type": "boolean"
        },
        "category": {
          "anyOf": [
            {
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "title": "Category"
        },
        "confidence": {
          "anyOf": [
            {
              "maximum": 10,
              "minimum": 0,
              "type": "integer"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "title": "Confidence"
        },
        "failure_scenario": {
          "anyOf": [
            {
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "title": "Failure Scenario"
        },
        "line": {
          "minimum": 1,
          "title": "Line",
          "type": "integer"
        },
        "path": {
          "title": "Path",
          "type": "string"
        },
        "severity": {
          "$ref": "#/$defs/Severity"
        },
        "side": {
          "default": "RIGHT",
          "enum": [
            "LEFT",
            "RIGHT"
          ],
          "title": "Side",
          "type": "string"
        },
        "suggestion": {
          "anyOf": [
            {
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "title": "Suggestion"
        },
        "title": {
          "title": "Title",
          "type": "string"
        }
      },
      "required": [
        "path",
        "line",
        "severity",
        "title",
        "body"
      ],
      "title": "ReviewFinding",
      "type": "object"
    },
    "ReviewPreset": {
      "description": "How many model calls a review spends: the everyday path or the deep audit.\n\n``fast`` (the default) covers all nine built-in categories in FOUR calls,\none per concern: security, correctness (with stated intent folded in), code\nhealth, and artefacts (tests + documentation). The same four run on every\nprovider \u2014 worker count changes the schedule, not the call count.\n``full`` runs each of the nine categories as its own call for release\nbranches and deep audits. An explicit ``categories`` list always wins over\nthe preset.",
      "enum": [
        "fast",
        "full"
      ],
      "title": "ReviewPreset",
      "type": "string"
    },
    "RiskConfig": {
      "additionalProperties": false,
      "description": "Risk of change: a deterministic low/medium/high/critical verdict per PR.\n\nBuilt from the change's blast radius (how many workspace files import each\nchanged file, via an ast-grep scan), the High Impact path signals, change\nsize, and whether code changed without a test changing. No model call, so\nthe verdict is free, stable across re-runs, and something a gate can read.",
      "properties": {
        "core_paths": {
          "items": {
            "type": "string"
          },
          "title": "Core Paths",
          "type": "array"
        },
        "enabled": {
          "default": true,
          "title": "Enabled",
          "type": "boolean"
        }
      },
      "title": "RiskConfig",
      "type": "object"
    },
    "Severity": {
      "description": "Finding severity, ordered low \u2192 high for `min_severity` filtering.",
      "enum": [
        "info",
        "low",
        "medium",
        "high",
        "critical"
      ],
      "title": "Severity",
      "type": "string"
    },
    "StaticAnalysisConfig": {
      "additionalProperties": false,
      "description": "Static-analysis fusion: deterministic tool findings as LLM grounding.\n\nWhen enabled, the installed tools run over the already-fetched changed-file\ntexts (sandboxed subprocess, scrubbed environment, no network, never a\ncheckout) and their findings enter each lens prompt as untrusted HINTS \u2014\n\"confirm, contextualise, or discard\" \u2014 raising recall on the deterministic\nbugs models miss without posting raw linter noise. A tool that isn't\ninstalled is skipped silently, so the feature degrades to nothing on a\nminimal install.",
      "properties": {
        "ast_grep_rules": {
          "anyOf": [
            {
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "title": "Ast Grep Rules"
        },
        "enabled": {
          "default": false,
          "title": "Enabled",
          "type": "boolean"
        },
        "min_severity": {
          "$ref": "#/$defs/Severity",
          "default": "info"
        },
        "semgrep_rules": {
          "anyOf": [
            {
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "title": "Semgrep Rules"
        },
        "tool_min_severity": {
          "additionalProperties": {
            "$ref": "#/$defs/Severity"
          },
          "propertyNames": {
            "$ref": "#/$defs/StaticAnalysisTool"
          },
          "title": "Tool Min Severity",
          "type": "object"
        },
        "tool_mode": {
          "additionalProperties": {
            "$ref": "#/$defs/ToolMode"
          },
          "propertyNames": {
            "$ref": "#/$defs/StaticAnalysisTool"
          },
          "title": "Tool Mode",
          "type": "object"
        },
        "tools": {
          "default": [
            "ruff",
            "bandit",
            "semgrep",
            "mypy",
            "gitleaks",
            "zizmor",
            "ast-grep",
            "osv-scanner"
          ],
          "items": {
            "$ref": "#/$defs/StaticAnalysisTool"
          },
          "title": "Tools",
          "type": "array"
        }
      },
      "title": "StaticAnalysisConfig",
      "type": "object"
    },
    "StaticAnalysisTool": {
      "description": "A deterministic linter/SAST tool whose findings feed the review.\n\nThe value is the binary name looked up on PATH, so it may differ from the\nmember name where the binary is hyphenated.",
      "enum": [
        "ruff",
        "bandit",
        "semgrep",
        "mypy",
        "gitleaks",
        "zizmor",
        "ast-grep",
        "osv-scanner"
      ],
      "title": "StaticAnalysisTool",
      "type": "string"
    },
    "ToolMode": {
      "description": "How a tool's findings reach the review.\n\n``hint`` is the original fusion behaviour: findings become an untrusted\nHINTS block the model confirms, contextualises, or discards. ``finding``\nskips the model entirely and posts the tool's result as a review comment.\n\nWhich one is right is a property of the tool's precision, not a user\npreference: a secret is present or it isn't, so asking a model to \"confirm\nor discard\" a deterministic regex match only adds cost and doubt. A lint or\na SAST heuristic genuinely benefits from a model judging whether it matters\nhere. Per-tool defaults live in ``engine.static_analysis``; this enum is the\noverride users reach for via ``static_analysis.tool_mode``.",
      "enum": [
        "hint",
        "finding"
      ],
      "title": "ToolMode",
      "type": "string"
    }
  },
  "additionalProperties": false,
  "description": "How to run one review: provider/model, severity floor, filters, caps.",
  "properties": {
    "api_base": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "title": "Api Base"
    },
    "auto_describe": {
      "default": true,
      "title": "Auto Describe",
      "type": "boolean"
    },
    "auto_diagram": {
      "default": true,
      "title": "Auto Diagram",
      "type": "boolean"
    },
    "categories": {
      "default": [
        "security",
        "correctness",
        "deprecation",
        "tests",
        "documentation",
        "performance",
        "complexity",
        "intent",
        "ponytail",
        "spec"
      ],
      "items": {
        "$ref": "#/$defs/ReviewCategory"
      },
      "title": "Categories",
      "type": "array"
    },
    "context_lines": {
      "default": 20,
      "title": "Context Lines",
      "type": "integer"
    },
    "directory_rules": {
      "items": {
        "$ref": "#/$defs/DirectoryRule"
      },
      "title": "Directory Rules",
      "type": "array"
    },
    "exclude_paths": {
      "items": {
        "type": "string"
      },
      "title": "Exclude Paths",
      "type": "array"
    },
    "extra_lenses": {
      "items": {
        "$ref": "#/$defs/CustomLens"
      },
      "title": "Extra Lenses",
      "type": "array"
    },
    "fail_on": {
      "anyOf": [
        {
          "$ref": "#/$defs/Severity"
        },
        {
          "type": "null"
        }
      ],
      "default": null
    },
    "fallback_model": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "title": "Fallback Model"
    },
    "finding_rules": {
      "items": {
        "$ref": "#/$defs/FindingRule"
      },
      "title": "Finding Rules",
      "type": "array"
    },
    "function_context": {
      "default": true,
      "title": "Function Context",
      "type": "boolean"
    },
    "high_impact": {
      "default": true,
      "title": "High Impact",
      "type": "boolean"
    },
    "ignore_fingerprints": {
      "items": {
        "type": "string"
      },
      "title": "Ignore Fingerprints",
      "type": "array"
    },
    "include_paths": {
      "items": {
        "type": "string"
      },
      "title": "Include Paths",
      "type": "array"
    },
    "incremental": {
      "anyOf": [
        {
          "type": "boolean"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "title": "Incremental"
    },
    "language": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "title": "Language"
    },
    "learn_feedback": {
      "default": true,
      "title": "Learn Feedback",
      "type": "boolean"
    },
    "max_concurrency": {
      "anyOf": [
        {
          "minimum": 1,
          "type": "integer"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "title": "Max Concurrency"
    },
    "max_file_diff_lines": {
      "default": 2000,
      "minimum": 0,
      "title": "Max File Diff Lines",
      "type": "integer"
    },
    "max_files": {
      "default": 50,
      "title": "Max Files",
      "type": "integer"
    },
    "max_findings_per_lens": {
      "default": 50,
      "minimum": 0,
      "title": "Max Findings Per Lens",
      "type": "integer"
    },
    "max_input_tokens": {
      "default": 100000,
      "title": "Max Input Tokens",
      "type": "integer"
    },
    "max_review_seconds": {
      "default": 3600,
      "minimum": 0,
      "title": "Max Review Seconds",
      "type": "integer"
    },
    "max_review_tokens": {
      "default": 0,
      "minimum": 0,
      "title": "Max Review Tokens",
      "type": "integer"
    },
    "max_tokens": {
      "anyOf": [
        {
          "minimum": 0,
          "type": "integer"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "title": "Max Tokens"
    },
    "mid_review_retrieval": {
      "default": false,
      "title": "Mid Review Retrieval",
      "type": "boolean"
    },
    "min_confidence": {
      "default": 0,
      "maximum": 10,
      "minimum": 0,
      "title": "Min Confidence",
      "type": "integer"
    },
    "min_severity": {
      "$ref": "#/$defs/Severity",
      "default": "low"
    },
    "model": {
      "title": "Model",
      "type": "string"
    },
    "num_ctx": {
      "anyOf": [
        {
          "type": "integer"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "title": "Num Ctx"
    },
    "pr_labels": {
      "default": false,
      "title": "Pr Labels",
      "type": "boolean"
    },
    "preset": {
      "$ref": "#/$defs/ReviewPreset",
      "default": "fast"
    },
    "project": {
      "$ref": "#/$defs/ProjectProfile"
    },
    "provider": {
      "$ref": "#/$defs/Provider"
    },
    "reasoning_effort": {
      "anyOf": [
        {
          "enum": [
            "none",
            "minimal",
            "low",
            "medium",
            "high",
            "xhigh",
            "default"
          ],
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "title": "Reasoning Effort"
    },
    "recursive": {
      "default": true,
      "title": "Recursive",
      "type": "boolean"
    },
    "reflect": {
      "default": true,
      "title": "Reflect",
      "type": "boolean"
    },
    "reflect_model": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "title": "Reflect Model"
    },
    "repair_unparseable": {
      "default": true,
      "title": "Repair Unparseable",
      "type": "boolean"
    },
    "resolve_fixed": {
      "default": true,
      "title": "Resolve Fixed",
      "type": "boolean"
    },
    "retry_without_schema": {
      "default": true,
      "title": "Retry Without Schema",
      "type": "boolean"
    },
    "risk": {
      "$ref": "#/$defs/RiskConfig",
      "default": {
        "core_paths": [],
        "enabled": true
      }
    },
    "spec_paths": {
      "items": {
        "type": "string"
      },
      "title": "Spec Paths",
      "type": "array"
    },
    "spec_review": {
      "default": true,
      "title": "Spec Review",
      "type": "boolean"
    },
    "static_analysis": {
      "$ref": "#/$defs/StaticAnalysisConfig",
      "default": {
        "ast_grep_rules": null,
        "enabled": false,
        "min_severity": "info",
        "semgrep_rules": null,
        "tool_min_severity": {},
        "tool_mode": {},
        "tools": [
          "ruff",
          "bandit",
          "semgrep",
          "mypy",
          "gitleaks",
          "zizmor",
          "ast-grep",
          "osv-scanner"
        ]
      }
    },
    "structured_output": {
      "default": true,
      "title": "Structured Output",
      "type": "boolean"
    },
    "summary_template": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "title": "Summary Template"
    },
    "symbol_resolution": {
      "default": true,
      "title": "Symbol Resolution",
      "type": "boolean"
    },
    "temperature": {
      "default": 0.0,
      "title": "Temperature",
      "type": "number"
    },
    "timeout": {
      "anyOf": [
        {
          "type": "integer"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "title": "Timeout"
    },
    "triage_model": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "title": "Triage Model"
    },
    "unanchored_min_severity": {
      "$ref": "#/$defs/Severity",
      "default": "high"
    }
  },
  "required": [
    "provider",
    "model"
  ],
  "title": "ReviewConfig",
  "type": "object"
}
```

### ReviewFinding

```json
{
  "$defs": {
    "Severity": {
      "description": "Finding severity, ordered low \u2192 high for `min_severity` filtering.",
      "enum": [
        "info",
        "low",
        "medium",
        "high",
        "critical"
      ],
      "title": "Severity",
      "type": "string"
    }
  },
  "additionalProperties": false,
  "description": "A single inline review comment the model wants to post.",
  "properties": {
    "anchor": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "title": "Anchor"
    },
    "anchored": {
      "default": true,
      "title": "Anchored",
      "type": "boolean"
    },
    "body": {
      "title": "Body",
      "type": "string"
    },
    "broad": {
      "default": false,
      "title": "Broad",
      "type": "boolean"
    },
    "category": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "title": "Category"
    },
    "confidence": {
      "anyOf": [
        {
          "maximum": 10,
          "minimum": 0,
          "type": "integer"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "title": "Confidence"
    },
    "failure_scenario": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "title": "Failure Scenario"
    },
    "line": {
      "minimum": 1,
      "title": "Line",
      "type": "integer"
    },
    "path": {
      "title": "Path",
      "type": "string"
    },
    "severity": {
      "$ref": "#/$defs/Severity"
    },
    "side": {
      "default": "RIGHT",
      "enum": [
        "LEFT",
        "RIGHT"
      ],
      "title": "Side",
      "type": "string"
    },
    "suggestion": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "title": "Suggestion"
    },
    "title": {
      "title": "Title",
      "type": "string"
    }
  },
  "required": [
    "path",
    "line",
    "severity",
    "title",
    "body"
  ],
  "title": "ReviewFinding",
  "type": "object"
}
```

### ProviderResult

```json
{
  "additionalProperties": false,
  "description": "The normalised return of one LLM completion, with token usage.",
  "properties": {
    "attempts": {
      "default": 1,
      "minimum": 1,
      "title": "Attempts",
      "type": "integer"
    },
    "cache_creation_tokens": {
      "default": 0,
      "title": "Cache Creation Tokens",
      "type": "integer"
    },
    "cache_read_tokens": {
      "default": 0,
      "title": "Cache Read Tokens",
      "type": "integer"
    },
    "cost_usd": {
      "anyOf": [
        {
          "type": "number"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "title": "Cost Usd"
    },
    "input_tokens": {
      "title": "Input Tokens",
      "type": "integer"
    },
    "model": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "title": "Model"
    },
    "output_ceiling": {
      "anyOf": [
        {
          "type": "integer"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "title": "Output Ceiling"
    },
    "output_tokens": {
      "title": "Output Tokens",
      "type": "integer"
    },
    "reasoning_tokens": {
      "anyOf": [
        {
          "type": "integer"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "title": "Reasoning Tokens"
    },
    "text": {
      "title": "Text",
      "type": "string"
    }
  },
  "required": [
    "text",
    "input_tokens",
    "output_tokens"
  ],
  "title": "ProviderResult",
  "type": "object"
}
```

### PRContext

```json
{
  "$defs": {
    "RiskAssessment": {
      "additionalProperties": false,
      "description": "A change's risk verdict: its level, the ranked reasons, and the gaps.\n\n``level`` None means the assessment itself failed and nothing is claimed.\n``unassessed`` lists changed code files whose blast radius was not measured,\nso a ``low`` verdict can never pass for a measured clean result.",
      "properties": {
        "level": {
          "anyOf": [
            {
              "enum": [
                "low",
                "medium",
                "high",
                "critical"
              ],
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "title": "Level"
        },
        "reasons": {
          "items": {
            "$ref": "#/$defs/RiskReason"
          },
          "title": "Reasons",
          "type": "array"
        },
        "unassessed": {
          "items": {
            "type": "string"
          },
          "title": "Unassessed",
          "type": "array"
        }
      },
      "required": [
        "level"
      ],
      "title": "RiskAssessment",
      "type": "object"
    },
    "RiskReason": {
      "additionalProperties": false,
      "description": "One reason a risk verdict is at its level.\n\n``factor`` names the rule that fired and ``key`` what makes it distinct from\nanother reason of the same factor (the High Impact area, or the factor\nitself): two distinct ``high`` reasons make a change ``critical``, while two\nTerraform files are one reason. ``text`` is rendered Markdown, paths already\nbacktick-stripped.",
      "properties": {
        "factor": {
          "enum": [
            "blast_radius",
            "area",
            "size",
            "untested",
            "scope",
            "profile"
          ],
          "title": "Factor",
          "type": "string"
        },
        "key": {
          "title": "Key",
          "type": "string"
        },
        "level": {
          "enum": [
            "low",
            "medium",
            "high",
            "critical"
          ],
          "title": "Level",
          "type": "string"
        },
        "text": {
          "title": "Text",
          "type": "string"
        }
      },
      "required": [
        "factor",
        "key",
        "level",
        "text"
      ],
      "title": "RiskReason",
      "type": "object"
    }
  },
  "additionalProperties": false,
  "description": "Everything the engine needs about a PR \u2014 fetched via API, never checkout.",
  "properties": {
    "base_sha": {
      "title": "Base Sha",
      "type": "string"
    },
    "changed_files": {
      "items": {
        "type": "string"
      },
      "title": "Changed Files",
      "type": "array"
    },
    "commit_messages": {
      "items": {
        "type": "string"
      },
      "title": "Commit Messages",
      "type": "array"
    },
    "description": {
      "default": "",
      "title": "Description",
      "type": "string"
    },
    "diff": {
      "title": "Diff",
      "type": "string"
    },
    "feedback_downvotes": {
      "default": [],
      "items": {
        "type": "string"
      },
      "title": "Feedback Downvotes",
      "type": "array",
      "uniqueItems": true
    },
    "file_contents": {
      "additionalProperties": {
        "type": "string"
      },
      "title": "File Contents",
      "type": "object"
    },
    "head_branch": {
      "default": "",
      "title": "Head Branch",
      "type": "string"
    },
    "head_sha": {
      "title": "Head Sha",
      "type": "string"
    },
    "open_finding_threads": {
      "default": 0,
      "minimum": 0,
      "title": "Open Finding Threads",
      "type": "integer"
    },
    "pr_number": {
      "title": "Pr Number",
      "type": "integer"
    },
    "repo": {
      "title": "Repo",
      "type": "string"
    },
    "risk": {
      "anyOf": [
        {
          "$ref": "#/$defs/RiskAssessment"
        },
        {
          "type": "null"
        }
      ],
      "default": null
    },
    "scan_contents": {
      "additionalProperties": {
        "type": "string"
      },
      "title": "Scan Contents",
      "type": "object"
    },
    "title": {
      "default": "",
      "title": "Title",
      "type": "string"
    }
  },
  "required": [
    "diff",
    "changed_files",
    "base_sha",
    "head_sha",
    "repo",
    "pr_number"
  ],
  "title": "PRContext",
  "type": "object"
}
```

---

<!-- Source: https://lgtmaybe.coles.codes/reference/profile/ -->

# Profile Reference

`--profile` prints a breakdown of a review at the end of the run: where the time
went, what each model call spent, and what it produced. `--profile-json PATH`
writes the same data as JSON.

Both flags are on the local `lgtmaybe review` command. On the GitHub Action, set
the `profile: true` input to print the table in the Action log; there is no JSON
file there.

**Parse the JSON, not the table.** The table's layout is not a contract and may
change. The JSON payload carries a `schema_version` so you can pin against it.

## Which Stream the Table Uses

The table goes to **stdout** for a human-format review, and to **stderr** when
the findings themselves are machine-readable (`--format json`, `--format agent`,
`--format sarif` or `--json`), so stdout stays parseable. Without `--profile`, a local review
still prints its `tokens:` footer, always to stderr.

`--profile-json` writes to a **file**, so it never collides with either stream.
If the path can't be written, lgtmaybe logs a warning and carries on; the review
is not lost to a diagnostic file.

## Layout

```text
== lgtmaybe profile ==
total wall time: 41.3s

stage              elapsed
split                0.01s
...

call             batch tries   elapsed   in_tok  out_tok think_tok  think_% cache_rd cache_wr findings  error
security             1     1     8.20s    36918     1286       400       5%        0        0        2  -
reflect              0     1     3.10s     4000      200         -        -        0        0        -  -

cache: 0 tokens read / 0 created across 2 calls
findings: 2 parsed / 1 returned
...
tokens: 42,404 billable (40,918 in / 1,486 out) across 2 calls
```

The stage table lists each pipeline stage (`split`, `expand`, `review`,
`reflect`, `post` and so on) in the order it ran. The call table lists every
model call, slowest first.

## The Call Table's Columns

| Column | Meaning |
|---|---|
| `call` | The lens ID (`security`, `correctness`, `code-health`, `artefacts`, `spec`, a single category under `full`, or a custom lens ID), or another call: `reflect`, `triage`, `validate`, `repair:<lens>` |
| `batch` | Which batch of files the call covered; `0` for calls outside the per-batch fan-out, such as `reflect` and `triage` |
| `tries` | Requests actually issued, including adapter retries (`0` when unknown) |
| `elapsed` | Wall time for the call, including retries |
| `in_tok` | Prompt tokens |
| `out_tok` | Completion tokens, including any reasoning tokens |
| `think_tok` | Reasoning tokens, when the route reports them |
| `think_%` | Reasoning tokens as a share of the call's `max_tokens` ceiling |
| `cache_rd` | Prompt-cache tokens read |
| `cache_wr` | Prompt-cache tokens written |
| `findings` | Findings parsed from this call |
| `error` | The failure reason, when the call failed |

The change overview's calls (description, High Impact Areas, diagrams) are not
in the profile.

## Summary Lines

These lines follow the call table. A line whose data is missing is left out.

| Line | Meaning |
|---|---|
| `cache:` | Prompt-cache tokens read and written across all calls |
| `findings: N parsed / M returned` | Findings parsed from the calls, and how many survived dedupe, reflection and filtering |
| `models:` | Which models answered, when more than one did (see below) |
| `reasoning:` | Reasoning tokens as a share of output tokens, over the calls that reported a breakdown |
| `largest reasoning share:` | The single call that spent the most of its `max_tokens` ceiling on reasoning, by lens and batch |
| `tokens:` | Billable tokens (input + output) across all calls |
| `estimated cost:` | A price estimate from litellm's pricing map, shown only when it priced at least one call, and noting how many it priced |

### The `models:` Line

When every call was answered by the same model, there is no `models:` line. When
a second model answered any of them (`fallback_model`, whether the engine
escalated a truncation to it or the adapter fell back on its own), a line names
each model and how many calls it took:

```text
models: luna (7 calls), sonnet (1 call)
```

There is no per-call model column, because it would repeat one string down the
whole table. The per-call value is in the JSON payload and the structured
`provider call` log line, omitted where the adapter reports no model.

### What `-` Means

A dash means **unknown**, never zero.

`think_tok` is a dash when the route reported no reasoning breakdown, because
`0` would claim the model did no thinking. `think_%` is a dash when the call had
no ceiling to be a share of. `findings` is a dash for a call that doesn't
produce review findings at all, which is different from a call that produced
none and shows `0`.

This is what breaks naive parsers: `int(row["think_tok"])` raises on a dash. In
the JSON payload the same value is `null`.

## The JSON Payload

```json
{
  "schema_version": 1,
  "wall_seconds": 12.4,
  "total_tokens": 41231,
  "total_cost_usd": null,
  "returned_findings": 3,
  "stages": [{"name": "redact", "elapsed": 0.01}],
  "calls": [
    {
      "label": "security",
      "batch": 1,
      "elapsed": 8.2,
      "attempts": 1,
      "input_tokens": 36918,
      "output_tokens": 1286,
      "cache_read_tokens": 0,
      "cache_creation_tokens": 0,
      "error": null,
      "reasoning_tokens": null,
      "output_ceiling": null,
      "findings": 2,
      "model": "openrouter/google/gemini-3.8-flash",
      "cost_usd": null,
      "reasoning_share": null
    }
  ]
}
```

Notes:

- `reasoning_tokens`, `reasoning_share`, `output_ceiling`, `findings`, `error`,
  `model` and `cost_usd` are `null` when unknown or absent, the JSON
  counterpart of the table's `-`.
- `reasoning_share` is the raw ratio the table rounds into `think_%`.
- `total_tokens` is input + output across all calls. Cache counters are left
  out because routes disagree on whether a cached read is already inside the
  prompt count, so adding them would double-count on some routes.
- `cost_usd` and `total_cost_usd` are estimates from litellm's pricing map, and
  `null` (never `0`) when nothing was priced.
- `returned_findings` is the count after the whole pipeline, which can be lower
  than the calls' parsed total once dedupe, reflection and filtering have run.

## Stability

`schema_version` is bumped when the payload's shape changes in a way a pinned
consumer would notice. Adding a field is not such a change; removing one or
changing what it means is.

---

<!-- Source: https://lgtmaybe.coles.codes/explanation/what-gets-reviewed/ -->

# What Gets Reviewed

This page explains what lgtmaybe looks at, how it bounds the work, and what the
output looks like on a pull or merge request and on the command line.

## What It Looks At

lgtmaybe reviews the **diff of a pull request** (or a GitLab merge request): the
lines it adds or changes, not the whole repository. It fetches that diff from
your code host's REST API and **never checks out or runs the PR's code**, so a
malicious change can't execute anything in the reviewer's environment. The diff
is treated as untrusted input throughout, including against prompt-injection
attempts hidden in PR text.

To review changes in context, lgtmaybe also pads each changed hunk with
**surrounding lines** read from the head revision of the changed file. The
model uses them to understand the change (the enclosing function, nearby
definitions) but only comments on changed lines. The padding is budget-scaled
and capped by `context_lines` (default 20, `0` disables it). It is fetched
read-only via the API and redacted like the diff.

Before the diff reaches the model it is cleaned:

- **Non-reviewable files are skipped**: lockfiles, minified assets and
  sourcemaps, vendored and build-output directories (`vendor/`,
  `node_modules/`, `dist/`, `build/`, `third_party/`), generated code (protobuf
  output, `.d.ts`, Dart `.g.dart`, test snapshots), generated LLM-index corpora
  (`llms.txt` / `llms-full.txt`), and binaries. Reviewing them is noise and
  wastes tokens.
- **Oversized files are skipped**: a single file whose diff runs past
  `max_file_diff_lines` (default 2000, `0` disables) is dropped and named in the
  summary. Name matching only catches generated files whose name admits it; a
  hand-named 154,000-line data blob is caught on size alone.
- **Secrets are redacted**: anything that looks like a key, token, private key
  or credential is stripped from the diff before it leaves your environment for
  the provider.

## Correctness & Logic

The model is prompted to hunt for the bugs a change introduces and grade them
by impact:

- **Null / None dereferences**: a value that can be empty used without a guard.
- **Off-by-one and boundary errors**: `<` vs `<=`, fencepost mistakes, empty-
  and single-element edge cases.
- **Mismatched or inverted ranges**: `start`/`end` swapped, a lower bound above
  its upper bound.
- **Unhandled error and exception paths**: failures silently swallowed, or state
  left half-updated.
- **Incorrect conditionals**: inverted booleans, `and`/`or` mix-ups, missing
  branches.
- **Resource leaks and ordering**: handles or locks not released,
  use-after-close, bad concurrent sequencing.
- **Races and concurrency**: check-then-act (TOCTOU), shared mutable state
  without synchronisation, coroutines called without `await`, blocking calls in
  async paths.
- **Numeric and date/time bugs**: overflow, float equality, division by zero,
  money in binary floats; timezone-naive datetimes, epoch-unit confusion, DST.
- **Aliasing and mutation**: mutable default arguments, mutating a collection
  while iterating it, sharing a mutable value the caller still owns.

=== "On a pull request"

    ![An inline lgtmaybe review comment flagging a [HIGH] possible None dereference, where get_user can return None but .email is accessed without a guard](../assets/review-correctness.png){ width="660" }

=== "On the CLI"

    ![The lgtmaybe CLI printing a [HIGH] None-dereference finding for demo/orders.py](../assets/cli-correctness.png){ width="660" }

## Security Review

The model is prompted with an OWASP-aligned checklist and told to grade what it
finds `high` or `critical` and name the vulnerability class in the title. It
looks for:

- **Injection**: SQL/NoSQL, OS command, and template/LDAP injection.
- **Cross-site scripting (XSS)**: unescaped user input rendered into HTML/JS.
- **CSRF and open redirect**: unprotected state-changing endpoints,
  user-controlled redirect targets.
- **Hardcoded secrets**: keys, tokens, passwords, or private keys in the diff.
- **Broken authn / authz**: missing permission checks, IDOR, auth bypass, and
  JWT or session pitfalls (unverified signatures, `alg` confusion, missing
  expiry).
- **Path traversal and unsafe file access**: user input in file paths, `../`,
  zip-slip extraction, and unrestricted file uploads.
- **SSRF**: server-side fetches of user-controlled URLs without allow-listing.
- **Insecure deserialisation and unsafe eval**: `pickle`/`yaml.load`/`eval` on
  untrusted data, and XML parsed with external entities enabled (XXE).
- **Mass assignment / over-posting**: request bodies bound straight onto models.
- **Weak cryptography**: MD5/SHA1 for passwords, ECB mode, disabled TLS
  verification, predictable randomness for security tokens.
- **Sensitive-data exposure**: secrets or PII in logs, error responses, or
  analytics (passwords, API keys, tokens/session IDs, SSNs, payment-card data).
- **CI / IaC misconfiguration**: untrusted input interpolated into workflow
  `run:` steps, third-party actions not pinned to a SHA, overly broad IAM
  policies, public buckets, privileged containers, secrets echoed into build
  logs.
- **Resource / DoS safety**: missing timeouts, unbounded loops or allocations,
  regexes vulnerable to catastrophic backtracking (ReDoS).

=== "On a pull request"

    ![An inline lgtmaybe review comment flagging a [CRITICAL] SQL injection vulnerability in a find_user function, explaining the unsafe string concatenation and suggesting a parameterised query](../assets/review-sql-injection.png){ width="660" }

=== "On the CLI"

    ![The lgtmaybe CLI printing a [CRITICAL] SQL injection finding for demo/db_queries.py](../assets/cli-security.png){ width="660" }

This shapes *what* the reviewer flags. How lgtmaybe protects **itself** from a
malicious PR (secret redaction, prompt-injection defence) is covered in
[Data and Privacy](data-and-privacy.md).

## Deprecation & Dependency Health

The reviewer also flags **factually outdated** code when the diff shows it.
These are objective, not stylistic:

- deprecated language or framework APIs (with the modern replacement when
  known),
- targeting an end-of-life runtime or language version,
- adding or pinning an end-of-life or abandoned dependency,
- pinning a dependency to a version with a known security advisory, and
- a new dependency whose name looks like a typosquat of a popular package, or
  whose licence conflicts with the project's.

It raises these only when the diff itself shows the change, and does not
speculate about code it cannot see.

=== "On a pull request"

    ![An inline lgtmaybe review comment flagging a [MEDIUM] deprecated datetime.utcnow() call and suggesting datetime.now(timezone.utc)](../assets/review-deprecation.png){ width="660" }

=== "On the CLI"

    ![The lgtmaybe CLI printing a [MEDIUM] deprecated-API finding for demo/scheduler.py](../assets/cli-deprecation.png){ width="660" }

## Test Coverage & Documentation

Two lighter checks round out a review:

- **Missing or weak tests**: when the diff adds a function, branch, or error
  case with no accompanying test, the reviewer raises a `low`/`medium` finding
  with a concrete, runnable test in its `suggestion` field, in the project's
  existing test idiom. Tests added in the diff that don't really test anything
  are flagged too: assertion-free, mocked until only the mock is exercised, or
  flaky (sleep-based waits, wall-clock or ordering dependence). Renames,
  comments, and trivial formatting changes are left alone.
- **Documentation gaps and stale docs**: a public surface added without a
  docstring, or a name or signature that contradicts what the code does, is
  flagged at `info`/`low`. A docstring or comment the change just made wrong is
  flagged up to `medium`, because a comment that lies is worse than none.
  Private helpers and self-evident code are left alone.

A missing test, with a runnable test in the suggestion:

=== "On a pull request"

    ![An inline lgtmaybe review comment flagging a [LOW] new branch added without a test, with a runnable pytest suggestion](../assets/review-tests.png){ width="660" }

=== "On the CLI"

    ![The lgtmaybe CLI printing a [LOW] missing-test finding for demo/discount.py](../assets/cli-tests.png){ width="660" }

A documentation gap on a new public function:

=== "On a pull request"

    ![An inline lgtmaybe review comment flagging an [INFO] public function missing a docstring, with a suggested docstring](../assets/review-documentation.png){ width="660" }

=== "On the CLI"

    ![The lgtmaybe CLI printing an [INFO] missing-docstring finding for demo/client.py](../assets/cli-documentation.png){ width="660" }

## Performance

The reviewer watches for performance regressions the change introduces, graded
by impact (`low`, up to `high` when the cost scales with input size or sits in a
hot path):

- **N+1 queries or calls in a loop**: a query, request, or other expensive call
  issued once per iteration that could be batched or hoisted out.
- **Inefficient algorithms**: accidentally quadratic (`O(n²)`) work where linear
  is feasible, or a linear scan where a set or dict lookup would do.
- **Redundant computation**: recomputing the same value inside a loop instead of
  hoisting or memoising it.
- **Unnecessary allocations and copies**: building large intermediates or
  copying big buffers on a hot path when streaming or in-place work would do.
- **Blocking I/O on a hot path**: synchronous I/O, sleeps, or lock contention
  where non-blocking handling is expected.
- **Unbounded or over-fetching queries**: loading whole tables into memory, or
  missing pagination and limits.
- **Unbounded growth and leaks**: caches without eviction, listeners or
  subscriptions never removed, queues that only grow.

It sticks to changes the diff shows and skips micro-optimisations with no
measurable impact.

=== "On a pull request"

    ![An inline lgtmaybe review comment flagging a [HIGH] N+1 query inside a loop, suggesting a single batched query](../assets/review-performance.png){ width="660" }

=== "On the CLI"

    ![The lgtmaybe CLI printing a [HIGH] N+1-query finding for demo/reports.py](../assets/cli-performance.png){ width="660" }

## Complexity

A restrained lens that flags code harder to read, test, or maintain than it
needs to be (`info`/`medium`), preferring a concrete simplification in the
`suggestion` field:

- **High cyclomatic complexity or deep nesting**: many branches, or deeply
  nested conditionals and loops that would read better with early returns.
- **Over-long, low-cohesion functions**: a function doing several unrelated
  things that should be split.
- **Duplicated logic**: non-trivial logic repeated in the diff that belongs in
  a shared helper.
- **Excessive parameters or boolean-flag arguments**, **convoluted
  expressions**, and **dead or unreachable code**.

Like the documentation lens, it stays quiet on code that is already simple.

=== "On a pull request"

    ![An inline lgtmaybe review comment flagging a [MEDIUM] deeply nested conditional and suggesting guard clauses](../assets/review-complexity.png){ width="660" }

=== "On the CLI"

    ![The lgtmaybe CLI printing a [MEDIUM] deep-nesting finding for demo/router.py](../assets/cli-complexity.png){ width="660" }

## Intent: Does the PR Do What It Says?

The intent lens compares the diff against the PR's **stated intent** and flags
mismatches at `medium`, or `high` when the unexplained change is
security-relevant:

- **Out-of-scope changes**: a hunk unrelated to the stated intent, such as a
  "fix typo" PR that also touches auth logic, CI workflows, dependency pins, or
  permissions. Smuggled security-relevant changes are the most valuable catch.
- **Contradictions**: the code does the opposite of, or something materially
  different from, what the title or commits claim.
- **Unfulfilled intent**: the PR promises behaviour the diff never implements.

Where the stated intent comes from:

- **On a GitHub PR**: the PR title, description, and the first line of each
  commit message, fetched via the API.
- **On the CLI**: the commit subjects from your local `git log` against the
  base branch, so the lens works without GitHub, in `--working` mode too. With
  no commits beyond the base yet (or with `--uncommitted`), nothing states an
  intent and the lens is skipped.

Under the default `fast` preset, intent is folded into the correctness call
rather than run as a call of its own.

### The Lens Is Told What It Was Not Shown

An intent lens judging "did the author keep their promise?" against a
*filtered* diff will call a kept promise broken. Files go missing for seven
reasons: the generated/binary/vendored skip, your `include_paths` /
`exclude_paths` globs, the `max_file_diff_lines` size cap, the `max_files` cap,
a triage skip, an incremental scope, and simply being in another batch (lenses
run once **per batch**, so on a large PR each call sees only part of the
change).

So each call is told which of the PR's files it cannot see. The list goes to
every lens, and the intent lens also gets the rule that goes with it: a claim
about a file that was not shown is **not shown, not undone**. The list is
derived from what is left of the PR after the batch, so it covers every one of
those mechanisms without caring which applied. It is capped, then wrapped and
neutralised as untrusted data, because filenames are attacker-controlled on a
fork PR too. That is why lgtmaybe doesn't report "the stated intent to
regenerate X is not reflected in the diff" when X is a generated file it was
never allowed to read.

The intent text is attacker-controlled on a fork PR, so it is treated exactly
like the diff: secrets are redacted, it is wrapped as untrusted data with
neutralised delimiters, and the model is told never to follow instructions
inside it. Only the call that carries the intent lens ever sees it. When a PR
states no intent at all, the lens is skipped instead of spending a model call.

## Spec: Does the PR Deliver the Specification It Commits To?

If your repository drives its work from a committed specification, that spec is
a better statement of intent than a PR description: it is structured, it
predates the code, and its task list records what the author claims to have
finished. The spec lens checks the diff against it in **both** directions.

lgtmaybe recognises three layouts out of the box, plus your own:

| Workflow | Detected by | Read |
|---|---|---|
| [OpenSpec](https://github.com/Fission-AI/OpenSpec) | `openspec/changes/<id>/`, `openspec/specs/<capability>/` | proposal, delta specs, design, tasks (archived changes are ignored) |
| [GitHub Spec Kit](https://github.com/github/spec-kit) | `.specify/`, or `specs/<slug>/` with a `spec.md` **and** a `plan.md` | spec, plan, tasks |
| [Kiro](https://kiro.dev/docs/specs/) | `.kiro/specs/<feature>/` | requirements, design, tasks |
| Your own layout | `spec_paths` globs in `.lgtmaybe.yml` | whichever of those filenames are present |

### What It Reports

**The diff falling short of the spec**

- **Contradicts an explicit requirement**: the code does the opposite of a
  stated SHALL/MUST or acceptance criterion (`high`).
- **A ticked task that is not delivered** (`medium`); see below.
- **A requirement in scope with nothing implementing it** (`medium`).
- **An acceptance criterion with no test** (`low`).

**The spec falling short of the diff**: the half people miss, and the more
reliable of the two, because the evidence is entirely in the diff.

- **Behaviour no requirement covers**: a new endpoint, state, error path, limit
  or side effect the spec never mentions (`low`/`info`).
- **A requirement the change made stale** (`low`).
- **An unresolved `[NEEDS CLARIFICATION]`** still sitting in a requirement this
  PR implements (`info`).

### Ticked Checkboxes Are Claims

All three workflows track progress with markdown checkboxes, and a PR that
implements tasks *flips* them:

```diff
-- [ ] T014 [US1] Enforce the 30-day link expiry in src/links/service.py
+- [x] T014 [US1] Enforce the 30-day link expiry in src/links/service.py
```

That flip is already in the diff. lgtmaybe extracts it with no model call and
no extra file read, and hands the lens the list as *claims the author made in
this pull request*. That turns a vague question ("did this deliver the spec?")
into a precise one ("is T014 actually here?"). A ticked task is something to
**check**, never to assume false: the lens flags it only when the diff shows
the work is absent.

### Which Spec, and When It Stays Quiet

A monorepo can hold forty spec directories, so lgtmaybe ranks them against the
PR (the PR edits the spec, its branch is named after one, or its title,
description or commits name one) and sends at most two. Spec Kit names branches
after the spec directory, which is what makes the branch signal work. **When
nothing matches, the lens does not run at all**: no model call, no prompt bytes.
The same is true when no spec system is present, which is the common case, so
a repository without specs pays nothing.

Because it needs its own large block, the spec lens is a call of its own: a
fifth call under the default `fast` preset, made only when a spec matched. Turn
it off with `--no-spec` on the CLI, or `spec_review: false` in `.lgtmaybe.yml`
or as an Action input.

### The Spec Lens Is Told What It Was Not Shown

A requirement is delivered by *code*, so this lens is even more exposed than
the intent lens to the filtered-diff trap: told nothing, it reports every
requirement implemented in another batch as undelivered. It gets the same
correction: the list of the PR's files this call cannot see, and the rule that a
requirement delivered in one of them is **not shown, not undelivered**.

Spec text is treated exactly like the diff: redacted, wrapped as untrusted data
in its own neutralised block, and never obeyed. That is deliberate. A spec is
usually committed in the same PR that implements it, so on a fork PR the author
controls the requirements their own change is judged against. Files the PR
changes are read from its head text for the same reason (the base branch does
not have them yet); everything else comes from the checked-out workspace, which
on `pull_request_target` is the trusted base branch. The worst a planted spec
can do is suppress a spec finding, because no other lens sees the block.

## Ponytail: The Laziest Senior Dev in the Room

The best code is the code you never wrote. Inspired by the
[Ponytail](https://github.com/DietrichGebert/ponytail) skill, this lens reviews
new code with a senior engineer's reflex to **not** add code, and flags what
needn't exist at all (graded `info` to `medium`, and deliberately restrained):

- **Needless code (YAGNI)**: speculative generality, "just in case" parameters,
  an abstraction with a single caller, or scaffolding for a future that isn't
  here.
- **Reinventing the standard library**: hand-rolled code that a built-in, the
  standard library, or an already-imported dependency does directly.
- **Could be far shorter**: several lines doing what one clear expression would.
- **Premature configurability**: flags, hooks, or options no caller uses yet.

It prefers deleting or collapsing code over adding to it, and puts the smaller
replacement in the suggestion. The complexity lens asks "is this code hard to
follow?"; Ponytail asks "should this code exist at all?"

## How the Scope Is Bounded

Every run is bounded so a large PR can't run away on latency. All of these are
configurable in `.lgtmaybe.yml` (see
[Configure .lgtmaybe.yml](../how-to/configure-lgtmaybe-yml.md)):

| Knob | Default | Effect |
|---|---|---|
| `preset` | `fast` | `fast` uses four calls (security, correctness, code health, artefacts) on every provider, plus a fifth spec call when a spec matches; `full` runs one call per lens. |
| `max_files` | 50 | Reviews the top-N changed files and posts a "reviewed top N of M" notice if there are more. |
| `max_file_diff_lines` | 2000 | Skips any single file whose diff is longer, naming it in the summary. `0` disables. |
| `max_input_tokens` | 100,000 | Batches the diff so each model call stays within budget. Left at the default, it is capped at what the model's context window can take. |
| `max_concurrency` | 6, every provider | Concurrent model calls across the whole fan-out (all batches share one pool). Local throughput is set on the server (`OLLAMA_NUM_PARALLEL`, llama.cpp `-np`, vLLM batching); set this to `1` for serial. |
| `max_review_seconds` | 3600 | Soft wall-clock ceiling: past it, queued calls are skipped and the review posts partial results with a notice. `0` disables. |
| `categories` | all | Which review lenses run (the nine everyday lenses, plus `spec` when a spec matches). An explicit list overrides the preset grouping and runs each listed lens as its own call. |
| `context_lines` | 20 | Ceiling on surrounding lines added around each hunk; the budget may use fewer. `0` disables context expansion. |
| `min_severity` | `low` (`medium` for a [`prototype` project](../how-to/calibrate-reviews-to-your-project.md)) | Drops findings below the floor (`info` → `low` → `medium` → `high` → `critical`). `low` keeps everything except pure-`info` narration. |
| `include_paths` / `exclude_paths` | none | Glob filters to focus the review. |

> These bound a **single run**, not the number of runs. On a public repo,
> anyone who can open a PR or comment can trigger a run, and each run calls your
> chosen LLM provider. See the cost disclaimer in
> [Use as a GitHub Action](../how-to/use-as-github-action.md).

## What a Finding Contains

Findings are structured data, not prose, so they render the same everywhere.
Each finding has:

| Field | Meaning |
|---|---|
| `path` | File the comment attaches to |
| `line` | Line in the diff |
| `side` | `RIGHT` (added/changed) or `LEFT` (removed) |
| `severity` | `info` / `low` / `medium` / `high` / `critical` |
| `title` | One-line summary |
| `body` | The explanation |
| `failure_scenario` | For defect findings: the trigger, the changed behaviour, and the observable impact |
| `suggestion` | Optional suggested replacement code |
| `anchor` | The flagged line, verbatim, used to place the comment on the right line |
| `anchored` | Whether `anchor` matched a changed line; an unanchored finding moves to the review body |
| `broad` | Set by reflection when the fix needs a broad change (redesign, infra, API contract) rather than a local edit; broad findings go to a collapsed "Broader observations" section |
| `category` | The lens that raised it |
| `confidence` | The self-reflection auditor's 0-10 score (absent when reflection is off) |

The nine everyday review categories are security, correctness, deprecation,
tests, documentation, performance, complexity, intent, and ponytail; `spec` is a
tenth that runs only when a committed spec matches the PR. The default `fast`
preset covers the nine in four calls, one per concern: security, correctness
(with stated intent folded in), code health (performance, complexity, ponytail,
deprecation), and artefacts (tests, documentation). The same four run on every
provider; worker count changes only how they are scheduled. `preset: full` runs
each category as its own call, with a worked example of its own finding type.

Security, correctness, deprecation, and performance findings without a concrete
`failure_scenario` are dropped, as are `fast` code-health findings the model left
unattributed (any of them may be a performance or deprecation one), and the rest
are merged and de-duplicated. A
self-reflection pass then audits the rest and drops low-confidence
findings, so the model's first guesses are filtered before anything is posted.

The reviewer only sees the diff and some surrounding context: a *slice* of the
codebase, not the whole thing. When a concern depends on code it can't see (a
guard, a base class, an idempotency check that may live in an unshown file), it
hedges and lowers the severity rather than asserting the thing is missing, and
the self-reflection pass drops findings that rest on such assumptions. Genuine
gaps in the diff itself, such as a changed path with no test or a new public
surface left undocumented, are exempt and still raised.

## What the Response Looks Like

### On a Pull or Merge Request

The rendering below is GitHub's. GitLab, Gitea and Azure DevOps show the same
findings with
the same badges; [how each host posts them](#how-each-host-posts) differs, and
is covered at the end of this section.

lgtmaybe posts **one review** containing:

- an **inline comment** on the exact changed line for each finding, and
- a **summary** that names the model used.

Each finding lands on the line that triggered it, with its severity in the
title, the explanation in the body, and, where the fix is clear, a suggested
change you can commit straight from the PR. A finding the engine could not
place on a changed line is listed in the summary instead of posted on a guessed
line, and only when it is serious enough (`unanchored_min_severity`, default
`high`).

![An inline lgtmaybe review comment flagging a [MEDIUM] server-side request forgery (SSRF) risk where a user_id is concatenated into a URL, with a suggested validation fix](../assets/review-ssrf.png){ width="660" }

![An inline lgtmaybe review comment flagging a [CRITICAL] command injection vulnerability in an archive function using subprocess with shell=True, with a suggested fix that avoids the shell](../assets/review-command-injection.png){ width="660" }

The summary carries a hidden marker (`<!-- lgtmaybe -->`). Re-running at the
same commit **updates** that summary instead of creating a duplicate; a
completed review at a new commit posts a fresh summary and leaves the older one
as history. Findings already posted are never posted twice.

#### Reading the Title Line

A comment's title line carries the finding's provenance in its brackets:

```
**[HIGH · security · 80%] User input is concatenated into the SQL string**
```

- **`HIGH`** is the severity.
- **`security`** is the lens that raised it: one of the review categories (or
  your own id if you added a [custom lens](../how-to/add-a-custom-lens.md)).
  It is the same value you match on in `finding_rules`, so a badge you keep
  seeing and don't want tells you exactly which rule to write.
- **`80%`** is the self-reflection auditor's confidence that the finding is
  real, reached by trying to disprove it. It is the auditor's 0-10 score shown
  as a percentage, so `min_confidence: 5` is the same floor as `50%`. Set that
  floor to drop everything below a score you choose.

Each part drops away when it isn't there: with `reflect: false` there is no
score, and the badge is just the lens.

Neither GitHub's nor Gitea's review API can edit an inline comment once it is
posted, so an inline comment's score is **frozen at first post**: a later run
that judges the same finding differently won't change it. Findings in the
summary body (the "Additional findings" and "Broader observations" sections)
are rewritten on every run, so those badges do track. Severity and title have
always behaved this way; the badge just makes it visible.

### Resolving Conversations Once They're Fixed

Each inline comment also carries hidden per-finding ids. When you push a fix and
lgtmaybe runs again, it checks its own open conversations. If a finding it
raised is **no longer produced** *and* GitHub marks that thread **outdated**
(the code under it changed), lgtmaybe treats it as fixed: it replies
`✅ Looks resolved.` and resolves the conversation. Both conditions must hold,
so a thread is never collapsed just because the lines around it shifted, or
because one run happened not to re-flag it while the code stayed the same.

This is on by default. To leave conversations for manual resolution, set
`resolve_fixed: false` in `.lgtmaybe.yml` (or the Action's `resolve_fixed`
input). Resolving a thread uses GitHub's GraphQL API; the workflow's default
`GITHUB_TOKEN` (with `pull-requests: write`, already needed to post the review)
is enough. The step is best-effort: if it can't run, the review still posts
normally.

On **GitLab** the same thing happens over plain REST. On **Gitea** it does not
happen at all: Gitea has no thread-resolution API, so finding comments stay open
for a human to close. On **Azure DevOps** the thread is set to *Resolved*.
Azure DevOps has no "outdated" flag, so lgtmaybe records the iteration each
finding thread was posted at and compares the file then against the file now.
It only resolves threads its own token opened, and only after a complete run.

### How Each Host Posts

The findings are identical everywhere. What each host's API allows is not:

| | GitHub | GitLab | Gitea | Azure DevOps |
|---|---|---|---|---|
| Inline comments | one batched review | one discussion per finding | one batched review | one thread per finding |
| Summary | the review body, edited in place | a note, edited in place | an issue comment, edited in place | a general thread, edited in place |
| Avoiding duplicates on a re-run | skips ids already posted | skips ids already posted | skips ids already posted | skips ids it posted itself, once the token's identity is known |
| Auto-resolve a fixed finding | ✅ GraphQL | ✅ REST | ✗ no API | ✅ REST |
| Incremental review of new commits | ✅ | ✅ | ✗ no compare diff | not yet |
| Learn from 👎 on a finding | ✅ reactions | ✅ award emoji | ✗ | not yet |

A submitted **Gitea** review can't be edited, so its summary moves to an
ordinary comment and duplicate findings are filtered *before* posting.
**GitLab** has no batched review object, so each finding becomes its own
positioned discussion. **Azure DevOps** serves no diff at all, so lgtmaybe
builds one from the file contents before and after the change, and each finding
becomes its own positioned thread.

When a PR is clean (no findings, and every file was within the caps), the
summary is short:

```
👍 LGTM!

0 findings · risk low (no risk signals) · provider anthropic · model claude-sonnet-4-6 · lgtmaybe 2.11.1
```

The risk segment comes from the [risk of change](risk-of-change.md) assessment
and is absent when that is turned off (`--no-risk`, or `risk.enabled: false`). If the file cap kicked in, the summary says so
(for example "Reviewed the top 50 of 120 changed files"). lgtmaybe never fails
silently: any error is posted back to the PR as a short comment.

### On the Command Line

`lgtmaybe review` runs the same pipeline over your local `git` diff and prints
the findings. It posts nothing and needs no GitHub token. By default it diffs
the current branch against the remote primary branch (`origin/HEAD`, falling
back to `origin/main`/`origin/master`, then a local `main`/`master`). An empty
diff makes no model call; when none of those branches exists it exits non-zero
and asks for `--base`.

- `--working` reviews the whole worktree (branch commits plus uncommitted
  edits) against that same base.
- `--uncommitted` reviews only the uncommitted edits against `HEAD`.
- `--base <ref>` picks a different base.

Both worktree modes include files you haven't `git add`ed yet (a brand-new file
is often the one you most want a second pair of eyes on), while anything
`.gitignore` excludes stays out. The default output is a readable listing
followed by the summary line:

```console
$ lgtmaybe review --provider ollama --model qwen3.6:27b --api-base http://localhost:11434
src/app.py:2  [MEDIUM] Import order (confidence 6/10)
  sys should be sorted before os

1 finding · risk low (no risk signals) · provider ollama · model qwen3.6:27b · lgtmaybe 2.11.1
```

![The lgtmaybe review command running in a terminal, printing a [MEDIUM] import-order finding with its file and line, then a summary line naming the model](../assets/cli-example.png){ width="660" }

`--format` selects the output. `--json` is shorthand for `--format json`, which
prints the findings as a JSON array (every field in the table above) so the same
structured data can be piped into other tooling:

```console
$ lgtmaybe review --provider ollama --model qwen3.6:27b --api-base http://localhost:11434 --json
[{"path": "src/app.py", "line": 2, "side": "RIGHT", "severity": "medium",
  "title": "Import order", "body": "sys should be sorted before os",
  "failure_scenario": null, "suggestion": null, "anchor": "import sys",
  "anchored": true, "broad": false, "confidence": 6, "category": "complexity"}]
```

`--format agent` turns the findings into plain correction instructions an AI
coding agent can read and apply, for a local review-and-fix loop. See
[Fix findings with an AI agent](../how-to/fix-findings-with-an-ai-agent.md).
`lgtmaybe mcp` serves both shapes to a coding agent as MCP tool calls; see
[Use from a coding agent over MCP](../how-to/use-from-a-coding-agent-over-mcp.md).

`--format sarif` prints a SARIF 2.1.0 log for GitHub code scanning or an IDE
SARIF viewer: one rule per lens, the severity folded onto SARIF's three levels,
and the finding's fingerprint and identity as `partialFingerprints`. See
[Upload findings as SARIF](../how-to/upload-findings-as-sarif.md).

## See Also

- [Getting Started](../tutorial/getting-started.md): run your first review
- [Architecture](architecture.md): the review pipeline, stage by stage
- [Data and Privacy](data-and-privacy.md): what is sent where

---

<!-- Source: https://lgtmaybe.coles.codes/explanation/risk-of-change/ -->

# Risk of Change

Every review carries a **risk of change** verdict: `low`, `medium`, `high` or
`critical`, with the reasons that produced it. It answers a different question
from the findings. A finding says "this line is wrong". The verdict says "this
change can reach a long way, so read it carefully", even when every line is
right.

The verdict is deterministic. It makes no model call, so it costs nothing, it
reads the same on every re-run of the same head, and a workflow can rely on it.

## Where You See It

The review summary line names the level and its top reason:

```text
3 findings · risk high (`src/core/models.py` is imported by 84 files, +2 more) · provider … · model … · lgtmaybe …
```

The change overview lists every reason in a **Risk of Change** section above
High Impact Areas. The summary carries the verdict even when the overview is
turned off or skipped, so every review has it.

The summary also carries a hidden marker, `<!-- lgtmaybe-risk:high -->`, and
with `pr_labels` on the PR gets a `risk/<level>` label. Locally, `lgtmaybe
review` prints the same summary line.

## How the Level Is Decided

Each factor raises the level, and the verdict takes the highest:

| Factor | Raises to |
|---|---|
| A changed file imported by 5 or more files in the repository | `medium` |
| A changed file imported by 20 or more files, or matching a `risk.core_paths` glob | `high` |
| Touching an infrastructure, security, data migration, or backup and recovery path | `high` |
| Touching a path for any other High Impact area (dependencies, compatibility, cost, …) | `medium` |
| 500 or more changed lines | `medium` |
| 50 or more changed lines of code with no test file changed | `medium` |
| A [`regulated` project stage](../how-to/calibrate-reviews-to-your-project.md) | `medium` |

The area rows use the same path patterns as the
[High Impact Areas](../how-to/generate-a-change-diagram.md#high-impact-areas)
floor, applied to changed files that are not tests or documentation.

Two distinct `high` reasons make the change `critical`: a widely imported
module and a Terraform file, say, or an auth change and a database migration.
Two files in one area count once. A change touching only documentation and
tests is `low`, whatever the project stage. The `regulated` floor never goes
above `medium`, so it can't combine with one other `high` reason to make a
change `critical`.

## Blast Radius

The importer count comes from one [ast-grep](https://ast-grep.github.io/) scan
of the checked-out repository for Python, TypeScript and JavaScript imports. It
parses files and runs nothing. On `pull_request_target` the checkout is the
trusted base branch, and nothing from the scan is sent to the model.

The count measures the code that already depends on a file. A file the PR adds
has no existing importers, importers the PR itself changes are left out
because they are already in the diff, and test files don't count as importers.

When a blast radius can't be measured, the verdict names the file instead of
guessing. This happens for a language the scan doesn't read, when there is no
checkout, when the ast-grep binary is missing or the scan fails, or when the
scan runs past its 20-second limit. A `low` verdict with
unmeasured files always says so:

```text
risk low (blast radius not assessed: `lib/billing.rb`)
```

The import count can't see modules reached by dynamic import, TypeScript path
aliases or barrel re-exports. Name those in `risk.core_paths` (see
[`risk`](../how-to/configure-lgtmaybe-yml.md#risk)).

## Gating on the Verdict

lgtmaybe never approves or blocks a PR on its risk. The marker lets you decide
what a level means for your team. For example, a step after the lgtmaybe step
can request a second reviewer when the latest review summary carries
`lgtmaybe-risk:critical`:

```yaml
- name: Require a second reviewer on critical changes
  env:
    GH_TOKEN: ${{ github.token }}
    PR: ${{ github.event.pull_request.number }}
  run: |
    latest=$(gh pr view "$PR" --json reviews \
      --jq '[.reviews[].body // "" | select(contains("lgtmaybe-risk:"))] | last // ""')
    if grep -q 'lgtmaybe-risk:critical' <<<"$latest"; then
      gh pr edit "$PR" --add-reviewer my-org/platform-leads
    fi
```

Reading only the latest summary matters: each new head commit gets a fresh
summary, so an older review can still carry a level the current change no
longer has.

If the assessment itself fails, the summary reads `risk unavailable` and no
marker is written, so a gate reads a missing marker as "unknown", never as a
level.

---

<!-- Source: https://lgtmaybe.coles.codes/explanation/architecture/ -->

# Architecture

lgtmaybe uses a **hexagonal architecture** (ports and adapters). The core
defines the ports in `core/ports.py` and never imports an adapter; adapters
implement those ports. Tests swap in fakes without patching, and a new model
backend or code host plugs in without touching the engine.

## Ports and Adapters

```mermaid
flowchart TB
    subgraph core["core - never imports an adapter"]
        ports["ports.py<br/>ProviderClient · ReviewGateway · ReviewEngine"]
        models["models.py<br/>ReviewConfig · ReviewFinding · ProviderResult · PRContext"]
    end
    providers["providers/<br/>litellm adapter"] -- implements --> ports
    github["github/<br/>GitHub REST adapter"] -- implements --> ports
    gitlab["gitlab/<br/>GitLab REST adapter"] -- implements --> ports
    gitea["gitea/<br/>Gitea REST adapter"] -- implements --> ports
    azure["azure_devops/<br/>Azure DevOps REST adapter"] -- implements --> ports
```

Two independent choices meet at these ports: which **model** reviews the code
(`ProviderClient`) and which **code host** the review is posted to
(`ReviewGateway`). Any combination works, because neither side knows about the
other.

`core/ports.py` defines three structural protocols (`typing.Protocol`). An
adapter satisfies one by having the right methods; it never inherits from it.

- `ProviderClient` has one method: `complete(messages, model, **opts)` returns
  a `ProviderResult` (text plus token usage).
- `ReviewGateway` has three. `get_pr_context()` fetches the change diff and
  metadata. `post_review(findings, summary)` posts batched inline comments and
  a summary. `post_issue_comment(body)` posts a standalone comment (used by
  `/ask`, by failure notices, and as the describe/diagram fallback).
- `ReviewEngine` has `review(ctx, cfg)`, which returns `(findings, summary)`.
  The engine takes a `PRContext` and never sees a gateway, which is why the
  local CLI can build a `PRContext` from `git` with no code host at all.

The ports are frozen: the providers, forge adapters, engine and CLI all build
against these signatures, so change one only deliberately. (`GitHubGateway`
survives as an alias of `ReviewGateway` for adapters written before lgtmaybe
supported more than one host.)

## Code Hosts (Forges)

Internally lgtmaybe calls a code host a **forge**, to keep it distinct from a
model *provider*. `core/forge.py` holds the host-specific logic that lives
outside the adapters: a `Forge` enum, a `PRLocator` (forge, host, repo, number)
parsed from a change-request URL, and the environment variable that holds each
host's token.

The URL is all lgtmaybe needs:

| URL shape | Forge | Token |
|---|---|---|
| `github.com/org/repo/pull/42` | GitHub | `GITHUB_TOKEN` |
| `gitlab.com/group/sub/project/-/merge_requests/42` | GitLab | `GITLAB_TOKEN` |
| `gitea.example.com/org/repo/pulls/42` | Gitea | `GITEA_TOKEN` |
| `dev.azure.com/org/project/_git/repo/pullrequest/42` | Azure DevOps | `AZURE_DEVOPS_TOKEN` or `SYSTEM_ACCESSTOKEN` |

The host is parsed from the URL rather than assumed, because self-hosted GitLab
and Gitea are the normal case. `cli._GATEWAY_BUILDERS` maps the resolved forge
to its adapter.

### Required Surface vs Optional Capabilities

`ReviewGateway` is deliberately three methods. Everything richer is an optional
`Supports*` protocol that the CLI probes for and skips when absent. An adapter
is useful long before it is complete, and it can be honest about what its host
cannot do instead of failing at run time.

| Capability | GitHub | GitLab | Gitea | Azure DevOps |
|---|---|---|---|---|
| `SupportsFileContents`: read head text for context | ✅ | ✅ | ✅ | ✅ |
| `SupportsDescribe` / `SupportsDiagram`: upserted comments | ✅ | ✅ | ✅ | ✅ |
| `SupportsConversation`: read existing comments | ✅ | ✅ | ✅ | ✅ |
| `SupportsLabels` | ✅ | ✅ | ✅ | not yet |
| `SupportsChecks`: check run / commit status | ✅ | ✅ | ✅ | not yet |
| `SupportsThreadResolution`: auto-resolve a fixed finding | ✅ GraphQL | ✅ REST | ✗ no API | not yet |
| `SupportsIncremental`: review only new commits | ✅ | ✅ | ✗ no compare diff | not yet |
| `SupportsPermissions`: only writers run slash commands | ✅ | ✗ no slash commands | ✅ | ✗ no slash commands |
| `SupportsFeedback`: learn from 👎 reactions | ✅ | ✅ award emoji | ✗ | not yet |
| `SupportsBaseCheckout`: clone base for symbol lookup | ✅ | ✗ | ✗ | not yet |

`tests/test_ports.py` asserts the GitHub adapter satisfies every capability,
and the GitLab, Gitea and Azure DevOps tests assert those adapters do **not**
claim what they cannot serve. That keeps this table honest.

### Where the Hosts Differ

Gitea is close to GitHub; GitLab and Azure DevOps are not.

- **GitHub** batches every inline comment into one review object and resolves
  threads over GraphQL.
- **Gitea** mirrors GitHub's API, but a submitted review is immutable. So the
  summary lives in an ordinary issue comment (which can be edited), and
  findings are de-duplicated *before* posting by reading the hidden ids already
  on the pull request.
- **GitLab** has no batched review object. Each finding is its own
  **discussion**, positioned by old/new path and line plus the merge request's
  `base_sha`/`start_sha`/`head_sha`. Threads resolve over plain REST. Its
  compare API returns per-file patches and no "ahead" status, so the adapter
  reassembles the increment into a unified diff and checks from the commits'
  parent ids that the new head builds on the last reviewed one. It also works
  out the "lines moved" signal GitLab doesn't expose, from the diff since each
  discussion was posted.
- **Azure DevOps** serves no unified diff. The adapter lists the latest
  iteration's changed files against the merge base, fetches each file's text
  on both sides, and renders the diff with `core/diffbuild.py`, so the shared
  parser reads it like any other. Each finding is its own thread positioned by
  `threadContext`, and the summary is a general thread edited in place.

lgtmaybe uses GitHub's `RIGHT`/`LEFT` side vocabulary internally and translates
it at each adapter's boundary: `new_position`/`old_position` on Gitea,
`new_line`/`old_line` on GitLab, right-file/left-file ranges on Azure DevOps.

### Entrypoints

GitHub and Gitea share one entrypoint. Gitea Actions reimplements the GitHub
Actions runtime, so the same `action` command and container work on both. The
only variable that differs is `GITHUB_SERVER_URL`, which Gitea points at your
own instance.

GitLab CI has neither an event payload file nor `INPUT_*` variables, so it gets
its own `gitlab-ci` command that reads GitLab's predefined `CI_*` variables.
Everything downstream (locator, gateway registry, engine) is shared. With no
`synchronize` action to key on, `gitlab-ci` treats an unset `incremental` as on:
the first pipeline has no watermark yet, so it is a full review anyway.
Azure Pipelines is in the same position, so `azure-pipelines` reads its
predefined `SYSTEM_*` and `BUILD_*` variables.

## Review Pipeline

The engine runs a pipeline of composable stages:

```
fetch → redact → compress → prompt → parse → re-anchor → suppress
      → evidence gate → merge/dedupe → reflect → filter → post
```

The prompt/parse stage fans out, one concurrent model call per review lens,
before the findings funnel back into a single stream:

```mermaid
flowchart TD
    fetch["fetch<br/>diff via API, never a checkout"] --> compress["redact + compress<br/>skip generated files · pad context · batch to budget"]
    compress --> security["security lens"]
    compress --> correctness["correctness lens<br/>+ stated intent"]
    compress --> codehealth["code-health lens<br/>performance · complexity · ponytail · deprecation"]
    compress --> artefacts["artefacts lens<br/>tests · documentation"]
    security --> anchor["re-anchor<br/>snap lines to the real diff"]
    correctness --> anchor
    codehealth --> anchor
    artefacts --> anchor
    anchor --> suppress["suppress<br/>ignored fingerprints · pragmas · 👎"] --> evidence["evidence gate<br/>defects need a failure scenario"] --> dedupe["merge / dedupe"] --> reflect["reflect<br/>validate scenarios · drop low-confidence"] --> filter["filter<br/>severity floor · finding rules"] --> post["post<br/>inline comments + summary"]
```

The four lens calls shown are the default `fast` grouping, and the same four run
on every provider; worker count decides only whether they overlap. The `full`
preset fans out one call per category. A spec lens (when a committed spec
matches the PR) and any custom lenses join the same fan-out.

1. **fetch**: `ReviewGateway.get_pr_context()` retrieves the change diff and
   metadata from the forge's API. No change code is checked out or executed,
   and the diff is treated as untrusted input throughout.

2. **redact + compress**: secrets are redacted from the diff before anything
   else sees it. Generated files, lockfiles, minified assets and vendored code
   are skipped, then the `include_paths` / `exclude_paths` filters and the
   `max_file_diff_lines` and `max_files` caps apply. Each remaining hunk is
   padded with surrounding lines from the file's head revision (fetched by the
   gateway, never a checkout), capped by `context_lines` and the token budget.
   The result is batched to fit `max_input_tokens`; when that is left at its
   default, it is first capped at what the model's context window can take. With `recursive`
   on (the default), a single file over budget is walked hunk by hunk instead of
   sent whole. The padded diff is for the model only: inline-comment positions
   are rebuilt from the **real** diff at post time, so a finding on a padding
   line maps to nothing and is dropped rather than mis-posted.

3. **prompt + parse**: this stage **fans out one model call per review lens**,
   and `preset` decides the lens set. `fast` (the default) covers all nine
   everyday categories in **four calls**, one per concern: security,
   correctness (with stated intent when present), code health (performance,
   complexity, ponytail, deprecation), and artefacts (tests, documentation).
   `full` runs one call per category. Every (batch, lens) task shares **one
   `ThreadPoolExecutor`** over the synchronous provider port, sized by
   `max_concurrency` (default 6 for every provider, local included), so batches
   never wait on each other.

   Each call is shaped as a shared cacheable prefix (a lens-independent system
   preamble, then the wrapped diff) followed by the lens-specific instructions
   as the final user block. On routes that take an explicit cache breakpoint
   (anthropic, bedrock Claude/Nova, vertex Claude and Gemini, zai GLM, and
   openrouter's claude/gemini/glm/minimax families) the prefix is marked with
   `cache_control`. Backends that cache automatically (OpenAI, Azure, DeepSeek)
   need no marker. On a cache-capable route, every call after a batch's first
   reads the preamble and diff from cache; other routes get the blocks merged
   into one plain message and make no caching promise. On big diffs a warm-up
   primer runs the first lens alone, so a concurrent first wave doesn't pay for
   several cache writes.

   Each lens prompt asks for JSON matching the `ReviewFinding` schema (`path`,
   `line`, `side`, `severity`, `title`, `body`, `failure_scenario`,
   `suggestion`, `anchor`) and carries prompt-injection defence instructions.
   Each response is parsed and validated with Pydantic. A reply that does not
   parse is sent back once for repair (`repair_unparseable`, default on); a call
   that still fails is named in the summary's incomplete-results notice rather
   than silently discarded.

   With `mid_review_retrieval` on (default **off**), a lens has a third option
   besides asserting or hedging a cross-file claim: **ask to read the code**.
   Alongside its findings it may return `needs`, the file paths or symbols it
   must see. The engine fetches them through the same read-only, redacting
   boundary reflection uses (never a checkout), then re-runs *that one lens*
   with the text added to its own uncached block. The shared prefix is
   untouched, so the batch's other lenses still read it from cache. It is
   bounded to **one hop**: the re-run cannot defer again, it fetches at most
   five files within a quarter of `max_input_tokens`, and a deferral that
   arrives past the review deadline or token budget is skipped with the usual
   incomplete-results notice. Both calls' findings are merged, so a deferral
   can only add findings. The cost is up to one extra model call per (batch,
   lens), which is why it ships off.

4. **re-anchor**: `_snap_findings` rebinds each finding's `line` to the changed
   line whose content matches the finding's verbatim `anchor`, rather than
   trusting the model's line arithmetic. A finding whose anchor matches nothing
   is marked `anchored=False`; it must clear `unanchored_min_severity` (default
   `high`) and is then demoted to the review body instead of posted on a
   guessed line.

5. **suppress**: findings a team has marked known-fine are dropped before they
   cost any reflection tokens: fingerprints in `ignore_fingerprints`, a
   `# lgtmaybe: ignore` pragma on (or just above) the flagged line, and findings
   a reviewer reacted 👎 to on an earlier run (`learn_feedback`). Neither a 👎
   nor a pragma (which the PR's author writes) suppresses a high or critical
   security finding; only `ignore_fingerprints` in trusted config can.

6. **evidence gate**: security, correctness, deprecation and performance
   findings must carry a concrete `failure_scenario`: the trigger, the changed
   behaviour, and the observable impact. The rule applies regardless of the
   severity the model chose, so lowering a finding to `low` cannot bypass it.
   Gap and maintainability findings (tests, documentation, complexity) do not
   need a runtime failure; a fast-preset code-health finding the model left
   unattributed does, since it may be a performance or deprecation one.

7. **merge/dedupe**: findings from every lens are merged and de-duplicated
   (`_dedupe`, keyed on path/line/side; scan findings on their title too, so
   distinct tool hits never merge). It runs after the two filters above, so a
   copy they would drop never displaces the finding that shares its line.

8. **reflect**: a self-reflection pass (`engine/reflect.py`) asks the provider
   to audit its own findings, scores each 0-10, and drops the ones it marks
   low-confidence (plus any below `min_confidence`). If the verdict can't be
   parsed, every finding is kept. `--no-reflect` skips the pass. The auditor
   also tries to disprove each claimed failure scenario against the diff and
   the grounded file text.

   When the auditor would drop a finding *only* because it can't see code
   outside the diff, it **defers** by naming what it needs: a file path or a
   **symbol**. A path is fetched read-only (`get_file_contents`). A symbol is
   located by **ast-grep** (`engine/astgrep.py`), which structurally searches a
   corpus for the file that *defines* it: the local worktree for the CLI, or a
   read-only shallow clone of the trusted **base** branch on GitHub. That file
   is fetched through the same read-only boundary, and the auditor re-judges
   with the real definition in front of it instead of guessing about an unseen
   guard or base class.

   This stays inside the fork-safety model: ast-grep only *parses* the corpus
   (never executes it), and the base clone is never the PR head. Symbol
   resolution needs the bundled `ast-grep` binary and a corpus; without either
   it falls back to the path-only fetch, and `--no-symbol-resolution` disables
   it. It is bounded by the same hop and file caps as the path fetch.

9. **filter**: findings below `min_severity` are dropped, then any
   `finding_rules` (drop or re-grade by path, lens, title or severity) apply.

10. **post**: findings are posted as one batched review on GitHub (per-host
    differences are in [What gets reviewed](what-gets-reviewed.md#how-each-host-posts)).
    A hidden marker identifies lgtmaybe's summary: a re-run at the same commit
    edits it in place, and a completed review at a new commit posts a fresh
    summary, leaving the older ones as history. Each inline comment carries two
    hidden per-finding ids, so a re-run never re-posts a finding it already
    made. On a re-run, conversations whose finding is gone **and** whose thread
    GitHub marks outdated are replied to and resolved (`resolve_fixed`, default
    on). The REST review API can't resolve a thread, so this step uses GitHub's
    GraphQL API. It is best-effort: a failure never blocks the review.

## Provider Strategy and Factory

Provider selection uses the **strategy pattern**: `--provider` picks a
`ProviderClient` strategy, and a small factory (`providers/factory.py`) builds
it. litellm normalises every provider to one `completion()` call, so the
factory stays small and the engine stays provider-agnostic.

Credential resolution is a **chain of responsibility**
(`providers/credentials.py`): each provider knows how to find its own
credentials (an API key, ambient cloud credentials, or none for ollama).
lgtmaybe never stores or logs credentials.

## Reliability: Retries, Timeouts and Concurrency

The provider wrapper (`LiteLLMProvider`) and the engine cooperate so a flaky
network recovers but a dead-end failure surfaces fast.

- **Retries are classified, not blanket.** Transient failures are retried with
  exponential backoff and jitter, up to four attempts: capacity rate limits
  (`429 rate_limit_exceeded`), the provider's own connect/read timeouts,
  connection errors (such as an ollama server still warming up), and 5xx
  responses. **Permanent** failures are not retried: bad credentials
  (`AuthenticationError`), malformed or unsupported requests
  (`BadRequestError`, including content-policy blocks), unknown models
  (`NotFoundError`), denied permissions, and **quota/billing** limits
  (`429 insufficient_quota`). A quota error can never succeed on retry; across
  every lens it would only turn an instant "out of credit" into many minutes of
  wasted runner time, so lgtmaybe raises it immediately. An optional
  `fallback_model` is still tried once.

- **A blown wall clock is permanent too.** When a call outlives lgtmaybe's own
  per-request timeout, a retry would send the identical request to the
  identical model against the identical budget, and fail the same way after
  another full timeout. So the wall timeout is raised after one attempt, and
  the failure reports how many attempts it used. The `fallback_model` is a
  different request, so it still gets its turn with a fresh budget.

- **Retried smaller, not repeated.** A wall timeout says something about the
  *payload*, and the payload is the one thing the engine can change. So the
  batch is split (halved by file, or by hunk for a single-file batch) and each
  piece is reviewed with a fresh budget. A slow lens no longer discards the
  whole batch's review. The split goes **one** level deep: a piece that times
  out again just fails, so a model that cannot answer at any size can't cascade
  through the review budget. The summary says when a batch had to be shrunk,
  because a silent split would hide that every run is at the edge of what the
  model can finish in time.

- **A second model is the last remedy for a truncation, not the first.** A
  truncated lens has two remedies that stay on the model you chose: shrink the
  payload (the split above) when the *answer* outgrew the output ceiling, and
  lower `reasoning_effort` when the *thinking* did. Both are cheap, and both act
  on what the token counts say went wrong. Switching to `fallback_model` says
  nothing about the failure: it re-sends the same request at the same ceiling
  and hopes a second model finishes it. So it runs only after the targeted
  remedy has failed, once for the whole batch rather than once per split piece.
  The adapter still rescues every other kind of failure itself, and either way
  the summary names the lens and the model that answered it. A review the
  primary model could not finish must not read like one it did.

  Reach for this rule before routing on diff size. Truncation does not track
  size: in our measurements a fifteen-line diff truncated at the same ceiling as
  a large multi-file one, because the ceiling goes on thinking. A size threshold
  spends the strong model on diffs that were already fine; a fallback keyed on
  the failure costs nothing until one happens.

- **A rate limit waits on its own, slower ladder.** The general ladder starts at
  a tenth of a second, which suits a blip such as a connection reset. A capacity
  429 is not a blip: gateways that meter an API key meter it *per minute*, so a
  ladder that fits four attempts into a few seconds lands them all in the same
  window, where they fail identically. Rate limits therefore back off on a
  20s-60s ladder, and prefer the server's own **`Retry-After`** when it sends one
  (in either RFC form, clamped at 120s so a gateway asking for an hour can't eat
  the run's wall clock). Both ladders stay inside the 2.5× retry budget below,
  which is checked against the *upcoming* wait, so a backoff that would exceed
  the budget ends the call instead of sleeping.

- **A call that failed on the provider gets one more go.** Once the fan-out has
  drained, the calls that failed *provider-side* are re-run once, in a narrow
  pool of their own. Without this, a single flaky call voided that lens's
  findings for the round until somebody re-ran by hand. Nothing else is
  re-run, because a second attempt would repeat the same failure at full price:
  unparseable output returns the same answer at temperature 0, a truncation
  hits the same ceiling, a batch the split already retried has had the one retry
  that can help, a dead key or spent quota won't fix itself mid-review, and a
  ceiling you set is not a fault to retry past. Every rescue re-checks the
  deadline, the token budget and the interrupt first, and a healthy run costs
  **zero** extra calls.

- **One retry layer.** litellm's own internal retry loop is disabled
  (`num_retries=0`), so failures aren't ground through two stacked backoff
  layers. lgtmaybe owns the retry policy in one place.

- **Per-request timeout and a shared retry budget.** Every model call carries a
  timeout: 600s for direct cloud providers, and 1800s for providers that may
  front a slow model: ollama, openai-compatible (local servers) and openrouter
  (a gateway to arbitrary models, including slow reasoning ones). For ollama
  and openai-compatible the default is multiplied by the fan-out width, because
  a single-slot local server queues the other calls while their clocks run.
  `timeout` / `--timeout` overrides all of this. All attempts for one call share
  a wall-clock budget of **2.5× the timeout**, so a flaky model can't burn four
  full timeouts plus backoff per lens. lgtmaybe's own workflows also set a
  job-level `timeout-minutes` (75) above `max_review_seconds`, so the soft
  deadline (which still posts partial findings) fires before the runner kills
  the job; do the same in yours. If the runner does cut the job short, the
  CLI's signal handler is the backstop (below).

- **A whole-review deadline.** `max_review_seconds` (default 3600, `0`
  disables) is a soft ceiling on the run. Once it passes, queued model calls are
  skipped, in-flight ones finish and their findings post, and the summary
  carries an explicit incomplete-results notice. It can never produce a silent
  LGTM: a run where every call failed or was skipped still fails loudly.

- **A termination signal does the same thing.** The CLI installs a
  SIGINT/SIGTERM handler that sets the deadline's state, so a job the runner
  cancels or times out stops dispatching model calls and posts what it already
  has, with a notice naming the interruption. Only the CLI entrypoint installs
  the handler (importing lgtmaybe as a library never touches your signal
  handlers), and it restores the previous handler as it fires, so a second
  signal still kills the process.

- **Incompleteness is visible on the PR, not just in the log.** *Any* failed
  lens call (a per-call timeout, a provider error, unparseable output) raises
  the incomplete-results notice, naming the lenses it lost and why: a missing
  security lens and a missing documentation lens are the same count and very
  different news. The engine also stamps a hidden `<!-- lgtmaybe-incomplete -->`
  marker. Because later findings arrive as separate review comments while the
  first run's summary is only edited in place, an incomplete run also posts the
  notice as its own PR comment, and so does a review that failed outright. The
  summary also carries a hidden `<!-- lgtmaybe-lenses:… -->` list of the lenses
  that completed every call, so a gate that wants "every lens ran" can require
  the set it expects instead of inferring it from the absence of a notice. A
  matched spec that did not fit the spec budget is named the same way, because
  a spec lens that should have run and did not is a gap, not a no-match.

- **A diff the host will not serve is a review that did not happen.** GitHub
  answers the diff request with 406 once a PR passes 300 files or 20,000 lines.
  lgtmaybe reads PR content through that API only, never a checkout, so there is
  nothing to review and no retry will change it. The run posts a "not reviewed"
  notice quoting GitHub's reason with a hidden `<!-- lgtmaybe-skipped -->`
  marker, stamps nothing complete, and exits zero. It is not a failure (a red
  job for a PR the reviewer could never read only teaches people to ignore red
  jobs), and it is never an LGTM. The fix is a smaller PR; no reviewer setting
  moves the host's limit.

- **One global fan-out pool.** Every (batch, lens) call runs through a single
  `ThreadPoolExecutor` sized by `max_concurrency`, default **6 workers** for
  every provider. An extra worker can cut a full-latency wave off the wall
  clock, but the fan-out shares *one* API key, and past some width the burst
  rate-limits itself against a gateway that meters per minute. Raise it if your
  rate tier is generous.

  Local providers get the same 6. A default ollama serves one request at a
  time, but that is a property of the *server*: one that can batch would be
  capped for nothing, and one that can't loses nothing by having work queued
  (ollama queues up to `OLLAMA_MAX_QUEUE` rather than failing). Local
  throughput is decided by `OLLAMA_NUM_PARALLEL`, llama.cpp's `-np` or vLLM's
  batching, and raising those costs memory per slot; see
  [running locally](../how-to/run-locally-with-ollama.md). Drop
  `max_concurrency` to 1 for a very slow local model, where six queued calls
  could each wait out the timeout. Because the pool is shared across batches,
  wall time is `ceil(batches × lenses / workers)` call-latencies rather than
  `batches × ceil(lenses / workers)`.

## Dependency Injection

The engine receives its ports by injection: `LLMReviewEngine` takes a
`ProviderClient` (plus optional file-fetch and symbol-resolver callables), and
`review()` takes a `PRContext`. In production the CLI wires the real adapters;
in tests the fakes in `tests/fakes/` (`FakeProvider`, `FakeGitHub`,
`FakeEngine`) drop in directly, so swapping a port never needs patching.

## Why Not a Plugin Framework or Event Bus

Both were considered and skipped. The current providers fit cleanly in a
strategy and factory, and a plugin registry would add indirection with no
present benefit. An event bus would complicate the linear pipeline without
enabling any feature the product needs. Either can be revisited if a concrete
requirement arises.

---

<!-- Source: https://lgtmaybe.coles.codes/explanation/auth-model/ -->

# Auth Model

lgtmaybe supports seven hosted providers plus local ollama, and an
`openai-compatible` escape hatch for any server that speaks the OpenAI `/v1`
wire format (DeepSeek's API, llama.cpp, LM Studio, vLLM). The design principle
is **no static cloud credentials**. Cloud providers use ambient, short-lived
tokens. Key-based SaaS providers (openai, anthropic, openrouter, zai) need an
API key, which lives in a secret store, never in committed config. Azure
straddles both: it accepts a resource key but falls back to ambient Entra
(Azure AD) credentials, and it always needs the resource endpoint
(`AZURE_API_BASE`), because each Azure OpenAI deployment lives at its own URL.

## Why Keyless for Cloud

Static credentials, such as an AWS access key pair or a GCP service-account
JSON file, have a long lifetime and broad scope. If one leaks from a CI log, a
misconfigured secrets manager, or a compromised runner, an attacker keeps
access until someone rotates the key by hand.

OIDC (AWS), Workload Identity Federation (GCP), and federated credentials on an
Entra app (Azure) issue tokens that:

- expire in minutes, not years;
- are scoped to a single workflow run;
- are never stored as static secrets, so there is no long-lived key to leak;
- are tied to the specific repository and branch through the OIDC claims.

The exchange has the same shape on all three clouds, and no secret ever enters
the workflow:

```mermaid
sequenceDiagram
    participant W as GitHub Actions run
    participant O as GitHub OIDC issuer
    participant STS as Cloud token service<br/>(AWS STS / GCP STS / Entra)
    participant M as Model API<br/>(Bedrock / Vertex / Azure OpenAI)
    W->>O: request identity token (id-token: write)
    O-->>W: signed JWT with repo + branch claims
    W->>STS: exchange JWT for cloud credentials
    STS-->>W: short-lived, scoped credentials
    W->>M: lgtmaybe review calls with ambient creds
```

In the GitHub Action, pass `aws_role_arn`, `gcp_wif_provider` or
`azure_client_id` and the action runs this exchange for you. The workflow needs
`permissions: id-token: write`.

For these reasons, Bedrock and Vertex are ambient-credential-only providers:
`--api-key` is ignored for them. Azure defaults to the same keyless path
(GitHub OIDC to Entra, through `azure-identity`'s `DefaultAzureCredential`) but
also accepts a resource key for teams that can't adopt federation yet.

## Chain of Responsibility

When lgtmaybe starts, it resolves credentials for the selected provider:

1. **Bedrock** hands credential discovery to the AWS SDK, which finds GitHub
   OIDC role credentials, `~/.aws` profiles, environment variables, or an
   ECS, EKS or EC2 role. lgtmaybe never asks for a key, and missing
   credentials surface as an error from the first model call.
2. **Vertex** checks for ambient GCP credentials first: Workload Identity
   Federation, Application Default Credentials from
   `gcloud auth application-default login`, or a project set in the
   environment. Failing those, it asks `google-auth`, which also finds the
   metadata-server credentials of GCE, GKE Workload Identity, Cloud Run and
   Cloud Build. If there are none, lgtmaybe fails immediately with a message
   explaining how to authenticate. It never falls back to a key.
3. **Azure** always needs the resource endpoint (`AZURE_API_BASE` or
   `--api-base`). It uses a key when one is supplied (`AZURE_API_KEY` or
   `--api-key`). Otherwise it goes keyless and mints an Entra token from
   ambient credentials: GitHub OIDC federation in CI, or `az login` or a
   managed identity locally. The token is refreshed as it nears expiry, so a
   review that runs past an hour keeps working. Keyless needs the
   `azure-identity` package (`pip install 'lgtmaybe[azure]'`; the Action image
   includes it). With neither a key nor an ambient credential, lgtmaybe fails
   at start-up with a message naming both options and the SDK's own reason.
4. **ollama** needs no credentials. `--api-base` points it at the server and
   defaults to `http://localhost:11434`.
5. **zai** (GLM / Zhipu AI) needs `ZAI_API_KEY` or `--api-key`. Without an
   endpoint override, litellm's native `zai/` route targets the international
   endpoint; set `--api-base` or `ZAI_API_BASE` for the China or coding-plan
   endpoint.
6. **openai-compatible** always needs the endpoint (`--api-base` or
   `OPENAI_COMPATIBLE_API_BASE`), since choosing your own is the point. The key
   is optional. Hosted endpoints like DeepSeek take one (`--api-key` or
   `OPENAI_COMPATIBLE_API_KEY`). Local servers (llama.cpp, LM Studio, vLLM)
   need none, and lgtmaybe sends a harmless placeholder key because the
   underlying OpenAI client rejects an empty one.
7. **openai, anthropic and openrouter** read the key from `--api-key`, or else
   from `OPENAI_API_KEY`, `ANTHROPIC_API_KEY` or `OPENROUTER_API_KEY`. With
   neither, lgtmaybe fails with a message naming the variable to set.

!!! note "In the GitHub Action, use the `api_key` and `api_base` inputs"

    The Action's container receives only its `INPUT_*` variables and the
    cloud-auth `AWS_*`, `GOOGLE_*`, `CLOUDSDK_*` and `AZURE_*` variables. A
    provider key set as a step `env:` variable such as `ANTHROPIC_API_KEY` does
    not reach it, so pass the key with `api_key: ${{ secrets.… }}` and any
    endpoint with `api_base`.

## Provider Auth Summary

| Provider | Credential type | How to supply |
|---|---|---|
| openai | API key | `OPENAI_API_KEY` env var or `--api-key` |
| anthropic | API key | `ANTHROPIC_API_KEY` env var or `--api-key` |
| openrouter | API key | `OPENROUTER_API_KEY` env var or `--api-key` |
| zai | API key (+ optional endpoint) | `ZAI_API_KEY` env var or `--api-key`; optional `ZAI_API_BASE` / `--api-base` for the China / coding-plan endpoint |
| bedrock | Ambient AWS creds | GitHub OIDC role or local `~/.aws`; IAM needs only `bedrock:InvokeModel*` |
| vertex | Ambient GCP creds | GitHub WIF or local ADC (`gcloud auth application-default login`) |
| azure | Ambient Entra creds (keyless) or API key, + endpoint | GitHub OIDC to an Entra federated credential, or local `az login` / managed identity; or `AZURE_API_KEY`. Always with `AZURE_API_BASE` / `--api-base` |
| ollama | None | `--api-base` pointing at the local or remote server (default `http://localhost:11434`) |
| openai-compatible | Optional key, + endpoint | `--api-base` / `OPENAI_COMPATIBLE_API_BASE` (e.g. `https://api.deepseek.com/v1` or `http://localhost:8000/v1`); key via `--api-key` / `OPENAI_COMPATIBLE_API_KEY`, or none for keyless local servers |

## Code-Host Auth

Everything above authenticates lgtmaybe to a **model provider**. Reading the
diff and posting the review need a separate credential, chosen by the host in
the change-request URL:

| Host | Variable | What it needs |
|---|---|---|
| GitHub | `GITHUB_TOKEN` | `contents: read` and `pull-requests: write`. The Action's default token is enough |
| GitLab | `GITLAB_TOKEN` | A project access token with the `api` scope, Developer role or above |
| Gitea | `GITEA_TOKEN` | A token with `write:repository`, `write:issue` and `read:user` |
| Azure DevOps | `AZURE_DEVOPS_TOKEN`, else `SYSTEM_ACCESSTOKEN` | A personal access token with Code (Read & write) scope, or the pipeline's own token with *Contribute to pull requests* granted to the build service |

These are always tokens. There is no keyless path to a code host, and lgtmaybe
does not attempt one. The token is read from the environment only and, like a
provider key, is never written to `.lgtmaybe.yml` or logged.

!!! note "Keyless cloud auth is GitHub-only"

    Bedrock, Vertex, and Azure go keyless by exchanging a **GitHub Actions**
    OIDC token. GitLab CI, Gitea Actions and Azure Pipelines have no equivalent
    exchange wired up, so on those hosts use an API-key provider, or `ollama` against a
    runner-local model, which needs no credential at all.

## Least-Privilege IAM

For Bedrock, the minimum IAM policy is `bedrock:InvokeModel` and (if streaming)
`bedrock:InvokeModelWithResponseStream` on the specific model ARN. No other AWS
permissions are needed or requested.

For Vertex, `roles/aiplatform.user` on the project is enough. Do not use
`roles/editor` or `roles/owner`.

For keyless Azure, the Entra app needs only the **Cognitive Services OpenAI
User** role on the Azure OpenAI resource (enough to call deployments, not to
manage them) plus a federated credential scoped to your repository. No owner or
contributor role is required.

## API Keys in Secrets, Not Config

Every API key (openai, anthropic, openrouter, zai, an Azure resource key, or a
hosted `openai-compatible` key) belongs in a secret store: a GitHub Actions
secret, a GitLab masked CI/CD variable, or a Gitea Actions secret. Never commit
one to `.lgtmaybe.yml` or any other file in the repository. lgtmaybe does not
log or display key values.

---

<!-- Source: https://lgtmaybe.coles.codes/explanation/data-and-privacy/ -->

# Data and Privacy

This page explains what lgtmaybe sends to a model provider, what it redacts
first, how local mode differs, and how credentials are handled.

## What Is Sent to the LLM Provider

Under the default `fast` preset, lgtmaybe makes four core review calls per diff
batch; under `full`, one per built-in category. A matching committed spec adds
a separate spec-review call, and a reflection pass follows. These calls can
contain:

- **The PR diff**: the unified diff of changed files, after generated files,
  lockfiles, minified assets, and vendored code have been skipped. File paths
  appear in the diff headers, and each batch also lists the PR's changed files
  its own diff does not show.
- **Surrounding context lines**: unchanged lines around each changed hunk, read
  from the head revision of the **changed files only**. They give the model the
  enclosing function and definitions, which cuts false positives. Each hunk
  gets up to `context_lines` (default 20, `0` disables it) before it, extended
  up to the enclosing function or class signature where that can be found, and
  about a quarter as many after it. The amount shrinks as the diff grows, and
  the text is redacted like the diff. On GitHub it is fetched read-only through
  the API; pull request code is never checked out or executed.
- **The PR's stated intent**: the PR title, description, and the first line of
  each commit message (on the CLI, the commit subjects from your local
  `git log`). It feeds the intent check ("does the PR do what it says?"), is
  redacted like the diff, wrapped as untrusted data, and sent on only one
  review call: the correctness call under `fast`, or the dedicated intent lens
  under `full`. The change overview's calls also send it (see below).
- **A system prompt**: the fixed instructions that tell the model to return
  structured JSON findings.
- **A project paragraph**, if the `project` block is set: its stage guidance,
  `domain` and `notes`, as written in `.lgtmaybe.yml`, with delimiter tokens
  neutralised. Don't put anything in `notes` you wouldn't send to the model
  provider.
- **Configured instructions and reference files**, if `directory_rules` is set.
  Reference files come from the trusted base-branch workspace, are bounded, and
  are redacted before model calls.
- **Static-analysis hints**, if `static_analysis` is enabled: redacted findings
  from linters run locally over the changed files.
- **Matching specification text**, when a repository has a supported spec
  workflow and the change matches a committed spec. The spec lens sends a
  bounded, redacted selection from that spec.
- **Additional file context**, when a review or reflection needs to check a
  finding against another file. Retrieval is read-only, bounded, and redacted.
  `mid_review_retrieval` is opt-in.

With `triage_model` set, one extra call sends the same redacted per-file diffs
to the triage model, on the same provider.

Review calls do not send:

- PR comments or review threads
- Commit message bodies (only the first line of each message)
- The repository name, PR number, or commit SHAs
- Unrequested repository files or the whole repository
- Committer identity or email addresses
- Any other data from the repository's git history

The **change overview** (`/diagram`, `auto_diagram`) makes up to three calls
(description, High Impact Areas, diagrams) using the redacted diff and, when
present, the redacted stated intent. The High Impact Areas call also sends the
paths of changed files that match its risk patterns, which are already in the
diff. No new data flows; these calls only ask for a different output. The
standalone `/describe` comment sends the same inputs again, and `/ask` sends
your question with the redacted diff.

The overview comment includes an "Open full screen"
[mermaid.live](https://mermaid.live) link per diagram, whose URL fragment
embeds that diagram's source. That source is already in the posted comment and
was built from redacted model output. Browsers decode the fragment client-side
and never send it to a server, and nothing is fetched unless a reader clicks
the link.

## Secret Redaction Before Egress

Before the diff is sent to any provider, lgtmaybe scans it for patterns that
look like secrets and replaces the matched values with `[REDACTED]`. The same
scrub applies to the context lines read from changed files, the stated-intent
text (PR title, description, commit subjects), static-analysis hints, spec
text, and any fetched file context. Recognised formats include:

- **Cloud and provider keys**: AWS access key IDs (`AKIA…`), OpenAI keys
  (`sk-…`), Stripe secret keys (`sk_live_…` / `sk_test_…`), and Google API keys
  (`AIza…`).
- **Source-control, chat and registry tokens**: GitHub classic tokens (`ghp_`,
  `gho_`, …), GitHub fine-grained PATs (`github_pat_…`), Slack tokens
  (`xoxb-…`), npm tokens (`npm_…`), and PyPI tokens (`pypi-…`).
- **JSON Web Tokens**: `eyJ….eyJ….…` and five-part encrypted JWEs, as whole
  tokens, since the payload can carry claims and PII.
- **Private keys**: PEM `-----BEGIN … PRIVATE KEY-----` blocks, encrypted ones
  included.
- **Generic credentials**: `api_key` / `secret_key` / `access_token` / `token`
  assignments with a value of 16 or more characters, quoted `password` /
  `passwd` / `passphrase` / `pwd` literals (the key may be quoted too, as in
  JSON), `Authorization: Bearer/Basic …`
  headers, passwords in connection-string URLs (`scheme://user:secret@host`),
  and Azure storage or Cosmos connection-string keys (`AccountKey=…` /
  `SharedAccessKey=…`).

For credential assignments only the value is replaced. The key name or URL
host stays readable, so the reviewer can still reason about the change.

Redaction is the **first** pipeline stage, before the diff is compressed or the
prompt is built, so redacted values never reach the LLM.

Redaction is a best-effort defence. Do not commit real secrets and rely on it
alone.

## Model Replies in the Log

When a model's reply can't be parsed into findings, lgtmaybe logs why. At the
default log level it records only the kind of parse failure (`prose`,
`malformed_json`, `not_findings`, `schema`, `truncated`, `empty`), the reply's
length in characters, and a short hash that tells you whether two runs failed
on the same reply. It never logs the content.

Set `LGTMAYBE_LOG_LEVEL=DEBUG` and the log line also carries a capped excerpt
of the reply (the first 2,000 and last 500 characters, with the gap marked).
This lets you diagnose a model that stops following the output schema without
re-running the review. The excerpt:

- passes through the same secret redaction described above, and is redacted
  **before** it is cut, so a truncated match can't slip past the redactor;
- is written to **stderr** with the other structured JSON log lines, never to
  stdout (the machine-readable channel) and never to the PR;
- is model output, which can quote the diff back. That is why it is off unless
  you ask for it.

## The Repair Re-ask

When a review call's reply can't be parsed into findings, lgtmaybe sends that
reply back to the **same provider** once, with the output schema and a request
to re-express it in the required shape (`repair_unparseable`, on by default).

This adds no new data. The reply is the model's own output about a diff it has
already seen, and the repair call carries **no diff, no context lines, and no
PR metadata**: only the reply itself, capped and wrapped as untrusted data. It
happens at most once per failed call and never recursively.

## The Schema-less Re-run

When that reformat also fails *and* the original call carried the provider's
JSON schema, lgtmaybe re-runs that one lens once without the schema
(`retry_without_schema`, on by default). The prompt asks for JSON regardless,
so a model whose constrained decoding produced something unreadable often
answers fine without it.

Unlike the repair, this **does re-send the diff**. It is the same request minus
one parameter, to the same provider, so no new data leaves the process, but it
is a second full send rather than a small one. It runs at most once per lens
per batch, only after the cheaper repair has failed, and only where a schema
was sent. Set `retry_without_schema: false` to turn it off.

## Prompt-Injection Defence

PR diff content is treated as untrusted input throughout the pipeline.
lgtmaybe defends in depth (OWASP LLM01):

1. The diff is wrapped in explicit `DIFF_START`/`DIFF_END` delimiters and
   labelled as untrusted data. The stated-intent text gets its own
   `INTENT_START`/`INTENT_END` block with the same labelling, and every other
   untrusted block (static-analysis hints, spec text, path signals, fetched
   context) has its own delimiter family.
2. Forged delimiter markers smuggled into the diff or the intent text are
   **neutralised** before wrapping, both marker families in both blocks, so a
   malicious PR can't close a data block early, append its own instructions,
   or fake an intent block from inside the diff.
3. The system prompt tells the model to ignore any instructions embedded in the
   diff or the intent text that try to change reviewer behaviour.
4. The model's response must validate against a strict JSON schema
   (`extra="forbid"`). Drifted or injected fields are rejected rather than
   acted on.

lgtmaybe does not execute any code from the PR.

## Ollama: Local or Self-Hosted

With `--provider ollama`, the diff and all other data go only to the ollama
server you name with `--api-base` (default `http://localhost:11434`). If that
server is on your machine, no data leaves it. If it is a remote host (a
Tailscale peer, a self-hosted VM), data goes only to that host. The same holds
for an `openai-compatible` server you run yourself.

lgtmaybe does not operate ollama. See ollama's own documentation for its data
handling.

## Hosted Providers: Data Handling

With any hosted provider, the redacted diff and the other inputs above are sent
over HTTPS to that provider's inference endpoint. Review each provider's data
handling policy, for example:

- **AWS Bedrock**: [AWS Bedrock data protection](https://docs.aws.amazon.com/bedrock/latest/userguide/data-protection.html)
- **Google Vertex AI**: [Vertex AI data governance](https://cloud.google.com/vertex-ai/docs/general/data-governance)
- **Azure OpenAI**: [Data, privacy, and security for Azure OpenAI](https://learn.microsoft.com/en-us/legal/cognitive-services/openai/data-privacy)
- **OpenAI**: [OpenAI API data privacy](https://openai.com/policies/api-data-privacy)
- **Anthropic**: [Anthropic privacy policy](https://www.anthropic.com/legal/privacy)
- **OpenRouter**: [OpenRouter privacy policy](https://openrouter.ai/privacy)

For z.ai and hosted `openai-compatible` endpoints such as DeepSeek, check that
provider's own policy.

## Credentials

lgtmaybe does not log or persist API keys. API-key providers receive the key
only to authenticate model calls. For Bedrock, Vertex, and keyless Azure,
lgtmaybe uses short-lived ambient credentials and does not write them to disk.
See [Auth Model](./auth-model.md) for details.

## GitHub Token

`GITHUB_TOKEN` is used to:

1. Read the PR diff and metadata through the GitHub REST API.
2. Post the review (inline comments and summary) back to the PR.

The token is never sent to an LLM provider. It needs only `contents: read` and
`pull-requests: write`. GitLab and Gitea use their own tokens, listed in
[Code-Host Auth](./auth-model.md#code-host-auth).

## Fork Pull Requests

On GitHub, lgtmaybe uses the `pull_request_target` trigger, which runs in the
context of the **base branch**. Code from the fork is never checked out or
executed, and the diff is fetched only through the host's API. A malicious PR
therefore can't reach the repository's secrets.

lgtmaybe never checks out or executes change-request code on any host, so a
change has nothing of its own to run inside the review. That does not protect
the pipeline definition. A GitLab merge request pipeline, a Gitea
`pull_request` run and an Azure Pipelines pull request build all run the CI
configuration from the change itself, so anyone who can push a branch can
rewrite the job that holds the secrets. Gitea 1.21 and later supports
`pull_request_target`, which the supplied Gitea workflow uses; GitLab and Azure
DevOps have no equivalent. Each guide's security section covers who can change
the pipeline and how to keep the secrets out of their reach:
[GitLab](../how-to/review-on-gitlab.md#security-who-can-change-the-pipeline),
[Gitea](../how-to/review-on-gitea.md#security-who-can-change-the-workflow) and
[Azure DevOps](../how-to/review-on-azure-devops.md#security-who-can-change-the-pipeline).

---

<!-- Source: https://lgtmaybe.coles.codes/explanation/trust-and-cost/ -->

# Trust and Cost

You decide **who can trigger a review**. Hosted models charge for each run, so
that choice affects cost. The setup steps are in
[Use as a GitHub Action](../how-to/use-as-github-action.md).

## Who Do You Want Reviews to Run For?

There is no single right answer. It depends on your repo and provider:

- **Everyone**, including strangers opening fork PRs. Good for a welcoming
  open-source project where every contributor should get feedback.
- **Trusted contributors**: members and collaborators, the people who already
  have a relationship with the repo. This is the default.
- **Admins and owners only**: the tightest setting, handy while you try
  lgtmaybe out.

The example workflows ship with the trusted-contributors setting, and widening
or narrowing it is a one-line change.

## The Cost Angle

This choice matters only because hosted providers bill per run:

- **ollama** runs the model on your own hardware, so there is no per-run cost.
  Trigger it for whoever you like. The same goes for an `openai-compatible`
  server you host yourself.
- **Hosted providers** (OpenAI, Anthropic, OpenRouter, z.ai, Bedrock, Vertex,
  Azure, and hosted `openai-compatible` endpoints such as DeepSeek) charge for
  the tokens each review uses.

So on a hosted provider with a public repo, "everyone" means anyone can start a
paid run. That is fine for plenty of projects; just choose it deliberately. The
default keeps reviews to people you already trust, and you can open it up
whenever you want.

Built-in limits keep any single run bounded, whoever starts it:

- `max_files` (default 50) and `max_file_diff_lines` (default 2000) limit how
  much of the diff is reviewed. See [What Gets Reviewed](what-gets-reviewed.md).
- `max_input_tokens` (default 100k) limits how much one call sends. A larger
  diff is split into more batches, not dropped.
- `max_review_seconds` (default 3600) stops dispatching new calls once a run
  has taken an hour.
- `max_review_tokens` (off by default) stops dispatching new calls once a run
  has spent a token budget you set.

When a run hits a limit, it degrades rather than fails. A review call that
outruns its wall clock, or writes until it hits the `max_tokens` ceiling, is
not simply lost: lgtmaybe splits the batch in half, reviews the pieces, and
keeps anything the model had already written. That costs up to twice the calls
for the affected batch and is bounded to one split. A partial review always
says so: the summary carries a "results may be incomplete" notice, and a lens
that was cut short is never presented as a clean bill of health. lgtmaybe will
not answer 👍 LGTM for code no model finished reading.

To measure what a run actually spends and bring it down (triage, static
analysis, prompt caching, and the `max_review_tokens` ceiling), see
[Reduce Review Cost](../how-to/reduce-review-cost.md).

## How the Choice Is Expressed

The example workflows gate the `review` job on the triggering user's
[author association](https://docs.github.com/en/graphql/reference/enums#commentauthorassociation),
GitHub's classification of a user's relationship to the repo. By default the
job runs when that association is `OWNER`, `MEMBER`, or `COLLABORATOR`:

```yaml
if: >-
  (github.event_name == 'pull_request_target' &&
   contains(fromJson('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.pull_request.author_association)) ||
  (github.event.issue.pull_request &&
   contains(fromJson('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association) &&
   (contains(github.event.comment.body, '/review') ||
    contains(github.event.comment.body, '/improve') ||
    contains(github.event.comment.body, '/ask') ||
    contains(github.event.comment.body, '/describe') ||
    contains(github.event.comment.body, '/diagram')))
```

A maintainer can still review an outside contributor's PR at any time by
commenting `/review` on it, because the maintainer's own association passes the
gate. To change the policy, edit the list:

- **Open it to everyone**: drop the author-association checks so any PR or
  `/review` comment runs a review.
- **Welcome returning contributors**: add `CONTRIBUTOR`, which covers anyone
  who has committed to the repo before.
- **Tighten to admins**: keep just `OWNER` (and `MEMBER` for your org).

The gate lives in your workflow YAML, not in the action's code, so the policy
for automatic PR reviews is entirely yours. Slash commands have one floor the
YAML cannot lower: lgtmaybe runs a command only when the commenter has write
access to the repository, and ignores anyone else's with a log line and no
model call. So opening the comment arm to everyone, or adding `CONTRIBUTOR`,
changes who triggers a run, not who can spend the model through one.

The slash-command half of the comment arm saves runner time rather than
tokens. `issue_comment` fires on every comment on every pull request. Without
the check, a comment with no slash command still claims a runner, pulls the
container and boots Python before lgtmaybe exits. Filtering in the `if:` costs
nothing, and on a busy runner pool it keeps reviews from queueing behind
ordinary comment threads. The check is deliberately looser than lgtmaybe's own
parser (a case-insensitive substring match, not a prefix match), because a
guard stricter than the parser would silently disable a command that used to
work. Replies inside a finding's review thread don't trigger a run at all; a
pushed fix is checked by the next incremental review.

## If You Want Extra Guardrails

Optional, for repos that want more:

- Require approval for fork-PR workflow runs in
  **Settings → Actions → General → Fork pull request workflows**.
- Put the provider key behind a protected `environment`.
- Set a spending limit in your provider console.

## Reviews Are Safe to Run for Anyone

Whoever triggers a review, a malicious PR can't use it to do harm. On GitHub,
lgtmaybe triggers on `pull_request_target` (so it has the secrets it needs) but
never checks out or executes PR code. It fetches the diff through the host's
API and treats it as untrusted input. That second part is what does the work,
and it holds the same way on GitLab and Gitea, which have no
`pull_request_target` of their own. So opening the gate wide is a cost
decision, not a security one. The full boundary (secret redaction,
prompt-injection defence, and fork safety) is in
[Data and Privacy](data-and-privacy.md).
