Multi-Agent PR Code Review with LangGraph

Build a CLI that reviews GitHub PRs using parallel AI agents in LangGraph.js.

Introduction

30 Second Summary

Code reviews are essential for catching bugs before they ship, but reviewing every pull request thoroughly takes time and mental energy. What if you could build AI agents that specialize in different aspects of code review and run them all at once?

In this project, you will build a TypeScript command-line tool that reviews any GitHub pull request using multiple AI agents running in parallel. You'll use LangGraph.js to orchestrate specialist agents that analyze code for security issues, bugs, and quality problems.

What You'll Build

A multi-agent CLI tool powered by LangGraph.js and Google Gemini that fetches a GitHub PR diff and runs it through three specialist review agents in parallel, producing a prioritized code review.

By the end of this project, you'll have:

  • A working LangGraph.js StateGraph with fan-out/fan-in parallel agent execution that orchestrates multiple AI agents simultaneously.
  • Three specialist AI agents (Security, Bug Detection, Code Quality) that return structured findings using Zod schemas.
  • A CLI tool that fetches real GitHub PR diffs via the Octokit library and prints a prioritized markdown review to your terminal.
  • Secret Mission: Add an intelligent Orchestrator agent that routes to only the relevant agents based on file types in the PR.

Are there any prerequisites?

You'll need intermediate TypeScript knowledge, familiarity with async/await and Promises, and a basic understanding of LLM concepts like prompting and structured output.

Set Up Node.js, TypeScript, and Project Structure

Before you can build the multi-agent review pipeline, you need a working TypeScript environment.

In this step, you'll install Node.js, scaffold a TypeScript project, pull in all the dependencies your agents will use, and create placeholder files for each module.

In this step, get ready to:
  • Install and verify Node.js.
  • Initialize a TypeScript project and install all dependencies.
  • Create the source file structure with placeholder modules.
Verify Node.js is installed

Node.js is the JavaScript runtime that powers your TypeScript project and all its dependencies.

  • Open your terminal by pressing Cmd+Space (macOS) or the Windows key (Windows), then typing Terminal and pressing Enter.
  • Check if Node.js is installed by running this command:
node --version

✔️ I see a version number

You're all set. Move on to the next substep.

ⓧ Command not found

Node.js is not installed yet. Follow these steps to install it:

  • Visit the official Node.js download page:
  • https://nodejs.org/en/download
  • Download the LTS installer for your operating system.
  • Run the installer and accept the default settings at each prompt.
  • Close and reopen your terminal, then run node --version again to confirm the install.
Initialize the project and install dependencies

You'll create a dedicated project folder, initialize it with npm, and install the libraries your agents need.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir pr-review-agent
cd pr-review-agent
  • Initialize a new Node.js project by running:
npm init -y

What does this command do?

The -y flag tells npm to accept all defaults, generating a package.json file without asking questions. This file tracks your project's metadata and dependencies.

  • Install the project's main dependencies by running:
npm install @langchain/langgraph @langchain/google-genai @langchain/core octokit zod dotenv
  • Install the development dependencies by running:
npm install --save-dev tsx typescript

What are these packages?

  • @langchain/langgraph provides the StateGraph for orchestrating your agents.
  • @langchain/google-genai connects to Google Gemini for LLM calls.
  • octokit is GitHub's official REST API client.
  • zod defines schemas for structured LLM output.
  • dotenv loads environment variables from a .env file.
  • tsx runs TypeScript files directly without a separate compile step.

Install failed?

If you see permission errors on macOS, prefix the command with sudo. On Windows, make sure you're running the terminal as Administrator.

If you see network errors, check your internet connection and try again.

Help me troubleshoot my npm install error.

Now open your project in VS Code so you can edit the configuration files.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type VS Code and press Enter to open it.
  • Click File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop, select the pr-review-agent folder, and click Open.
  • Create a new file called tsconfig.json in the root of pr-review-agent by clicking the new file icon in the Explorer sidebar.
  • Paste the following configuration into tsconfig.json:
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "esModuleInterop": true,
    "strict": true,
    "outDir": "./dist",
    "rootDir": "./src",
    "declaration": true,
    "skipLibCheck": true
  },
  "include": ["src/**/*"]
}

What does tsconfig.json do?

This file tells the TypeScript compiler how to process your code. Key settings include:

  • target: ES2022 compiles to modern JavaScript with top-level await support.
  • module: ESNext uses ES module syntax (import/export).
  • strict: true enables all strict type-checking options for safer code.
  • Save tsconfig.json.

Next, update your package.json to enable ES modules and add a convenient run script.

  • Open package.json in the Explorer sidebar.
  • Replace the entire contents with the following:
{
  "name": "pr-review-agent",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "review": "tsx src/index.ts"
  },
  "dependencies": {
    "@langchain/core": "^1.1.44",
    "@langchain/google-genai": "^2.1.31",
    "@langchain/langgraph": "^1.3.4",
    "dotenv": "^16.4.7",
    "octokit": "^4.1.2",
    "zod": "^3.25.76"
  },
  "devDependencies": {
    "tsx": "^4.19.3",
    "typescript": "^5.7.3"
  }
}

Why replace the whole file?

The npm init -y command generates extra fields (main, keywords, license) that this project does not need. The critical additions are "type": "module" (enables ES module imports) and the "review" script (so you can run npm run review later).

  • Save package.json.

Seeing red squiggles in VS Code?

If VS Code shows JSON errors, check for trailing commas or mismatched brackets. JSON does not allow a comma after the last item in an object or array.

Help me fix the JSON error in my package.json.

Create the source file structure

