Skip to main content

Command Palette

Search for a command to run...

BDK

GitHub agents

GitHub agents work both directions. They wake from repo events, read the latest PR and CI state, then write back reviews, comments, checks, or pushes. Pull events from Cursor, so you don't expose a public webhook URL.

Connect GitHub in Cursor for the repos you care about (Settings or cursor.com/dashboard). Sign the host in with bdk login. cursorAccount reuses that connection for events and API calls. You don't set up a GitHub App or a PAT.

Start from a template when one fits: security reviewer or issue triager.

Review a pull request

Wake when a PR is ready for review. Fetch the latest diff in a tool, then post a verdict.

// bot/channels/github.tsimport { defaultGitHubAuth, githubChannel } from "@cursor/bdk/channels/github";export default githubChannel({  // Skip the agent's own comments so replies don't re-trigger it.  botName: "my-agent",  cursorAccount: { repos: ["acme/checkout"] },  onPullRequest: (ctx, pr) => {    if (pr.draft) {      return null;    }    if (pr.action !== "opened" && pr.action !== "ready_for_review") {      return null;    }    // `auth` starts a model turn running as the sender.    return { auth: defaultGitHubAuth(ctx) };  },});
// bot/tools/submit_review.tsimport { parseGitHubPrContinuationKey } from "@cursor/bdk/channels/github";import { defineTool } from "@cursor/bdk/tools";import { z } from "zod";export default defineTool({  description:    "Post the review decision on this session's pull request. Call once.",  inputSchema: z.object({    verdict: z.enum(["approve", "request_changes"]),    summary: z      .string()      .describe("One or two sentences explaining the verdict."),  }),  async execute({ verdict, summary }, ctx) {    if (ctx.session.purpose === "eval") {      return { posted: false, reason: "eval" };    }    const ref = parseGitHubPrContinuationKey(ctx.session.continuationKey ?? "");    if (ref === undefined) {      throw new Error("submit_review requires a GitHub pull request session");    }    let event: "APPROVE" | "REQUEST_CHANGES" = "REQUEST_CHANGES";    if (verdict === "approve") {      event = "APPROVE";    }    const octokit = await ctx.host.github.getOctokit();    await octokit.rest.pulls.createReview({      owner: ref.owner,      repo: ref.repo,      pull_number: ref.number,      event,      body: summary,    });    return { posted: true, pr: `${ref.owner}/${ref.repo}#${ref.number}` };  },});

The wake tells you the PR changed. Fetch title, files, and checks inside the turn or a tool.

Answer a mention

onComment replaces the default @botName gate. Return a turn when the comment is for this agent, then post the reply from a tool.

onComment: (ctx, comment) => {  if (!comment.body.includes("@my-agent")) {    return null;  }  return { auth: defaultGitHubAuth(ctx) };},
// bot/tools/reply.tsimport { parseGitHubPrContinuationKey } from "@cursor/bdk/channels/github";import { defineTool } from "@cursor/bdk/tools";import { z } from "zod";export default defineTool({  description: "Post a comment on this session's pull request.",  inputSchema: z.object({    body: z.string().describe("Markdown to post on the PR timeline."),  }),  async execute({ body }, ctx) {    if (ctx.session.purpose === "eval") {      return { posted: false, reason: "eval" };    }    const ref = parseGitHubPrContinuationKey(ctx.session.continuationKey ?? "");    if (ref === undefined) {      throw new Error("reply requires a GitHub pull request session");    }    const octokit = await ctx.host.github.getOctokit();    await octokit.rest.issues.createComment({      owner: ref.owner,      repo: ref.repo,      issue_number: ref.number,      body,    });    return { posted: true };  },});

Resolve owner, repo, and number from the session. Do not take a PR URL from the model. A later mention on the same PR resumes this conversation.

Triage a failing check

Start a model turn when CI fails. Run host code when the next step is already decided.

