Skip to content

GitHub agents ​

A GitHub agent wakes from repo events, reads the latest PR and CI state, then writes back reviews, comments, checks, or pushes. Every wake for the same pull request resumes one session, so the open, later comments, and CI results share one conversation.

Connect GitHub in Cursor for the repos you care about (Settings or cursor.com/dashboard), then sign the host in with agent-sdk login. cursorAccount reuses that connection for events and API calls, so you don't need a public webhook URL, a GitHub App, or a personal access token.

For a complete reviewer or issue triager, start from the review and triage templates.

Review a pull request ​

When someone opens a pull request or marks it ready for review, the agent starts a turn, reads the change, and posts its verdict as a review.

ts
// bot/channels/github.ts
import { defaultGitHubAuth, githubChannel } from "@grok/agent-sdk/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) };
  },
});
ts
// bot/tools/submit_review.ts
import { parseGitHubPrContinuationKey } from "@grok/agent-sdk/channels/github";
import { defineTool } from "@grok/agent-sdk/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 event only tells you that the pull request changed. Read the title, files, and checks during the turn.

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.

ts
onComment: (ctx, comment) => {
  if (!comment.body.includes("@my-agent")) {
    return null;
  }
  return { auth: defaultGitHubAuth(ctx) };
},
ts
// bot/tools/reply.ts
import { parseGitHubPrContinuationKey } from "@grok/agent-sdk/channels/github";
import { defineTool } from "@grok/agent-sdk/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 };
  },
});

The tool reads the pull request from the session, so the model never picks where the comment goes.

Triage a failing check ​

When CI fails, this hook labels the pull request with host code and no model turn. Return { auth: defaultGitHubAuth(ctx) } instead when the agent should read the logs and decide what to do.

ts
onCheckSuite: (ctx, suite) => {
  if (suite.conclusion !== "failure") {
    return null;
  }
  // `task` runs host code instead of a model turn.
  return { task: () => labelFailure(ctx, suite) };
},
ts
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 { auth }, { task }, or null, as handler results describes.

Choose watched repositories ​

The examples above list their repositories with cursorAccount: { repos: [...] }, and every repository in one channel must share one GitHub owner. To keep the list out of source, set cursorAccount: true and pass --repo to agent-sdk serve. A hosted deployment watches the repositories you pass to agent-sdk deploy in both cases.

text
agent-sdk serve --dir . --cursor-events \
  --repo acme/checkout --repo acme/payments
agent-sdk deploy --dir . \
  --cursor-events-repo acme/checkout \
  --cursor-events-repo acme/payments

Each deploy replaces the previous list, so pass every repository each time. Use agent-sdk deployment update to change the list without redeploying.

Call GitHub from deterministic code via ctx ​

With cursorAccount, hooks (ctx.github.getOctokit()) and tools (ctx.host.github.getOctokit()) share one credential. Hooks can also use ctx.thread.post(), ctx.repository, and ctx.conversation.

This hook reads the pull request, skips a draft, and posts before the model turn:

ts
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) };
},

Batch events ​

GitHub can send many events for one pull request within a few seconds. Coalesce them per pull request:

ts
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 it when the pull request closes.

Test with replay ​

agent-sdk github replay reads a pull request you can access and posts GitHub-shaped events to your channel.

text
agent-sdk github replay https://github.com/acme/checkout/pull/42 --dir . --action opened

# Replay everything the channel listens for, with CI failing
agent-sdk github replay acme/checkout#42 --dir . --events '*' --conclusion failure

# Save the payloads as fixtures instead of posting them
agent-sdk github replay acme/checkout#42 --dir . --events '*' --dry-run --fixtures-dir fixtures/github

Other hosts ​

The same hooks work on GitHub Enterprise Server, and on github.com when you already receive GitHub webhooks yourself. Point the channel at your server:

ts
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. On Cursor hosting, a delivery with a missing or wrong signature gets a 401 and never reaches your agent. Event pull and github replay only reach github.com, so test Enterprise Server with saved fixtures.

Pass each credential as { secretName: "NAME" }. When you run locally, the channel reads the environment variable with that name, and on Cursor hosting it reads the deployment secret with that name. To set those secrets on a deployment, see Add egress and secrets.

  • Build a PR reviewer covers the full loop.
  • Ask a coding agent to follow the bdk-github skill for the GitHub setup steps.