Your project will have five TypeScript modules inside a src/ directory. Each module handles a distinct responsibility.

  • Create the src directory and placeholder files by running these commands in the VS Code integrated terminal (Ctrl+` to open it):
mkdir src
touch src/state.ts src/github.ts src/agents.ts src/graph.ts src/index.ts

What are these files for?

  • state.ts defines the shared state that flows between agents.
  • github.ts handles fetching PR diffs from the GitHub API.
  • agents.ts contains the specialist review agents.
  • graph.ts wires agents together into the LangGraph.js pipeline.
  • index.ts is the CLI entry point that kicks off everything.
  • Add a placeholder comment to each file so they are not empty. Open each file in the Explorer and type:
// placeholder
  • Save all five files.
  • Confirm your file structure by running:
ls src

You should see agents.ts, github.ts, graph.ts, index.ts, and state.ts listed in the output.

Missing files in the output?

Make sure you ran the mkdir src command first. The touch command creates files but fails silently if the parent directory does not exist.

On Windows, touch may not be available. Use New-Item instead, or create the files manually in VS Code by right-clicking the src folder and selecting New File.

Help me create my project files on Windows.

✔️ Awesome, I've got everything!

Great. Double check you've saved all files before moving on.

ⓧ I'd like to double check the full code

Here is what tsconfig.json should look like:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "esModuleInterop": true,
    "strict": true,
    "outDir": "./dist",
    "rootDir": "./src",
    "declaration": true,
    "skipLibCheck": true
  },
  "include": ["src/**/*"]
}

Here is what package.json should look like:

{
  "name": "pr-review-agent",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "review": "tsx src/index.ts"
  },
  "dependencies": {
    "@langchain/core": "^1.1.44",
    "@langchain/google-genai": "^2.1.31",
    "@langchain/langgraph": "^1.3.4",
    "dotenv": "^16.4.7",
    "octokit": "^4.1.2",
    "zod": "^3.25.76"
  },
  "devDependencies": {
    "tsx": "^4.19.3",
    "typescript": "^5.7.3"
  }
}

Each file inside src/ should contain just:

// placeholder

Your project skeleton is ready. Next up, you'll configure your API keys so the agents can talk to Google Gemini and GitHub.

Configure API Keys and Environment

Your project structure is in place and all dependencies are installed. Now you need to connect your tool to two external services: Google Gemini for AI-powered code analysis, and the GitHub API for fetching pull request data.

Both services require API keys to authenticate your requests. The good news is that both are free for what you need here.

In this step, get ready to:
  • Create a Google Gemini API key from AI Studio.
  • Create a GitHub Personal Access Token for reading public PR data.
  • Store both keys in a secure environment file.
Get a Google Gemini API key

Your review agents use Google Gemini as their LLM backbone. To call Gemini from your code, you need an API key from Google AI Studio.

  • Open your browser and navigate to aistudio.google.com.
  • Sign in with your Google account.
  • Click Get API Key in the left sidebar.
  • Click Create API key.
  • If prompted to select a project, choose the default project or create a new one.
  • Copy your new API key and save it somewhere temporary (you will paste it into your project shortly).

No credit card required

Google AI Studio provides free-tier access to Gemini models. You can sign in, get an API key, and start sending requests within the free-tier rate limits. Your project uses about 4-5 requests per review, well within the daily allowance.

Seeing a permission denied error?

If you see an error creating your key, try signing in with a personal Google account (not a work or school account). Organization-managed accounts sometimes have restrictions that block API key creation.

You can also try creating the key in an Incognito/Private browser window, or create a new standalone Google Cloud project from the AI Studio interface.

help me fix a permission denied error when creating my Gemini API key in Google AI Studio

Create a GitHub Personal Access Token

Your tool needs to read pull request diffs from GitHub's REST API. A Personal Access Token authenticates these requests. Since you are only reading data from public repositories, the default token permissions are sufficient.

  • Go to github.com and sign in to your account.
  • Click your profile picture in the top-right corner, then click Settings.
  • In the left sidebar, scroll down and click Developer settings.
  • Under Personal access tokens, click Fine-grained tokens.
  • Click Generate new token.
  • Under Token name, enter pr-review-agent.
  • Under Expiration, select 30 days (or any duration you prefer).
  • Under Repository access, select Public Repositories (read-only).
  • Leave all other permissions at their defaults.
  • Click Generate token.
  • Copy the token value immediately and save it somewhere temporary.

Why a fine-grained token?

Fine-grained tokens let you grant only the minimum permissions your tool needs. Since you are only reading public PR diffs, the default public repositories read access is all that is required.

Fine-grained tokens always include read-only access to all public repositories on GitHub, which is exactly what your PR review tool needs.

Copy your token now

GitHub only shows your token value once. If you navigate away before copying it, you will need to delete the token and create a new one.

Set up your environment files

Your API keys need to live in a .env file that your application reads at runtime. You also need a .gitignore to prevent accidentally committing secrets to version control.

  • Create a new file called .env in your project root (the pr-review-agent folder) by running this command:
touch .env
  • Open .env and paste the following, replacing the placeholder values with your actual keys:
GOOGLE_API_KEY=your-google-ai-studio-api-key-here
GITHUB_TOKEN=your-github-personal-access-token-here

What do these variables do?

`GOOGLE_API_KEY` is the environment variable that the @langchain/google-genai package automatically picks up when initializing the Gemini model. No extra configuration needed in your code.

`GITHUB_TOKEN` is the variable your code will use to authenticate requests to the GitHub REST API via Octokit.

  • Create a .env.example file so other developers know what variables are needed, without exposing your real keys:
GOOGLE_API_KEY=your-google-ai-studio-api-key-here
GITHUB_TOKEN=your-github-personal-access-token-here
  • Create a .gitignore file to prevent secrets and build artifacts from being committed:
.env
node_modules/
dist/
  • Save all three files.
  • Confirm the files exist by running:
ls -a

You should see .env, .env.example, and .gitignore listed alongside your existing project files.

Don't see the dot files?

Files starting with a dot are hidden by default. The -a flag in ls -a shows hidden files. If you forgot the flag, you will not see them in the output.

Also confirm that you created the files inside your pr-review-agent folder, not in a parent directory.

help me find my .env file in my project directory

✔️ Awesome, I've got everything!

Great. Double check that you saved all three files in your project root.

ⓧ I'd like to double check the full code

Here are the three files you should have:

GOOGLE_API_KEY=your-google-ai-studio-api-key-here
GITHUB_TOKEN=your-github-personal-access-token-here
GOOGLE_API_KEY=your-google-ai-studio-api-key-here
GITHUB_TOKEN=your-github-personal-access-token-here
.env
node_modules/
dist/

Your environment is locked and loaded. Next up, you will write the function that fetches real PR diffs from GitHub using the token you just created.

Fetch PR Diffs from GitHub

Your API keys are configured and ready to go. Now you need actual code to feed into your review agents.

In this step, you'll build the GitHub integration module that fetches real pull request diffs. Using Octokit (GitHub's official REST client), you'll parse PR URLs and retrieve file changes in a structured format your agents can analyze.

In this step, get ready to:
  • Parse PR URLs to extract the owner, repo, and pull number.
  • Fetch file diffs from the GitHub REST API.
  • Test the module against a real public PR.
Set up the Octokit client and URL parser

The src/github.ts file will handle all communication with GitHub. It needs to authenticate with your token, accept a PR URL, and return structured diff data.

  • Open src/github.ts in VS Code and replace the placeholder comment with this setup code:
import { Octokit } from "octokit";

// Authenticate with your GitHub token from .env
const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN });

// Shape of a single file's diff data
export interface PRFile {
  filename: string;
  patch: string;
}

What does this code do?

Octokit is GitHub's official JavaScript/TypeScript client for their REST API. By passing process.env.GITHUB_TOKEN to it, all API requests will be authenticated with the token you set up in your .env file.

The PRFile interface defines the shape of each file's diff data: a filename and its patch content.

  • Save src/github.ts.

You won't see a change yet. The next function will use this client to fetch PR data.

Before you can fetch a diff, you need to extract the owner, repository name, and pull request number from a GitHub URL. A regex-based parser handles this.

  • Add the parsePRUrl function below the PRFile interface in src/github.ts:
export function parsePRUrl(url: string): {
  owner: string;
  repo: string;
  pullNumber: number;
} {
  // Extract owner, repo, and PR number from a URL like:
  // https://github.com/octocat/Hello-World/pull/1
  const match = url.match(
    /github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/
  );
  if (!match) {
    throw new Error(`Invalid GitHub PR URL: ${url}`);
  }
  return { owner: match[1], repo: match[2], pullNumber: parseInt(match[3]) };
}

What does this code do?

  • The regex matches the pattern github.com/{owner}/{repo}/pull/{number} and captures three groups: owner, repo name, and PR number.
  • If the URL doesn't match (e.g. a non-GitHub link), the function throws a descriptive error immediately.
  • parseInt(match[3]) converts the PR number from a string to a number, which the GitHub API expects.
  • Save src/github.ts.

You won't see a change yet. The fetchPRDiff function you'll write next uses parsePRUrl to break down the URL before calling the API.

Build the fetchPRDiff function

Now you'll write the main function that calls the GitHub API and structures the response into a format your review agents can consume.

  • Add the fetchPRDiff function below parsePRUrl in src/github.ts:
export async function fetchPRDiff(url: string): Promise<{
  files: PRFile[];
  diff: string;
}> {
  const { owner, repo, pullNumber } = parsePRUrl(url);

  // Fetch the list of changed files from the GitHub API
  const { data: files } = await octokit.rest.pulls.listFiles({
    owner,
    repo,
    pull_number: pullNumber,
  });

  // Filter to files that have patch data and map to our PRFile shape
  const prFiles: PRFile[] = files
    .filter((f) => f.patch)
    .map((f) => ({
      filename: f.filename,
      patch: f.patch!,
    }));

  // Combine all patches into a single diff string for the agents
  const diff = prFiles
    .map((f) => `--- ${f.filename} ---\n${f.patch}`)
    .join("\n\n");

  return { files: prFiles, diff };
}

What does this code do?

  • octokit.rest.pulls.listFiles calls the GitHub REST API endpoint that returns all files changed in a pull request, including their patch (diff) content.
  • The .filter((f) => f.patch) step removes files that don't have patch data (binary files or very large diffs get excluded by GitHub).
  • The final diff string concatenates all file patches into one block of text, separated by filename headers. This is what the AI agents will read.
  • Save src/github.ts.

Seeing a TypeScript error on f.patch!?

The ! after f.patch is a non-null assertion. It tells TypeScript that you've already confirmed this value exists (via the .filter() above). If VS Code underlines it, make sure you have the .filter((f) => f.patch) line directly above it.

help me fix a TypeScript non-null assertion error in my github.ts file

✔️ Awesome, I've got everything!

Great. Double check you've saved src/github.ts before moving on to testing.

ⓧ I'd like to double check the full code

import { Octokit } from "octokit";

const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN });

export interface PRFile {
  filename: string;
  patch: string;
}

export function parsePRUrl(url: string): {
  owner: string;
  repo: string;
  pullNumber: number;
} {
  const match = url.match(
    /github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/
  );
  if (!match) {
    throw new Error(`Invalid GitHub PR URL: ${url}`);
  }
  return { owner: match[1], repo: match[2], pullNumber: parseInt(match[3]) };
}

export async function fetchPRDiff(url: string): Promise<{
  files: PRFile[];
  diff: string;
}> {
  const { owner, repo, pullNumber } = parsePRUrl(url);

  const { data: files } = await octokit.rest.pulls.listFiles({
    owner,
    repo,
    pull_number: pullNumber,
  });

  const prFiles: PRFile[] = files
    .filter((f) => f.patch)
    .map((f) => ({
      filename: f.filename,
      patch: f.patch!,
    }));

  const diff = prFiles
    .map((f) => `--- ${f.filename} ---\n${f.patch}`)
    .join("\n\n");

  return { files: prFiles, diff };
}
Test against a real public PR

Your GitHub module is complete. Time to verify it works by fetching a real pull request diff.

  • Create a temporary test file at src/test-github.ts by running this command:
touch src/test-github.ts
  • Open src/test-github.ts and add the following test code:
import "dotenv/config";
import { fetchPRDiff } from "./github.js";

// A well-known public PR on GitHub's example repo
const testUrl = "https://github.com/octocat/Hello-World/pull/1";

const result = await fetchPRDiff(testUrl);
console.log(`Found ${result.files.length} changed files`);
console.log(result.diff.substring(0, 500));

What does this test do?

This script loads your .env file (so GITHUB_TOKEN is available), calls fetchPRDiff with a real PR URL, and prints how many files were changed plus a preview of the diff.

You can replace the URL with any public PR you'd like to test.

  • Run the test by executing this command:
npx tsx src/test-github.ts

You should see output showing the number of changed files and a preview of the diff content. Something like:

Found 1 changed files
--- README ---
@@ -1 +1 @@
-Hello World

Seeing an authentication error?

  • Check that your .env file has a valid GITHUB_TOKEN value (not the placeholder text).
  • Make sure the token has at least the default read permissions for public repositories.
  • If you see Invalid GitHub PR URL, double check the URL format matches https://github.com/owner/repo/pull/123.

help me debug a GitHub API authentication error in my fetchPRDiff function

  • Once the test works, delete the temporary test file by running:
rm src/test-github.ts

Your GitHub module is wired up and tested. Next up, you'll build the specialist AI agents that analyze these diffs for security issues, bugs, and code quality problems.

Build the Specialist Review Agents

You can now pull real PR diffs from GitHub into your application. The next challenge is turning that raw diff into actionable code review feedback.

In this step, you'll define the shared state that coordinates your agents, then build four specialist agents that each analyze code through a different lens. Each agent uses Google Gemini with structured output to produce typed, reliable findings.

In this step, get ready to:
  • Define the review state that coordinates agent findings.
  • Build specialist review agents with focused system prompts.
  • Test a single agent to verify structured output.
Define the review state

When multiple agents run in parallel, they each produce their own findings. The graph needs a way to merge those findings into a single collection without overwriting each other.

LangGraph.js solves this with Annotation.Root. You define a state shape where each key can have a reducer function that controls how values from multiple nodes get combined.

  • Open src/state.ts in VS Code and replace the placeholder comment with the following code:
import { Annotation } from "@langchain/langgraph";

// Define the shape of a single review finding
export interface ReviewFinding {
  severity: "critical" | "suggestion" | "nitpick";
  file: string;
  line?: number;
  issue: string;
  suggestion: string;
  agent: string;
}

// Define the shared state that all agents read from and write to
export const ReviewStateAnnotation = Annotation.Root({
  prUrl: Annotation<string>,
  diff: Annotation<string>,
  files: Annotation<string[]>({
    reducer: (left, right) => (right.length > 0 ? right : left),
    default: () => [],
  }),
  findings: Annotation<ReviewFinding[]>({
    // Concatenate findings from all agents into one array
    reducer: (left, right) => left.concat(right),
    default: () => [],
  }),
  summary: Annotation<string>({
    // Always use the latest summary (from the summarizer agent)
    reducer: (_left, right) => right,
    default: () => "",
  }),
});

export type ReviewState = typeof ReviewStateAnnotation.State;

What does this code do?

The ReviewFinding interface defines the shape of every issue an agent reports. Each finding has a severity level, the file it relates to, a description, and a suggestion for fixing it.

The ReviewStateAnnotation uses Annotation.Root to define the shared state for the entire graph. The findings key has a concat reducer. This means when three agents each return findings, they get merged into one array instead of overwriting each other.

The summary key uses a "last write wins" reducer because only the final summarizer agent writes to it.

  • Save src/state.ts.

Seeing a type error on Annotation?

Make sure your import is from "@langchain/langgraph" and not from a sub-path. The Annotation helper is exported from the main package entry point.

Help me debug my Annotation type error

Create the specialist review agents

Each agent is a function that receives the current state, sends the diff to Gemini with a specialized system prompt, and returns structured findings. You'll use a shared helper function to avoid repeating the same LLM invocation logic across all three specialists.

  • Open src/agents.ts and replace the placeholder comment with the imports, model setup, and Zod schema:
import { ChatGoogleGenerativeAI } from "@langchain/google-genai";
import { z } from "zod";
import type { ReviewState, ReviewFinding } from "./state.js";

// Initialize the Gemini model with zero temperature for consistent output
const model = new ChatGoogleGenerativeAI({
  model: "gemini-2.5-flash",
  temperature: 0,
});

// Define the expected shape of agent responses
const FindingsSchema = z.object({
  findings: z.array(
    z.object({
      severity: z.enum(["critical", "suggestion", "nitpick"]),
      file: z.string(),
      line: z.number().optional(),
      issue: z.string(),
      suggestion: z.string(),
    })
  ),
});

What does this code do?

The ChatGoogleGenerativeAI class connects to Google Gemini via the GOOGLE_API_KEY environment variable. Setting temperature: 0 makes responses deterministic so reviews are consistent.

The FindingsSchema tells Gemini exactly what shape to return. Instead of parsing free-text responses, the model returns typed objects that match your ReviewFinding interface. This is called structured output.

  • Below the FindingsSchema, add the runAgent helper function:
async function runAgent(
  state: ReviewState,
  agentName: string,
  systemPrompt: string
): Promise<{ findings: ReviewFinding[] }> {
  // Bind the Zod schema so Gemini returns structured JSON
  const structuredModel = model.withStructuredOutput(FindingsSchema);

  const response = await structuredModel.invoke([
    { role: "system", content: systemPrompt },
    {
      role: "user",
      content: `Review the following code diff and report any issues:\n\n${state.diff}`,
    },
  ]);

  // Tag each finding with the agent's name for attribution
  const findings: ReviewFinding[] = response.findings.map((f) => ({
    ...f,
    agent: agentName,
  }));

  return { findings };
}

What does this code do?

The runAgent helper is the core logic every specialist shares. It calls model.withStructuredOutput(FindingsSchema) to get a version of the model that always returns data matching your Zod schema.

Each agent passes a different systemPrompt to focus the LLM's attention on a specific category of issues. The response findings get tagged with the agentName so you know which agent flagged each issue.

  • Below runAgent, add the three specialist agent functions:
export async function securityAgent(
  state: ReviewState
): Promise<{ findings: ReviewFinding[] }> {
  return runAgent(
    state,
    "Security",
    `You are a security-focused code reviewer. Analyze the diff for:
- SQL injection vulnerabilities
- Hardcoded secrets or API keys
- XSS vulnerabilities
- Command injection
- Insecure deserialization
- Authentication/authorization flaws
- Path traversal

Only report genuine security issues. If there are no issues, return an empty findings array.`
  );
}

export async function bugDetectorAgent(
  state: ReviewState
): Promise<{ findings: ReviewFinding[] }> {
  return runAgent(
    state,
    "BugDetector",
    `You are a bug detection specialist. Analyze the diff for:
- Off-by-one errors
- Null/undefined dereferences
- Infinite loops or recursion
- Incorrect conditionals
- Race conditions
- Unhandled promise rejections
- Wrong return values or types

Only report genuine bugs. If there are no issues, return an empty findings array.`
  );
}

export async function codeQualityAgent(
  state: ReviewState
): Promise<{ findings: ReviewFinding[] }> {
  return runAgent(
    state,
    "CodeQuality",
    `You are a code quality reviewer. Analyze the diff for:
- Poor naming conventions
- Functions that are too long or complex
- Code duplication
- Magic numbers or strings
- Dead code
- Missing error handling
- Style inconsistencies

Only report meaningful quality issues. If there are no issues, return an empty findings array.`
  );
}

Why three separate agents?

A single prompt asking for "all issues" tends to produce shallow, unfocused results. By giving each agent a narrow mandate, the LLM focuses deeply on one category. This is the same pattern production code review tools use.

Each agent will run in parallel inside the graph, so three focused prompts complete in roughly the same wall-clock time as one.

  • Below the three specialist agents, add the summarizerAgent function:
export async function summarizerAgent(
  state: ReviewState
): Promise<{ summary: string }> {
  const { findings } = state;

  // Short-circuit if no issues were found
  if (findings.length === 0) {
    return { summary: "## Code Review Summary\n\n✅ No issues found. The code looks good!" };
  }

  const response = await model.invoke([
    {
      role: "system",
      content: `You are a tech lead summarizing a code review. Organize the findings into a clear, prioritized markdown report with these sections:
1. 🚨 Critical Issues (must fix before merge)
2. 💡 Suggestions (should consider)
3. 🔍 Nitpicks (minor improvements)

For each finding, include the file, the issue, and the suggested fix. Be concise but actionable.`,
    },
    {
      role: "user",
      content: `Here are the findings from the review agents:\n\n${JSON.stringify(findings, null, 2)}`,
    },
  ]);

  return { summary: response.content as string };
}

What does the summarizer do differently?

Unlike the three specialists, the summarizer does not use structured output. It produces a free-form markdown report because the final output is meant for human reading, not further machine processing.

It also short-circuits with a friendly message when no findings exist, saving an unnecessary LLM call.

  • Save src/agents.ts.

Seeing an import error on ./state.js?

TypeScript with ESM requires the .js extension in import paths even though the source file is .ts. Make sure your import reads from "./state.js" and not from "./state" or from "./state.ts".

Help me fix my module import error

✔️ Awesome, I've got everything!

Great. Double check you've saved both src/state.ts and src/agents.ts.

ⓧ I'd like to double check the full code

Here is the complete src/state.ts:

import { Annotation } from "@langchain/langgraph";

export interface ReviewFinding {
  severity: "critical" | "suggestion" | "nitpick";
  file: string;
  line?: number;
  issue: string;
  suggestion: string;
  agent: string;
}

export const ReviewStateAnnotation = Annotation.Root({
  prUrl: Annotation<string>,
  diff: Annotation<string>,
  files: Annotation<string[]>({
    reducer: (left, right) => (right.length > 0 ? right : left),
    default: () => [],
  }),
  findings: Annotation<ReviewFinding[]>({
    reducer: (left, right) => left.concat(right),
    default: () => [],
  }),
  summary: Annotation<string>({
    reducer: (_left, right) => right,
    default: () => "",
  }),
});

export type ReviewState = typeof ReviewStateAnnotation.State;

Here is the complete src/agents.ts:

import { ChatGoogleGenerativeAI } from "@langchain/google-genai";
import { z } from "zod";
import type { ReviewState, ReviewFinding } from "./state.js";

const model = new ChatGoogleGenerativeAI({
  model: "gemini-2.5-flash",
  temperature: 0,
});

const FindingsSchema = z.object({
  findings: z.array(
    z.object({
      severity: z.enum(["critical", "suggestion", "nitpick"]),
      file: z.string(),
      line: z.number().optional(),
      issue: z.string(),
      suggestion: z.string(),
    })
  ),
});

async function runAgent(
  state: ReviewState,
  agentName: string,
  systemPrompt: string
): Promise<{ findings: ReviewFinding[] }> {
  const structuredModel = model.withStructuredOutput(FindingsSchema);

  const response = await structuredModel.invoke([
    { role: "system", content: systemPrompt },
    {
      role: "user",
      content: `Review the following code diff and report any issues:\n\n${state.diff}`,
    },
  ]);

  const findings: ReviewFinding[] = response.findings.map((f) => ({
    ...f,
    agent: agentName,
  }));

  return { findings };
}

export async function securityAgent(
  state: ReviewState
): Promise<{ findings: ReviewFinding[] }> {
  return runAgent(
    state,
    "Security",
    `You are a security-focused code reviewer. Analyze the diff for:
- SQL injection vulnerabilities
- Hardcoded secrets or API keys
- XSS vulnerabilities
- Command injection
- Insecure deserialization
- Authentication/authorization flaws
- Path traversal

Only report genuine security issues. If there are no issues, return an empty findings array.`
  );
}

export async function bugDetectorAgent(
  state: ReviewState
): Promise<{ findings: ReviewFinding[] }> {
  return runAgent(
    state,
    "BugDetector",
    `You are a bug detection specialist. Analyze the diff for:
- Off-by-one errors
- Null/undefined dereferences
- Infinite loops or recursion
- Incorrect conditionals
- Race conditions
- Unhandled promise rejections
- Wrong return values or types

Only report genuine bugs. If there are no issues, return an empty findings array.`
  );
}

export async function codeQualityAgent(
  state: ReviewState
): Promise<{ findings: ReviewFinding[] }> {
  return runAgent(
    state,
    "CodeQuality",
    `You are a code quality reviewer. Analyze the diff for:
- Poor naming conventions
- Functions that are too long or complex
- Code duplication
- Magic numbers or strings
- Dead code
- Missing error handling
- Style inconsistencies

Only report meaningful quality issues. If there are no issues, return an empty findings array.`
  );
}

export async function summarizerAgent(
  state: ReviewState
): Promise<{ summary: string }> {
  const { findings } = state;

  if (findings.length === 0) {
    return { summary: "## Code Review Summary\n\n✅ No issues found. The code looks good!" };
  }

  const response = await model.invoke([
    {
      role: "system",
      content: `You are a tech lead summarizing a code review. Organize the findings into a clear, prioritized markdown report with these sections:
1. 🚨 Critical Issues (must fix before merge)
2. 💡 Suggestions (should consider)
3. 🔍 Nitpicks (minor improvements)

For each finding, include the file, the issue, and the suggested fix. Be concise but actionable.`,
    },
    {
      role: "user",
      content: `Here are the findings from the review agents:\n\n${JSON.stringify(findings, null, 2)}`,
    },
  ]);

  return { summary: response.content as string };
}
Test an agent in isolation

Before wiring up the full pipeline, you should verify that a single agent can call Gemini and return valid structured output. You'll create a quick test script with a deliberately insecure code snippet to confirm the security agent catches it.

  • Create a new file called src/test-agent.ts with the following code:
import "dotenv/config";
import { securityAgent } from "./agents.js";
import type { ReviewState } from "./state.js";

// Create a fake state with an obviously insecure diff
const testState: ReviewState = {
  prUrl: "https://github.com/test/test/pull/1",
  diff: `--- src/login.ts ---
+const password = "admin123";
+const query = \`SELECT * FROM users WHERE name = '\${userInput}'\`;`,
  files: ["src/login.ts"],
  findings: [],
  summary: "",
};

