Build an AI Code Review Bot with Claude and Python

Series
AI in Production: 30 Real-World Use Cases with Claude

Part 5 of 30 · View the full series

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 raw patch text

stdin / file

reviewer.py build prompt call Claude API

HTTPS

Claude API claude-sonnet-4-6 tool_use output

JSON

Findings file + line severity + fix

1. Capture 2. Package 3. Review 4. Act

git pre-push hook system + diff + tool structured output PR comment / exit 1

Figure 1: Data flow from 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:

  1. 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.
  2. 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_choice to force the model to always call that tool.
  3. Review: Send the message to the Claude API. Because stop_reason is tool_use, read the tool input block, which is already parsed JSON.
  4. 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 anthropic

The 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.diff

Produces 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.
Key idea: The first time you run the script the system prompt is written to Anthropic’s prompt cache (cache_create). On every subsequent call in the same five-minute window, the cached tokens cost 10% of the normal rate. For a CI pipeline that runs tens of reviews per hour, add up the savings: a 900-token system prompt cached across 50 calls per hour saves roughly 45,000 input tokens per hour at standard rates. See Part 4 for the full breakdown on prompt caching mechanics.

Wiring Into CI: The Full Integration Picture

Developer git push

pre-push hook reviewer.py

triggers

Claude API sonnet-4-6

Findings high severity? exit 0 / exit 1

Push allowed exit 0

Push blocked fix & re-push

high found clean

Figure 2: Pre-push hook flow. High-severity findings block the push. The developer fixes and re-pushes. No external service needed; the hook runs entirely on the developer’s machine.

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:

  1. Truncate at the script level (already implemented, MAX_DIFF_CHARS). Simple, but drops coverage on large diffs.
  2. Review per-file. Split the diff by file using git diff --name-only and call the reviewer once per file in a loop, parallelised with asyncio. Slower total wall time but full coverage.
  3. 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:

External references:

Previous