# 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](https://cursor.com/dashboard)). Sign the host in
with `agent-sdk 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](/docs/templates/security-reviewer.md) or
[issue triager](/docs/templates/triage.md).

## Review a pull request

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

```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 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.

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

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.

```ts
onCheckSuite: (ctx, suite) => {
  if (suite.conclusion !== "failure") {
    return null;
  }
  // `auth` starts a model turn to read logs and comment.
  return { auth: defaultGitHubAuth(ctx) };
},
```

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

```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 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](/docs/reference/channels.md) 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 `agent-sdk deploy`:

```text
agent-sdk 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. `agent-sdk serve` takes repeatable
`--repo` flags:

```ts
export default githubChannel({
  cursorAccount: true,
});
```

```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
```

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
[`agent-sdk deployment update`](/docs/reference/cli.md#deployment-update) 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()`

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

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.

```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 the queue when the PR
closes.

## Test with replay

Use Agent SDK to replay a PR you can read.

```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

# Snapshot payloads as fixtures instead of POSTing
agent-sdk github replay acme/checkout#42 --dir . --events '*' --dry-run --fixtures-dir 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:

```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.
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](/docs/guides/webhooks.md) 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](/docs/quickstart.md): the full read and write
  loop, one step at a time
- [Webhooks and custom channels](/docs/guides/webhooks.md): the HTTP mechanism
  under this pack
- [Evals](/docs/evals.md): turn replay snapshots into regression fixtures
- [`skills/github/SKILL.md`](/docs/package/skills/github/SKILL.md): the companion
  skill for coding agents
