Agentic PR/MR review tool for CI/CD pipelines. Unlike single-shot review tools, pergent spawns opencode as a subprocess that autonomously loops through the repository -- reading files, following references, grepping for patterns -- to build deep context before writing its review.
- Agentic review: opencode reads files, greps and follows references in the repository before it writes a finding, instead of judging the diff alone.
- Skills: review behaviour lives in Markdown skill files, each with its own scope (
paths,exclude) and its own idea of what is blocking. - Any size of change: changed files are split into batches that fit the model's context, and every skill and batch runs in parallel under one time budget.
- Structured findings and verdicts: each skill ends
no_blocking_issues,has_blocking_issuesorfailed; blocking findings are re-checked by a separate run before they count. - Honest failures: a timeout, context overflow or provider error is reported with its cause, never as a pass, and the CI job stays green.
- Read-only sandbox: the agent can only read, grep, glob and list; edits, shell, web access and
.envreads are denied, and secrets are redacted from everything pergent prints. - One comment, updated: GitHub and GitLab get a single review comment that is edited on every push instead of piling up;
--previous-reviewgives the model the last review's findings as context. - Agent mode:
pergent agentreviews a prepared checkout with trusted inputs only and delivers a versioned JSON result, for running pergent as a service.
- You define review behavior via skill files (
.mdfiles with review instructions) - For each skill, pergent picks the changed files in its scope, groups them into batches that fit the model's context, and spawns one
opencode runper batch, attaching that batch's diff - The agent explores the repo iteratively (reads files, greps, follows references) and answers with structured findings
- pergent merges the findings, re-checks the blocking ones, decides a verdict per skill, and posts one combined review comment on the PR/MR
Skills and batches run in parallel (see --concurrency), all within one time budget for the whole review (see --timeout).
A run gets only its own batch's diff attached. So that a run can see how another changed file changed (a skill with paths, or a batch of a big change, would otherwise miss a problem spanning two areas), pergent writes the diff of each reviewable changed file to its own temporary file, plus an index of them, once per review. Nothing is attached: a run that does not cover every such file is told in its message where the index is (batch, cross-batch and verification runs alike), and it can read the index and then the diffs it needs. A run may read them; nothing proves it does. Only files that at least one skill reviews, with no default exclusion, whose path the read rules do not deny (*.env, *.env.* except *.env.example, and in agent mode *.pem, *.key, *.tfvars) and whose diff fits in a batch, are listed. If the files cannot be written, pergent logs a warning and the review runs without them. When you use opencode's own config (no OPENCODE_PROVIDER), nothing is written and no run is told about diffs, because only pergent's generated config lets a run read outside the repository.
The agent also picks up project context from the repo root's AGENTS.md (or CLAUDE.md if there is no AGENTS.md). The file may be a symlink, but only to a regular file inside the checkout that the read rules allow (CLAUDE.md -> AGENTS.md is fine). A link that leaves the checkout, does not resolve, or points at a file the read rules deny (such as prod.env) is skipped with a warning naming it, and the next name is tried.
When OPENCODE_PROVIDER is set, pergent writes a temporary opencode.json for each opencode run and points opencode at it with OPENCODE_CONFIG. That config:
- defines a
pergentagent that carries the skill's prompt (its body plus pergent's output-format instructions) and--max-turnsas its step limit, and makes it the default agent (pergent also passes--agent pergent). The prompt is written to a file next toopencode.json(mode 0600, removed with it) and the config names the file with{file:...}, because opencode expands{env:VAR}and{file:path}anywhere in a config's text: a skill that mentions{env:GITLAB_TOKEN}or{file:missing}reaches the model exactly as written - allows only
read,glob,grepandlist, and deniesedit(which covers file writes and patches),bash,task,skill,webfetchandwebsearch - denies reading
*.env/*.env.*files (except*.env.example) - turns off language servers (
lsp: false), because opencode'sreadtool starts them whatever the permissions say, and a server can be a program from the reviewed branch (such asnode_modules/.bin/biome) running with your LLM key in its environment - turns off opencode's auto-compaction (
compaction.auto: false), so a run that fills the model's context fails with the overflow error instead of continuing from a summary - leaves the model's context and output limits to opencode's model registry, unless you set
--context-limit
pergent also sets OPENCODE_DISABLE_PROJECT_CONFIG=true, so an opencode.json or .opencode/ directory (agents, plugins, commands) in the reviewed branch can't change these permissions or run plugin code while your LLM key is in the environment. GITHUB_TOKEN and GITLAB_TOKEN are removed from opencode's environment: only pergent uses them. Because that setting also stops opencode from finding AGENTS.md / CLAUDE.md by itself, pergent passes the root file to opencode explicitly.
opencode checks the path it is asked to read, not where a symlink leads, so a link in the reviewed branch could expose a file beyond the checkout (x -> /etc/passwd, x -> ..). In platform mode (GitHub or GitLab CI), before any run, pergent deletes every symlink in the checkout whose target resolves outside it, or whose real target the read rules deny (safe.txt -> prod.env; the rules match the name being read, not where a link leads), and logs each path. .git is skipped. A link that dangles counts as leaving unless its target can be shown to stay inside. The link is deleted, never its target, and the review goes on; a link that cannot be deleted, or a directory that cannot be read, is a warning. --local never deletes anything: it reviews your own working tree and trusts the checkout. (Agent mode: see --scrub-instructions.)
Each opencode run also gets its own empty XDG_DATA_HOME, removed when the run ends. The agent can read only the tool-output folder in it, where opencode saves a long tool result, not the rest (your auth.json and the session database). opencode creates its SQLite database there on first start, and runs started at the same moment in one fresh directory (a new CI container) race on that setup. The losers exit before the model's first step. Your auth.json, if you have one, is copied in, so opencode auth login still works.
Each opencode run (one per batch) ends in one of two ways: the model's own final answer, in the required format, is parsed into findings, or the batch is reported as Review failed: with the cause and the files it covered. pergent never posts opencode's internal messages or the model's in-progress text ("Let me check...") as a review. A skill with any failed batch has the verdict failed, never "no blocking issues", even if the other batches found nothing.
| Cause | Example |
|---|---|
| Context overflow | The model's context window overflowed after 14 steps: the last completed step used 186,400 tokens (limit: the model's context window as known to opencode). ContextOverflowError: prompt is too long: 201234 tokens > 200000 maximum. |
| Timeout | Timed out after 10m0s (review budget 10m0s) during step 9; the last tool call was read on service/store.go. |
| opencode error | opencode reported APIError (HTTP 429): Rate limit exceeded. Gave up after 4 attempts. |
| Step limit | Reached the 20-step limit before the model finished the review (raise --max-turns to allow more steps). |
| opencode exited non-zero | opencode exited with code 1 at step 2 without producing a review. (The last lines of opencode's stderr follow in a code block.) |
| No answer | opencode exited normally after 6 steps, but the model did not produce a final answer (last step finished with reason "tool-calls"). |
- The CI job stays green when a review fails; the cause is in the comment and the job log. Configuration errors found before any review starts (missing token, bad flag) still fail the job.
- Transient provider errors (HTTP 429, 5xx, connection errors) are retried up to 3 times with exponential backoff (5s, 10s, 20s) within the review's
--timeoutbudget. opencode already retries these itself before giving up, so pergent's retries cover longer outages. If every attempt fails, the last error is reported as is. - Step limit: on the last allowed step opencode asks the model to stop and summarise its progress. That summary is not a review, so it is reported as a step-limit failure. If the model ignores the instruction and keeps calling tools, pergent stops opencode.
- An answer in the wrong format (no JSON block, or a block that does not parse) gets one repair run that rewrites it; if that fails too, the batch is reported as failed with an excerpt of the answer.
- Secrets are redacted from the comment and the log: the values of
OPENCODE_API_KEY,GITHUB_TOKENandGITLAB_TOKEN, bearer tokens, and API keys in URLs.
docker pull ghcr.io/gopaytech/pergent:latestThe image is built for linux/amd64 and linux/arm64 (from v0.1.2); Docker pulls the one that matches the host. It ships opencode, git and ripgrep (opencode's glob and grep tools need rg; from v0.1.3).
git clone https://github.com/gopaytech/pergent.git
cd pergent
make build
# Binary at ./bin/pergentRequires opencode installed on the system.
A build from source reports its version as dev; only a release image carries a real one. pergent.sh treats dev as older than any release, so such an image gets full reviews.
Run reviews locally and print output to stdout. No platform token or PR required.
Prerequisites:
- opencode installed
- A git repo with commits ahead of the base branch
Setup LLM (option A -- configure via env vars):
export OPENCODE_PROVIDER=anthropic # or openai, litellm, etc.
export OPENCODE_API_KEY=sk-your-api-key # your LLM API key
export OPENCODE_MODEL=claude-sonnet-4 # model to useSetup LLM (option B -- use opencode's existing config):
If you already have opencode configured (e.g., GitHub Copilot, custom opencode.json), leave OPENCODE_PROVIDER (and OPENCODE_MODEL) unset. pergent will use opencode's own config. In this mode pergent does not generate a config, so the agent sandbox above does not apply: opencode runs with whatever agents and permissions your config defines. pergent attaches the skill's instructions (with the output format) to each run as a file review-instructions.md, because they would otherwise live only in the config it did not write.
- With
--localthis needs nothing more: it is your own machine.OPENCODE_MODELwithoutOPENCODE_PROVIDERis ignored and logs a warning. - In GitHub or GitLab CI it needs an opt-in: set
PERGENT_USE_OPENCODE_CONFIG=true. Without it, an emptyOPENCODE_PROVIDERis a config error (an unset CI variable expands to an empty one, and the review would silently lose its sandbox). The error also namesOPENCODE_MODELwhen that is set without a provider. - This mode has no sandbox, so do not use it on untrusted changes. The project config is on, so the change's own
AGENTS.mdandopencode.json(agents, plugins, permissions) apply, opencode may run commands, andGITHUB_TOKEN/GITLAB_TOKENstay in opencode's environment because it may sign in with one. A pull request from a fork or an outside contributor can use that. pergent logs a warning on every such run. For untrusted changes, setOPENCODE_PROVIDERso pergent runs the review in its read-only sandbox.
Test the connection:
pergent --testRun:
# Using a preset skill
pergent --local --skill code-review
# Using a custom skill file
pergent --local --skill ./my-skills/custom-review.md
# Custom base branch (default: main)
pergent --local --base-branch develop --skill code-review
# Multiple skills
pergent --local --skill code-review --skill ./my-skills/security-check.mdmr-review:
stage: review
image: ghcr.io/gopaytech/pergent:latest
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
script:
- pergent --skill code-review
variables:
GITLAB_TOKEN: $GITLAB_TOKEN
OPENCODE_PROVIDER: anthropic
OPENCODE_API_KEY: $ANTHROPIC_API_KEY
OPENCODE_MODEL: claude-sonnet-4name: PR Review
on:
pull_request:
types: [opened, synchronize]
jobs:
review:
runs-on: ubuntu-latest
container:
image: ghcr.io/gopaytech/pergent:latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Run pergent
run: pergent --skill code-review
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
OPENCODE_PROVIDER: anthropic
OPENCODE_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
OPENCODE_MODEL: claude-sonnet-4pergent works with any OpenAI-compatible API. Set OPENCODE_BASE_URL to your endpoint and pergent automatically uses /chat/completions instead of /responses:
export OPENCODE_PROVIDER=openai
export OPENCODE_API_KEY=your-key
export OPENCODE_MODEL=your-model
export OPENCODE_BASE_URL=https://your-proxy.example.com/v1
pergent --local --skill code-reviewSkills are markdown files that define review behavior. The --skill flag accepts either a preset name or a file path.
Built-in skills embedded in the binary:
| Name | Description |
|---|---|
code-review |
General code quality, bugs, clarity, performance, security |
pergent --skill code-reviewCreate your own .md file with optional YAML frontmatter:
---
name: security-review
---
You are a security engineer reviewing a pull request.
Focus on authentication, authorization, injection vulnerabilities,
and sensitive data exposure.
...pergent --skill ./skills/security-review.mdThe frontmatter can narrow which changed files a skill reviews and ask for an extra check across batches:
---
name: api-review
paths:
- "api/**"
- "*.proto"
exclude:
- "**/*_test.go"
cross_batch: true
---| Field | Meaning |
|---|---|
paths |
Glob patterns. When set, the skill reviews only changed files matching one of them. Files that match none are listed in the comment's Files list as skipped, out of scope. Unset means every changed file. |
exclude |
Glob patterns for files the skill skips even though they are in scope. |
cross_batch |
true adds one extra run, after all batches, that looks for problems spanning batches (for example conflicting changes in different directories). It only runs when the change needed more than one batch. Default false. |
Patterns use /-separated paths from the repo root. * matches within one path segment and ** matches any number of segments. A pattern with no / (such as *.proto) matches the file name at any depth. A pattern with bad glob syntax, or one that can never match a file (api/, /api/**, ./api/**), is an error when the skill loads, because the skill would otherwise report a clean review on every change. For a directory, use api/**.
Every skill skips these files, and the comment's Files list shows them as skipped, with the reason:
- Always skipped: deleted files, binary files, and files with no content changes (for example a pure rename).
- Skipped unless a skill's
pathsmatch them: lockfiles (package-lock.json,go.sum,yarn.lock,pnpm-lock.yaml,Cargo.lock,composer.lock,Gemfile.lock,poetry.lock,Pipfile.lock), vendored code (anything under avendor,node_modulesorthird_partydirectory), and generated files (paths markedlinguist-generatedin the repo's.gitattributes, unless a later matching line unsets it with-linguist-generated,linguist-generated=falseor!linguist-generated, or files whose first lines say the code is generated and not to be edited).
A file larger than a batch can hold (about a quarter of the model's context window, 40,000 tokens when the window is unknown) is not reviewed and is reported as a failure for that file.
A review has two kinds of findings: blocking ones, which decide the verdict, and suggestions. Tell the skill what blocks by adding a ## Blocking heading (any level) to its body and listing the problems that must be fixed before merging:
## Blocking
- Logic errors that cause wrong behaviour, crashes, or data loss.
- Security vulnerabilities.
Everything else is a suggestion.When a skill has no ## Blocking section, pergent appends a default one: bugs, security vulnerabilities and broken configuration are blocking, and everything else is a suggestion. Before a blocking finding is reported, pergent runs a separate check that opens the code again and drops the finding if the problem is not real.
If a skill contains a checklist (section headings with items under them, as bullets or checkboxes), the model reports every item as a check: pass, fail or n/a, with the evidence it compared. The comment shows the checks by area: each checklist heading is a heading in the comment with a small table of Check, Status and Detail, failed checks first. No special syntax is needed; write the checklist as you would for a human reviewer. The ## Blocking section is not treated as a checklist. Findings still decide the verdict: a failed check should come with a finding, and the comment notes it when one doesn't.
pergent appends its own output-format instructions to every skill: the model must end its answer with one JSON block holding a short summary of what it checked and concluded, its checks (the skill's checklist, if it has one), and its findings. The comment shows the summary, and the agent result carries it too (summaries). A missing summary is not an error; the review still counts. Don't tell a skill how to format its answer (no "use bullet points", "start with a summary", or JSON schemas of your own). Instructions like that conflict with the required format and make runs fail. Write what to look for and what counts as blocking; pergent handles the rest.
pergent posts one comment per PR/MR, updated in place on each run. It leads with the result, the model's summary and the findings; the checks follow, by area. In order:
- a headline with the verdict over all skills (
✅ No blocking issues,❌ N blocking issues, or⚠️ N skills failed; a clean review that has dismissed blocking findings readsNo blocking issues (2 dismissed)) and, when a skill carries a model score, the confidence (· Confidence 3/5, or· Confidence not scoredwhen the skill that carried the score failed) - a small line of facts: the reviewed commit (first 7 characters), the number of files,
N of M checks passed, and the finding counts, ending with· N skills failedwhen there are blocking findings and a skill also failed - the model's reason for the score, as a one-sentence quote, only when the score is below 5 or capped
- a skills table (skill, verdict, confidence, findings), only when more than one skill ran
- failures, one
⚠️ Review failed:line each, with the cause, and the files that were not reviewed when the skill had several batches or several failures - the summary, when one skill ran in one batch and the model gave a summary:
**Summary:** …, 1 to 3 sentences. With several batches, one line per batch (- **Batch 1 of 3:** …) sits at the top of the checks instead, because the number of batches is not limited. With several skills, the summaries are in the skill sections below - the findings, one list across all skills: blocking findings first and open, suggestions collapsed. Each row shows the title,
file:linelinked to those lines, and the skill name when there are several. Open a row for the explanation, the suggested fix and, for a small fix, the exact replacement code (Suggested change, replacing lines 42–43:). The model gives replacement code for at most 10 lines; a suggestion that doesn't fit is dropped and the finding is kept - the dismissed findings, collapsed, when someone set any aside
- the details: with several skills, one
#### <skill>section for each skill that has a summary or checks, in the order of the skills table, holding its summary and then its checks. The checks, when a skill has a checklist, are by area: a#### <area>heading (##### <area>inside a skill section) with a table ofCheck,StatusandDetail. Areas keep the order they first appear in, and failed checks come first in their area, so one failure among many is not missed. Checks the model gave no area come last; beside named areas they get the headingOther checks, so they don't read as part of the area above, and with no named areas they have no heading. A check that several batches answered is one row, with the worst status - one collapsed
Files (N)list of every changed file, with the reason for each file a skill skipped or did not review - notes, such as a skill with no files in scope, a verification that was skipped, a failed check that has no matching finding, or the findings the model left out because people rejected the same mistake before (
Left out because of past dismissals: Missing nil check (Caller never passes nil), at most 20 listed) - a footer with the pergent version
- the hidden findings block (last line)
A skill with failed batches still shows the findings from the batches that finished, next to the failure.
A review with one blocking finding looks like this:
### ❌ 1 blocking issue · Confidence 3/5 (capped)
<sub>Commit `4f1c2a9` · 4 files · 2 of 3 checks passed · 1 blocking · 1 suggestion</sub>
> The retry loop in `internal/retry/retry.go` is new and untested. (capped: 1 blocking finding)
**Summary:** Read the new retry package and its two callers. The loop never checks for a cancelled context.
#### Findings (2)
▸ ❌ **Retry never stops on a cancelled context** · internal/retry/retry.go:42–47
▸ 💡 **Name the backoff constant** · internal/retry/backoff.go:9
#### Errors
| Check | Status | Detail |
|---|---|---|
| Context cancellation is honoured | ❌ | Do loops on ctx.Err() without returning |
| Backoff is capped | ✅ | maxDelay is applied in next |
#### Tests
| Check | Status | Detail |
|---|---|---|
| The failure path is tested | ✅ | TestDoGivesUp covers it |
▸ Files (4)
pergent v0.3.0(In the real comment each ▸ row is a collapsed <details> block, and the blocking one is open.)
The confidence is the model's own judgement of how safe the change is to merge, from what it reviewed. It is the lowest score among a skill's finished batches, and pergent caps it: a blocking finding caps it at 3, and a file skipped as deleted, binary, vendored, a lockfile or generated caps it at 4. A skill that failed is not scored. It is information only: it never changes a verdict. When no skill carries a model score (older answers, failed runs), the comment shows no confidence at all.
Links. In GitHub CI mode, file:line links to the file at the pull request's head commit (pull_request.head.sha); in GitLab CI mode, at CI_MERGE_REQUEST_SOURCE_BRANCH_SHA (set in merged-results pipelines) or else CI_COMMIT_SHA. A line link ends in ?plain=1#L<line>, so the anchor works for Markdown and other rendered files too. Local and agent mode have no links. actions/checkout checks out the merge commit of a pull request, so if the base branch changed the same file above a finding since the branch was cut, the line anchor on GitHub can be a few lines off.
Dismissing a finding. With pergent.sh, each finding also shows a ready-to-copy dismiss command with its ID, and a dismissed finding moves to the Dismissed list. The CLI's CI comment shows no finding IDs or dismiss command. Agent mode shows each finding's ID in its row (id …), and pergent.sh adds the dismiss command.
The last line of the comment is a hidden HTML comment holding the findings for the next run (see below). Don't edit it. Secrets are redacted from the comment and from that block.
A comment that would exceed the platform's size limit (65,536 characters on GitHub, 1,000,000 on GitLab) is shrunk to fit rather than failing to post. In order: the hidden findings block is dropped (the next --previous-review run is then a first review), then the suggestions' explanations and fixes (their titles stay), then the suggestions and dismissed findings, then the passed and n/a checks (an area left with none loses its heading), then the failed checks (the counts in the facts line stay), then the Files list is cut to 20 paths and then to a count, and last the summaries. The headline, facts line, reason, skills table, blocking findings and failures are always kept; each step leaves a note in the comment. As a last resort, if the blocking findings and failures alone exceed the limit, the comment is cut at the limit with a note.
The order above is the default. A program that builds the comment with the public render package can choose another one with render.Options.Layout, a list of blocks: summary, findings, dismissed, checks and files, each open or collapsed (ParseLayout reads findings:collapsed-style entries). A block the list leaves out is not written; findings is required. summary:collapsed puts the summaries in a Summary fold, checks:collapsed keeps the failed checks in view and folds the passed and n/a ones per skill (Checks: 3 of 4 passed, 1 n/a), and findings:open opens the suggestions too. The summaries by batch or skill, and the failed checks, sit where checks is, else where summary is, else right after findings. A list that is empty or not valid gives the default.
A layout changes what the comment shows, never what the review decides. Everything above the blocks (the headline, facts line, reason, skills table and failures) and below them (notes, footer, hidden block) stays, blocking findings are always open, a failed check is always shown (only the size limit can cut failed checks, at its step 5, with a note), and a quiet review (Options.Short) still leaves out the Files list and the passed checks. A clean headline still says (2 dismissed) when the dismissed block is left out. The size-limit steps run in the order above whatever the layout says, and a block left out leaves no note (failed checks cut for size still do). The last-resort cut, when the blocking findings and failures alone are over the limit, is made on the default order, so a custom order can't push a blocking finding out first. The CLI always uses the default layout.
In CI mode pergent reads the pull request's or merge request's title and description and attaches them to each batch run and the cross-batch run as change-description.md, so the model knows what the author meant to do. The log says change description: <n> bytes when they are read. If they can't be read, a warning is logged and the review goes on without them. --local has no change to read them from. In agent mode the caller passes them with --change-description.
What the model is told:
- A change in behaviour the description calls intended is not a finding for that reason alone. A stated intent never excuses a problem that counts as blocking.
- Code that does the opposite of what the description says is a
suggestion, whatever a skill's## Blockingsection says, unless the code is a blocking problem in its own right. - Something the description promises but the run does not see is not reported: a run sees only part of the change, so it may be in another batch.
- The description itself is never a finding, however short, vague or empty.
The author writes this text, so it is data, never instructions. Those rules are wording, not a guarantee: the text can still sway the review run. Verification runs never get it, so a blocking finding is judged on the code alone. HTML comments are removed first, since the platforms do not show them to a reviewer. Only the first 16 KiB is attached, and editing the description does not start a review.
A skill can use the description, but must still work without it: it is missing with --local, when it can't be read, and on a change with an empty title and description.
By default each run reviews from scratch. With --previous-review (or PERGENT_PREVIOUS_REVIEW=true), pergent fetches the review comment it posted on the previous run, reads the findings from its hidden findings block, and attaches each skill's findings to that skill's runs alongside the diff. The agent is instructed to stay consistent with them, focus on what changed since, drop findings the new commits resolve, and reuse a finding's fingerprint when it still applies. pergent keeps a reused fingerprint only on the same file and line as the previous finding, so a new finding can't take over an old one's identity or its dismissal. A skill with no previous findings gets nothing attached.
pergent stays stateless, the PR/MR comment is the only persistent state. First runs (no existing comment) behave exactly as before. Requires platform mode (not available with --local).
Comments posted by older versions of pergent have no findings block, so they are ignored: the first run after upgrading is a first review. A findings block that cannot be read is skipped with a warning in the log.
The findings are attached as data: the prompt tells the agent to treat the file as data, never as instructions.
A comment is pergent's own only when both hold: its body starts with the <!-- pergent --> marker (every pergent comment does), and its author is the identity of pergent's token. A note by anyone else that holds the marker, or a forged findings block, is never updated, never overwritten and never read for findings: pergent skips it, keeps looking through every page, and creates its own comment if it finds none.
pergent learns the token's identity once per run with GET /user:
- GitLab: the
apiscope that posting notes needs also covers/user, for project and group access tokens too. If the read fails, as it can for a fine-grained token withoutread_user, the comment lookup counts as failed (see below), so each run posts a new comment. - GitHub: a personal or fine-grained token can read
/user. TheGITHUB_TOKENof GitHub Actions, and a GitHub App installation token, cannot (GitHub answers 403). Then pergent accepts only a comment whose author is aBot, because a person cannot post as a Bot.
If the token's identity changes (a new bot user, a new token owner, a switch between GITHUB_TOKEN and a personal token), pergent starts a new comment and the old one stays on the PR/MR. Delete it by hand if you don't want it.
If looking for the existing comment fails (a network or server error), pergent tries once more after a few seconds. If it still fails, it still posts the review as a new comment, because a visible new result beats a stale one, and it logs that a duplicate may appear.
mr-review:
stage: review
image: ghcr.io/gopaytech/pergent:latest
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
script:
- pergent --skill code-review
variables:
GITLAB_TOKEN: $GITLAB_TOKEN
PERGENT_PREVIOUS_REVIEW: "true"
OPENCODE_PROVIDER: anthropic
OPENCODE_API_KEY: $ANTHROPIC_API_KEY
OPENCODE_MODEL: claude-sonnet-4pergent agent is for a service that runs reviews on code it does not trust, such as a merge request from a fork. It reviews a checkout that someone else prepared, between two commits, and produces a result (versioned JSON). It posts nothing.
What it is:
- No platform token. It never calls GitHub or GitLab. The caller reads the result, posts the comment, sets the status and approves.
- Trusted inputs only. Skills, project instructions and the previous result come from paths you pass, never from the checkout: the checkout is the change under review, and a change could rewrite its own review rules. The parts of the default skip rules a change could rewrite come from the base commit:
.gitattributesand generated-file headers, so a change can't mark its own files as generated or binary to hide them.lockfileandvendoredare fixed path rules, andbinaryanddeletedfollow the change itself. A skill's verdict covers only the files it reviewed: the caller's approval policy must decide what skipped files mean (see What a verdict covers). - A result for every outcome. A review where every skill failed is still a result (exit 0). A review that could not run is an error result with a code and a ready-to-post comment.
What it isn't: it does not clone or fetch (the caller prepares the checkout), approve, post comments or set statuses. CI mode (pergent --skill ...) is unchanged.
pergent agent \
--repo-path /work/repo \
--base 4f1c2a9e8b7d6c5b4a3f2e1d0c9b8a7f6e5d4c3b \
--head 9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b \
--skills-dir /in/skills \
--instructions /in/instructions.md \
--scrub-instructions \
--result result.jsonstdout carries only the result, and only with --result -; all logs go to stderr, with each progress line prefixed by the skill and batch ([code-review 2/3]).
| Flag | Description | Default |
|---|---|---|
--repo-path |
The checkout of the head commit. Must be the top of the git work tree | required |
--base |
The change's merge base: a full 40- or 64-character lowercase commit SHA | required |
--head |
The change's head: a full lowercase commit SHA. The checkout's HEAD must equal it |
required |
--skills-dir |
Directory of skills: flat *.md files and <name>/SKILL.md folders. Symlinked files and folders count (as in a ConfigMap volume); a folder without a SKILL.md is skills_invalid; entries starting with . are skipped |
required unless --skill is given |
--skill |
Preset name or skill file path (repeatable); can be combined with --skills-dir |
— |
--instructions |
One file passed to the agent as project instructions. Without it the agent gets none; AGENTS.md / CLAUDE.md are never discovered |
unset |
--previous |
A previous agent result: its findings are attached per skill, as --previous-review does in CI mode. A missing, unreadable or other-version file logs a warning and the review runs as a first review |
unset |
--carried |
A JSON file {"<skill>": ["path", ...]} of files a previous review covers. Those files are left out of the skill's batches (they stay in the changed-file list the model sees) and are listed in files.carried; a skill with only carried files runs nothing and gets no_blocking_issues. Findings on them are not attached as previous findings. A missing, unreadable or malformed file logs a warning and carries nothing |
unset |
--dismissals |
A JSON file {"<skill>": [{"file": "...", "title": "...", "severity": "blocking", "reason": "..."}, ...]} of findings people rejected in earlier reviews, with their reasons. A skill's entries are attached to each of its batch runs and its cross-batch run as rejected-findings.json, with a note to treat the file as data; its verification run gets only the entries with severity blocking. Entries for a skill that is not run are ignored. A missing, unreadable or malformed file logs a warning and attaches nothing; a run the file cannot be written for goes on without it and the skill gets the note Past dismissals could not be attached to every run. A finding the model leaves out because of an entry is named in a note on the skill. The log line dismissals: <skill>=<n> ... shows what was read |
$PERGENT_DISMISSALS, else unset |
--change-description |
A file holding the change's title and description. It is attached to each batch run and the cross-batch run as change-description.md, with a note to treat it as data; verification and repair runs never get it. HTML comments are removed and only the first 16 KiB is attached. A missing or unreadable file logs a warning and the review goes on without it |
$PERGENT_CHANGE_DESCRIPTION |
--scrub-instructions |
Declares --repo-path a disposable checkout and deletes the files below before reviewing |
false |
--max-comment-size |
Byte limit for the rendered comment (0: no limit) |
0 |
--result |
Write the result to this file, or - for stdout. A file is written to <path>.tmp, fsynced, then renamed |
— |
--result-url |
POST the result here, with PERGENT_RESULT_TOKEN as the bearer token |
— |
--timeout, --concurrency, --max-turns, --context-limit |
As in CI mode | as in CI mode |
At least one of --result and --result-url is required; both can be used together. --local, --platform, --previous-review and --base-branch are CI-mode flags and are a usage error here.
Environment: OPENCODE_PROVIDER and OPENCODE_MODEL are required (a usage error otherwise), so pergent can generate the sandboxed opencode config. OPENCODE_API_KEY and OPENCODE_BASE_URL work as in CI mode. PERGENT_RESULT_TOKEN is required with --result-url; it is removed from opencode's environment and redacted from the logs and the result (a value shorter than 8 characters is not redacted). GITHUB_TOKEN and GITLAB_TOKEN are not used in agent mode and are removed from opencode's environment too. opencode's own stderr is passed to pergent's with credentials redacted and the run's prefix on each line.
- git 2.40 or newer. The diff reads
.gitattributesfrom the base commit throughGIT_ATTR_SOURCE, which older git ignores. Below 2.40 pergent refuses with theinternalerror code. The Docker image (debian:trixie-slim) has a recent enough git. - Commits and checkout.
--baseand--headmust be full commit SHAs, not branch names or short SHAs. Both commits must exist in the checkout, and itsHEADmust be--head. The diff isgit diff <base> <head>, so no history between them is needed. - Trusted inputs outside the checkout.
--instructions,--skills-dir, a path given to--skill,--previous,--carried,--dismissalsand--change-descriptionmust not resolve inside--repo-path(a usage error). Nor may a--resultfile (the change could plant a symlink at its.tmpfile; the error result is not written there),$HOMEor$XDG_CONFIG_HOME, from which opencode loads its global config and plugins whatever the project-config switch says. Duplicate skill names are a usage error too: the caller decides which skill wins. - Instruction files and symlinks. opencode loads a nested
AGENTS.md,CLAUDE.mdorCONTEXT.mdwhenever the agent reads a file next to it, and no setting turns that off, so a change could plant instructions there. A symlink pointing outside the checkout would let the read-only agent read files beyond it, and so would a symlink inside the checkout to a file the read rules deny (safe.txt -> prod.env): the rules match the name being read, not where the link leads. Pass--scrub-instructionsand pergent deletes every file with those names (any case, the root included,.gitskipped), every symlink whose target resolves outside the checkout (a dangling one counts as outside unless its target can be shown to stay inside) and every symlink whose real target the read rules deny (agent mode's rules, so*.pem,*.keyand*.tfvarstoo), and logs each path. Without the flag, pergent refuses withuntrusted_instructionswhen such a file exists below the root, when a symlink resolves to the checkout root, when a symlink resolves outside the checkout, or when a symlink's real target is a file the read rules deny. A directory pergent cannot read is aninternalerror: it could hold a link the scan did not see. A service that reviews untrusted changes should always pass the flag. Changes to these files still show up in the diff and are reviewed as content. - Secrets in the checkout. In agent mode the agent is also denied reading
*.pem,*.keyand*.tfvars, on top of*.env/*.env.*. The checkout must not hold credentials in.git/config, which the agent can read: see the init container below.
| Code | Meaning |
|---|---|
0 |
A result was produced and delivered, even if every skill failed. |
2 |
Usage error. An error result is delivered only when a delivery target could be parsed. |
3 |
No review was possible (for example a missing commit or an untrusted checkout). The error result was delivered. |
4 |
A delivery failed. |
HTTP delivery never follows redirects. A 409 counts as delivered (the receiver already has the result); 401, 403, 413 and other 4xx replies fail without a retry; 429, 5xx and connection errors are retried after 2s, 4s and 8s. Each request times out after 30 seconds, which counts as a connection error. The full table is in the result schema.
After SIGTERM or SIGINT, pergent stops its opencode runs and fails the unfinished skills with a cause (cancelled, or stopped at the Job deadline if --timeout had already run out). A run already in flight gets the cause cancelled with the same reason, never exit_error. pergent still delivers a completed result. Delivery then gets 20 seconds with one retry after 1s, so it fits Kubernetes' 30-second grace period.
A Job prepares the checkout in an init container and runs pergent in the main container. The two get different secrets: the init container holds only a read-only git token, and pergent holds the LLM key and the result token, never a platform token.
apiVersion: batch/v1
kind: Job
metadata:
name: review-7f3a9c
spec:
backoffLimit: 0
# --timeout (10m) + 2 minutes to finish and deliver + allowance for the clone
activeDeadlineSeconds: 900
template:
spec:
restartPolicy: Never
terminationGracePeriodSeconds: 30
volumes:
- name: work
emptyDir: {}
- name: inputs
configMap:
name: review-7f3a9c-inputs
# ConfigMap keys can't contain "/", so items are required to put
# each skill under skills/. List every key.
items:
- key: instructions.md
path: instructions.md
- key: code-review.md
path: skills/code-review.md
initContainers:
- name: checkout
image: ghcr.io/gopaytech/pergent:latest
command:
- sh
- -ec
- |
# The token goes through git's environment config, so it is never
# in the remote URL or written to .git/config. Use the username your git
# host expects (x-access-token is GitHub's).
export GIT_CONFIG_COUNT=1
export GIT_CONFIG_KEY_0=http.extraHeader
export GIT_CONFIG_VALUE_0="Authorization: Basic $(printf 'x-access-token:%s' "$GIT_TOKEN" | base64 -w0)"
git init -q /work/repo
cd /work/repo
git remote add origin "$REPO_URL"
git fetch -q --depth=1 origin "$BASE_SHA" "$HEAD_SHA"
git checkout -q --detach "$HEAD_SHA"
env:
- name: REPO_URL
value: https://git.example.com/acme/widgets.git
- name: BASE_SHA
value: 4f1c2a9e8b7d6c5b4a3f2e1d0c9b8a7f6e5d4c3b
- name: HEAD_SHA
value: 9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b
- name: GIT_TOKEN
valueFrom:
secretKeyRef:
name: review-7f3a9c-git
key: token
volumeMounts:
- name: work
mountPath: /work
containers:
- name: pergent
image: ghcr.io/gopaytech/pergent:latest
command:
- pergent
- agent
- --repo-path=/work/repo
- --base=4f1c2a9e8b7d6c5b4a3f2e1d0c9b8a7f6e5d4c3b
- --head=9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b
- --skills-dir=/in/skills
- --instructions=/in/instructions.md
- --scrub-instructions
- --timeout=10m
- --result-url=https://reviews.example.com/runs/7f3a9c/result
env:
- name: OPENCODE_PROVIDER
value: anthropic
- name: OPENCODE_MODEL
value: claude-sonnet-4
- name: OPENCODE_API_KEY
valueFrom:
secretKeyRef:
name: review-7f3a9c-llm
key: api-key
- name: PERGENT_RESULT_TOKEN
valueFrom:
secretKeyRef:
name: review-7f3a9c-llm
key: result-token
volumeMounts:
- name: work
mountPath: /work
- name: inputs
mountPath: /in
readOnly: trueNotes:
activeDeadlineSecondscounts from the Job's start, so image pulls and the clone use it up. Keep it above--timeoutplus time to finish and deliver, so pergent's own budget always ends first.- The Job's deadline and a cancelled Job both send
SIGTERM;terminationGracePeriodSeconds: 30is what lets pergent deliver afterwards. - If the init container and the pergent container run as different users, git refuses the checkout as "dubious ownership" and pergent reports
checkout_missing. Run both as the same user, or addsafe.directoryfor the checkout (for exampleGIT_CONFIG_COUNT=1,GIT_CONFIG_KEY_0=safe.directory,GIT_CONFIG_VALUE_0=/work/repoin the pergent container's environment). - Fetching a commit by SHA depends on the git host allowing it. If the merge base can't be fetched that way, fetch the target branch with a larger
--depthinstead; pergent reportscommit_missingwhen a commit is absent. - A crash, an out-of-memory kill or a deadline kill delivers no result. The service must read the Job's status for those.
The result's fields, error codes and examples are in docs/agent-result-schema.md.
| Flag | Description | Default |
|---|---|---|
--skill |
Preset name or path to skill .md file (repeatable) |
required |
--local |
Local mode: diff from git, output to stdout | false |
--base-branch |
Base branch for local git diff | main |
--test |
Test opencode connection (sends a hello prompt) | false |
--previous-review |
Attach the findings from pergent's previous review comment as context on re-runs (platform mode only) | false |
--platform |
github or gitlab |
auto-detect |
--max-turns |
Max agentic turns per opencode run | 20 |
--context-limit |
Model context window in tokens. Only for models opencode has no limits for (e.g. behind a custom OPENCODE_BASE_URL); otherwise opencode's model registry is used |
unset |
--max-tokens |
Deprecated and ignored. It used to set the context window, which made opencode compact far below the model's real limit. Prints a warning | — |
--timeout |
Breaking: max wall-clock time for the whole review, all skills, batches, retries and checks together. It used to apply to each skill run separately, so a job that relied on a per-skill limit now shares one budget | 10m |
--concurrency |
Max opencode runs in flight at once, across all skills and batches. Must be at least 1 | 4 |
--repo-path |
Path to repo root | . |
| Variable | Description |
|---|---|
OPENCODE_PROVIDER |
LLM provider (anthropic, openai, etc.). Required in CI unless PERGENT_USE_OPENCODE_CONFIG=true |
OPENCODE_API_KEY |
API key for the LLM provider |
OPENCODE_MODEL |
Model to use (e.g., claude-sonnet-4). Ignored, with a warning or an error, when OPENCODE_PROVIDER is empty |
OPENCODE_BASE_URL |
Custom API base URL (for LiteLLM, vLLM, Ollama, etc.) |
OPENCODE_NPM |
Override AI SDK package (default: @ai-sdk/openai-compatible when OPENCODE_BASE_URL is set) |
| Variable | Description |
|---|---|
GITHUB_TOKEN |
GitHub API access token |
GITLAB_TOKEN |
GitLab API access token |
| Variable | Description |
|---|---|
PERGENT_MAX_TURNS |
Max agentic turns per opencode run |
PERGENT_CONTEXT_LIMIT |
Model context window in tokens (see --context-limit) |
PERGENT_MAX_TOKENS |
Deprecated and ignored (see --max-tokens) |
PERGENT_TIMEOUT |
Max wall-clock time for the whole review (see --timeout; breaking: it used to be per skill run) |
PERGENT_CONCURRENCY |
Max opencode runs in flight at once (see --concurrency) |
PERGENT_PREVIOUS_REVIEW |
true/false — attach the previous review's findings as context (explicit --previous-review flag wins over this) |
| Variable | Description |
|---|---|
PERGENT_USE_OPENCODE_CONFIG |
true/false — in GitHub/GitLab CI, allow a review with an empty OPENCODE_PROVIDER on opencode's own config. No sandbox: do not use on untrusted changes (see option B). Not needed with --local; agent mode always needs a provider |
Config resolution priority: CLI flags > PERGENT_* env vars > auto-detect from CI > defaults.
An unset or empty variable means the default. A value that can't be used is a config error that names the variable and the value (PERGENT_TIMEOUT="600" is not a duration such as 90s or 10m), not a silent fall back to the default. Numbers must be whole (PERGENT_MAX_TURNS and PERGENT_CONCURRENCY at least 1, PERGENT_CONTEXT_LIMIT not negative), PERGENT_TIMEOUT needs a unit, and PERGENT_PREVIOUS_REVIEW and PERGENT_USE_OPENCODE_CONFIG are true or false. --max-turns below 1 is rejected as well. A flag that is passed wins, and its variable is not read.
pergent shows step-by-step progress as opencode works. Skills and batches run in parallel, so lines from different runs interleave:
Step 1: starting...
Step 1: tool read
Step 1: done (472 tokens)
Step 2: starting...
Step 2: generating text...
Step 2: done (800 tokens)
If no steps appear, the model may be slow to respond. Test the connection:
pergent --testIf --test also hangs, test opencode directly:
opencode run "Say hello"If that also hangs, the issue is your LLM connection, not pergent.
The message is the cause (see When a review fails). To see everything opencode reported, dump its raw output:
PERGENT_DEBUG_OUTPUT=/tmp/pergent-raw.json pergent --local --skill code-review
cat /tmp/pergent-raw.*.jsonThis shows the raw NDJSON events. Every opencode run (and every retry of one) writes its own file, named from your path with a number before the extension (/tmp/pergent-raw.1.json, /tmp/pergent-raw.2.json, ...), and pergent logs each path as it writes it. A file that exists is never written over. The files are readable by you alone (mode 600), and credentials such as the API key are redacted. Treat them as private anyway: they hold the code and the review. The review is the text of the last step_start...step_finish block whose step_finish has a reason other than tool-calls. "type":"error" events carry opencode's error, and text events with "synthetic":true are opencode's own messages, never the model's.
Common causes:
- Wrong base branch: your repo uses
masternotmain. Use--base-branch master. - No git repo: run
git initand make at least one commit. - No remote:
origin/maindoesn't exist. Push to a remote or create the ref.
If you see errors mentioning /responses: Invalid model name or 404s from /v1/responses, your API proxy doesn't support OpenAI's Responses API. pergent handles this automatically when OPENCODE_BASE_URL is set — it uses @ai-sdk/openai-compatible which calls /chat/completions instead. Make sure OPENCODE_BASE_URL ends with /v1:
export OPENCODE_BASE_URL=https://your-proxy.example.com/v1The diff, skill instructions and the files the agent read no longer fit in the model's context. Either:
- Use a model with a larger context window
- Lower
--max-turns, so the agent reads less before answering - If opencode doesn't know your model's limits (custom
OPENCODE_BASE_URL), set--context-limitto the real window
If you set OPENCODE_PROVIDER, pergent generates a config for opencode. If the generated config doesn't match your opencode version, unset the provider env vars and let opencode use its own config:
unset OPENCODE_PROVIDER OPENCODE_MODEL OPENCODE_BASE_URL OPENCODE_API_KEY
pergent --local --skill code-reviewThis is for your own machine. In GitHub or GitLab CI the same step needs PERGENT_USE_OPENCODE_CONFIG=true, and then the review has no sandbox (see below).
If you already have opencode configured (e.g., connected to GitHub Copilot), leave OPENCODE_PROVIDER unset. pergent will skip config generation and opencode uses its own config:
pergent --local --skill code-reviewIn GitHub or GitLab CI, set PERGENT_USE_OPENCODE_CONFIG=true as well. Without it pergent stops with OPENCODE_PROVIDER is not set. That mode has no sandbox, so do not use it on untrusted changes (see option B).
In GitHub or GitLab CI, an empty OPENCODE_PROVIDER stops the review, so a CI variable that was never defined (it expands to an empty string) can't quietly turn the sandbox off. Set OPENCODE_PROVIDER and OPENCODE_MODEL (and OPENCODE_API_KEY), or, if you want opencode's own config and trust every change under review, set PERGENT_USE_OPENCODE_CONFIG=true. If the error also says OPENCODE_MODEL is set without OPENCODE_PROVIDER, the provider variable is the one that is missing.
Pressing Ctrl+C cleanly stops both pergent and the opencode subprocess.
| pergent | pr-agent | |
|---|---|---|
| Review approach | Agentic loop -- reads files, greps, follows references | Single API call with static diff |
| Context | Builds context iteratively, on-demand | Entire diff sent in one prompt |
| LLM support | Any provider via opencode | Specific provider integrations |
| Customization | Skill files (.md) |
Configuration flags |
| Small context models | Works with 128k models (reads on-demand) | Needs large context for big diffs |
MIT. See LICENSE.
