GitHub
Answer issues, pull requests, comments and code reviews with your agent, from a GitHub webhook.
Setup
GitHub needs no SDK: the channel calls the REST API with fetch and takes events from a webhook.
- A token for the bot. Give the bot an account of its own (or a GitHub App) and a token that reaches only the repositories it answers in: a fine-grained personal access token with read and write access to Issues and Pull requests of those repositories, or a GitHub App installation token. Avoid a classic token with
repo, which reaches every private repository of the account, and avoid your own token: the bot would post as you and take your comments for its own. Save it asGITHUB_CHANNEL_TOKEN=..., apart from anyGITHUB_TOKENof yours - A webhook. In the repository (or organization) settings, add a webhook with the payload URL
https://<your host>/github/webhook, content typeapplication/jsonand a secret of your choice, saved asGITHUB_WEBHOOK_SECRET=.... Pick the events Issues, Issue comments, Pull requests, Pull request reviews and Pull request review comments.
GitHub only pushes events, so the bot needs a URL GitHub can reach. To try it on your machine, a tunnel such as cloudflared tunnel --url http://localhost:8787 or ngrok http 8787 is enough.
Usage
import { Agent, Cogitator } from '@cogitator-ai/core';
import { Gateway, githubChannel } from '@cogitator-ai/channels';
const github = githubChannel({
token: process.env.GITHUB_CHANNEL_TOKEN!,
webhook: {
secret: process.env.GITHUB_WEBHOOK_SECRET!,
port: 8787, // its own server, at /github/webhook unless `path` says otherwise
},
repositories: ['acme/widgets'],
});
const cogitator = new Cogitator({
llm: { providers: { google: { apiKey: process.env.GOOGLE_API_KEY! } } },
memory: { adapter: 'memory' },
});
const memory = await cogitator.getMemory();
const gateway = new Gateway({
agent: new Agent({
name: 'github-assistant',
model: 'google/gemini-3.5-flash-lite',
instructions: 'You help maintainers on GitHub. Be precise and brief.',
}),
cogitator,
channels: [github],
...(memory && { memory }),
reactions: { enabled: true },
});
await gateway.start();With start() the channel loads the bot's login (GET /user) and opens its webhook server. It warns when repositories is not set, since the bot then answers in every repository that sends it events.
What It Answers
| Webhook event | Action | Message id | Text | Answered with |
|---|---|---|---|---|
issues | opened | issue:<number> | title and body | a comment on the issue |
pull_request | opened | issue:<number> | title and body | a comment on the pull request |
issue_comment | created | comment:<id> | the comment | a comment on the issue or PR |
pull_request_review | submitted | review:<id> | the review body | a comment on the pull request |
pull_request_review_comment | created | review_comment:<id> | the comment on a diff line | a reply in its review thread |
Other events and actions (edits, closes, labels) are answered 200 and left alone, a review without a body is skipped, and the ping GitHub sends when the webhook is created is answered pong.
- Conversations. A message's
channelIdisowner/repo#number, the same for every comment of an issue or pull request, so the Gateway keeps one conversation per issue or pull request and person. A review comment'stopicIdis the id of its review thread's first comment: each review thread is a conversation of its own, and its answer is a reply in it.groupIdis the repository. - Whose events. Only those of the repository's owner, the organization's members and collaborators (
author_associationOWNER,MEMBER,COLLABORATOR), whatever the trigger.associationsnames others (['OWNER', 'MEMBER', 'COLLABORATOR', 'CONTRIBUTOR']),'any'answers everyone, andauthorslists logins answered whatever their association. GitHub may show a member whose membership is private asCONTRIBUTOR: list them inauthors, or make the membership public. - Which events. By default (
trigger: 'mention') those that mention@<login>, outside code (fenced, indented or inline) and quoted lines, which only repeat a mention. The mention at the start is taken out of the text.trigger: 'all'answers every event of the table.repositorieslimits the bot toowner/reponames orowner/*. - Follow-ups. Once the bot answered someone in a conversation, their next comment there reaches it without a mention, such as the
yesthat answers a tool approval prompt, and every comment in a review thread the bot replied in does too. An answer is for the person whose comment it replies to (replyTo), so someone else asking meanwhile does not take their turn, and an answer that names no comment is for everyone who asked since the last one. Comments the bot does not take, such as an empty review, leave the turn as it was. The channel remembers the latest 1000 of these in memory. - What it skips. Its own comments (by login, with or without an app's
[bot]), and other bots and apps (sender.typeBot), so two bots cannot answer each other forever.allowBots: truelets bots in. - Users.
userIdis the sender's numeric GitHub id, which stays the same when they rename, anduserNametheir login.
Webhook Deliveries
Every delivery must carry an X-Hub-Signature-256 that is the HMAC SHA-256 of the body with the webhook secret, compared in constant time. A delivery without it, or signed with another secret, is refused with 401. A signed delivery is answered 200 at once and handled after, within GitHub's 10-second limit however long the agent takes. A delivery GitHub sends again is handled once: by its X-GitHub-Delivery, and by the issue, comment or review it carries, which the signature covers (the header is not). Both are remembered in memory for the latest 1000, so after a restart a redelivered event is answered again. Deliveries sent as application/x-www-form-urlencoded are read too.
When the bot's login is not known yet and GET /user fails, the delivery is still answered 200, since GitHub does not send it again by itself: the channel tries to load the login twice more, 2 and 10 seconds later, and drops the event with a logged error if it still cannot. A dropped event is forgotten, so Redeliver in the webhook settings sends it again. Once stop() was called, deliveries are answered 503.
Without port, mount handleWebhook in your own server. It needs the body exactly as received, which the signature covers, and works before start() too: the first delivery loads the bot's login. webhookListener() is the same as a Node request listener, which reads the body itself. A WebChat server takes it as a route, so one port serves both: webchatChannel({ port, routes: { '/github/webhook': github.webhookListener() } }).
import express from 'express';
const app = express();
app.post('/github/webhook', express.raw({ type: '*/*' }), async (req, res) => {
const response = await github.handleWebhook({
method: req.method,
headers: req.headers,
rawBody: req.body,
});
res.status(response.status).send(response.body);
});The channel's own server takes bodies up to 1 MB (413 beyond), and answers 404 on other paths and 400 to a request line it cannot read.
Answers
GitHub renders Markdown, so answers are sent as the agent writes them (nativeMarkdown). A comment holds 65536 characters, and a longer answer goes out as several comments, split at a line break where it can, with a code block cut in two closed and reopened.
GitHub notifies the people subscribed to an issue once, with a comment's first text, and limits how often content may be created and edited. So answers are sent once finished (editable: false). With stream: true the channel says it is editable and the Gateway streams answers into a comment it edits as the text grows. Edits of one comment go out at least 3 seconds apart (GITHUB_MIN_EDIT_INTERVAL), whatever the Gateway's flushInterval, with the latest text: about 20 a minute, under GitHub's limit of 80 content changes a minute.
sendText(channelId, text, options) comments on the issue or pull request, or replies in the review thread of options.topicId, or of a replyTo that is a review_comment:<id>. It resolves with comment:<id> or review_comment:<id>, which editText and deleteMessage take. Issues, pull requests and reviews cannot be edited through it.
- Reactions.
setReactionuses GitHub's eight reactions (+1,-1,laugh,confused,heart,hooray,rocket,eyes), by name or emoji, and replaces the bot's earlier reaction on the same message. Reactions to one message are set one after another, so the last one stays. The emojis of status reactions become the nearest GitHub has: 🤔 eyes, 🔥 rocket, 😱 confused. Other emojis are skipped, and so are reviews, which take no reactions. Withreactions: { enabled: true }in the Gateway, a mention gets 👀 while the agent works and 👍 once it answered. - Files. GitHub has no API to upload files to comments.
sendFilesends a file that has a publicurlas a comment that links it (an image shows inline), with its caption, and refuses a file that only has abuffer. - Typing. GitHub has no typing indicator, so
sendTypingdoes nothing.
Errors
REST calls fail with a FeedError (feed github) whose code says what happened and whether trying again helps:
| Response | code | retryAfter |
|---|---|---|
403 with x-ratelimit-remaining: 0, primary rate limit | rate_limited | until x-ratelimit-reset |
403 or 429 secondary rate limit | rate_limited | Retry-After, or a minute without it |
401, other 403 | auth | |
404, 410 | not_found | |
400, 422 (such as a body that is too long) | invalid_post | |
5xx, network errors | unavailable |
The message carries GitHub's own, with the validation errors it lists.
Security
Everyone who can comment on an issue or pull request writes text the agent reads, and an agent holds the bot's token and its tools. Treat what arrives as untrusted input:
- Who may talk to it. Keep the default
associations, or listauthors. Open it withassociations: 'any'only for an agent that has nothing to leak and no tool that changes anything. - What the token reaches. A fine-grained token or an App installation, scoped to the repositories the bot answers in, never your own token or a classic
repotoken. Withcogitator up, the channel readsGITHUB_CHANNEL_TOKEN, apart from theGITHUB_TOKENof thegithub_apitool. - Where it answers. Set
repositories, so a webhook added to another repository does not reach the agent. - Prompt injection. A comment can tell the agent to ignore its instructions, to read a secret or to call a tool. Give the agent only the tools the bot needs, ask approval for tools that write or delete, and keep secrets out of what it can read. Text quoted from an issue is no instruction to the agent: say so in its instructions.
GitHub Apps and Enterprise
An app answers as <app-slug>[bot]. Its installation token expires after an hour, so pass a function as token: it is called before every request, and can return a cached token it renews. An installation token cannot read GET /user, so set login too. People mention the app as @<app-slug>.
const github = githubChannel({
token: () => installationTokens.current(), // your own cache around POST /app/installations/{id}/access_tokens
login: 'helper-app[bot]',
webhook: { secret: process.env.GITHUB_WEBHOOK_SECRET!, port: 8787 },
});On GitHub Enterprise Server, set apiUrl to https://<host>/api/v3.
Configuration
interface GitHubChannelConfig {
token: string | (() => string | Promise<string>);
webhook: { secret: string; port?: number; path?: string; host?: string };
login?: string; // default from GET /user
trigger?: 'mention' | 'all'; // default 'mention'
repositories?: string[]; // 'owner/repo' or 'owner/*', default every repository
associations?: GitHubAssociation[] | 'any'; // default ['OWNER', 'MEMBER', 'COLLABORATOR']
authors?: string[]; // logins answered whatever their association
allowBots?: boolean; // default false
stream?: boolean; // default false
apiUrl?: string; // default https://api.github.com
fetch?: typeof fetch;
}In cogitator.yml, channels.github takes ownerIds (numeric user ids, from https://api.github.com/users/<login>, as numbers or strings), webhook (port, default 8787, and path, starting with /), trigger, repositories (each owner/repo or owner/*), associations, authors, login, allowBots and stream. It reads GITHUB_CHANNEL_TOKEN, GITHUB_WEBHOOK_SECRET and, for GitHub Enterprise Server, GITHUB_API_URL. Without GITHUB_CHANNEL_TOKEN it falls back to GITHUB_TOKEN, the token of the github_api tool, and warns: that token is often your own.
channels:
github:
ownerIds: [583231]
trigger: mention
repositories: ['acme/*']
associations: [OWNER, MEMBER, COLLABORATOR]The scaffolder makes a bot on GitHub with npx create-cogitator-app my-bot --preset channels --channels github, and cogitator init and cogitator wizard offer it too. With WebChat in the same project, the webhook is a route of the WebChat server, so a deploy that exposes one port reaches both.