const result = await securityAgent(testState);
console.log(JSON.stringify(result, null, 2));

What does this test do?

This script creates a fake review state containing two well-known security issues: a hardcoded password and a SQL injection vulnerability. If the security agent is working correctly, it should return findings flagging both problems with appropriate severity levels.

  • Run the test script by executing this command:
npx tsx src/test-agent.ts
  • You should see JSON output containing a findings array with entries for the hardcoded password and the SQL injection vulnerability. Each finding includes severity, file, issue, and suggestion fields.

Getting an authentication error?

Make sure your .env file contains a valid GOOGLE_API_KEY. The dotenv/config import at the top of the test file loads environment variables automatically.

If you see a rate limit error, wait a few seconds and try again. The Gemini free tier allows approximately 15 requests per minute.

Help me troubleshoot my Gemini API error

  • Once you've confirmed the output looks correct, delete the test file by running:
rm src/test-agent.ts

Your agents are ready. Next up, you'll wire them into a LangGraph StateGraph so they execute in parallel and feed their findings into the summarizer.

Wire Up the LangGraph Fan-Out/Fan-In Pipeline

You have three specialist agents that each know how to analyze code. You have a summarizer that turns raw findings into a prioritized report. Now you need the orchestration layer that makes them work together.

In this step, you will build the StateGraph that wires all four agents into a fan-out/fan-in pipeline. Three agents run in parallel, and their results converge at the summarizer before the graph ends.

