●FORK — Claude Code 2.1.212 changes what /fork does: it copies your conversation into a new background session with its own row in claude agents, so you can keep working. The old in-session subagent is now /subtask●LIMITS — WebSearch calls are now capped at 200 per session by default, and subagent spawns get the same 200 ceiling, so a runaway search or delegation loop stops on its own●MCPBG — MCP tool calls running past two minutes now move to the background automatically, keeping the session usable. Tune the threshold with CLAUDE_CODE_MCP_AUTO_BACKGROUND_MS●PLANFIX — Fixed plan mode auto-running file-modifying Bash commands such as touch and rm without a permission prompt or an SDK canUseTool callback●SONNET5 — Claude Sonnet 5 is running on introductory pricing of $2 per million input tokens and $10 per million output. After August 31 it moves to $3 and $15●IPO — Bankers are reportedly lining up investor meetings for Anthropic ahead of a possible public listing as soon as October●FORK — Claude Code 2.1.212 changes what /fork does: it copies your conversation into a new background session with its own row in claude agents, so you can keep working. The old in-session subagent is now /subtask●LIMITS — WebSearch calls are now capped at 200 per session by default, and subagent spawns get the same 200 ceiling, so a runaway search or delegation loop stops on its own●MCPBG — MCP tool calls running past two minutes now move to the background automatically, keeping the session usable. Tune the threshold with CLAUDE_CODE_MCP_AUTO_BACKGROUND_MS●PLANFIX — Fixed plan mode auto-running file-modifying Bash commands such as touch and rm without a permission prompt or an SDK canUseTool callback●SONNET5 — Claude Sonnet 5 is running on introductory pricing of $2 per million input tokens and $10 per million output. After August 31 it moves to $3 and $15●IPO — Bankers are reportedly lining up investor meetings for Anthropic ahead of a possible public listing as soon as October
Building a GitHub PR Review Bot with Claude API — From Webhook Signature Verification to Structured Reviews with Tool Use
Build a production PR review bot with Claude API and GitHub Webhooks. Enforce structured scoring, security scanning, and suggestions via Tool Use — with signature verification, debouncing, rate limits, and cost tracking drawn from real-world operation.
Once your team grows past five engineers, code review consistency becomes a real problem. Some reviewers catch every instance of a particular bug pattern; others let the same thing slide. Review quality drops when people are tired. And more fundamentally, nobody has agreed on what "good" actually looks like.
Claude API can help. But asking it to "review this code" in the simplest possible way won't give you something you can rely on — the output varies too much. What makes this work at production scale is two things: using Tool Use to enforce structured output, and building a clean pipeline from GitHub Webhook all the way to posted review comments.
This article builds a bot that automatically posts review comments when a PR is opened or updated. We'll go from architecture design to deployment, explaining the why behind each implementation choice along the way.
System Architecture Overview
The flow is straightforward:
GitHub PR opened/updated
↓
Webhook event (POST /webhook)
↓
Express server (signature verification)
↓
Fetch PR diff via GitHub API
↓
Claude API (Tool Use) generates structured review
↓
Post review comment via GitHub API
The tech stack:
Runtime: Node.js 22 + TypeScript
Web framework: Express.js 4.x
Claude API: @anthropic-ai/sdk v1.x
GitHub API: @octokit/rest v21
Deployment: Railway / Render (always-on server required)
Why You Need an Always-On Server
GitHub Webhooks deliver POST requests in real time. While Cloudflare Workers or Vercel Serverless Functions technically work, architectures with cold starts are risky here. GitHub sets a 10-second timeout on webhook delivery, and a cold start that delays your response can trigger a retry loop — meaning the same PR gets reviewed twice or more.
Railway starts at around $5/month for an always-on server. That's more than sufficient for most teams.
Receiving webhooks is where most implementations first go wrong: signature verification. GitHub attaches an X-Hub-Signature-256 header to every webhook request. Without verifying it, anyone can send forged requests to your endpoint.
// src/webhook.tsimport express from 'express';import crypto from 'crypto';import { handlePullRequestEvent } from './handlers/pullRequest';const app = express();// ⚠️ Critical: raw body required — do NOT use express.json()app.use( express.raw({ type: 'application/json' }));function verifyWebhookSignature( rawBody: Buffer, signature: string, secret: string): boolean { const hmac = crypto.createHmac('sha256', secret); hmac.update(rawBody); const expectedSignature = `sha256=${hmac.digest('hex')}`; // Use timingSafeEqual to prevent timing attacks try { return crypto.timingSafeEqual( Buffer.from(signature), Buffer.from(expectedSignature) ); } catch { // timingSafeEqual requires equal-length buffers return false; }}app.post('/webhook', async (req, res) => { const signature = req.headers['x-hub-signature-256'] as string; const eventType = req.headers['x-github-event'] as string; if (!signature) { return res.status(401).json({ error: 'Missing signature' }); } const isValid = verifyWebhookSignature( req.body as Buffer, signature, process.env.GITHUB_WEBHOOK_SECRET! ); if (!isValid) { console.error('Invalid webhook signature — possible spoofed request'); return res.status(401).json({ error: 'Invalid signature' }); } // Respond immediately — GitHub's timeout is 10 seconds res.status(202).json({ accepted: true }); const payload = JSON.parse((req.body as Buffer).toString()); if ( eventType === 'pull_request' && ['opened', 'synchronize'].includes(payload.action) ) { // Process asynchronously after responding handlePullRequestEvent(payload).catch(err => { console.error('[PR handler] Unhandled error:', err); }); }});app.get('/health', (_req, res) => { res.json({ status: 'ok', timestamp: new Date().toISOString() });});const PORT = process.env.PORT || 3000;app.listen(PORT, () => { console.log(`Webhook server listening on port ${PORT}`);});
Two things worth explaining here.
Why express.raw() instead of express.json(): The HMAC signature is computed against the raw bytes of the request body. Once Express parses the JSON, you've lost the original bytes. Even re-serializing with JSON.stringify() won't work reliably — property ordering and whitespace can differ. This is the single most common source of 401 errors in webhook integrations.
Why respond before processing: GitHub's webhook timeout is 10 seconds. Claude API calls can take several seconds. If you wait for the review to complete before responding, you'll hit the timeout, GitHub will retry, and the same PR gets reviewed multiple times. Send 202 immediately, then process asynchronously.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Prevent GitHub's 10s timeout and retry loops with express.raw() signature verification and an immediate 202 response
✦Force structured overall_score / security_issues through Tool Use (tool_choice: any) with exponential backoff for production stability
✦Operational metrics: a 30→90s debounce cut duplicate reviews ~70%; output tokens run 600-900 (clean) vs 2,500-3,500 (critical found)
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
A note on the 500-line limit: Claude Sonnet 4.6 has a 1M-token context window, but stuffing an enormous diff into a single request drives up cost and degrades review quality. Claude tends to give more superficial feedback when overwhelmed with too much code at once. The 500-line cutoff keeps each review focused and cost-effective.
Skipping test files is intentional too. Reviewing test code requires different heuristics (coverage completeness, test isolation, assertion quality) — mixing it with production code review tends to dilute both. Start with production code and add a separate test review pass later if needed.
Step 3: Structured Reviews with Tool Use
This is the heart of the implementation. Free-form reviews are inconsistent — the structure varies, some issues get missed, and downstream formatting becomes fragile. Tool Use enforces a consistent schema, making the rest of the pipeline predictable.
The tool_choice: { type: 'any' } setting deserves a specific callout. Without it, Claude may respond with plain text rather than calling the tool — particularly when it decides the code "looks fine" and wants to just say so. The any option forces at least one tool call. Using { type: 'tool', name: 'submit_code_review' } works too if you only have one tool, but any is more flexible as your toolset grows.
With the structured review in hand, we post it using the GitHub Pull Request Review API.
// src/github/postReview.tsimport { Octokit } from '@octokit/rest';import type { ReviewResult } from '../claude/reviewer';export async function postReviewComment( owner: string, repo: string, pullNumber: number, review: ReviewResult): Promise<void> { const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN, }); // REQUEST_CHANGES only for critical/high security issues const requiresChanges = review.security_issues.some( issue => issue.severity === 'critical' || issue.severity === 'high' ); const event = requiresChanges ? 'REQUEST_CHANGES' : 'COMMENT'; const body = formatReviewBody(review); await octokit.pulls.createReview({ owner, repo, pull_number: pullNumber, event, body, }); console.log( `[GitHub] Posted review on PR #${pullNumber} (${event})` );}function formatReviewBody(review: ReviewResult): string { const scoreEmoji = review.overall_score >= 80 ? '🟢' : review.overall_score >= 60 ? '🟡' : '🔴'; let body = `## 🤖 Claude Code Review\n\n`; body += `${scoreEmoji} **Quality Score: ${review.overall_score}/100**\n\n`; body += `${review.summary}\n\n`; if (review.security_issues.length > 0) { body += `### 🔒 Security Issues\n\n`; for (const issue of review.security_issues) { const badge = issue.severity === 'critical' ? '🚨 CRITICAL' : issue.severity === 'high' ? '⚠️ HIGH' : issue.severity === 'medium' ? '⚡ MEDIUM' : 'ℹ️ LOW'; body += `**${badge}** \`${issue.file}\`\n\n`; body += `\`\`\`\n${issue.line_hint}\n\`\`\`\n\n`; body += `${issue.description}\n\n`; body += `> 💡 **Recommendation**: ${issue.recommendation}\n\n`; body += `---\n\n`; } } if (review.quality_issues.length > 0) { body += `### 🔍 Quality Issues\n\n`; const categoryLabel: Record<string, string> = { error_handling: 'Error Handling', performance: 'Performance', readability: 'Readability', testability: 'Testability', }; for (const issue of review.quality_issues) { body += `**[${categoryLabel[issue.category]}]** \`${issue.file}\`\n\n`; body += `${issue.description}\n\n`; body += `> 💡 ${issue.suggestion}\n\n`; } } if (review.improvements.length > 0) { body += `### 💡 Suggestions (Optional)\n\n`; const typeLabel: Record<string, string> = { refactor: 'Refactoring', optimization: 'Optimization', best_practice: 'Best Practice', }; for (const improvement of review.improvements) { body += `- **[${typeLabel[improvement.type]}]** ${improvement.description}\n`; } body += '\n'; } body += `---\n*This review was automatically generated by Claude Sonnet 4.6. Treat as a first pass, not a final verdict.*`; return body;}
Using REQUEST_CHANGES automatically may feel heavy-handed. I started with COMMENT for everything too. In practice, critical-severity findings posted as plain comments get overlooked. Limiting REQUEST_CHANGES to high-severity security issues is the right balance for most teams. Make this configurable via environment variable so you can tune it without redeploying.
Step 5: The Pull Request Handler
Putting it all together:
// src/handlers/pullRequest.tsimport { getPRDiff } from '../github/prDiff';import { reviewPRDiff } from '../claude/reviewer';import { postReviewComment } from '../github/postReview';interface PullRequestPayload { action: string; pull_request: { number: number; title: string; body: string | null; draft: boolean; }; repository: { owner: { login: string }; name: string; };}// Debounce map: prevents reviewing the same PR multiple times// when commits arrive in quick successionconst pendingReviews = new Map<string, ReturnType<typeof setTimeout>>();export async function handlePullRequestEvent( payload: PullRequestPayload): Promise<void> { const { pull_request, repository } = payload; const owner = repository.owner.login; const repo = repository.name; const pullNumber = pull_request.number; const prKey = `${owner}/${repo}#${pullNumber}`; // Skip draft PRs — they're not ready for review if (pull_request.draft) { console.log(`[PR #${pullNumber}] Skipping draft PR`); return; } // Debounce: if another commit arrives within 30s, reset the timer if (pendingReviews.has(prKey)) { clearTimeout(pendingReviews.get(prKey)!); console.log(`[PR #${pullNumber}] Debounce: timer reset`); } const timer = setTimeout(async () => { pendingReviews.delete(prKey); await executeReview( owner, repo, pullNumber, pull_request.title, pull_request.body || '' ); }, 30_000); // Wait 30 seconds, process only the final push pendingReviews.set(prKey, timer); console.log(`[PR #${pullNumber}] Review scheduled in 30s: ${pull_request.title}`);}async function executeReview( owner: string, repo: string, pullNumber: number, prTitle: string, prDescription: string): Promise<void> { console.log(`[PR #${pullNumber}] Starting review`); const prDiff = await getPRDiff(owner, repo, pullNumber); if (prDiff.files.length === 0) { console.log(`[PR #${pullNumber}] No reviewable files — skipping`); return; } if (prDiff.skippedFiles.length > 0) { console.log(`[PR #${pullNumber}] Skipped files:`, prDiff.skippedFiles); } const diffContent = prDiff.files .map( f => `### ${f.filename} (${f.status}, +${f.additions}/-${f.deletions})\n${f.patch}` ) .join('\n\n'); // Log estimated token usage before the API call const estimatedInputTokens = Math.ceil(diffContent.length / 4); console.log( `[PR #${pullNumber}] Files: ${prDiff.files.length}, ` + `estimated input tokens: ~${estimatedInputTokens}` ); const review = await reviewPRDiff( diffContent, prTitle, prDescription, prDiff.skippedFiles ); console.log( `[PR #${pullNumber}] Review done: score ${review.overall_score}/100, ` + `security: ${review.security_issues.length}, quality: ${review.quality_issues.length}` ); await postReviewComment(owner, repo, pullNumber, review); console.log(`[PR #${pullNumber}] Complete`);}
The debounce is more important than it might seem. Developers often push several small commits in a row when iterating on a PR. Without debouncing, each push triggers a separate review, costing several API calls and posting multiple review comments on the same PR. A 30-second window captures the "burst" and processes only the final state.
Common Mistakes and Pitfalls
I run the automated content pipeline behind my own indie developer projects on the Claude API, and I had built a near-identical webhook-to-structured-output flow there long before adapting it for code review. Most of what follows are problems I hit the hard way in production.
Mistake 1: Using express.json() and always getting 401
Described above, but worth emphasizing: express.json() parses the body before you can compute the signature. Use express.raw({ type: 'application/json' }) and parse manually. The error message ("Invalid signature") gives no hint that the body format is the issue, which leads to hours of debugging GitHub settings that aren't the problem.
Mistake 2: Duplicate reviews on the same PR
The synchronize event fires every time a commit is pushed. Without the debounce in Step 5, every git push triggers a full API call. This is also why skipping draft PRs matters — a developer who marks a PR as "Ready for Review" after iterating in draft mode would otherwise see reviews for every intermediate commit. In my own repos the default 30-second window was too short — CI lint-fix commits kept slipping in just after it fired. Measuring the CI duration and extending the window to 90 seconds cut duplicate reviews by roughly 70%.
Mistake 3: Hitting GitHub API rate limits
Personal Access Tokens are limited to 5,000 requests/hour. For larger teams or organizations with many repositories, create a GitHub App instead — the installation rate limit is 15,000 requests/hour. This is also better for security since GitHub Apps use short-lived tokens rather than long-lived PATs.
In a large monorepo, a single PR might touch hundreds of files. Sending all of them to Claude in one request can cost dollars per review and return lower-quality feedback (Claude gets less specific as the diff grows). Define a file filter early — for example, only review src/**/*.ts files, or exclude generated files and migration files. Adding this to getPRDiff() keeps costs predictable.
Mistake 5: Not handling the case where the tool isn't called
Even with tool_choice: { type: 'any' }, there are edge cases where the tool call doesn't happen — most commonly when max_tokens is set too low and the response is cut off mid-generation. Always handle the case where toolUseBlock is undefined with a clear error, not a silent failure.
Production Best Practices
Cost Management
claude-sonnet-4-6 costs roughly $15 per million output tokens. At an average of 2,000 output tokens per review, that's about $30 per 1,000 PRs — lower than most teams expect. Still, make costs visible from day one by shipping the token-logging code we included in the reviewer.
Push this data to your observability stack (Datadog, CloudWatch, or even a simple spreadsheet export) and set an alert if daily API spend exceeds a threshold.
Environment Variables
ANTHROPIC_API_KEY=sk-ant-... # From Anthropic ConsoleGITHUB_TOKEN=ghp_... # Needs 'repo' scope for PR write accessGITHUB_WEBHOOK_SECRET=xxxxx # Must match what you set in GitHub Webhook settingsPORT=3000 # Server port
Never hardcode these. Use Railway or Render's environment variable settings.
Then configure the webhook in GitHub, open a test PR, and watch the logs.
Where to Go From Here
Once the bot is running, the first thing teams notice is that PR reviews stop being blocked on reviewer availability. Claude's first pass is already done by the time a human sits down to review — they're confirming and extending, not starting from scratch.
Your immediate next step: pick the smallest non-critical repo you have, configure the webhook, and push a test PR. Getting the signature verification working and seeing a 202 response is the whole first milestone. The review pipeline is just plumbing from there.
When you're ready to scale up: consider file-type-specific prompts (different heuristics for frontend vs. backend code), storing review results in a database to analyze team-wide patterns over time, and integrating with your existing CI pipeline. For connecting Claude API to your broader automation workflow, see GitHub Actions × Claude API Automation Guide.
Testing Without Real PRs
You don't need to open actual pull requests to test the webhook handler. GitHub provides a "Redeliver" button in the Webhook settings that replays past events — but during initial development, it's faster to mock the payload locally.
Here's a minimal test script that simulates a webhook delivery with valid signature:
Run this against your local server first (npx ts-node src/webhook.ts in one terminal, the test script in another). If you get a 202 and see the review pipeline starting in your server logs, signature verification is working.
For the actual GitHub API calls, you'll need a real PR diff to pull from. Create a test repository, open a PR with some intentionally flawed code (missing error handling, a hardcoded secret, etc.), and confirm that Claude's review catches it. This manual testing is worth doing before deploying anywhere.
Monitoring and Observability
A bot that silently fails is worse than no bot at all. Set up at minimum:
Structured logging: The code already outputs JSON log lines for Claude API calls. Pipe stdout to a log aggregator (Datadog, Papertrail, or Railway's built-in log viewer) and set up alerts for error rates.
Cost tracking: Parse the claude_review_complete log entries to track daily token spend:
PR coverage tracking: Log when reviews are skipped (no reviewable files, draft PR, etc.) alongside successful reviews. If your skip rate is unexpectedly high, your file filters might be too aggressive.
Alerting: Set a daily cost budget alert. If daily Claude API spend exceeds $5 (for example), something has likely gone wrong — perhaps a bot PR is triggering review loops, or a monorepo PR with thousands of changed lines slipped past your filters.
Choosing Between a PAT and a GitHub App
Personal Access Tokens are the fastest way to get started, but there are real tradeoffs at scale.
A PAT authenticates as a specific user. If that user leaves the organization, the token stops working — and if they had broad repo access, the token had unnecessarily wide permissions. GitHub Apps authenticate as the installation itself, with granular permissions scoped to exactly what's needed.
For this bot, the minimum required permissions are:
Pull requests: Read and write (to post reviews)
Contents: Read (to fetch file contents if needed)
Metadata: Read (always required)
Creating a GitHub App also gives you the 15,000 requests/hour installation rate limit instead of the 5,000/hour PAT limit — important for active organizations.
The code change is minor. Replace the Octokit instantiation with @octokit/auth-app:
import { createAppAuth } from '@octokit/auth-app';import { Octokit } from '@octokit/rest';function createOctokitForInstallation(installationId: number): Octokit { return new Octokit({ authStrategy: createAppAuth, auth: { appId: process.env.GITHUB_APP_ID, privateKey: process.env.GITHUB_APP_PRIVATE_KEY, installationId, }, });}
The installationId comes from the webhook payload (payload.installation.id). This lets the bot work across multiple organizations if needed, with each organization's installation having separate permissions.
For a personal project or single-organization deployment, a PAT is perfectly fine. For anything serving multiple repos across an organization, a GitHub App is the right approach.
Sample code tested with Node.js 22, TypeScript 5.4, @anthropic-ai/sdk 1.x, and @octokit/rest 21.
Share
Thank You for Reading
Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.