Appearance
GitHub agents
Wake your agent from repository events without exposing a public webhook URL. Prefer serve --cursor-events: the host long-polls Cursor's SCM event stream for repos you've connected to Cursor. You still declare a githubChannel so hooks decide what each event does.
The companion skill for coding agents is skills/github/SKILL.md.
Pull events from Cursor
Connect GitHub in Cursor for the repositories you care about (Settings or cursor.com/dashboard). That gives your account access and lets Cursor receive the repo's webhooks. Sign the host in (agent-sdk login or CURSOR_API_KEY), then opt the channel into the Cursor account connection:
ts
export default githubChannel({
cursorAccount: {
repos: ["owner/repo"],
// permissions?: "read" | "pr-write" | "contents-write"
// default "pr-write" (comments / PR writes, no contents:write)
},
// hooks...
});cursorAccount starts the event relay and mints one short-lived GitHub credential scoped to those repositories. ctx.github, ctx.host.github, and child gh commands share it. The Agent SDK refreshes the credential before expiry. No GitHub App key, PAT, or separate gh auth login is needed on the host.
Choose permissions by what the agent needs:
permissions | Use when |
|---|---|
"read" | Inspect PRs / issues / statuses only |
"pr-write" (default) | Comment, review, update PR/issue metadata |
"contents-write" | Push code, or post merge-box checks |
contents-write is an explicit opt-up. progress.commitStatus posts a GitHub check run (checks:write). Hosted cursorAccount mints that permission on "contents-write" tokens. Enabling commitStatus opts a "pr-write" channel up to that tier so github-proxy can post the check. "pr-write" without commitStatus is enough for comments and banners. Prefer "pr-write" unless the agent must push or post a merge-box check.
Selected repositories must share one GitHub owner (one App installation). Configuration that spans owners fails at startup / mint time.
To keep repository scope in deployment config instead, use cursorAccount: true and pass it at serve time:
bash
agent-sdk serve --dir . --cursor-events --repo owner/repoRepeat --repo for each repository. The stream and credential are resolved as the signed-in Cursor principal. serve refuses to start signed out.
Offset and consumer id live under <state-root>/cursor-events/. CURSOR_API_BASE_URL overrides the backend. The stream carries event metadata, not full webhook bodies, so your agent should re-read the PR or checks from GitHub instead of trusting a snapshot in the wake.
This is the preferred production path: no public URL, no repo admin webhook, and no inbound network for GitHub deliveries.
Define the channel
Author agent/channels/github.ts with githubChannel() from @cursor/july/channels/github. It mounts POST /<slug>/v1/channels/github and publishes the events it dispatches on. That event set comes from the hooks you declare, or you pin it with webhookEvents. Cursor event pull and local replay both use it.
ts
import { defaultGitHubAuth, githubChannel } from "@cursor/july/channels/github";
export default githubChannel({
botName: "my-agent", // or GITHUB_APP_SLUG; used to ignore self-comments
cursorAccount: { repos: ["owner/repo"] },
onPullRequest: (ctx, pr) =>
pr.action === "opened" ? { auth: defaultGitHubAuth(ctx) } : null,
onCheckSuite: (ctx, suite) =>
suite.conclusion === "failure" ? { task: () => triage(ctx) } : null,
});The hooks are onPullRequest, onComment, onIssue, onCheckSuite, onCheckRun, onWorkflowRun, onStatus, the catch-all onEvent, and the lifecycle pair onStart / onStop. Each hook returns one of three things:
| Return | Meaning |
|---|---|
{ auth } | Start or continue a model turn as that actor. A chat session exists and shows up in the playground. |
{ task } | Host-side work. The delivery is 202-ACKed immediately and the task runs past GitHub's ~10-second timeout. No chat session. |
null | Skip this delivery. |
{ auth } may also carry workspaceFiles — the same session seed Slack and send() use. Pass a function to fetch after a 202 so I/O can miss GitHub's ~10s window.
Return { task } when the wake drives deterministic code. A security reviewer can run its whole review loop this way and report through PR comments. Return { auth } when the model needs to reason about the event.
Without cursorAccount, outbound GitHub API calls prefer App installation tokens when GITHUB_APP_ID and GITHUB_APP_PRIVATE_KEY are set (with an installation id from the event or GITHUB_APP_INSTALLATION_ID). On serve warmup, App-backed hosts also export a short-lived installation token as GH_TOKEN so tools that shell out to gh (a host-side prepare_review tool, say) authenticate without a PAT. For local testing, skip the App credentials and use GITHUB_TOKEN, GH_TOKEN, or gh auth login.
Test wakes locally
Use fixtures and github replay so you can develop without waiting on live pushes. Both target the same channel route the Cursor relay uses.
Post a saved fixture
For offline tests, POST a saved payload with an x-github-event header. A --dev server does not require a signature:
bash
curl -s -X POST http://127.0.0.1:3000/<slug>/v1/channels/github \
-H 'content-type: application/json' \
-H 'x-github-event: pull_request' \
-d @fixtures/github/pull_request.synchronize.jsonDon't hand-write payloads. Snapshot real ones with replay's --dry-run --out, below.
Test with github replay
Use replay for deterministic tests and hillclimbing. It reads a real PR with gh api, synthesizes GitHub-shaped payloads, signs them when a secret is configured, and POSTs them at the channel. Pull access is enough: no admin, and an env GITHUB_TOKEN is fine here. The same input produces the same delivery.
bash
# Replay a pull_request delivery for a PR
agent-sdk github replay https://github.com/owner/repo/pull/123 --dir .
# Replay everything the channel listens for, with CI failing
agent-sdk github replay owner/repo#123 --dir . --events '*' --conclusion failure
# Inspect payloads without POSTing, and snapshot them as fixtures
agent-sdk github replay owner/repo#123 --dir . --events '*' --dry-run --out fixtures/github--events defaults to pull_request, and '*' means the channel's declared set. --action, --conclusion, --comment, and --context shape each synthesized event. --secret (or GITHUB_WEBHOOK_SECRET) signs them.
Receive webhooks directly
Most hosts should pull events from Cursor instead. Use the HTTP channel route when you already terminate GitHub webhooks yourself, or when you are POSTing fixtures and replay locally.
With a webhook secret configured, the route admits everyone (allowAll()) and the channel verifies X-Hub-Signature-256 before parsing. The HMAC becomes the request principal. Without a secret, the route is loopback-only. The exception is serve --dev, which admits unsigned loopback deliveries so fixtures and replay work with zero config. Non-dev targets that accept real GitHub POSTs always need the secret, and the same value must live on the server and on whatever signs deliveries.
Handle high event volume
These patterns come from running a PR agent against real traffic:
- Debounce per PR (~3 seconds, latest event wins), and re-buffer while CI settles. Skip a flush when a turn for that PR is already running.
- Persist the buffer in
host.kvbefore you acknowledge a wake, and restore it on channel start. A restart must not drop buffered wakes. - Key sessions with a stable continuation token (
pr:owner/repo#N) so every wake resumes the PR's conversation. Cross-channel resume needs an affinity store mapping PR → SDK agent id; write it from anagent.boundhook withctx.host.kv. - Keep payload details out of wake prompts. Send a generic "re-check the PR" and let the agent re-read source of truth instead of trusting a stale snapshot.
- Cancel PR-scoped reminders on
pull_request.closed. - Decide explicitly which repos the agent may act on. Without an allowlist the channel wakes for whatever deliveries reach it, and every wake spends real model budget.
Show PR progress
Autofix-style agents need a deterministic merge-box check and a sticky PR comment that converges when the turn ends. Configure that on the channel with progress.commitStatus and progress.banner. A hook can read and write ctx.host.kv and ctx.host.files after a turn. Use that for derived state. Keep GitHub check-run and banner writes on the channel.
ts
import { defaultGitHubAuth, githubChannel } from "@cursor/july/channels/github";
export default githubChannel({
botName: "autofix",
deliverReplies: false,
progress: {
reactions: false,
commitStatus: {
context: "autofix",
pending: "Autofix running",
success: "Autofix finished",
failure: "Autofix failed",
},
banner: {
pending: "Autofix running…",
success: "Autofix finished",
failure: "Autofix failed",
},
},
// commitStatus posts a Checks API run. Hosted cursorAccount opts the
// minted token up to contents-write (checks:write). Banner also works
// on pr-write without commitStatus.
cursorAccount: {
repos: ["owner/repo"],
},
onPullRequest: (ctx, pr) =>
pr.action === "opened" ? { auth: defaultGitHubAuth(ctx) } : null,
});Default stream events drive the lifecycle:
| Event | Check run | Banner |
|---|---|---|
turn.started | in_progress | create (or keep) the sticky comment |
turn.completed | completed / success | PATCH the same comment |
turn.failed / session.failed | completed / failure | PATCH the same comment |
Omit commitStatus / banner, or set them to false, to keep today's behavior. Reactions still default on; set reactions: false when the eyes emoji is noise. Descriptions are optional; defaults derive from botName or the check context.
The check run posts to channel.state.headSha. PR and CI wakes seed and refresh it (refreshState on continuation). A first wake that is only an issue_comment has no head SHA in the payload, so the check is skipped until a PR/CI wake stores one; the banner still posts. Review-comment wakes carry pull_request.head.sha when GitHub includes it.
The sticky comment id and latest check-run id live on durable GitHubChannelState (session record). Each wake also passes refreshState so headSha / refs update on continuation without wiping those ids. A later turn on the same SHA creates a new check run — GitHub cannot reopen a completed run. Persist other derived state with ctx.host.kv or ctx.host.files. stateRoot resets on hosted replace.
Override events when the mapping is custom. Approval Buddy posts commit status from turn.started / action.result / turn.failed and stays never-red; that pattern still wins when you replace a default handler key. Handlers you author replace the matching defaults (same as progress.reactions composition today).
Related
- Webhooks and custom channels: the HTTP mechanism under this pack
- Evals: turn replay snapshots into regression fixtures
- Cloud runtime: attach PRs to cloud VMs
- Hooks: observe-only; use channel
progressfor GitHub surfaces