In this step, get ready to:
  • Build the StateGraph with fan-out edges to three parallel agents and fan-in edges to the summarizer.
  • Understand how the reducer collects findings from all agents before the summarizer runs.
  • Test the full pipeline against a real GitHub PR diff.
Build the StateGraph with fan-out/fan-in edges

The fan-out/fan-in pattern means multiple nodes execute in parallel within the same "superstep," then all converge on a single downstream node. In LangGraph.js, you create this by adding multiple edges from the same source to different targets.

  • Open src/graph.ts in VS Code and replace the placeholder comment with the following code:
import { StateGraph } from "@langchain/langgraph";
import { ReviewStateAnnotation } from "./state.js";
import {
  securityAgent,
  bugDetectorAgent,
  codeQualityAgent,
  summarizerAgent,
} from "./agents.js";

export function buildReviewGraph() {
  // Create a new StateGraph using our typed annotation
  const graph = new StateGraph(ReviewStateAnnotation)
    // Register each agent as a named node
    .addNode("security", securityAgent)
    .addNode("bugDetector", bugDetectorAgent)
    .addNode("codeQuality", codeQualityAgent)
    .addNode("summarizer", summarizerAgent)
    // Fan-out: __start__ sends state to all three agents in parallel
    .addEdge("__start__", "security")
    .addEdge("__start__", "bugDetector")
    .addEdge("__start__", "codeQuality")
    // Fan-in: each agent feeds into the summarizer
    .addEdge("security", "summarizer")
    .addEdge("bugDetector", "summarizer")
    .addEdge("codeQuality", "summarizer")
    // Summarizer is the final node before the graph ends
    .addEdge("summarizer", "__end__");

  // Compile the graph into an executable runnable
  return graph.compile();
}

