Guide #8 Use Case — Engineering 22 min read March 2026

The AI Code Reviewer: Clones, Tests, and Reports on Every PR

Every pull request deserves a full review: clone the branch, install dependencies, run the test suite, check for regressions, verify the UI if it's a frontend change. Nobody has time to do this for every PR. So most PRs get a skim-and-approve.

An AI agent does the full review in 3–5 minutes, on every PR, automatically. It SSH's into a fresh terminal sandbox, checks out the branch, runs the tests, optionally spins up browser containers to screenshot the UI, and comments on the PR with results, diffs, and annotated screenshots. Cost: $0.05 per PR.

The review that nobody does

In theory, every PR gets a thorough review before merge. A senior engineer clones the branch, runs the tests locally, checks for regressions, maybe boots up the app and clicks through the changed UI. In practice, this happens for maybe 10% of PRs. The rest get a 60-second code skim in the GitHub diff viewer and an approval.

The gap between "what a good review looks like" and "what actually happens" is enormous. It's not that engineers don't care. It's that a proper review takes 20–30 minutes of context-switching: pull the branch, install deps, wait for the build, run the tests, boot the dev server, check the UI. For the third PR of the day, nobody is doing all that. They read the diff, check for obvious mistakes, and approve.

The AI code reviewer doesn't replace the human review. It replaces the review that nobody was doing: the execution-based review. Did the tests pass? Did anything regress? Does the UI look right? The human reviewer can focus on architecture, logic, and intent — the things only a human can judge — because the mechanical verification is already done.

Typical PR review (human only)

  • Read the diff in the GitHub UI for 2–5 minutes
  • Check for obvious bugs, style issues, naming
  • Assume the tests pass because CI is green (or isn't configured)
  • Don't check the UI — no time to boot the dev server
  • Approve after a surface-level read
  • Miss regressions that only appear at runtime

AI-augmented PR review

  • Agent clones branch, installs deps, runs full test suite
  • Test results posted as a PR comment within 5 minutes
  • If frontend: browser containers screenshot every changed route
  • Visual diff between main and branch screenshots
  • Claude reads the diff and writes review notes
  • Human reviewer reads the agent's report, focuses on design
$0.05
Cost per PR review
3–5 min
Time to review
100%
PRs reviewed (not 10%)

How it works

A GitHub webhook fires when a PR is opened or updated. Your webhook handler creates a terminal sandbox, hands it the PR details, and the agent takes it from there. If the PR includes frontend changes, the agent also spins up browser containers to take screenshots.

GitHub webhook: PR opened
Webhook handler (your server or Lambda)
↓ creates
Terminal sandbox
git clone → checkout branch → install deps → run tests
↓ if frontend changes
Browser containers (parallel)
Chrome: main branch
screenshot every route
Chrome: PR branch
screenshot every route
Visual diff: compare screenshots pixel by pixel
↓ results
Post PR comment: test results + visual diffs + review notes

The two review modes

Not every PR needs the full treatment. The agent operates in two modes depending on what changed:

ModeTriggerWhat it doesTimeCost
Backend-only No files in src/components/, pages/, public/, etc. Clone, install, run test suite, post results 2–4 min ~$0.03
Full-stack Any frontend file changed All of the above + boot dev server + screenshot routes in 2 browsers + visual diff 4–8 min ~$0.08

Step 1: Set up the GitHub webhook handler

The webhook handler receives GitHub's pull_request event, extracts the relevant info, and kicks off the review. This can run as a Lambda function, a small server, or even a cron that polls for new PRs.

python — webhook_handler.py
import asyncio
import json
import os
import hmac
import hashlib
import httpx
from fastapi import FastAPI, Request, HTTPException

app = FastAPI()

SANDBOX_URL = os.environ["SANDBOX_URL"]
SANDBOX_API_KEY = os.environ["SANDBOX_API_KEY"]
GITHUB_TOKEN = os.environ["GITHUB_TOKEN"]
GITHUB_WEBHOOK_SECRET = os.environ["GITHUB_WEBHOOK_SECRET"]
HEADERS = {
    "Authorization": f"Bearer {SANDBOX_API_KEY}",
    "Content-Type": "application/json",
}

# Files that indicate frontend changes
FRONTEND_PATTERNS = [
    "src/components/", "src/pages/", "src/app/",
    "public/", ".css", ".tsx", ".jsx",
    "templates/", "static/",
]


@app.post("/webhook/github")
async def handle_github_webhook(request: Request):
    # Verify webhook signature
    body = await request.body()
    sig = request.headers.get("X-Hub-Signature-256", "")
    expected = "sha256=" + hmac.new(
        GITHUB_WEBHOOK_SECRET.encode(), body, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(sig, expected):
        raise HTTPException(401, "Invalid signature")

    event = request.headers.get("X-GitHub-Event")
    payload = json.loads(body)

    if event == "pull_request" and payload["action"] in ("opened", "synchronize"):
        pr = payload["pull_request"]
        asyncio.create_task(run_review(
            repo=payload["repository"]["full_name"],
            pr_number=pr["number"],
            branch=pr["head"]["ref"],
            base_branch=pr["base"]["ref"],
            clone_url=payload["repository"]["clone_url"],
            changed_files=[f["filename"] for f in pr.get("files", [])],
        ))

    return {"status": "accepted"}

Step 2: The review agent

The run_review function creates a sandbox, runs the tests, optionally captures screenshots, and posts a PR comment with the results.

python — review_agent.py
async def run_review(
    repo: str,
    pr_number: int,
    branch: str,
    base_branch: str,
    clone_url: str,
    changed_files: list,
):
    has_frontend = any(
        any(pattern in f for pattern in FRONTEND_PATTERNS)
        for f in changed_files
    )

    async with httpx.AsyncClient(timeout=600) as client:
        # 1. Create sandbox
        sandbox = (await client.post(
            f"{SANDBOX_URL}/api/sandboxes", headers=HEADERS,
            json={
                "name": f"pr-review-{repo.split('/')[-1]}-{pr_number}",
                "instance_type": "ab0t.small",
                "auto_stop_minutes": 15,
            },
        )).json()
        sandbox_id = sandbox["sandbox_id"]

        # 2. Wait for boot
        for _ in range(24):
            status = (await client.get(
                f"{SANDBOX_URL}/api/sandboxes/{sandbox_id}",
                headers=HEADERS)).json()
            if status["status"] == "running": break
            await asyncio.sleep(5)

        # 3. Clone, install, test
        clone_cmd = f"""
cd /workspace &&
git clone https://x-access-token:{GITHUB_TOKEN}@github.com/{repo}.git project &&
cd project &&
git checkout {branch} &&
npm install 2>&1 | tail -5 &&
echo '--- TESTS ---' &&
npm test 2>&1;
echo "EXIT_CODE=$?"
"""
        test_result = (await client.post(
            f"{SANDBOX_URL}/api/sandboxes/{sandbox_id}/execute",
            headers=HEADERS,
            json={"command": clone_cmd},
        )).json()

        test_output = test_result["stdout"]
        test_passed = "EXIT_CODE=0" in test_output

        # 4. Get the diff for AI review
        diff_result = (await client.post(
            f"{SANDBOX_URL}/api/sandboxes/{sandbox_id}/execute",
            headers=HEADERS,
            json={"command": f"cd /workspace/project && git diff {base_branch}...{branch} | head -2000"},
        )).json()

        # 5. Visual review (if frontend)
        visual_report = ""
        if has_frontend:
            visual_report = await visual_review(
                client, sandbox_id, repo, branch, base_branch)

        # 6. AI-powered code review
        review_notes = await ai_code_review(
            diff_result["stdout"], test_output, changed_files)

        # 7. Post PR comment
        comment = format_pr_comment(
            test_passed, test_output, review_notes, visual_report, changed_files)
        await post_pr_comment(repo, pr_number, comment)

        # 8. Cleanup
        await client.delete(
            f"{SANDBOX_URL}/api/sandboxes/{sandbox_id}", headers=HEADERS)

Step 3: AI-powered code review with Claude

The agent sends the diff and test output to Claude for analysis. Claude reads the code changes, identifies potential issues, and writes review notes in the same format a senior engineer would use.

python — ai_review.py
import anthropic

async def ai_code_review(
    diff: str,
    test_output: str,
    changed_files: list,
) -> str:
    """Use Claude to review the code diff and write review notes."""
    client = anthropic.Anthropic()

    response = client.messages.create(
        model="claude-sonnet-4-6-20250514",
        max_tokens=4096,
        messages=[{
            "role": "user",
            "content": f"""You are a senior software engineer reviewing a pull request.

## Changed files
{json.dumps(changed_files, indent=2)}

## Diff
```
{diff[:8000]}
```

## Test output
```
{test_output[-3000:]}
```

Write a concise code review. Focus on:
1. **Bugs** — logic errors, edge cases, null/undefined risks
2. **Security** — injection, XSS, secrets in code, insecure defaults
3. **Performance** — N+1 queries, unnecessary re-renders, missing indexes
4. **Test coverage** — are the new code paths tested?
5. **Style** — only if inconsistent with the surrounding code

For each finding, cite the specific file and line. Use this format:

### [category emoji] Finding title
**File:** `path/to/file.ts:42`
[1-2 sentence description of the issue and suggested fix]

If the code looks good, say so. Don't fabricate issues.
End with a one-line overall assessment: APPROVE, REQUEST_CHANGES, or COMMENT.""",
        }],
    )

    return response.content[0].text
Why Claude Sonnet, not Opus?

For code review, Sonnet 4.6 is the right model. It's fast (response in 3–5 seconds), cheap (~$0.01 per review), and excellent at spotting bugs and security issues in diffs. Save Opus for architectural decisions that require deeper reasoning. At $0.01/PR, you can afford to review every single PR — even the one-line typo fixes.

Step 4: Visual regression testing with browser containers

If the PR touches frontend code, the agent boots the app on the sandbox and screenshots every changed route. It does this twice — once on the base branch, once on the PR branch — and generates a visual diff.

python — visual_review.py
async def visual_review(
    client,
    sandbox_id: str,
    repo: str,
    branch: str,
    base_branch: str,
) -> str:
    """
    Boot the app, screenshot routes on base and PR branch,
    compare visually.
    """
    routes_to_check = ["/", "/dashboard", "/settings", "/pricing"]

    # Screenshot base branch
    await client.post(
        f"{SANDBOX_URL}/api/sandboxes/{sandbox_id}/execute",
        headers=HEADERS,
        json={"command": f"cd /workspace/project && git checkout {base_branch} && npm run build && npm start &"},
    )
    await asyncio.sleep(10)  # Wait for dev server

    base_screenshots = await capture_screenshots(
        client, routes_to_check, sandbox_id, prefix="base")

    # Kill dev server, switch to PR branch, rebuild
    await client.post(
        f"{SANDBOX_URL}/api/sandboxes/{sandbox_id}/execute",
        headers=HEADERS,
        json={"command": f"pkill -f 'npm start'; cd /workspace/project && git checkout {branch} && npm run build && npm start &"},
    )
    await asyncio.sleep(10)

    pr_screenshots = await capture_screenshots(
        client, routes_to_check, sandbox_id, prefix="pr")

    # Compare screenshots with Claude vision
    diffs = await compare_screenshots(base_screenshots, pr_screenshots)

    return format_visual_report(diffs)


async def capture_screenshots(
    client,
    routes: list,
    sandbox_id: str,
    prefix: str,
) -> dict:
    """Create a browser container, navigate to each route, screenshot."""
    browser_data = (await client.post(
        f"{SANDBOX_URL}/api/browsers", headers=HEADERS,
        json={"browser_type": "chrome", "enable_debug_port": True},
    )).json()
    container_id = browser_data["container_id"]

    # Wait for browser
    for _ in range(40):
        status = (await client.get(
            f"{SANDBOX_URL}/api/containers/{container_id}/status",
            headers=HEADERS)).json()
        if status["state"] == "assigned": break
        await asyncio.sleep(3)

    cdp_url = status["cdp_url"]
    screenshots = {}

    async with async_playwright() as p:
        browser = await p.chromium.connect_over_cdp(cdp_url)
        page = await browser.contexts[0].new_page()
        await page.set_viewport_size({"width": 1280, "height": 720})

        # The dev server runs on the sandbox — use its internal IP
        sandbox_ip = (await client.get(
            f"{SANDBOX_URL}/api/sandboxes/{sandbox_id}",
            headers=HEADERS)).json()["instance_ip"]

        for route in routes:
            url = f"http://{sandbox_ip}:3000{route}"
            try:
                await page.goto(url, wait_until="networkidle", timeout=15000)
                await page.wait_for_timeout(1000)
                screenshots[route] = await page.screenshot()
            except Exception as e:
                screenshots[route] = None

    # Release browser back to pool
    await client.post(
        f"{SANDBOX_URL}/api/containers/{container_id}/release",
        headers=HEADERS)

    return screenshots

Comparing screenshots with Claude vision

Rather than pixel-diffing (which flags every subpixel antialiasing difference), the agent sends both screenshots to Claude and asks for a semantic visual diff: "What changed between these two versions of the page?"

python
async def compare_screenshots(base: dict, pr: dict) -> list:
    """Compare base and PR screenshots using Claude vision."""
    client = anthropic.Anthropic()
    diffs = []

    for route in base:
        if not base[route] or not pr.get(route):
            diffs.append({"route": route, "status": "error", "note": "Screenshot missing"})
            continue

        b64_base = base64.b64encode(base[route]).decode()
        b64_pr = base64.b64encode(pr[route]).decode()

        response = client.messages.create(
            model="claude-sonnet-4-6-20250514",
            max_tokens=1024,
            messages=[{
                "role": "user",
                "content": [
                    {"type": "text", "text": f"Compare these two screenshots of the '{route}' route. The first is the base branch, the second is the PR branch. Describe any visual differences. If they look identical, say 'No visual changes.' Be concise."},
                    {"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": b64_base}},
                    {"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": b64_pr}},
                ],
            }],
        )

        diff_text = response.content[0].text
        diffs.append({
            "route": route,
            "status": "changed" if "no visual changes" not in diff_text.lower() else "unchanged",
            "description": diff_text,
        })

    return diffs
Semantic diffs vs pixel diffs

Pixel-diff tools (like Percy or Chromatic) flag every tiny rendering difference: font smoothing, antialiasing, sub-pixel shifts. Teams learn to ignore these and the signal-to-noise ratio drops. Claude's vision comparison identifies meaningful changes: "The pricing card moved from the left to the center" or "A new warning banner appeared at the top." These are the differences the reviewer actually cares about.

Step 5: Posting the PR comment

The agent formats all results into a single GitHub PR comment. Test results, AI review notes, and visual diffs are all in one place. The human reviewer reads this comment before looking at the code.

python — pr_comment.py
def format_pr_comment(
    test_passed: bool,
    test_output: str,
    review_notes: str,
    visual_report: str,
    changed_files: list,
) -> str:
    status_icon = "✅" if test_passed else "❌"
    status_text = "All tests passed" if test_passed else "Tests failed"

    comment = f"""## AI Code Review

{status_icon} **Tests: {status_text}**

Test output (click to expand) ``` {test_output[-5000:]} ```
### Files changed ({len(changed_files)}) {chr(10).join(f'- `{f}`' for f in changed_files[:20])} --- ### Code review {review_notes} """
if visual_report: comment += f""" --- ### Visual review {visual_report} """ comment += """ --- Reviewed by AI Code Reviewer on Sandbox Platform. Compute cost: ~$0.05.""" return comment async def post_pr_comment(repo: str, pr_number: int, comment: str): """Post a comment on the GitHub PR.""" async with httpx.AsyncClient() as client: await client.post( f"https://api.github.com/repos/{repo}/issues/{pr_number}/comments", headers={ "Authorization": f"Bearer {GITHUB_TOKEN}", "Accept": "application/vnd.github+json", }, json={"body": comment}, )

What the PR comment looks like

The human reviewer opens the PR and sees a structured comment at the top:

Example PR comment

Tests: All tests passed (142 passed, 0 failed, 2.3s)

Code review:

  • Bug: src/api/users.ts:87 — The findUser query doesn't handle the case where orgId is undefined. This will throw at runtime if the JWT is missing the org claim. Add a null check or default.
  • Security: src/api/auth.ts:23 — The rate limiter key uses req.ip which can be spoofed behind a proxy. Use X-Forwarded-For or the verified client IP from your load balancer.
  • Style: src/utils/format.ts:12 — The surrounding code uses camelCase for function names, but this one uses snake_case. Minor but inconsistent.

Visual: The /dashboard route shows a new sidebar widget that wasn't in the base branch. The /settings route is unchanged. No regressions detected.

Overall: APPROVE — Tests pass, no critical issues. The findUser null check should be addressed before merge.

Alternative: use Claude Code as the reviewer

Instead of writing a custom review agent, you can install Claude Code on the sandbox and prompt it directly. This is faster to set up and leverages Claude Code's built-in understanding of code review:

bash — run inside sandbox
# Clone, checkout, and ask Claude Code to review
cd /workspace
git clone https://github.com/your-org/your-repo.git project
cd project
git checkout feature/new-auth-flow

# Run Claude Code with the review task
claude "Review this branch against main.

1. Run the full test suite and report results.
2. Read the diff (git diff main...HEAD) and write a code review.
3. Focus on bugs, security issues, and missing test coverage.
4. Save your review to /workspace/review.md.
5. If there are frontend changes, start the dev server and
   use the Sandbox Platform API to create a Chrome browser container,
   screenshot the main routes, and note any visual changes."

Claude Code writes a review script, runs it, and produces a Markdown review file. You can then post that file as a PR comment programmatically, or just read it.

Integrating with existing CI

Most teams already have CI (GitHub Actions, CircleCI, Jenkins). The AI reviewer doesn't replace CI — it complements it. CI runs the test suite. The AI reviewer reads the test results, reviews the code, and adds the visual check. Here's how they fit together:

ResponsibilityCIAI Reviewer
Run test suiteYes (primary)Yes (redundant safety net)
Lint / format checksYesNo (CI handles this)
Build verificationYesNo
Code review commentsNoYes (primary)
Visual regressionSometimes (Percy/Chromatic)Yes (semantic, not pixel)
Security analysisSometimes (Snyk/CodeQL)Yes (in diff context)
Test output interpretationNo (just pass/fail)Yes (explains failures)
Skip the sandbox test run if CI already ran tests

If your CI pipeline already runs the test suite, you can skip the test execution in the sandbox and have the AI reviewer read the CI output instead. Pass the CI log URL to the review agent and it will analyze the test results without re-running them. This drops the review time from 3–5 minutes to under 30 seconds and the cost from $0.05 to $0.01.

Scaling: reviewing 50 PRs a day

A busy team might open 30–50 PRs a day. Each review creates a ab0t.small sandbox (and optionally 2 browser containers). At $0.05/PR, that's $2.50/day — less than a latte.

Concurrency

Multiple PRs can be reviewed simultaneously since each gets its own sandbox. If 5 PRs open in the same minute, 5 sandboxes spin up in parallel. No queuing, no contention.

Sandbox lifecycle

Each review sandbox lives for 5–10 minutes and then auto-stops. Set auto_stop_minutes: 15 to catch stragglers. The sandbox is disposable — there's no state to preserve. Next PR, next sandbox.

Cost at scale

PRs per dayDaily costMonthly costvs. a human reviewer
10$0.50$11$11 vs ~$2,500 (1 hr/day of senior eng time)
30$1.50$33$33 vs ~$7,500
50$2.50$55$55 vs ~$12,500
100$5.00$110$110 vs ~$25,000

Handling monorepos

In a monorepo, a PR might touch files across multiple packages. The review agent needs to know which test suites to run and which dev servers to boot.

python
# Determine affected packages from changed files
def affected_packages(changed_files: list) -> set:
    packages = set()
    for f in changed_files:
        parts = f.split("/")
        if len(parts) >= 2 and parts[0] == "packages":
            packages.add(parts[1])
    return packages

# Run tests only for affected packages
packages = affected_packages(changed_files)
for pkg in packages:
    test_cmd = f"cd /workspace/project/packages/{pkg} && npm test"
    result = await execute(sandbox_id, test_cmd)
    # Collect results per package

For Nx or Turborepo monorepos, use the tool's own affected detection: nx affected --target=test or turbo run test --filter=...[origin/main]. This is faster than testing everything.

Production tips

Use ab0t.small, not ab0t.medium

Most test suites don't need 4 GB of RAM. A ab0t.small (2 GB) handles the vast majority of Node.js, Python, and Go projects. Use ab0t.medium only for Java/Scala projects with large JVM heaps or projects with memory-hungry build steps.

Cache npm/pip packages with a custom Docker image

The biggest time sink is npm install. Build a custom Docker image with your project's dependencies pre-installed and pass it as docker_image when creating the sandbox. Install time drops from 30–60 seconds to near-zero.

Don't re-run tests if CI already ran them

If your CI pipeline runs the full test suite, have the AI reviewer read the CI log instead of re-running tests on the sandbox. Use the sandbox only for the visual review and the Claude-powered code analysis. Faster and cheaper.

Filter noise from the review

Claude sometimes flags style issues that don't matter (single vs double quotes, trailing commas). Add a system prompt or CLAUDE.md that says: "Only flag bugs, security issues, and performance problems. Ignore style unless it's egregiously inconsistent."

Update your CLAUDE.md per-repo

The review quality improves dramatically with a good CLAUDE.md that describes the project's conventions, architecture, and known quirks. "We use Drizzle ORM, not Prisma." "The legacy/ directory is frozen — don't suggest changes there." "Our API uses snake_case, not camelCase."

Rate-limit the webhook

A force-push that updates 10 branches triggers 10 webhook events. Debounce by PR number — if a new event arrives for the same PR within 30 seconds of the previous one, cancel the previous review and start fresh.

Troubleshooting

Test suite fails on the sandbox but passes locally

Environment differences. The sandbox runs a clean Ubuntu image; your laptop has a dozen globally-installed tools. Add all dependencies explicitly to package.json or requirements.txt. Check for hardcoded paths, localhost assumptions, or environment variables that exist on your machine but not on a fresh VM.

Dev server doesn't start for visual review

The dev server might bind to localhost only. On the sandbox, the browser container connects via the sandbox's IP, not localhost. Ensure the dev server binds to 0.0.0.0: npm start -- --host 0.0.0.0 or set the HOST=0.0.0.0 environment variable.

Visual diff flags everything as changed

If the dev server takes longer than expected to boot, the screenshot captures a loading spinner instead of the rendered page. Increase the wait time after starting the server, or poll the dev server's health endpoint before screenshotting.

PR comment is too long

GitHub has a 65,536 character limit on PR comments. If the test output is massive, truncate it with [-5000:] and add "Full output available in the sandbox logs." For very large diffs, send only the first 8,000 characters to Claude and note the truncation.

Sandbox creation is slow during peak hours

If your team opens a burst of PRs at 10am, the EC2 provisioning might take longer than usual. Use warm pools for the browser containers to eliminate that latency. For the sandbox itself, consider maintaining a pool of stopped sandboxes that can be started (resumed) faster than creating new ones.


What's next

Review every PR, automatically

Fresh sandbox. Full test suite. Visual diffs. AI code review. Five minutes. Five cents.

Get Started Free