onCheckSuite: (ctx, suite) => {  if (suite.conclusion !== "failure") {    return null;  }  // `auth` starts a model turn to read logs and comment.  return { auth: defaultGitHubAuth(ctx) };},
onCheckSuite: (ctx, suite) => {  if (suite.conclusion !== "failure") {    return null;  }  // `task` runs host code instead of a model turn. No chat session.  return { task: () => labelFailure(ctx, suite) };},
async function labelFailure(ctx, suite) {  const pr = suite.pullRequests[0];  if (pr === undefined) {    return;  }  const octokit = await ctx.github.getOctokit();  await octokit.rest.issues.addLabels({    owner: ctx.repository.owner,    repo: ctx.repository.name,    issue_number: pr.number,    labels: ["ci-failed"],  });}

Events

Declare a hook for each event that should wake the agent: onPullRequest, onComment, onIssue, onCheckSuite, onCheckRun, onWorkflowRun, onStatus, and the catch-all onEvent.

Each hook returns one of three outcomes:

ReturnUse when
{ auth }The model should reason about the event. The turn shows up in the playground.
{ task }Host code can handle the wake. No chat session.
nullSkip the event.

Channels covers the shared session behavior.

Choose watched repositories

The examples above list their repositories in the channel with cursorAccount: { repos: [...] }. A hosted deployment receives events for the repositories set at deploy time, so pass each one to bdk deploy:

bdk deploy --dir . --cursor-events-repo acme/checkout

To keep the list out of source, set cursorAccount: true and pass the repositories on the command line instead. bdk serve takes repeatable --repo flags:

export default githubChannel({  cursorAccount: true,});
bdk serve --dir . --cursor-events \  --repo acme/checkout --repo acme/paymentsbdk deploy --dir . \  --cursor-events-repo acme/checkout \  --cursor-events-repo acme/payments

The --cursor-events-repo flags on a deploy form its complete list and replace the previous one, so pass every repository on each redeploy. A deploy without the flag watches the agent's GitHub source repository instead. Use bdk event-repos to change or clear the list without redeploying.

PR and agent affinity

Every wake for the same repo and PR number resumes the same session. The channel keys turns as github-pr:acme/checkout#42. Opens, comments, and CI results share that conversation.

Call GitHub from deterministic code via ctx

cursorAccount puts one credential on both surfaces:

  • Channel hooks: ctx.github.getOctokit(), ctx.thread.post(), ctx.repository, and ctx.conversation

  • Tools: ctx.host.github.getOctokit()

onPullRequest: async (ctx, pr) => {  if (pr.action !== "opened") {    return null;  }  const octokit = await ctx.github.getOctokit();  const { data } = await octokit.rest.pulls.get({    owner: ctx.repository.owner,    repo: ctx.repository.name,    pull_number: pr.number,  });  if (data.draft) {    return null;  }  await ctx.thread.post(`Reviewing ${pr.url}`);  return { auth: defaultGitHubAuth(ctx) };},

Re-read the PR here when the hook must decide. Tools use the host client for the same API during the turn.

Batch events

A change to a single request typically fans out multiple events, sometimes hundreds. A push can fire synchronize, several check runs, and comments in a few seconds. By default, individual channel hooks or webhooks do not debounce for you.

Coalesce per PR. Flush the latest wake. Skip a flush when a turn for that PR is already running.

const pending = new Map();function enqueue(pr, flush) {  const key = `${pr.owner}/${pr.repo}#${pr.number}`;  const existing = pending.get(key);  if (existing !== undefined) {    clearTimeout(existing);  }  pending.set(    key,    setTimeout(() => {      pending.delete(key);      flush();    }, 3_000)  );}

Persist that buffer across restarts and drop the queue when the PR closes.

Test with replay

Use BDK to replay a PR you can read.

bdk github replay https://github.com/acme/checkout/pull/42 --dir . --action opened# Replay everything the channel listens for, with CI failingbdk github replay acme/checkout#42 --dir . --events '*' --conclusion failure# Snapshot payloads as fixtures instead of POSTingbdk github replay acme/checkout#42 --dir . --events '*' --dry-run --out fixtures/github

Practices

  • Re-read GitHub in the turn or a tool. Do not stuff the event payload into the prompt.

  • Set botName so replies do not re-wake the agent.

  • Watch an explicit repo list (cursorAccount.repos). Repos under one channel must share one GitHub owner.

  • Cancel leftover work on pull_request.closed.

Other hosts

The same hooks work on GitHub Enterprise Server. Point the channel at your server and add a repo webhook:

export default githubChannel({  api: {    apiBaseUrl: "https://github.example.com/api/v3",  },  credentials: {    token: { secretName: "GITHUB_TOKEN" },    webhookSecret: { secretName: "GITHUB_WEBHOOK_SECRET" },  },});

Add a repository webhook for https://<your-host>/<slug>/v1/channels/github with the same secret. Event pull and github replay target GitHub.com, so test Enterprise Server with saved fixtures.

Prefer event pull on github.com. Use the channel route directly when you already terminate GitHub webhooks yourself. Set a webhook secret on both sides and name it, as above, rather than reading it: locally the channel reads that environment variable, and on Cursor hosting the platform verifies X-Hub-Signature-256 with the deployment secret of that name at ingestion, so an unsigned or mis-signed delivery gets a 401 back to GitHub before any compute starts. A literal or function value keeps verification inside the handler only. The Webhooks guide covers the shared mechanism.

Name the API credentials the same way. Every credentials field — token, installationToken, appId, privateKey, webhookSecret — takes { secretName: "NAME" }: locally the channel reads that environment variable, and on Cursor hosting the deployment secret of that name. deploy then lists the names the agent expects and warns about any that are not set yet, before the first API call fails inside the guest. architecture: "v2" requires the named form (a numeric appId is configuration and stays allowed); omitting a field keeps its env fallback. architecture: "v1" still accepts literals and () => process.env.X getters.