What does this code do?

The StateGraph takes your ReviewStateAnnotation as its type parameter, which means every node receives and returns the same typed state object.

The three .addEdge("__start__", ...) calls are what create parallel execution. LangGraph groups all nodes that share the same source into a single "superstep" and runs them concurrently.

The three .addEdge("...", "summarizer") calls are the fan-in. The summarizer node only executes once all three upstream agents have finished and their results have been merged into state.

  • Save src/graph.ts.

Seeing red squiggles in VS Code?

If VS Code highlights the imports, make sure you saved src/state.ts and src/agents.ts from the previous steps. The .js extensions in the import paths are required for ESM resolution even though the source files are .ts.

help me fix TypeScript import errors in my LangGraph project

Understand how findings merge at the summarizer

The magic that makes fan-in work is the reducer you defined on the findings field in src/state.ts.

How do parallel results combine?

Each agent returns { findings: [...] }. When multiple agents write to the same state field in the same superstep, LangGraph calls the field's reducer to merge their outputs. Your findings reducer uses left.concat(right), which concatenates each agent's array into one combined list.

By the time the summarizer runs, state.findings contains the combined output from all three specialist agents. The summarizer then organizes these into Critical Issues, Suggestions, and Nitpicks.

Test the pipeline against a real PR

The proper CLI entry point comes in the next step. For now, you will create a quick test script to verify the graph executes correctly end-to-end.

  • Create a new file called src/test-review.ts and paste the following code:
import "dotenv/config";
import { fetchPRDiff } from "./github.js";
import { buildReviewGraph } from "./graph.js";

// Pick any public GitHub PR with code changes
const prUrl = "https://github.com/octocat/Hello-World/pull/1";

async function test() {
  console.log(`Fetching PR diff from: ${prUrl}`);
  const { files, diff } = await fetchPRDiff(prUrl);
  console.log(`Found ${files.length} changed files`);

  console.log("Running review agents in parallel...");
  const graph = buildReviewGraph();

  const result = await graph.invoke({
    prUrl,
    diff,
    files: files.map((f) => f.filename),
    findings: [],
    summary: "",
  });

  console.log(result.summary);
  console.log(`Total findings: ${result.findings.length}`);
}

test().catch(console.error);

What does this test do?

This script fetches a real PR diff from GitHub, passes it through the full graph pipeline, and prints the summarizer's prioritized review. You can replace the URL with any public PR that has code changes for a more interesting review.

  • Save src/test-review.ts.
  • Run the test script by running this command:
npx tsx src/test-review.ts

The script takes 10-30 seconds while the three agents run in parallel and Gemini processes the diff.

You should see output that includes the number of changed files, followed by a markdown-formatted code review summary organized into Critical Issues, Suggestions, and Nitpicks.

