TL;DR
- An ai code review bot reads a raw
git diff, sends it to Claude with a structured reviewer prompt, and returns findings as typed JSON (file, line, severity, suggestion). - Use Claude’s tool-use feature to force structured output. No fragile regex parsing of prose.
- The same script wires into a Git pre-push hook in two lines or into a GitHub Actions / GitLab CI step with no extra infrastructure.
- Prompt caching on the system prompt cuts costs by up to 90% on large diff batches. See Part 4 for the full caching guide.
- claude-sonnet-4-6 handles most diffs well. Switch to claude-opus-4-8 only for security-critical or architectural reviews where higher reasoning depth pays for itself.
- The complete POC is under 150 lines of Python and has zero runtime dependencies beyond the Anthropic SDK.
Why Teams Build an AI Code Review Bot
Code review is the most consistent bottleneck in any engineering team’s delivery cycle. A senior engineer might spend two to four hours a day reading diffs. At a 10-person team with two seniors, that’s 40 to 80 engineer-hours a week on review. Most of that time catches the same categories of issues: missing null checks, accidental secrets committed to source, SQL built from string concatenation, inefficient loops that could be a list comprehension, missing error handling on network calls.
An ai code review bot does not replace a human reviewer. It handles the first pass, the mechanical layer: style violations, obvious bugs, security anti-patterns, missing tests for changed code paths. It returns findings before the human ever opens the diff. The human reviewer arrives at a diff that has already caught the low-hanging bugs and can focus on architecture, intent, and correctness.
The economics are straightforward. A 500-line diff costs about $0.01 to $0.03 to review with claude-sonnet-4-6. A senior engineer’s hourly rate translates to far more than that per review. Even a modest catch rate on real bugs (say, one caught defect per 20 reviews) saves hours of debugging in production.
What the Bot Actually Catches
- Hardcoded credentials, API keys, and tokens in diffs
- SQL concatenation and other injection vectors
- Unhandled exceptions on I/O, network, and database calls
- Use of deprecated or unsafe standard-library functions
- Off-by-one errors in loop boundaries
- Missing input validation on function arguments
- Dead code introduced by the diff
- Inconsistency with patterns visible elsewhere in the diff
What It Does Not Replace
The bot works on text. It cannot run the code, execute tests, check runtime behavior, or understand your team’s full business domain. Architectural decisions, API contract changes, subtle race conditions that depend on distributed state, and review of intent (does this feature actually solve the right problem?) still require a human. Think of the bot as a very fast junior reviewer who reads fast, never gets tired, and always flags the same checklist.
Architecture of the AI Code Review Bot
git diff through the reviewer script to structured findings. The same binary runs as a pre-push hook or a CI step.The pipeline has four stages:
- Capture: Collect the raw unified diff. In a pre-push hook this is
git diff origin/main...HEAD. In CI it is the diff between the PR base and head. - Package: Wrap the diff in a system prompt that gives Claude its reviewer persona, then define a tool schema that describes the output shape you want. Pass
tool_choiceto force the model to always call that tool. - Review: Send the message to the Claude API. Because
stop_reasonistool_use, read the tool input block, which is already parsed JSON. - Act: In a hook, print findings and exit 1 if any severity is high. In CI, post findings as a PR comment via the GitHub API and annotate lines.
Structured Output with Tool Use
Part 3 of this series covered structured output from Claude in detail. The short version: telling Claude “respond in JSON” in the system prompt sometimes works but occasionally drifts. Using a tool definition with tool_choice forces the model through a schema-validated path every time. For an automated pipeline that feeds structured data downstream, the schema-enforced path is the only acceptable option.
The tool we define for the code reviewer looks like this:
REVIEW_TOOL = {
"name": "report_findings",
"description": (
"Report all code review findings for the provided diff. "
"Call this tool exactly once with all findings combined."
),
"input_schema": {
"type": "object",
"properties": {
"findings": {
"type": "array",
"items": {
"type": "object",
"properties": {
"file": {"type": "string"},
"line": {"type": "integer"},
"severity": {"type": "string", "enum": ["high", "medium", "low", "info"]},
"category": {"type": "string"},
"suggestion": {"type": "string"}
},
"required": ["file", "line", "severity", "category", "suggestion"]
}
},
"summary": {"type": "string"}
},
"required": ["findings", "summary"]
}
}
The enum on severity means you will never get a surprise string like “critical” or “warning”. The four values map cleanly to exit codes: high fails the hook, the others produce warnings or annotations.
Why Not Rely on Prose Output?
Prose output forces you to write a parser. That parser will break the moment the model changes a phrase. The tool use guide in Part 2 explains the full mechanics of how Claude decides to call a tool and how you read the result. For this use case, because we pass tool_choice={"type": "tool", "name": "report_findings"}, the model always calls the tool. block.input is already a Python dict you can pass directly to your downstream logic.
The Complete POC
Install and Project Layout
pip install anthropicThe project is a single directory with four files:
ai-code-reviewer/
reviewer.py # main script
hook.sh # git pre-push hook wrapper
.env.example # environment variable template
requirements.txt # pinned dependencies
requirements.txt
anthropic>=0.30.0
.env.example
# Copy to .env and fill in your key.
# Never commit the real .env file.
ANTHROPIC_API_KEY=sk-ant-...
Full Source: reviewer.py
#!/usr/bin/env python3
"""
AI code review bot using Claude and Python.
Usage:
# Review staged changes vs main:
git diff origin/main...HEAD | python reviewer.py
# Review a specific diff file:
python reviewer.py --diff path/to/changes.diff
# JSON output only (for CI pipelines):
git diff origin/main...HEAD | python reviewer.py --json-only
Exit codes:
0 No high-severity findings (review passed)
1 One or more high-severity findings (review failed)
2 Script error (API failure, empty diff, etc.)
"""
import argparse
import json
import os
import sys
import textwrap
import time
import anthropic
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
MODEL = "claude-sonnet-4-6"
MAX_TOKENS = 4096
MAX_DIFF_CHARS = 80_000 # ~20k tokens; diffs larger than this are truncated
SYSTEM_PROMPT = textwrap.dedent("""\
You are a senior software engineer performing a thorough code review.
You will receive a unified diff (git diff output).
Your job is to identify:
- Security vulnerabilities (hardcoded secrets, injection vectors, insecure deserialization)
- Correctness bugs (off-by-one errors, unchecked null/None, integer overflow, incorrect conditionals)
- Error handling gaps (uncaught exceptions on I/O, missing rollback on DB errors)
- Performance problems that are clearly visible in the diff (O(n^2) in a hot path, N+1 queries)
- Deprecated or dangerous API usage
- Significant style violations that hurt readability or maintainability
Rules:
- Only report issues for lines that appear in the diff (added lines starting with +).
- Do NOT report issues on lines prefixed with - (deleted lines).
- Line numbers refer to the target file, as shown in the diff hunk header (@@).
- For each finding provide a concrete, actionable suggestion. Do not say "fix this".
Say exactly what to change and why.
- Use severity "high" only for issues that could cause security breaches, data loss,
or silent data corruption. Use "medium" for correctness bugs that would surface at runtime.
Use "low" for style and maintainability. Use "info" for observations with no action required.
- If the diff has no issues worth reporting, return an empty findings array and a
summary that says the diff looks clean.
""")
REVIEW_TOOL = {
"name": "report_findings",
"description": (
"Report all code review findings for the provided diff. "
"Call this tool exactly once with all findings combined."
),
"input_schema": {
"type": "object",
"properties": {
"findings": {
"type": "array",
"items": {
"type": "object",
"properties": {
"file": {"type": "string"},
"line": {"type": "integer"},
"severity": {
"type": "string",
"enum": ["high", "medium", "low", "info"]
},
"category": {"type": "string"},
"suggestion": {"type": "string"}
},
"required": ["file", "line", "severity", "category", "suggestion"]
}
},
"summary": {"type": "string"}
},
"required": ["findings", "summary"]
}
}
# ---------------------------------------------------------------------------
# Core review logic
# ---------------------------------------------------------------------------
def review_diff(diff_text: str, json_only: bool = False) -> dict:
"""
Send diff_text to Claude and return the structured findings dict.
Raises SystemExit(2) on API errors after simple retry logic.
"""
if not diff_text.strip():
print("No diff content found. Nothing to review.", file=sys.stderr)
sys.exit(2)
# Truncate very large diffs with a visible warning.
if len(diff_text) > MAX_DIFF_CHARS:
if not json_only:
print(
f"[warn] Diff is {len(diff_text):,} chars; truncating to {MAX_DIFF_CHARS:,}. "
"Large diffs may miss later files.",
file=sys.stderr
)
diff_text = diff_text[:MAX_DIFF_CHARS] + "\n\n[... diff truncated ...]"
client = anthropic.Anthropic() # reads ANTHROPIC_API_KEY from env
user_message = f"Please review the following git diff:\n\n```diff\n{diff_text}\n```"
for attempt in range(1, 4):
try:
msg = client.messages.create(
model=MODEL,
max_tokens=MAX_TOKENS,
system=[
{
"type": "text",
"text": SYSTEM_PROMPT,
"cache_control": {"type": "ephemeral"}
}
],
tools=[REVIEW_TOOL],
tool_choice={"type": "tool", "name": "report_findings"},
messages=[{"role": "user", "content": user_message}]
)
break
except anthropic.APIStatusError as exc:
if exc.status_code in (429, 529) and attempt < 3:
wait = 2 ** attempt
print(f"[warn] Rate limited. Retrying in {wait}s (attempt {attempt}/3)...",
file=sys.stderr)
time.sleep(wait)
continue
print(f"[error] Claude API error: {exc}", file=sys.stderr)
sys.exit(2)
except anthropic.APIError as exc:
print(f"[error] Claude API error: {exc}", file=sys.stderr)
sys.exit(2)
# The model was forced to call the tool, so stop_reason == "tool_use".
tool_block = next(
(b for b in msg.content if b.type == "tool_use" and b.name == "report_findings"),
None
)
if tool_block is None:
print("[error] Claude did not call the expected tool. Raw response:", file=sys.stderr)
print(msg.model_dump_json(indent=2), file=sys.stderr)
sys.exit(2)
result = tool_block.input # already a dict, schema-validated by the model
result["usage"] = {
"input_tokens": msg.usage.input_tokens,
"output_tokens": msg.usage.output_tokens,
"cache_creation_input_tokens": getattr(msg.usage, "cache_creation_input_tokens", 0),
"cache_read_input_tokens": getattr(msg.usage, "cache_read_input_tokens", 0),
}
return result
# ---------------------------------------------------------------------------
# Output helpers
# ---------------------------------------------------------------------------
SEVERITY_COLOR = {
"high": "\033[91m", # bright red
"medium": "\033[93m", # yellow
"low": "\033[94m", # blue
"info": "\033[90m", # grey
}
RESET = "\033[0m"
def print_human_readable(result: dict) -> None:
findings = result.get("findings", [])
summary = result.get("summary", "")
usage = result.get("usage", {})
print()
print("=" * 72)
print(" AI Code Review Results")
print("=" * 72)
if not findings:
print("\n No issues found. Diff looks clean.\n")
else:
for i, f in enumerate(findings, 1):
sev = f["severity"]
color = SEVERITY_COLOR.get(sev, "")
print(f"\n [{i}] {color}{sev.upper()}{RESET} {f['category']}")
print(f" File : {f['file']}:{f['line']}")
# Wrap long suggestions at 68 chars, indent continuation lines.
wrapped = textwrap.fill(f["suggestion"], width=68,
initial_indent=" Fix : ",
subsequent_indent=" ")
print(wrapped)
print()
print(f" Summary: {summary}")
print()
if usage:
cache_hit = usage.get("cache_read_input_tokens", 0)
created = usage.get("cache_creation_input_tokens", 0)
print(f" Tokens: {usage['input_tokens']} in / {usage['output_tokens']} out"
f" | cache_create={created} cache_hit={cache_hit}")
print("=" * 72)
print()
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
def main() -> None:
parser = argparse.ArgumentParser(description="AI code review bot (Claude)")
parser.add_argument("--diff", help="Path to a .diff file (default: read from stdin)")
parser.add_argument("--json-only", action="store_true",
help="Print raw JSON findings to stdout and nothing else")
args = parser.parse_args()
if not os.environ.get("ANTHROPIC_API_KEY"):
print("[error] ANTHROPIC_API_KEY is not set.", file=sys.stderr)
sys.exit(2)
if args.diff:
with open(args.diff, "r", encoding="utf-8") as fh:
diff_text = fh.read()
else:
if sys.stdin.isatty():
print("[error] No diff provided. Pipe git diff output or use --diff.",
file=sys.stderr)
sys.exit(2)
diff_text = sys.stdin.read()
result = review_diff(diff_text, json_only=args.json_only)
if args.json_only:
print(json.dumps(result, indent=2))
else:
print_human_readable(result)
# Exit 1 if any finding is high severity.
high_count = sum(1 for f in result.get("findings", []) if f["severity"] == "high")
if high_count > 0:
if not args.json_only:
print(f" {high_count} high-severity finding(s). Review failed.", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()
Pre-Push Git Hook
Save the following as .git/hooks/pre-push and make it executable with chmod +x .git/hooks/pre-push.
#!/usr/bin/env bash
# .git/hooks/pre-push
# Runs the AI code review bot before every push.
# Set SKIP_AI_REVIEW=1 to bypass in an emergency.
set -euo pipefail
if [[ "${SKIP_AI_REVIEW:-0}" == "1" ]]; then
echo "[pre-push] AI review skipped (SKIP_AI_REVIEW=1)."
exit 0
fi
# Load .env if present (for local development).
if [[ -f .env ]]; then
set -o allexport
source .env
set +o allexport
fi
REMOTE="$1"
URL="$2"
BASE_BRANCH="${AI_REVIEW_BASE:-origin/main}"
echo "[pre-push] Running AI code review against $BASE_BRANCH ..."
DIFF=$(git diff "$BASE_BRANCH"...HEAD 2>/dev/null || true)
if [[ -z "$DIFF" ]]; then
echo "[pre-push] No diff vs $BASE_BRANCH. Nothing to review."
exit 0
fi
echo "$DIFF" | python reviewer.py
EXIT_CODE=$?
if [[ $EXIT_CODE -eq 1 ]]; then
echo ""
echo "[pre-push] Push blocked: high-severity findings detected."
echo " Fix the issues above or set SKIP_AI_REVIEW=1 to force push."
exit 1
fi
exit 0
GitHub Actions CI Step
Add this job to any existing workflow. It posts findings as a comment on the PR using the GitHub CLI, which is pre-installed on all GitHub-hosted runners.
name: AI Code Review
on:
pull_request:
branches: [main, master]
jobs:
ai-review:
runs-on: ubuntu-latest
permissions:
pull-requests: write
contents: read
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # need full history for the diff
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install dependencies
run: pip install anthropic
- name: Run AI code review
id: review
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
run: |
git diff origin/${{ github.base_ref }}...HEAD | \
python reviewer.py --json-only > findings.json
EXIT_CODE=$?
echo "exit_code=$EXIT_CODE" >> $GITHUB_OUTPUT
continue-on-error: true # we handle the exit code ourselves below
- name: Post findings as PR comment
if: always()
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
python - <<'PYEOF'
import json, subprocess, sys
with open("findings.json") as f:
result = json.load(f)
findings = result.get("findings", [])
summary = result.get("summary", "")
if not findings:
body = "**AI Code Review:** No issues found. " + summary
else:
rows = []
for fn in findings:
rows.append(
f"| `{fn['file']}:{fn['line']}` "
f"| {fn['severity'].upper()} "
f"| {fn['category']} "
f"| {fn['suggestion']} |"
)
table = (
"| Location | Severity | Category | Suggestion |\n"
"|---|---|---|---|\n" +
"\n".join(rows)
)
body = f"**AI Code Review**\n\n{table}\n\n**Summary:** {summary}"
subprocess.run(
["gh", "pr", "comment", "--body", body],
check=True
)
PYEOF
- name: Fail if high-severity findings
if: steps.review.outputs.exit_code == '1'
run: |
echo "High-severity findings detected. See PR comment for details."
exit 1
Sample Run
Given this diff in a file called sample.diff:
diff --git a/app/db.py b/app/db.py
index 3a1b2c3..4d5e6f7 100644
--- a/app/db.py
+++ b/app/db.py
@@ -12,6 +12,12 @@ import psycopg2
+DB_PASSWORD = "hunter2"
+
def get_connection():
- return psycopg2.connect(os.environ["DATABASE_URL"])
+ return psycopg2.connect(
+ host="prod-db.internal",
+ user="admin",
+ password=DB_PASSWORD
+ )
+
+def run_query(user_id):
+ conn = get_connection()
+ cur = conn.cursor()
+ cur.execute("SELECT * FROM users WHERE id = " + user_id)
+ return cur.fetchall()
Running:
python reviewer.py --diff sample.diffProduces output like:
========================================================================
AI Code Review Results
========================================================================
[1] HIGH Security: Hardcoded Credential
File : app/db.py:13
Fix : Move DB_PASSWORD to an environment variable:
DB_PASSWORD = os.environ["DB_PASSWORD"]. Committing a
real password to source control exposes it in git history
permanently, even if the line is later deleted.
[2] HIGH Security: SQL Injection
File : app/db.py:22
Fix : Use a parameterised query: cur.execute("SELECT * FROM
users WHERE id = %s", (user_id,)). String concatenation
allows an attacker to pass arbitrary SQL as user_id.
[3] MEDIUM Error Handling: Missing Connection Close
File : app/db.py:20
Fix : Wrap the body of run_query in a try/finally block and
call conn.close() in the finally clause, or use a context
manager (with get_connection() as conn:) to ensure the
connection is returned to the pool on error.
Summary: Two critical security issues (hardcoded credential + SQL
injection) and one medium error-handling gap. Do not merge until
the high-severity findings are resolved.
Tokens: 1243 in / 387 out | cache_create=921 cache_hit=0
========================================================================
2 high-severity finding(s). Review failed.
Wiring Into CI: The Full Integration Picture
GitLab CI Equivalent
For GitLab, the same script works. Add a .gitlab-ci.yml job:
ai-code-review:
stage: test
image: python:3.12-slim
script:
- pip install anthropic
- git diff origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME...HEAD |
python reviewer.py
only:
- merge_requests
variables:
ANTHROPIC_API_KEY: $ANTHROPIC_API_KEY # set in GitLab CI/CD settings
allow_failure: false
Handling Large Monorepo Diffs
A monorepo PR can produce a 50,000-line diff. Three options:
- Truncate at the script level (already implemented,
MAX_DIFF_CHARS). Simple, but drops coverage on large diffs. - Review per-file. Split the diff by file using
git diff --name-onlyand call the reviewer once per file in a loop, parallelised withasyncio. Slower total wall time but full coverage. - Review only changed files above a risk threshold. Use claude-haiku-4-5 as a fast triage step: send each file name and a one-line summary and ask for a risk score. Only send the full diff for high-risk files to claude-sonnet-4-6. This is the model-routing pattern from Part 27.
Model Selection for Code Review
| Model | Best for | Approx. cost per 500-line diff | Latency |
|---|---|---|---|
claude-haiku-4-5 |
Triage / routing, high-volume pre-commit hooks, style-only checks | ~$0.003 | 1.5s |
claude-sonnet-4-6 |
Default production reviewer. Catches logic bugs + security issues well. | ~$0.015 | 4s |
claude-opus-4-8 |
Architectural review, security audit of critical paths, complex concurrency bugs | ~$0.075 | 10s |
These estimates assume a 1,500-token system prompt (cached after the first call), a 2,000-token diff, and a 400-token response. Real costs vary by diff size. The cache hit on the system prompt cuts input-side costs significantly from the second call onward.
When to Upgrade to Opus
Most teams run sonnet for daily PR review and only route to opus for:
- Changes to authentication or authorization code
- Changes to cryptographic primitives or key management
- Database schema migrations on tables with PII
- Changes flagged as “security-related” by a fast haiku pass
The three-tier routing approach is covered in Part 27: Cut AI Costs. The same logic applies here: let the cheapest model do the triage, spend the expensive tokens on the cases that need them.
Common Pitfalls
1. Sending the Entire Repo, Not the Diff
A common mistake is sending the full file instead of the diff. The diff is what changed. Sending the full file makes the model review code the author did not touch, generates false positives on pre-existing issues, and burns tokens. Always use git diff origin/main...HEAD (three dots, not two). Two-dot diff includes commits already on main; three-dot diff is the symmetric difference, which is what you want for a PR.
2. Trusting the Severity Enum Without a Policy
The model’s judgment about what is “high” versus “medium” is probabilistic. Define a policy in the system prompt that matches your team’s risk threshold. The system prompt in this POC is explicit: high severity means security breach, data loss, or silent data corruption. If you omit this definition the model will use its own judgment, which may be more or less strict than your team’s.
3. Blocking on Every Finding
Blocking pushes on medium or low severity will cause developers to add SKIP_AI_REVIEW=1 to their shell profile within a week. Only exit 1 on high severity. Log everything else as informational annotations on the PR. The goal is to catch the bugs that would cause incidents, not to enforce style preferences at push time.
4. Not Caching the System Prompt
The system prompt in this POC is a list with a cache_control block. If you flatten it to a plain string you lose the caching benefit. On a busy team running 100 reviews per day, the system prompt cache alone saves around 90,000 input tokens per day at the current cache-read price (10% of standard). The cache TTL is five minutes; for a CI job this is typically plenty since sequential jobs in a workflow share the cache window.
5. Ignoring the Truncation Warning
If your diff is over 80,000 characters and you see the truncation warning, the reviewer has not seen the tail of your diff. Either split by file or raise MAX_DIFF_CHARS and accept higher token costs. Claude’s context window comfortably holds 200k tokens, so the 80k-char truncation in this script is a cost guard, not a hard technical limit.
6. Running the Reviewer on Merge Commits
Merge commits produce large, noisy diffs that mix unrelated changes. Always compute the diff against the base branch rather than the previous commit. The git diff origin/main...HEAD form handles this correctly.
Cost and Latency Notes
| Scenario | Diff size | Input tokens (est.) | Output tokens (est.) | Cost (sonnet, cached sys prompt) | Wall time |
|---|---|---|---|---|---|
| Small fix (1 file) | 50 lines | 800 | 200 | ~$0.003 | 2s |
| Medium PR (5 files) | 500 lines | 2,500 | 500 | ~$0.012 | 4s |
| Large PR (20 files) | 2,000 lines | 8,000 | 900 | ~$0.040 | 8s |
| Large PR, cached sys prompt | 2,000 lines | 8,000 (921 cached) | 900 | ~$0.037 | 8s |
Costs above use mid-2026 pricing for claude-sonnet-4-6 at $3/M input tokens and $15/M output tokens, with cached tokens at $0.30/M. For 200 PRs per month on a 10-person team, total cost is around $3 to $10 per month. That is well within the budget of a single engineering hour saved.
If latency matters (you do not want developers waiting more than 5 seconds for a hook), use claude-haiku-4-5. It returns findings in under two seconds on most diffs and its security-pattern recognition is sufficient for the most common issues (hardcoded secrets, injection, missing error handling).
Production Considerations
Running a review script on your laptop is one thing. Running an ai code review bot as a gate that the whole team depends on is another. A few things bite teams once the bot moves from a weekend experiment to a daily dependency.
Controlling False Positives
The fastest way to lose a team’s trust in the bot is a flood of findings that are technically true but practically noise. If every review returns twelve “low” findings about variable naming, developers stop reading the output. Three habits keep the signal high:
- Cap the count. Add an instruction to the system prompt: report at most the eight most important findings, and prefer high and medium severity over style nits. The model honors a stated ceiling well.
- Suppress known patterns. Keep a short list of patterns your team has decided are acceptable (a logger that the bot keeps flagging, a fixture password in a test file). Append that list to the system prompt as explicit “do not report” rules.
- Separate blocking from advisory. Only high severity should ever block a push or fail a build. Everything else is an annotation the author can read and ignore without friction.
Measure the accept rate. If your team accepts fewer than half of the high-severity findings, the severity policy in the prompt is too loose and needs tightening. The goal is a bot whose high findings are almost always real.
Keeping Secrets Out of the Pipeline
The bot reads diffs that may contain secrets the author committed by accident. That is exactly the kind of thing you want it to catch, but it also means the diff text travels to the API. Two safeguards matter. First, never log the full diff to a CI artifact or a shared console where it persists; the script in this article prints findings, not the raw diff. Second, run a local secret scanner such as a pre-commit secret check before the diff ever reaches the bot, so a leaked key is caught and the commit is rejected before any network call. The AI reviewer is a second layer of defense on secrets, not the first.
Store the ANTHROPIC_API_KEY in your CI provider’s encrypted secret store (GitHub Actions secrets, GitLab CI/CD variables). Never write it into the workflow file or a committed .env. The SDK reads it from the environment, so the key never appears in your code.
Idempotency and Re-Runs
A CI job can run several times on the same PR as the author pushes fixes. If the job posts a new comment every run, the PR fills up with stale reviews. Before posting, look for an existing comment from the bot (match on a hidden marker string in the comment body) and edit it in place rather than creating a new one. The GitHub CLI supports this through the comments API; the pattern keeps one living review comment that always reflects the latest diff.
Rate Limits and Concurrency
If a team merges twenty PRs in an hour, twenty review jobs hit the API at once. The retry-with-backoff loop in the POC handles transient 429 responses, but for sustained high volume you want a small concurrency cap. Run reviews through a queue that allows, say, five concurrent calls. For very high volume, the Message Batches API processes large numbers of reviews asynchronously at a lower cost when you do not need the result in the next few seconds. A nightly batch review of every open PR is a good fit for that mode.
Language-Specific Review Profiles
Parse the diff header to detect the languages involved. If the diff touches .py files, add a Python-specific section to the system prompt (mention common anti-patterns: mutable default arguments, bare except clauses, using is for value equality). If it touches .tf files, add Terraform-specific guidance. This is the same conditional-prompt pattern used in the Terraform review bot in Part 9.
Learning From Past Reviews
Store every finding in a database table (file, line, severity, category, was_accepted). After a human reviews the AI’s findings, record which ones they accepted and which they dismissed. Use this data to tune the system prompt. If the bot consistently flags something your team considers acceptable, add it to the “do not report” list in the prompt. If it misses a category, add examples.
Embedding in Your IDE
The script reads from stdin, so you can pipe to it from any context. A VS Code task that runs git diff HEAD -- ${file} | python reviewer.py on save gives in-editor feedback on the current file’s changes without waiting for a push.
Connecting to Pull Request Review APIs
The GitHub REST API allows posting inline review comments with a specific line reference. Convert each finding’s file and line to a GitHub review comment using the pull_requests.create_review_comment endpoint. This puts the AI’s suggestion exactly on the diff line in the GitHub PR interface, matching the experience of a human code reviewer.
Frequently Asked Questions
Will the AI code review bot catch every bug?
No. The bot reviews text, not runtime behavior. It reliably catches patterns that are visible in the diff: hardcoded secrets, string-concatenated SQL, missing error handling, deprecated API calls. It will not catch bugs that depend on runtime state, distributed system timing, or business logic it has no context for. Treat it as a first pass that handles the mechanical checklist so human reviewers can focus on intent and architecture.
How do I prevent developers from bypassing the pre-push hook?
Git hooks are local and can always be skipped with --no-verify or by deleting the hook file. For reliable enforcement, move the check to CI where developers do not control the environment. Use the pre-push hook as a fast, convenient local gate and the CI check as the authoritative gate. The CI step cannot be bypassed without repository admin access.
Can I run this on a private codebase without sending code to Anthropic?
The Anthropic API is the transport. Your diff is sent over HTTPS to Anthropic’s servers and processed there. Anthropic’s enterprise agreements include data processing terms. For codebases with strict data residency requirements, check the enterprise API agreement or consider a private deployment. For most commercial codebases, the same data-handling rules you apply to other SaaS tools (GitHub, Sentry, Datadog) apply here.
How large a diff can Claude handle?
Claude’s context window is 200,000 tokens, which corresponds to roughly 600,000 to 800,000 characters of code. A typical PR diff is well within this. The MAX_DIFF_CHARS limit in the script (80,000 characters) is a cost guard, not a technical ceiling. Raise or remove it if your diffs regularly exceed that size and you are comfortable with the token cost.
How do I make the findings actionable for junior engineers?
The system prompt in this POC requires concrete suggestions: not “fix this null check” but “add a guard clause: if user_id is None: raise ValueError('user_id required')“. If your team needs more educational context, add a learning_note field to the tool schema and instruct the model to include a one-sentence explanation of why the pattern is problematic. Junior engineers benefit from the “why” alongside the “what”.
Can I use this for code review on a non-Python codebase?
Yes. The script is language-agnostic. It sends the raw diff and Claude understands dozens of languages. The system prompt mentions common patterns in language-neutral terms. For language-specific depth, add a language detection step (parse the +++ file headers in the diff) and append a language-specific section to the system prompt. The Terraform review bot in Part 9 demonstrates the same pattern applied to infrastructure code.
What happens if the Claude API is down during a push?
The script exits with code 2 on API errors, not code 1. The pre-push hook only blocks on exit 1 (high-severity findings). An API error prints a warning and allows the push to proceed. This is intentional: the AI reviewer is a convenience gate, not a hard dependency. You can invert this behavior (fail-closed) by changing the hook to exit 1 on exit code 2 if your team prefers to block until the reviewer is available.
This article is Part 5 of the AI in Production: 30 Real-World Use Cases with Claude series. Other parts in this series that pair well with this one:
- Part 3: Structured Output from Claude covers the tool-use pattern used here in depth.
- Part 4: Prompt Caching with Claude explains the cache_control mechanism applied to the system prompt above.
- Part 9: Review Terraform with AI applies the same diff-review pattern to infrastructure-as-code.
- Part 8: AI Test Generation turns the changed code into Pytest test cases automatically.
- Part 27: Cut AI Costs covers the haiku/sonnet/opus routing pattern referenced in the model selection section.
External references: