# How to build an AI code review bot that runs the code it reviews Check the pull request out in a sandbox, let the model run its tests and linters there, then post the review from your service. **A reviewer that can run the code catches what reading misses, and on Runtime its machine costs $0.03125 an hour while the model thinks**, for 2 vCPUs and 4 GiB. Runtime bills measured CPU, not reserved cores, which made it 42% to 88% cheaper than eleven other sandbox providers on a job that mostly waits, at rates checked 23 September 2026 ([the comparison](/docs/pricing#published-rate-comparison)). ## Reading a diff versus running it A model that only reads a diff guesses. It cannot tell whether a new test passes, whether a changed function still type-checks, or whether the "harmless refactor" breaks an import two files away. Given a machine, the reviewer can find out, and then say so with the command and its output as evidence. That turns "this might fail" into "`npm test` fails on line 41 with this error", which is the comment a person acts on. The machine has to be isolated, because the code under review is by definition not yet trusted, and the model is steered partly by text inside the pull request, where a prompt injection can hide. ## Split the bot into two halves | Half | Where it runs | Holds | | -------- | ----------------- | --------------------------------------- | | Reviewer | A Runtime sandbox | The checkout, the tools, no write token | | Poster | Your service | The GitHub token that can comment | The sandbox never has a token that can merge, push or comment. If the pull request talks the model into doing something hostile, the worst it can reach is a throwaway machine. ## The core loop ```ts check import { Sandbox } from "withruntime"; import { sandboxTools } from "withruntime/tools"; const repo = "https://github.com/your-org/your-repo.git"; const pr = 1234; await using sbx = await Sandbox.create({ diskMiB: 8192, timeoutSeconds: 3600, idlePauseSeconds: 900, labels: { bot: "review", pr: String(pr) }, network: { internet: true, allow: ["github.com", "*.github.com", "registry.npmjs.org"] }, }); await sbx.exec(["git", "clone", repo, "repo"], { check: true, timeoutMs: 300_000 }); await sbx.exec(["git", "fetch", "origin", `pull/${pr}/head:pr`], { cwd: "/workspace/repo", check: true, }); await sbx.exec("git checkout pr && npm ci", { cwd: "/workspace/repo", check: true, timeoutMs: 600_000, }); // Facts to seed the model's context before it starts exploring. const changed = await sbx.exec("git diff --name-only origin/HEAD...pr", { cwd: "/workspace/repo" }); const tests = await sbx.exec("npm test", { cwd: "/workspace/repo", timeoutMs: 900_000 }); const lint = await sbx.exec("npx eslint $(git diff --name-only origin/HEAD...pr -- '*.ts')", { cwd: "/workspace/repo", timeoutMs: 300_000, }); const tools = sandboxTools(sbx); // the model can run and read anything else it needs console.log(changed.stdout, tests.exitCode, lint.exitCode, tools.length); // ... the model's review loop runs here, and returns { summary, comments } ... ``` ```python check from withruntime import Sandbox from withruntime.tools import sandbox_tools repo = "https://github.com/your-org/your-repo.git" pr = 1234 with Sandbox.create( disk_mib=8192, timeout_seconds=3600, idle_pause_seconds=900, labels={"bot": "review", "pr": str(pr)}, network={"internet": True, "allow": ["github.com", "*.github.com", "pypi.org", "*.pythonhosted.org"]}, ) as sbx: sbx.exec(["git", "clone", repo, "repo"], check=True, timeout_ms=300_000) sbx.exec(["git", "fetch", "origin", f"pull/{pr}/head:pr"], cwd="/workspace/repo", check=True) sbx.exec("git checkout pr && pip install -e '.[test]' ruff", cwd="/workspace/repo", check=True, timeout_ms=600_000) changed = sbx.exec("git diff --name-only origin/HEAD...pr", cwd="/workspace/repo") tests = sbx.exec("python3 -m pytest -q", cwd="/workspace/repo", timeout_ms=900_000) lint = sbx.exec("ruff check $(git diff --name-only origin/HEAD...pr -- '*.py')", cwd="/workspace/repo") run, read, write, ls = sandbox_tools(sbx) print(changed.stdout, tests.exit_code, lint.exit_code) # ... the model's review loop runs here, and returns summary and comments ... ``` Seeding the context with the changed files, the test result and the lint result saves the model several tool calls. From there it uses the four sandbox tools to read callers, run a single test, or try an edge case the author did not. ## Post the review from your service GitHub's `POST /repos/{owner}/{repo}/pulls/{pull_number}/reviews` takes a `body`, an `event` of `APPROVE`, `REQUEST_CHANGES` or `COMMENT`, and `comments` with a `path`, a `line` and a `body` each. GitHub warns that creating content too quickly there can trigger secondary rate limits, so post one review per pull request, not one call per comment. ```ts check type Finding = { path: string; line: number; body: string }; export async function postReview( owner: string, repo: string, pr: number, summary: string, findings: Finding[], ) { await fetch(`https://api.github.com/repos/${owner}/${repo}/pulls/${pr}/reviews`, { method: "POST", headers: { Authorization: `Bearer ${process.env.GITHUB_TOKEN}`, Accept: "application/vnd.github+json", }, body: JSON.stringify({ body: summary, event: "COMMENT", comments: findings }), }); } ``` Use `COMMENT`, not `APPROVE`, until you trust the bot. A person still merges. ## Private repositories To let the sandbox clone or read a private repository without holding a token, store the token as a Runtime secret limited to GitHub's hosts. Every sandbox gets a placeholder in its environment, and the host's proxy puts the real value into HTTPS requests to those hosts only ([secrets sandboxes never see](/docs/security#secrets-sandboxes-never-see)). Give it read access only: this is the reviewer half. ## What a review bot needs | Need | How Runtime covers it | | ----------------------------------- | ---------------------------------------------------------------------------- | | Running untrusted pull request code | A Firecracker microVM with its own kernel per review | | A token the model cannot leak | Secrets injected by the host's proxy; the guest holds a placeholder | | Tools for any model framework | `sandboxTools(sbx)` or `sandbox_tools(sbx)`, and adapters for ten frameworks | | Tests with a database or queue | `sudo enable-docker`, then Compose, inside the same sandbox | | Waiting on the model | $0.03125 an hour at 2 vCPU, 4 GiB; `idlePauseSeconds` pauses when idle | | A second look after new commits | Pause keeps the checkout and installs; the next call wakes it | | Many open pull requests | 100 sandboxes at once on a paid account to start | ## What it costs Take 2,000 reviews a month. Each keeps a 2 vCPU, 4 GiB sandbox for 15 minutes, mostly waiting on the model, and install, tests and lint use 120 CPU-seconds: ``` CPU: 2,000 × 120 s / 3,600 × $0.025 = $1.67 Memory: 2,000 × 900 s / 3,600 × 4 GiB × $0.0075 = $15.00 Total: $16.67 ``` Under a cent a review, from $0.025 per vCPU-hour of measured CPU and $0.0075 per GiB-hour ([pricing](/docs/pricing)). Memory dominates because most of a review is the model reading, which is why a sandbox that pauses when idle pays off. Model tokens are billed by your model provider. A new account's trial covers 50 hours with no card. ## Sources - [GitHub REST API: create a review for a pull request](https://docs.github.com/en/rest/pulls/reviews?apiVersion=2022-11-28#create-a-review-for-a-pull-request): the endpoint, its fields and the secondary rate limit note, read 25 September 2026. - [Checking out pull requests locally](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/reviewing-changes-in-pull-requests/checking-out-pull-requests-locally), read 25 September 2026. Related: [CI for agent pull requests](/use-cases/ci-for-agent-prs), [static analysis agent](/use-cases/static-analysis-agent), [coding agent sandbox](/use-cases/coding-agent-sandbox), [Claude Code in a sandbox](/integrations/claude-code). Facts on this page were checked on 25 September 2026.