Try a PR with more code

The octocat/Hello-World/pull/1 PR is very small. For a more interesting review, try any public PR that modifies TypeScript or JavaScript files. The agents produce richer feedback on larger diffs.

Seeing an authentication error?

Make sure your .env file contains both GOOGLE_API_KEY and GITHUB_TOKEN with valid values. The GitHub token needs read access to public repositories.

If you see a rate limit error from Gemini, wait 60 seconds and try again. The free tier allows 10-15 requests per minute.

help me debug my LangGraph test script that is failing with an API error

  • Once you have confirmed the pipeline works, delete the test file by running:
rm src/test-review.ts

Why delete the test file?

In the next step, you will build a proper CLI entry point in src/index.ts with argument parsing and formatted output. This test file was just a quick verification that the graph runs correctly.

✔️ Awesome, I've got everything!

Great. Double check you have saved src/graph.ts and deleted src/test-review.ts.

ⓧ I'd like to double check the full code

import { StateGraph } from "@langchain/langgraph";
import { ReviewStateAnnotation } from "./state.js";
import {
  securityAgent,
  bugDetectorAgent,
  codeQualityAgent,
  summarizerAgent,
} from "./agents.js";

export function buildReviewGraph() {
  const graph = new StateGraph(ReviewStateAnnotation)
    .addNode("security", securityAgent)
    .addNode("bugDetector", bugDetectorAgent)
    .addNode("codeQuality", codeQualityAgent)
    .addNode("summarizer", summarizerAgent)
    .addEdge("__start__", "security")
    .addEdge("__start__", "bugDetector")
    .addEdge("__start__", "codeQuality")
    .addEdge("security", "summarizer")
    .addEdge("bugDetector", "summarizer")
    .addEdge("codeQuality", "summarizer")
    .addEdge("summarizer", "__end__");

  return graph.compile();
}

Your multi-agent pipeline is wired up and tested. Next up, you will build a polished CLI entry point so you can review any PR with a single command.

Build the CLI Entry Point

Your multi-agent pipeline is fully wired. The StateGraph fans out to three specialist agents, collects their findings via the reducer, and fans in to the summarizer.

Now you need a way to actually run it. In this step, you'll build the CLI entry point that accepts a GitHub PR URL from the command line, feeds the diff through your pipeline, and prints a formatted, prioritized review to the console.

In this step, get ready to:
  • Create the src/index.ts entry point with argument parsing and error handling.
  • Add formatted console output with severity breakdowns for the review.
  • Run the full pipeline end-to-end against a real public GitHub PR.
Write the CLI entry point

The entry point ties all your modules together. It reads the PR URL from the command line, fetches the diff, invokes the graph, and displays the results.

  • In VS Code, open src/index.ts (the file currently has a placeholder comment).
  • Replace the entire contents with the following code:
import "dotenv/config";
import { fetchPRDiff } from "./github.js";
import { buildReviewGraph } from "./graph.js";

async function main() {
  // Read the PR URL from the command-line arguments
  const prUrl = process.argv[2];

  // Exit early with usage instructions if no URL is provided
  if (!prUrl) {
    console.error("Usage: npx tsx src/index.ts <github-pr-url>");
    console.error("Example: npx tsx src/index.ts https://github.com/owner/repo/pull/123");
    process.exit(1);
  }

  console.log(`\n🔍 Fetching PR diff from: ${prUrl}\n`);
  const { files, diff } = await fetchPRDiff(prUrl);
  console.log(`📁 Found ${files.length} changed files\n`);

  // Build and invoke the multi-agent review graph
  console.log("🤖 Running review agents in parallel...\n");
  const graph = buildReviewGraph();

  const result = await graph.invoke({
    prUrl,
    diff,
    files: files.map((f) => f.filename),
    findings: [],
    summary: "",
  });

  // Print the formatted review summary
  console.log("═".repeat(60));
  console.log(result.summary);
  console.log("═".repeat(60));
  console.log(`\n📊 Total findings: ${result.findings.length}`);
  console.log(
    `   🚨 Critical: ${result.findings.filter((f) => f.severity === "critical").length}`
  );
  console.log(
    `   💡 Suggestions: ${result.findings.filter((f) => f.severity === "suggestion").length}`
  );
  console.log(
    `   🔍 Nitpicks: ${result.findings.filter((f) => f.severity === "nitpick").length}`
  );
}

main().catch(console.error);

