---
title: "Chat SDK"
description: "Build a bot that reviews a pull request when someone mentions it, and keeps each thread's conversation in a durable workflow."
canonical_url: "https://github-tools.com/integrations/chat-sdk"
---
# Chat SDK

> Build a bot that reviews a pull request when someone mentions it, and keeps each thread's conversation in a durable workflow.

## Overview

With [Chat SDK](https://chat-sdk.dev), the GitHub tools and [Vercel Workflow](https://github-tools.com/integrations/workflow), you can build a bot that reviews a pull request when someone mentions it. Chat SDK receives platform webhooks and hands your code a thread and a message. Mention the bot on a pull request, and it reviews the change and posts a comment. Follow-ups in the same thread continue the same workflow run.

Pick it when people talk to the agent in a GitHub, Slack or Discord thread rather than in your own UI. There's no GitHub-tools entry point for Chat SDK: the bot calls `createGithubAgent` from `@github-tools/sdk` inside a workflow step. The complete bot is [`examples/pr-review-agent`](https://github.com/vercel-labs/github-tools/tree/main/examples/pr-review-agent), a Nitro app.

## Install

<code-group>

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

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

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

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

</code-group>

The bot is four files: a token helper, the bot's handlers, the workflow, and the webhook route. The sections below build each one.

## Scope the token to one repository

The bot acts with a GitHub App token from [Vercel Connect](https://github-tools.com/auth#vercel-connect). It mints that token for the webhook's repository only, so a prompt that names another repository can't reach it. The same token checks whether the person talking has write access:

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

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

export async function canWrite(owner: string, repo: string, username: string) {
  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'
}
```

## Answer mentions

`onNewMention` starts a workflow and stores its run ID on the thread. `onSubscribedMessage` sends each follow-up to that run. Both ignore people without write access, because anything they ask would run with the App's permissions:

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

export interface ThreadState { runId?: string }
export interface Target { owner: string, repo: string, pullNumber: number }

type GitHubRaw = {
  comment: { user: { login: string } }
  repository: { name: string, owner: { login: string } }
  prNumber: number
}

const adapters = { github: createGitHubAdapter() }

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

function target(message: Message): Target {
  const raw = message.raw as GitHubRaw
  return { owner: raw.repository.owner.login, repo: raw.repository.name, pullNumber: raw.prNumber }
}

function isTrusted(message: Message) {
  const { owner, repo } = target(message)
  return canWrite(owner, repo, (message.raw as GitHubRaw).comment.user.login)
}

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

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

## Keep the conversation in a workflow

Each turn runs `createGithubAgent` inside a `"use step"` function, so a failed turn retries as a whole. A hook keyed on the run ID keeps the workflow waiting for the next message:

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

async function runTurn(prompt: string, target: Target) {
  'use step'
  const agent = createGithubAgent({
    model: 'anthropic/claude-opus-5.5',
    token: repositoryToken(target.owner, target.repo),
    preset: 'code-review',
    requireApproval: false,
    context: target,
    additionalInstructions: 'Post your answer as a comment on the pull request.',
  })
  await agent.generate({ prompt })
}

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

  await runTurn(prompt, target)
  for await (const event of hook) await runTurn(event.text, target)
}
```

The model replies by calling `addPullRequestComment`, which is why the instructions tell it to.

## Receive GitHub webhooks

The route hands each webhook to the bot:

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

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

In the repository's **Settings > Webhooks**, point a webhook at this route with a secret, and subscribe to **Issue comments** and **Pull request review comments**. The adapter reads `GITHUB_WEBHOOK_SECRET` and its own GitHub credentials from the environment. Set `GITHUB_AGENT_USERNAME` to the bot's username.

## Add Slack or Discord

Add `@chat-adapter/slack` or `@chat-adapter/discord` to `adapters`, and the same handlers answer there. A Slack or Discord message carries no repository, so set `context` from your own mapping, or let the model ask. Keep the token scoped to the repositories that channel may touch.

## FAQ

<accordion>
<accordion-item label="Why does the bot run without approval?">

A pull request thread has no approval button, so `requireApproval: false` is deliberate. The safeguards are the trusted-author check and the single-repository token.

</accordion-item>

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

Chat SDK ignores messages from the bot's own account to prevent loops. Mention the bot from a different account than the one it posts as.

</accordion-item>

<accordion-item label="What happens to conversations when the process restarts?">

`createMemoryState()` loses the run IDs when the process restarts. In production, use a persistent state adapter such as `@chat-adapter/state-redis` or `@chat-adapter/state-pg`.

</accordion-item>

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

Yes, 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.
