---
title: "Review pull requests"
description: "Build a bot that reviews a pull request when someone mentions it, posts the review as a comment, and answers follow-ups in the same thread."
canonical_url: "https://github-tools.com/guides/review-pull-requests"
---
# Review pull requests

> Build a bot that reviews a pull request when someone mentions it, posts the review as a comment, and answers follow-ups in the same thread.

To review pull requests with an AI agent, give a model the `code-review` preset and run it when someone mentions the bot on a pull request: it reads the description, the changed files and the checks, then posts its review as a comment. This guide builds that bot with [Chat SDK](https://github-tools.com/integrations/chat-sdk) and [Vercel Workflow](https://github-tools.com/integrations/workflow), from the runnable [`examples/pr-review-agent`](https://github.com/vercel-labs/github-tools/tree/main/examples/pr-review-agent).

## What you'll build

Comment `@my-review-agent review this PR` on a pull request. The bot adds an `eyes` reaction, reads the pull request with `getPullRequestContext` and `listPullRequestFiles`, and posts a review with `addPullRequestComment`. Reply in the same thread, for example "is the retry logic safe?", and it answers with the review still in its history.

Each turn runs as a workflow step, so a review that hits a timeout or a redeploy resumes instead of starting over. Mentioned on an issue instead, the bot answers there with `addIssueComment`.

<pr-review-mention>



</pr-review-mention>

## Before you start

- A Nitro app, or a copy of [`examples/pr-review-agent`](https://github.com/vercel-labs/github-tools/tree/main/examples/pr-review-agent), which is a Nitro server built with Vite.
- A GitHub connector in [Vercel Connect](https://github-tools.com/auth#create-a-github-connector), installed on the repositories the bot reviews, and `VERCEL_OIDC_TOKEN` pulled with `vercel env pull`.
- A GitHub account for the bot, separate from the account you comment with.
- A tunnel such as `cloudflared` to receive webhooks on your machine.

## 1. Install the packages

<code-group>

```bash [pnpm]
pnpm add @github-tools/sdk @vercel/connect chat @chat-adapter/github @chat-adapter/state-memory workflow ai zod
pnpm add -D nitro vite
```

```bash [npm]
npm install @github-tools/sdk @vercel/connect chat @chat-adapter/github @chat-adapter/state-memory workflow ai zod
npm install -D nitro vite
```

```bash [yarn]
yarn add @github-tools/sdk @vercel/connect chat @chat-adapter/github @chat-adapter/state-memory workflow ai zod
yarn add -D nitro vite
```

```bash [bun]
bun add @github-tools/sdk @vercel/connect chat @chat-adapter/github @chat-adapter/state-memory workflow ai zod
bun add -d nitro vite
```

</code-group>

Register the workflow module in Nitro, so `"use workflow"` and `"use step"` functions compile, and serve Nitro through Vite:

```ts [nitro.config.ts]
import { defineConfig } from 'nitro'

export default defineConfig({
  serverDir: './server',
  modules: ['workflow/nitro'],
})
```

```ts [vite.config.ts]
import { defineConfig } from 'vite'
import { nitro } from 'nitro/vite'

export default defineConfig({
  plugins: [nitro()],
})
```

Add `"dev": "vite"` to the `scripts` in `package.json`.

The bot is four files under `server/`:

```text
server/
├── lib/
│   ├── github.ts                  # repository-scoped token, commenter permission check
│   └── agent.ts                   # Chat SDK bot and mention handlers
├── routes/webhooks/github.post.ts # webhook endpoint
└── workflows/review.ts            # the review agent, one step per turn
```

## 2. Scope the token to one repository

The bot acts as a GitHub App, so anything it's asked to do runs with the App's permissions. `repositoryToken` mints a token for the repository the webhook came from only, and `canWrite` checks that the person who mentioned the bot can write to it:

```ts [server/lib/github.ts]
import { createOctokit, resolveGithubToken } from '@github-tools/sdk'
import { connectGithubToken } from '@github-tools/sdk/connect'

const CONNECTOR = process.env.GITHUB_CONNECT_CONNECTOR!

export function repositoryToken(owner: string, repo: string) {
  return connectGithubToken(CONNECTOR, { preset: 'code-review', params: { repositories: [`${owner}/${repo}`] } })
}

export async function canWrite(owner: string, repo: string, username: string): Promise<boolean> {
  const octokit = createOctokit(await resolveGithubToken(repositoryToken(owner, repo)))
  const { data } = await octokit.rest.repos.getCollaboratorPermissionLevel({ owner, repo, username })
  return data.permission === 'admin' || data.permission === 'write'
}
```

`preset: 'code-review'` asks Connect for only the [permissions that preset needs](https://github-tools.com/auth#permissions-per-preset). A prompt that names another repository can't reach it with this token.

## 3. Answer mentions

`onNewMention` reacts, subscribes to the thread and starts a workflow run, then stores the run ID on the thread. `onSubscribedMessage` sends each follow-up to that run:

```ts [server/lib/agent.ts]
import { Chat, emoji, type Message } from 'chat'
import { createGitHubAdapter } from '@chat-adapter/github'
import { createMemoryState } from '@chat-adapter/state-memory'
import { resumeHook, start } from 'workflow/api'
import { reviewWorkflow } from '../workflows/review'
import { canWrite } from './github'

export interface ThreadState { runId?: string }
export interface GitHubContext { owner: string, repo: string, issueNumber: number, isPullRequest: boolean }
export type ChatTurnPayload = { text: string }

type GitHubRawMessage = {
  comment: { user: { login: string } }
  repository: { name: string, owner: { login: string } }
  prNumber: number
  threadType?: 'issue' | 'pr'
}

const adapters = { github: createGitHubAdapter() }

export const agent = new Chat<typeof adapters, ThreadState>({
  userName: process.env.GITHUB_AGENT_USERNAME!,
  adapters,
  state: createMemoryState(),
}).registerSingleton()

function githubContext(message: Message): GitHubContext {
  const raw = message.raw as GitHubRawMessage
  return {
    owner: raw.repository.owner.login,
    repo: raw.repository.name,
    issueNumber: raw.prNumber,
    isPullRequest: raw.threadType !== 'issue',
  }
}

function isTrustedAuthor(message: Message) {
  const { owner, repo } = githubContext(message)
  return canWrite(owner, repo, (message.raw as GitHubRawMessage).comment.user.login)
}

agent.onNewMention(async (thread, message) => {
  if (!await isTrustedAuthor(message)) return
  await thread.createSentMessageFromMessage(message).addReaction(emoji.eyes)
  await thread.subscribe()
  const run = await start(reviewWorkflow, [message.text, githubContext(message)])
  await thread.setState({ runId: run.runId })
})

agent.onSubscribedMessage(async (thread, message) => {
  if (!await isTrustedAuthor(message)) return
  const state = await thread.state
  if (state?.runId) await resumeHook<ChatTurnPayload>(state.runId, { text: message.text })
})
```

## 4. Review in a workflow

Each turn creates a `code-review` agent inside a `"use step"` function, so a failed turn retries as a whole. The workflow runs the first turn, then waits on a hook keyed by its run ID for the next message:

```ts [server/workflows/review.ts]
import { createGithubAgent } from '@github-tools/sdk'
import { createHook, getWorkflowMetadata } from 'workflow'
import type { ChatTurnPayload, GitHubContext } from '../lib/agent'
import { repositoryToken } from '../lib/github'

async function runAgentTurn(prompt: string, ctx: GitHubContext) {
  'use step'
  const agent = createGithubAgent({
    model: 'anthropic/claude-opus-5.5',
    token: repositoryToken(ctx.owner, ctx.repo),
    preset: 'code-review',
    requireApproval: false,
    context: {
      owner: ctx.owner,
      repo: ctx.repo,
      ...(ctx.isPullRequest ? { pullNumber: ctx.issueNumber } : { issueNumber: ctx.issueNumber }),
    },
    additionalInstructions: `
      Always post your response as a comment on the thread, with addPullRequestComment
      on a pull request or addIssueComment on an issue. If you find problems, give
      specific feedback with file paths. If the change looks good, say what you checked.
    `,
  })
  const { text } = await agent.generate({ prompt })
  return text
}

export async function reviewWorkflow(prompt: string, ctx: GitHubContext) {
  'use workflow'
  using hook = createHook<ChatTurnPayload>({ token: getWorkflowMetadata().workflowRunId })

  await runAgentTurn(prompt, ctx)
  for await (const event of hook) {
    await runAgentTurn(event.text, ctx)
  }
}
```

`context` points every tool at the mentioned pull request, so the model doesn't have to find it ([Context](https://github-tools.com/context)). The `code-review` system prompt tells the model to start with `getPullRequestContext`, then read only the diffs it needs with `listPullRequestFiles`.

`requireApproval: false` is deliberate: a pull request thread has no approval button, and the review is a comment a person reads anyway. The safeguards are the commenter check and the single-repository token.

## 5. Receive webhooks

The route hands each GitHub webhook to the bot:

```ts [server/routes/webhooks/github.post.ts]
import { defineHandler } from 'nitro/h3'
import { agent } from '../../lib/agent'

export default defineHandler(event => agent.webhooks.github!(event.req, {
  waitUntil: task => event.waitUntil(task),
}))
```

## Run it

Set the environment in `.env`, next to the `VERCEL_OIDC_TOKEN` that `vercel env pull` wrote:

```bash [.env]
GITHUB_CONNECT_CONNECTOR=github/my-connector
GITHUB_WEBHOOK_SECRET=a-random-secret
GITHUB_AGENT_USERNAME=my-review-agent
GITHUB_TOKEN=github_pat_xxxxxxxxxxxx
```

`GITHUB_AGENT_USERNAME` is the bot account's username: Chat SDK uses it to detect mentions. The GitHub adapter reads `GITHUB_WEBHOOK_SECRET` to verify webhooks, and needs its own credentials to react and read threads: `GITHUB_TOKEN` for the bot account, or `GITHUB_APP_ID` and `GITHUB_PRIVATE_KEY` for a GitHub App.

Start the dev server and a tunnel:

<code-group>

```bash [pnpm]
pnpm dev
```

```bash [npm]
npm run dev
```

```bash [yarn]
yarn dev
```

```bash [bun]
bun run dev
```

</code-group>

```bash [Terminal]
cloudflared tunnel --url http://localhost:3000
```

In the repository's **Settings** > **Webhooks**, add a webhook with the tunnel URL followed by `/webhooks/github`, content type `application/json`, the same secret, and the **Issue comments** and **Pull request review comments** events.

Open a pull request and comment `@my-review-agent review this PR` from your own account. The `eyes` reaction appears within a few seconds, and the review follows as a comment. `npx workflow web` shows the run and each step it took.

## FAQ

<accordion>
<accordion-item label="Why doesn't the bot answer my mention?">

Chat SDK ignores messages from the bot's own account to prevent loops, so mention it from a different account. The bot also ignores people without write access to the repository.

</accordion-item>

<accordion-item label="Can the bot leave a formal review with inline comments?">

Yes. `createPullRequestReview` is in the `code-review` preset. Ask for it in `additionalInstructions`, for example "Submit your review with createPullRequestReview, with inline comments on the lines you mention."

</accordion-item>

<accordion-item label="What happens to threads when the server restarts?">

`createMemoryState()` keeps the run IDs in memory, so after a restart, follow-ups in existing threads go unanswered. In production, use a persistent state adapter such as `@chat-adapter/state-redis`.

</accordion-item>

<accordion-item label="Can I use a personal access token instead of Vercel Connect?">

Yes. Pass `token: process.env.GITHUB_TOKEN` instead of `repositoryToken(...)`. The token is then not limited to the webhook's repository, so give it access to that repository only.

</accordion-item>

<accordion-item label="Can I review from Slack too?">

Yes. Add `@chat-adapter/slack` to `adapters`. A Slack message carries no repository, so set `context` from your own mapping of channels to repositories. See [Chat SDK](https://github-tools.com/integrations/chat-sdk#add-slack-or-discord).

</accordion-item>

<accordion-item label="How do I see token usage and cost?">

The example wraps the model with [evlog](https://evlog.dev)'s AI logger, which records token usage, tool calls, cost and timing for each turn.

</accordion-item>
</accordion>


## Sitemap

See the full [sitemap](https://github-tools.com/sitemap.md) for all pages.
