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:
| Return | Use 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. |
null | Skip 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/checkoutTo 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/paymentsThe --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, andctx.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/githubPractices
-
Re-read GitHub in the turn or a tool. Do not stuff the event payload into the prompt.
-
Set
botNameso 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.
Related
- Build a PR reviewer: the full read and write loop, one step at a time
- Webhooks and custom channels: the HTTP mechanism under this pack
- Evals: turn replay snapshots into regression fixtures
skills/github/SKILL.md: the companion skill for coding agents