What does this code do?

  • import "dotenv/config" loads environment variables from your .env file (your API keys) before anything else runs.
  • process.argv[2] grabs the first argument after the script path. If the learner forgets to pass a URL, the script exits with helpful usage instructions.
  • fetchPRDiff (from your GitHub module) fetches the changed files and their patches from the PR.
  • graph.invoke() kicks off the full fan-out/fan-in pipeline. All three agents run in parallel, then the summarizer produces the final review.
  • The output section uses a separator line of ═ characters to frame the summary, followed by a severity breakdown so you can see at a glance how many critical issues, suggestions, and nitpicks were found.
  • Save src/index.ts.

Seeing red squiggles in VS Code?

If TypeScript highlights import errors, make sure each import path ends with .js (not .ts). This is required for ESM resolution with "type": "module" in package.json.

help me fix import resolution errors in my TypeScript ESM project

✔️ Awesome, I've got everything!

Great. Double check you've saved src/index.ts before moving on.

ⓧ I'd like to double check the full code

import "dotenv/config";
import { fetchPRDiff } from "./github.js";
import { buildReviewGraph } from "./graph.js";

async function main() {
  const prUrl = process.argv[2];

  if (!prUrl) {
    console.error("Usage: npx tsx src/index.ts <github-pr-url>");
    console.error("Example: npx tsx src/index.ts https://github.com/owner/repo/pull/123");
    process.exit(1);
  }

  console.log(`\n🔍 Fetching PR diff from: ${prUrl}\n`);
  const { files, diff } = await fetchPRDiff(prUrl);
  console.log(`📁 Found ${files.length} changed files\n`);

  console.log("🤖 Running review agents in parallel...\n");
  const graph = buildReviewGraph();

  const result = await graph.invoke({
    prUrl,
    diff,
    files: files.map((f) => f.filename),
    findings: [],
    summary: "",
  });

  console.log("═".repeat(60));
  console.log(result.summary);
  console.log("═".repeat(60));
  console.log(`\n📊 Total findings: ${result.findings.length}`);
  console.log(
    `   🚨 Critical: ${result.findings.filter((f) => f.severity === "critical").length}`
  );
  console.log(
    `   💡 Suggestions: ${result.findings.filter((f) => f.severity === "suggestion").length}`
  );
  console.log(
    `   🔍 Nitpicks: ${result.findings.filter((f) => f.severity === "nitpick").length}`
  );
}

main().catch(console.error);
Run the full pipeline against a real PR

Time to see all your agents in action. You'll point the CLI at a real public GitHub PR and watch the multi-agent review unfold.

  • Pick any public GitHub PR URL. If you don't have one in mind, you can use any open-source repository's PR that has code changes (not just documentation).
  • Run the pipeline by executing this command in your terminal:
npx tsx src/index.ts [[PR_URL="https://github.com/owner/repo/pull/123"]]

What should I see?

The script takes 10-30 seconds to complete (three agents run in parallel, then the summarizer processes their findings). You should see progress messages followed by a formatted review.

The output starts with a file count, shows the separator lines framing the markdown summary, and ends with a severity breakdown listing how many critical issues, suggestions, and nitpicks were found.

  • Confirm you see the separator lines (═══════) framing the review summary and the finding counts at the bottom.

Seeing an authentication error?

If you see a 401 or 403 error from GitHub, your GITHUB_TOKEN may be invalid or expired. Return to your GitHub Developer Settings and generate a new fine-grained token.

If you see a Google API error, check that your GOOGLE_API_KEY in .env is correct and that you haven't hit the free-tier rate limit (1,500 requests per day).

help me debug API authentication errors in my multi-agent review tool

Secret mission

Intelligent Agent Routing

Right now, your pipeline runs all three specialist agents on every PR, regardless of what files changed. A CSS-only PR still gets a full security scan. A documentation PR still goes through bug detection. That wastes time and API calls. In this secret mission, you'll add an Orchestrator agent that intelligently routes to only the relevant agents based on the file types in the diff.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally and uses free-tier APIs, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • The pr-review-agent project folder on your Desktop (source code, node_modules/, config files).
  • A Google Gemini API key (created at aistudio.google.com).
  • A GitHub Personal Access Token (fine-grained).
  • A .env file containing both credentials.

Keep everything running

No action needed. Choose this if you're still actively building or plan to review more PRs soon.

  • Your project folder stays on your Desktop, ready to use anytime.
  • Your API key and GitHub token remain active for future runs.
  • The Gemini free tier renews daily (1,500 requests/day), so there is nothing to worry about cost-wise.

Pause - I'll come back to this later

Revoke your credentials to stay safe while you're away. You can regenerate them when you come back.

  • Revoke your GitHub Personal Access Token: go to GitHub > Settings > Developer settings > Personal access tokens > Fine-grained tokens, find your token, and click Delete.
  • Revoke your Gemini API key: go to aistudio.google.com, open API keys, find your key, and delete it.
  • Keep the project folder on your Desktop. When you return, generate new keys and update your .env file.

Delete - I don't want to use this again

Remove all project resources and credentials. This is a full cleanup.

  • Revoke your GitHub Personal Access Token: go to GitHub > Settings > Developer settings > Personal access tokens > Fine-grained tokens, find your token, and click Delete.
  • Revoke your Gemini API key: go to aistudio.google.com, open API keys, find your key, and delete it.
  • Delete the project folder by running this command on macOS/Linux:
rm -rf ~/Desktop/pr-review-agent

On Windows, run this in PowerShell:

Remove-Item -Recurse -Force ~\Desktop\pr-review-agent
  • Confirm the folder is gone by running:
ls ~/Desktop/pr-review-agent

You should see a "No such file or directory" message.

Nice Work!

Nice Work!

You just built a production-style multi-agent code review system. Point it at any public GitHub PR, and three specialist AI agents analyze it in parallel before a summarizer delivers a prioritized review.

Here's what you learned:

  • Built a LangGraph.js StateGraph with fan-out/fan-in parallel execution of multiple specialist AI agents, using Annotation reducers for typed state management.
  • Integrated with the GitHub REST API using Octokit to fetch real PR diffs and parse them into structured file data.
  • Used Google Gemini with structured output powered by Zod schemas to produce reliable, prioritized code reviews from a working CLI tool.
  • Added intelligent routing with an Orchestrator agent that dynamically skips irrelevant review agents based on file types in the PR.

Ready to quiz yourself?