Coding agents have been helping us ship more PRs than ever, but those PRs still need reviewing. I got curious how big this change actually is, so I pulled the numbers from GitHub. In January 2024, about 3.3 million pull requests were opened in public repos. By September 2026, that number was 20 million, roughly six times as many.
The numbers by month
| Month | PRs opened |
|---|---|
| Jan 2024 | 3,314,277 |
| Feb 2024 | 3,384,416 |
| Mar 2024 | 3,652,461 |
| Apr 2024 | 3,683,605 |
| May 2024 | 3,779,929 |
| Jun 2024 | 3,380,668 |
| Jul 2024 | 3,456,764 |
| Aug 2024 | 3,278,558 |
| Sep 2024 | 3,492,979 |
| Oct 2024 | 3,966,286 |
| Nov 2024 | 3,821,360 |
| Dec 2024 | 3,687,405 |
| Jan 2025 | 3,799,730 |
| Feb 2025 | 3,966,284 |
| Mar 2025 | 4,531,629 |
| Apr 2025 | 4,444,944 |
| May 2025 | 4,718,753 |
| Jun 2025 | 4,972,950 |
| Jul 2025 | 5,245,440 |
| Aug 2025 | 5,359,032 |
| Sep 2025 | 5,708,934 |
| Oct 2025 | 6,570,161 |
| Nov 2025 | 6,545,524 |
| Dec 2025 | 6,389,929 |
| Jan 2026 | 7,040,349 |
| Feb 2026 | 7,910,383 |
| Mar 2026 | 10,106,258 |
| Apr 2026 | 10,372,781 |
| May 2026 | 11,923,378 |
| Jun 2026 | 12,577,642 |
| Jul 2026 | 14,665,608 |
| Aug 2026 | 16,487,333 |
| Sep 2026 | 20,048,893 |
However, if agents write the code and humans still review everything manually, reviews become the bottleneck. I think this is the next problem we need to solve, at least for most of the PRs we ship. To get there, we need to stop treating code review as a manual checkpoint and build the rules and feedback loops that make agent reviews reliable.
The first step is to ask your agent to review its own code when it's done. On its own, this isn't reliable enough, for a couple of reasons. First, your agent builds up memories and skills on your computer as you work, and it brings them into every review. For example, while rushing a hotfix to production, you might have told your agent to dismiss a valid concern. If it saved that feedback, it could have shaped every review since, without you realizing it.
This also goes the other way. You might have led work on a part of the codebase, defined its standards and interfaces, and debugged production issues there with your agents. Your agents learned from that too, saving memories and building skills along the way. So your local agent and your teammate's local agent can review the same PR and come back with different results. (Surprise: your senior was getting different results with the same prompt after all. You weren't being delusional.)
The differences don't stop at context. We also have different tools and different levels of access to the services we use, often depending on seniority. For example, a read-only connection to the production database can help an agent review a migration PR, while access to Grafana can help it review a fix for a production bug. But an organization can't just hand out god credentials to everyone on the engineering team.
This brings us to a code review agent that runs independently of our computers. It runs remotely on every pull request, the same way for everyone, no matter who opened the PR or whose agent wrote it. The engineering team owns its tools, skills and context, just as it owns CI and the deploy pipeline.
This gives us a few benefits:
- Every PR gets reviewed, and the review is visible on the PR. We don't have to take a teammate's word that their agent reviewed it locally.
- The team gives the reviewer the access it needs, so a good review doesn't depend on what the person opening the PR can access.
- The team maintains shared skills that explain how the codebase works and why certain decisions were made. As we learn something new, we can add it for future reviews.
- The prompt and skills are version-controlled. If the reviewer starts acting weird, we can see what changed.
- The review doesn't depend on who prompted it or what's on their machine.
Below is how we set this up, and then a small experiment that shows what each piece adds.
How the reviewer runs
The whole setup is a GitHub Actions workflow and a folder of markdown files. When a PR is opened or updated, the workflow sends one request to epho. epho starts a sandbox, clones the repos, and runs a coding agent with our prompt, our skills and the tools we allowed. The agent reads the change, checks what it needs to, and posts one review on the PR.
Each PR gets its own chat with the reviewer, which stays open for the life of the PR. New commits go to the same conversation, so the reviewer remembers its earlier feedback and only reviews what's changed. It doesn't repeat comments. When a commit fixes an issue it flagged, it replies "✅ Fixed in abc1234" and resolves the thread, so anyone opening the PR can see what still needs attention.
The files
.github/
├── workflows/
│ └── epho-code-review.yml
└── epho-review/
├── system-prompt.md how to review and post (copy as is)
├── follow-up.md what to do when new commits are pushed
├── request.jq what the agent gets: repos, tools, skills
└── skills/ what this team knows
├── your-skill.md
└── ...
The workflow
This is the whole workflow. It finds the PR's earlier chat if there is one, builds the request, sends it, and waits for the review to finish.
.github/workflows/epho-code-review.yml
name: epho · code review
on:
pull_request:
types: [opened, synchronize, reopened]
permissions:
contents: read
pull-requests: write
concurrency:
group: epho-review-${{ github.repository_id }}-pr-${{ github.event.pull_request.number }}
cancel-in-progress: false
jobs:
review:
if: github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
HEAD_REF: ${{ github.event.pull_request.head.ref }}
BASE_REF: ${{ github.event.pull_request.base.ref }}
BEFORE_SHA: ${{ github.event.before }}
CHAT_CACHE_PREFIX: epho-chat-v2-${{ github.repository_id }}-pr-${{ github.event.pull_request.number }}-
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
ref: ${{ github.event.pull_request.base.sha }}
- name: Find this PR's epho chat
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
with:
path: ${{ runner.temp }}/epho-chat
key: ${{ env.CHAT_CACHE_PREFIX }}${{ github.run_id }}-${{ github.run_attempt }}
restore-keys: ${{ env.CHAT_CACHE_PREFIX }}
- name: Review this PR with epho
id: review
env:
EPHO_KEY: ${{ secrets.EPHO_API_KEY }}
OPENCODE_KEY: ${{ secrets.OPENCODE_KEY }}
WEB_REPO: ${{ github.repository_owner }}/invoices-web
WEB_REPO_TOKEN: ${{ secrets.PAT_KEY_REPOS }}
SUPABASE_MCP_URL: ${{ secrets.SUPABASE_MCP_URL }}
SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }}
run: |
set -euo pipefail
CTX="$RUNNER_TEMP/ctx"
mkdir -p "$CTX"
CHAT_FILE="$RUNNER_TEMP/epho-chat/chat_id"
shopt -s nullglob
for f in .github/epho-review/skills/*.md; do
jq -n --arg name "$(basename "$f" .md)" --rawfile content "$f" '{name: $name, content: $content}'
done | jq -s . > "$CTX/skills.json"
run_turn() {
jq -n \
--arg chat_id "$1" \
--slurpfile skills "$CTX/skills.json" \
--rawfile system_prompt .github/epho-review/system-prompt.md \
--rawfile follow_up .github/epho-review/follow-up.md \
-f .github/epho-review/request.jq > "$CTX/request.json"
curl -sS -N https://app.epho.io/api/v1/chat \
-H "Authorization: Bearer $EPHO_KEY" \
-H "Content-Type: application/json" \
-d @"$CTX/request.json" > "$CTX/stream.txt" || true
CHAT_ID=$(grep -o '"chat_id":"[^"]*"' "$CTX/stream.txt" | head -1 | cut -d'"' -f4 || true)
STATUS=$(grep -o '"type":"done","status":"[a-z_]*"' "$CTX/stream.txt" | tail -1 | cut -d'"' -f8 || true)
if [ -n "$CHAT_ID" ] && [ -z "$STATUS" ]; then
echo "Stream ended before the turn finished; waiting for chat $CHAT_ID"
for _ in $(seq 1 180); do
STATUS=$(curl -sS "https://app.epho.io/api/v1/chat/$CHAT_ID/response" \
-H "Authorization: Bearer $EPHO_KEY" | jq -r '.turns[-1].status // empty' || true)
case "$STATUS" in completed|failed|canceled|timed_out) break ;; esac
sleep 20
done
fi
echo "epho chat: ${CHAT_ID:-none} · status: ${STATUS:-none}"
}
PREVIOUS_CHAT=$(cat "$CHAT_FILE" 2>/dev/null || true)
CHAT_ID="" STATUS=""
if [ -n "$PREVIOUS_CHAT" ]; then
echo "Continuing chat $PREVIOUS_CHAT"
run_turn "$PREVIOUS_CHAT"
fi
if [ "$STATUS" != "completed" ]; then
if [ -n "$PREVIOUS_CHAT" ]; then
echo "::warning::Could not continue chat $PREVIOUS_CHAT; starting a new one."
fi
run_turn ""
fi
if [ "$STATUS" != "completed" ]; then
echo "::error::epho review run ended as '${STATUS:-no status}'."
exit 1
fi
if [ "$CHAT_ID" != "$PREVIOUS_CHAT" ]; then
mkdir -p "$(dirname "$CHAT_FILE")"
echo "$CHAT_ID" > "$CHAT_FILE"
echo "new_chat=true" >> "$GITHUB_OUTPUT"
fi
- name: Remember this PR's epho chat
if: steps.review.outputs.new_chat == 'true'
uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
with:
path: ${{ runner.temp }}/epho-chat
key: ${{ env.CHAT_CACHE_PREFIX }}${{ github.run_id }}-${{ github.run_attempt }}
What the agent gets
request.jq turns the workflow's environment into the request below. This is where the team decides what the reviewer can see: which repos, which tools, which skills. Secrets come from GitHub Actions secrets and never sit in the repo.
The request epho receives (shortened)
{
"harness": "opencode",
"model": "opencode/gpt-6.1-sol",
"system_prompt": "# Pull request review\n...",
"skills": [
{ "name": "your-skill", "content": "---\nname: your-skill\n..." },
...
],
"repos": [
{ "url": "https://github.com/acme/invoices-api", "branch": "the-pr-branch", "token": "…" },
{ "url": "https://github.com/acme/invoices-web", "branch": "main", "token": "…" }
],
"mcp_servers": [
{ "name": "supabase", "type": "http", "url": "…read_only=true…",
"headers": { "Authorization": "Bearer …" } }
],
"prompt": "Review this pull request and post your review."
}
request.jq
def process_env: {
GITHUB_TOKEN: env.GH_TOKEN,
GH_REPO: env.GH_REPO,
REPO_DIR: "/tmp/agent-runner-persistent/\(env.GH_REPO | sub("/"; "__"))",
WEB_REPO_DIR: "/tmp/agent-runner-persistent/\(env.WEB_REPO | sub("/"; "__"))",
PR_NUMBER: env.PR_NUMBER,
HEAD_SHA: env.HEAD_SHA,
HEAD_REF: env.HEAD_REF,
BASE_REF: env.BASE_REF,
BEFORE_SHA: env.BEFORE_SHA
} | with_entries(select((.value // "") != ""));
def per_turn: {
provider_api_key: env.OPENCODE_KEY,
repos: [
{ url: "https://github.com/\(env.GH_REPO)", branch: env.HEAD_REF, token: env.GH_TOKEN },
{ url: "https://github.com/\(env.WEB_REPO)", branch: "main", token: env.WEB_REPO_TOKEN }
],
process_env: process_env
};
if $chat_id == "" then
per_turn + {
harness: "opencode",
model: "opencode/gpt-6.1-sol",
system_prompt: $system_prompt,
skills: $skills[0],
mcp_servers: [
{ name: "supabase", type: "http", url: env.SUPABASE_MCP_URL,
headers: { Authorization: "Bearer \(env.SUPABASE_ACCESS_TOKEN)" } }
],
prompt: "Review this pull request and post your review."
}
else
per_turn + {
chat_id: $chat_id,
prompt: $follow_up
}
end
The prompt
The system prompt tells the agent how to review code, what code quality and security checks to run, and how to write and post comments. You can copy it into your repo unchanged.
system-prompt.md (119 lines)
# Pull request review
## Your job
Review one GitHub pull request and post what you find as a single review.
## Environment
These are set in the environment and refreshed on every message. Read them from the environment each time; don't reuse values from earlier messages.
| Variable | Meaning |
| --- | --- |
| `GITHUB_TOKEN` | token for `gh` |
| `GH_REPO` | `owner/repo` |
| `REPO_DIR` | where the repo is cloned |
| `PR_NUMBER` | the pull request |
| `HEAD_SHA` | the commit to review and attach the review to |
| `HEAD_REF` | the PR branch |
| `BASE_REF` | the branch the PR merges into |
| `BEFORE_SHA` | the commit you reviewed last time (only set when new commits were pushed) |
The clone is shallow and isn't updated between messages. Sync it at the start of every task, and run git, gh and grep from inside it:
```sh
cd "$REPO_DIR" && git fetch origin "$HEAD_SHA" && git checkout --detach "$HEAD_SHA"
```
You may get more than this repo: other repos, MCP tools, more environment variables. Your skills say what they are and when to use them.
## Process
1. Read what the PR is meant to do and the diff:
```sh
gh pr view "$PR_NUMBER" --json title,body,author
gh pr diff "$PR_NUMBER"
```
2. Read the list of skills in your instructions. Load each skill whose description fits this change and follow it completely. A PR often needs several.
3. Do the code review and the security review below.
4. Post one review. If the PR is clean, post a one-line review that starts with ✅.
## Code review
- Check that the code does what the PR description says.
- Read each changed file completely, not only the changed lines.
- For each function the diff changes, find its callers with grep and read them. A change that breaks a caller outside the diff is a bug in this PR.
- For each condition, query and loop, think about the values at the edges: empty, null, zero, one, many, the oldest data, an unusual status.
- Look for errors that are caught and ignored, and failures that aren't reported.
- Look for tests that cover the change. New logic without a test is P2.
- Read the code before you claim something. Don't guess.
- Don't comment on formatting, naming or import order.
## Security
Report a problem only when you can show it: name the input, the path through the code and the harm. Don't report theoretical risks or general best practices.
- **Injection**: input that reaches SQL, a shell command, a template or a file path built from strings. Parameterized queries are safe.
- **Input**: every external input is validated where it enters. A request body passed straight into an insert or update lets a caller set any column.
- **Access**: who can call this code, and can they reach data or actions that aren't theirs?
- **Data exposure**: exception messages, stack traces or SQL in responses; secrets or personal data in logs; endpoints that return or change many rows without a limit.
- **Secrets**: keys, tokens and passwords in code, tests or config. Configuration comes from the environment.
- **Dependencies**: for a new package, check the name for typos of a popular package, install scripts, and whether it's maintained.
- **CI**: `pull_request_target` that runs PR code with secrets, `${{ }}` expressions inside `run:`, actions not pinned to a commit SHA, `permissions:` that grow without a reason.
## Writing comments
- Start every comment with a header line giving its severity and kind, then a blank line, e.g. `🟠 **P1** · 🐛 **Bug**`.
| Severity | Meaning |
| --- | --- |
| 🔴 **P0** | must fix before merging: data loss or corruption, a security hole, a change that breaks production or another service |
| 🟠 **P1** | should fix: wrong behavior, an edge case that real data hits |
| 🟡 **P2** | consider fixing: error handling gaps, missing tests, simplifications |
| Kind | Meaning |
| --- | --- |
| 🐛 **Bug** | the code doesn't do what it should |
| 🔒 **Security** | a vulnerability or leaked secret |
| ⚠️ **Error handling** | a failure that isn't caught or reported |
| ✂️ **Simplification** | the same behavior with less code |
- One issue per comment: what's wrong, why it matters, and how to fix it.
- Write like a colleague would: short and direct. No praise, no hedging, and don't restate what the diff already shows.
- Never repeat one of your earlier review threads. Find them with:
```sh
gh api graphql -F owner="${GH_REPO%/*}" -F repo="${GH_REPO#*/}" -F pr="$PR_NUMBER" -f query='
query($owner: String!, $repo: String!, $pr: Int!) {
repository(owner: $owner, name: $repo) { pullRequest(number: $pr) {
reviewThreads(first: 100) { nodes { id isResolved path line
comments(first: 1) { nodes { id author { login } body } } } } } } }' \
--jq '[.data.repository.pullRequest.reviewThreads.nodes[]
| select(.comments.nodes[0].author.login == "github-actions")
| {thread_id: .id, isResolved, path, line, body: .comments.nodes[0].body}]'
```
## Posting
1. Write the review to `review.json` and post it:
```sh
gh api --method POST "repos/$GH_REPO/pulls/$PR_NUMBER/reviews" --input review.json
```
```json
{ "commit_id": "<HEAD_SHA>", "event": "COMMENT",
"body": "<one to three sentence summary>",
"comments": [ { "path": "<file>", "line": <line in the new file>, "body": "<the comment>" } ] }
```
Put each issue in `comments`, on its line.
2. If GitHub rejects the review with a 422 (it couldn't place a line), post one comment with the full review instead, each issue under its header with its file and line:
```sh
gh pr comment "$PR_NUMBER" --body "<your review in markdown>"
```
3. Always use event `COMMENT`.
follow-up.md, sent when new commits are pushed
New commits were pushed. You already reviewed this PR in this conversation.
Sync the clone, then review what changed since your last review:
```sh
cd "$REPO_DIR" && git diff "$BEFORE_SHA" "$HEAD_SHA"
```
Use the whole PR diff for context. If `BEFORE_SHA` isn't set, or this fails (the branch was force-pushed or rebased), review the whole PR diff instead:
```sh
cd "$REPO_DIR" && git merge-base --is-ancestor "$BEFORE_SHA" "$HEAD_SHA"
```
Fetch your earlier review threads. For each unresolved one:
- If the new code fixes it, reply with a check mark and resolve the thread:
```sh
gh api graphql -f thread=<thread_id> -f body="✅ Fixed in ${HEAD_SHA:0:7}" -f query='
mutation($thread: ID!, $body: String!) {
addPullRequestReviewThreadReply(input: {pullRequestReviewThreadId: $thread, body: $body}) { comment { id } }
resolveReviewThread(input: {threadId: $thread}) { thread { isResolved } } }'
```
- If it's still a problem, leave it alone. Don't post it again.
Only resolve your own threads, and only when the code fixes them. Post comments only for problems that are new. If there's nothing new, skip the review; don't post an empty one.
Skills
Skills are where the team's knowledge goes. Each one is a short markdown file: a description that tells the agent when to use it, and the rules and steps to follow when it does.
A skill holds knowledge that is not in the code. Usually, this knowledge is in the heads of the people on the team. For example, a skill can tell the agent why the team made a decision, or which step of a migration the team is on.
When the team learns something new, like the fix from last month's incident, it goes into a skill, and every review after that uses it.
Does it make a difference?
To test this out, we built a toy invoicing app with an API, a separate frontend and a Postgres database containing two years of slightly messy data. Then we created a few API PRs with problems that aren't obvious from the diff alone.
We tested three reviewer setups, running each one three times on every PR. All used the same model, prompt and sandbox setup. The difference was which repos, tools and team skills they could access:
- ✓the prompt
- ✓the repo
- –other repos
- –database
- –team skills
- ✓the prompt
- ✓the repo
- ✓the frontend repo
- ✓read-only database
- –team skills
- ✓the prompt
- ✓the repo
- ✓the frontend repo
- ✓read-only database
- ✓four team skills
PR 1: An API change that breaks the frontend
The first PR removes the old amount field from the API. Nothing in the API repo breaks, but the frontend still uses that field to display prices. Without it, every price shows up as $NaN.
Reviewer 1 only had the API repo and missed the problem in all three runs. Reviewers 2 and 3 also had the frontend repo. Both caught it every time and pointed to the screens that would break.
But that PR had another problem. The team was moving amounts from dollars to cents in four steps. This PR removed the old field before the frontend had switched to the new one.
That migration plan wasn't in the code or the database. We'd written it down in a team skill. Only Reviewer 3 had that skill, and it caught the skipped step in all three runs.
PR 2: A migration that fails on existing data
The next PR adds a case-insensitive unique index on customer emails. But the database already has entries like [email protected] and [email protected]. The new index treats those as duplicates, so the migration would fail.
Reviewer 1 spotted the possibility but couldn't confirm it: If any such rows exist, this index creation fails with a unique violation.
Reviewer 2 queried the database and found 37 duplicate groups across 74 customers. It could confirm the migration would fail.
Reviewer 3 found the same duplicates, but marked the issue P0, a blocker, rather than P1. The team's migration skill told it that a migration that fails on production data must block the merge.
What about a clean PR?
We also included a clean PR as a control. All three reviewers left the same single note about missing tests. In this experiment, the extra context helped catch problems without adding more comments to the clean PR.
Even in a limited experiment like this, the same model and prompt caught different problems depending on what the agent had access to. Putting the repos, tools and team skills together in an isolated sandbox, and starting the agent in the parent directory, gives it a clear place to work from. It can explore both repos and use the same team knowledge and access for every review.
Check out the full experiment
Everything above is in the invoices-api repo: the workflow, the prompt, the skills, and every pull request from the experiment with its reviews. The frontend it reviews against is invoices-web.
$ curl app.epho.io/api/v1/chat
run claude code, codex, or opencode in the cloud.
one request starts an agent in a sandbox and streams the work back. no sdk, no daemon, no infra on your side.

