Cogitator
Channels

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.

  1. 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 as GITHUB_CHANNEL_TOKEN=..., apart from any GITHUB_TOKEN of yours
  2. A webhook. In the repository (or organization) settings, add a webhook with the payload URL https://<your host>/github/webhook, content type application/json and a secret of your choice, saved as GITHUB_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 eventActionMessage idTextAnswered with
issuesopenedissue:<number>title and bodya comment on the issue
pull_requestopenedissue:<number>title and bodya comment on the pull request
issue_commentcreatedcomment:<id>the commenta comment on the issue or PR
pull_request_reviewsubmittedreview:<id>the review bodya comment on the pull request
pull_request_review_commentcreatedreview_comment:<id>the comment on a diff linea 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 channelId is owner/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's topicId is 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. groupId is the repository.
  • Whose events. Only those of the repository's owner, the organization's members and collaborators (author_association OWNER, MEMBER, COLLABORATOR), whatever the trigger. associations names others (['OWNER', 'MEMBER', 'COLLABORATOR', 'CONTRIBUTOR']), 'any' answers everyone, and authors lists logins answered whatever their association. GitHub may show a member whose membership is private as CONTRIBUTOR: list them in authors, 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. repositories limits the bot to owner/repo names or owner/*.
  • Follow-ups. Once the bot answered someone in a conversation, their next comment there reaches it without a mention, such as the yes that 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.type Bot), so two bots cannot answer each other forever. allowBots: true lets bots in.
  • Users. userId is the sender's numeric GitHub id, which stays the same when they rename, and userName their 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. setReaction uses 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. With reactions: { 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. sendFile sends a file that has a public url as a comment that links it (an image shows inline), with its caption, and refuses a file that only has a buffer.
  • Typing. GitHub has no typing indicator, so sendTyping does nothing.

Errors

REST calls fail with a FeedError (feed github) whose code says what happened and whether trying again helps:

ResponsecoderetryAfter
403 with x-ratelimit-remaining: 0, primary rate limitrate_limiteduntil x-ratelimit-reset
403 or 429 secondary rate limitrate_limitedRetry-After, or a minute without it
401, other 403auth
404, 410not_found
400, 422 (such as a body that is too long)invalid_post
5xx, network errorsunavailable

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 list authors. Open it with associations: '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 repo token. With cogitator up, the channel reads GITHUB_CHANNEL_TOKEN, apart from the GITHUB_TOKEN of the github_api tool.
  • 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.

cogitator.yml
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.

On this page