Skip to content
You are reading documentation for unreleased main. Read the 0.1 version.

GitHub Integration

GitHub is a served code host: a delivery from github.com or a GitHub Enterprise Server wakes the seat it concerns, and crewlet github provision registers the webhooks that carry it. A company can run it beside GitLab — they are two hosts with different repositories on them, which is what a migration and an open-source presence both look like.

Three surfaces, and they are deliberately separate:

  • Inbound — integrations.github plus POST /webhooks/github. This is how a review request, an assignment, a comment or a red workflow run reaches an agent.
  • Tools — the GitHub MCP server, a shared: false entry in mcp_servers with each agent’s token in role.mcp_env.github. This is how an agent reads, reviews and tracks.
  • Identity. One GitHub App per agent, in role.integrations.github. An app carries exactly one bot identity, so each agent has its own, created and installed from the dashboard and bounded by an access tier. See One GitHub App per agent.

GitHub tools are for reading, reviewing and tracking code — diffs, comments, reviews, run status. Authoring code changes goes through the code sandbox, which opens a pull request under the agent’s own identity.


Connect GitHub in Settings › Integrations. The engine generates the webhook secret and the reconcile loop registers the hook on its next tick, running the same pass crewlet github provision runs with the same secret store behind it. The connect step asks for a personal access token nowhere — only the organization, which is the one answer nobody else has.

The form asks one question about coverage — which GitHub activity should reach your agents — with two answers. It opens on repositories where an agent’s app is installed, which is what most companies want and needs nothing further: each app carries its own webhook, so connecting and installing is the whole setup. The other answer, every repository in your organization including new ones, needs an organization token with admin:org_hook; the form asks for it only under that answer, and requires it there. See What a company hears about.

The loop keeps checking after that, so a grant you change at GitHub is reflected on the screen within a tick without anything to press.

Giving each agent its own bot identity is a separate, per-seat flow that the loop cannot run for you, because it needs your GitHub session: see One GitHub App per agent.

See Running the provisioning pass.

integrations:
github:
enabled: true
# Omit url for github.com. An Enterprise Server names itself here.
url: "https://github.example.com"
webhook_secret: "${GITHUB_WEBHOOK_SECRET}" # required when enabled
token: "${GITHUB_ENGINE_TOKEN}" # read credential; see below
provisioning: # read by the engine's pass and the CLI
org: acme
repos: [acme/api, acme/web]
org_webhook: auto
  • url is optional and its absence is meaningful. Leave it unset for github.com, whose API lives on a different host (api.github.com) rather than a path on the web UI. An Enterprise Server names itself and the REST base is derived as <url>/api/v3 — there is no second address to keep in step, and pasting the API base in instead of the instance URL is accepted rather than doubled.

  • webhook_secret is required when enabled, and it is the organization’s: it verifies the bare route and any seat with no app of its own. A company whose agents all hold their own apps still sets one, because enabling this block turns on the organization route. Every delivery to POST /webhooks/github is verified as HMAC-SHA256 over the raw body against X-Hub-Signature-256; a route with nothing to verify with answers 503 rather than accepting the delivery. Unlike GitLab’s, this secret has no required shape — GitHub takes any string and signs with it verbatim — so there is no wrong shape to catch, only a wrong value.

  • POST /webhooks/github/{handle} is the same route, addressed to one seat, and is what a per-agent GitHub App delivers to.

    It verifies against that seat’s own secret. GitHub generates a signing secret per app, at conversion time, and returns it once, so an agent’s deliveries are signed with its app’s secret rather than the organization’s. The route reads the seat’s own webhook_secret first and falls back to integrations.github.webhook_secret, which is what a single organization app pointed at one seat signs with. With neither, the route answers 503.

    GitHub delivers to every app installed on a repository, each delivery carrying its own X-GitHub-Delivery. A repository five agents work therefore produces five deliveries of one comment. Those are not duplicates to collapse — they are five agents being told, which is the point of each holding its own app — and without the seat in the path nothing downstream can tell them apart from a redelivery of one. The handle travels on the published event as handle.

    The bare POST /webhooks/github stays for a single app serving a whole organisation, where a delivery names no seat and the organization’s secret is the only one that could have signed it. The seat is never a way past the signature check: what the handle selects is which credential the delivery is checked against, not whether it is checked.

  • token (optional) is the credential the organization-level reconcile reads and registers hooks with, and crewlet github provision requires it. A company whose agents each hold their own app needs none: each app carries its own hook, and participant fan-out is read through those apps. See Participants. With no token the organization pass reads nothing and says so as a note, which is not a fault: there is nothing it was going to do for such a company.

    Installing an agent’s App is not a substitute for it. An organization-wide hook needs the admin:org_hook scope, which only a user token carries — no App installation grants it, at any permission. So an operator who was told no webhook on <org> and installed the per-agent app, exactly as the card was asking, watched the warning stay put. A card must never prescribe an install as the remedy for a missing org hook, and a company whose agents carry their own apps is not blocked at all — it has coverage, narrower than the whole organization, reported as such.

    It is offered but never on the connect step, and it is required by an ANSWER rather than in general: choose organization-wide coverage on the form and it is the one thing standing between that answer and its being true; choose the other and it is a credential the company never uses. The form hides it until that choice is made and then requires it, and the API refuses the same submission, so a caller that skips the form cannot store a choice the engine could not carry out.

    Asking every company for a hand-minted personal access token before anything works put a permanent note on cards with nothing wrong with them; having no field for it at all left the warning pointing at a setting the dashboard could not set. It now appears with the finding that needs it.

  • provisioning: says where hooks are registered and which organization these agents work in, and it is read by the engine’s own pass as well as by the CLI. org is the GitHub organization holding the repositories, and it is also the account a per-agent app is registered under; repos are owner/repo entries to hook individually; org_webhook is auto / true / false, described under Webhooks. The config default is auto; the dashboard form opens on true, because a fallback nobody was told about leaves a company believing it has one hook when it has several.

A GitHub delivery names people by login, and nothing in the org chart says which account a seat holds. At boot the engine calls GET /user with each seat’s own credential and registers whatever account answers.

A declared login beside the token would be cheaper and is the wrong shape: a declaration that disagrees with the credential is a misroute nothing can detect, and it would make the engine name a variable the seat’s own tools do not read. The credential is read from role.mcp_env.github under whichever key that seat’s tool stack uses — GITHUB_TOKEN, GITHUB_PERSONAL_ACCESS_TOKEN, GH_TOKEN, or an Authorization header (the Bearer and token schemes are both stripped).

The lookup is cached against the credential, not the seat: identity is a function of the token, so a config apply that changed something else costs no requests, and a rotated token costs exactly one.

A seat whose lookup fails is left unresolved rather than failing the boot — GitHub may be rate-limiting — and the engine says so per seat (github_seat_identity_unresolved). That seat receives no GitHub events until the next apply re-resolves it. A company with no resolved seats logs github_has_no_seat_identities, because the integration is completely inert in that state and nothing else would say so.

A seat with no token here is not a broken seat. On the current design each agent carries its own GitHub App, and a seat that names no mcp_env.github credential is acting through it — so the reconcile reports that seat as unclaimed rather than as a failed identity, and the provisioning command prints it under “act through their own GitHub App” rather than under a list of things to fix. The three outcomes a seat can have are therefore resolved (a token named an account), unclaimed (no token, which is expected) and refused (a token this run could not turn into an account, and the only one that becomes a finding).

That distinction was missing, and its absence was loud: every agent seat in a company on the app design was reported as a failed identity, permanently degraded, with the integration card saying each of them could receive no GitHub events while all of them were working.

A human seat holds no tool credential. It is addressed by contact.github_login in the org chart, which the party registry registers directly — so a person can be mentioned in a comment and reached by the engine’s notification spine without ever holding a token here.

A human seat carries no integrations.github block either. That block is a seat’s own app, the identity an agent acts as, and nothing creates, installs or reconciles an app for a person. A document that gives one to a human seat is refused at that block; the rule is an admission rule, so a revision being applied that carries one still runs.

A GitHub App has exactly one bot identity, derived from its slug, and nothing varies it: not a token, not a header, not a manifest field. An agent that is to act as itself on GitHub therefore needs an app of its own, so the engine creates one per seat rather than one per company.

That is a constraint rather than a preference. Agents cannot share one app and keep distinct identities. A single company-wide app would have every agent comment, review and commit as the same account, and an @mention of one of them would no longer say which agent was meant.

Two acts, both performed by a person signed in to GitHub, and the second can be a day after the first.

  1. Create the app. Settings › Integrations asks the engine for a manifest and submits it to GitHub as a form POST carrying the operator’s own GitHub session. GitHub shows what is about to be created and, on approval, sends the browser back to the engine with a one-time code. The engine converts the code, seals the private key that comes back, and records the app on the seat.
  2. Install the app. Creating an app grants it nothing: an app with no installation can see no repository. The page the operator lands on after creation links straight to the install page for the app that was just created, and the install is where the repositories are chosen.

Neither act can be automated, and that is GitHub’s shape. A manifest is submitted with the browser session of somebody who may create apps on that organization, and GitHub offers no server-to-server equivalent. This is the one part of any integration here that a reconcile pass cannot do on its own.

GitHubCrewletOperator's browserGitHubCrewletOperator's browserPOST /setup/integrations/github/app {"seat": "senior-engineer"}1manifest, action URL, signed state2form POST of the manifest (operator's own session)3confirm the app, then redirect4GET /webhooks/github-app?code=...&state=...5POST /app-manifests/{code}/conversions6app id, slug, private key, webhook secret (once only)7seal the key and the webhook secret, record the app on the seat8"App created", then follows the install link after 5 seconds9install the app on the organization10GET /webhooks/github-app?installed=senior-engineer11

The card catches up immediately, not on a cadence

Section titled “The card catches up immediately, not on a cadence”

An agent’s App is private, so GitHub sends this engine nothing when you install it — there is no webhook for it to arrive on, and the reconcile loop is what finds the installation, by listing the App’s own installations with the App’s own key.

That discovery used to wait out the loop’s admin backoff, which runs from fifteen seconds to ten minutes, and the backoff is longest exactly when you have just done the thing it is waiting for. Measured: an install completed in about eight seconds, then several minutes of a card still asking for it, reloaded by hand, reasonably read as the install not having worked.

Two things now close that gap:

  • GitHub’s return brings the next pass forward. The install redirect lands at /webhooks/github-app, and that arrival asks the loop to look now. It is not a write and nothing in the redirect is believed — no installation id travels with the ask — so the pass that runs is the same verified listing as always, and adopting anything from an unauthenticated query stays out of the question. See the cadence for the rate limit on that route and why it is where it is.
  • Settings › Integrations re-asks when you come back to the tab. Setting an integration up means leaving for GitHub and returning, and returning is a stronger signal that the answer moved than any poll interval can be. The screen held its pre-departure reading for up to a minute otherwise, which is the other half of what made this look broken.

Together the card is normally correct by the time you switch back to it. If it is not — an engine built without the loop wired, or a second install inside the rate-limited window — the return page says so plainly instead of promising a wait it is not taking, and POST /setup/integrations/github/provision runs the pass on demand.

  • integrations.public_base_url. Three addresses are baked into an app at creation: where its deliveries go, where the browser returns after the creation, and where it returns after the install. Only a person at GitHub can change them afterwards, so the engine refuses to begin without a public base (409 no_public_url) rather than create an app that would have to be created again. The reconcile loop reports the same gap for the org- and repository-level hooks: with no public base there is no address for GitHub to deliver to, so the integration is reported degraded naming that field rather than ready with nothing registered anywhere.
  • integrations.github.provisioning.org. It names the account the app is registered under, and the account matters: an app registered under a person’s own account cannot be installed on the organization that owns the repositories. With no organization set, the operator is sent to their personal app registration page instead.
  • integrations.github.url, when the company runs Enterprise Server. Unset means github.com.
Manifest fieldWhat the engine puts in it
nameThe company name and the seat’s role name, joined and cut to 34 runes
urlhttps://crewlet.ai
publicfalse. The app is the company’s own
hook_attributes.url<public_base_url>/webhooks/github/<handle>
redirect_url<public_base_url>/webhooks/github-app
setup_url<public_base_url>/webhooks/github-app?installed=<handle>
default_eventsissues, issue_comment, pull_request, pull_request_review, pull_request_review_comment
default_permissionsThe seat’s tier, below

The events are named, never *. An app with a delivery address and no events subscribes to nothing, receives nothing, and reports itself healthy while doing so. push is deliberately absent: it is the highest-volume event a busy repository produces and the router drops every one. workflow_run is not in an app’s set either, so a failed-run notice reaches a seat through the organization or repository hook the provisioning pass registers rather than through the seat’s own app.

A seat’s tier is the one field in this block a person writes, and it decides two things: the permissions the app is created with, and the permissions every token minted for that app carries.

TierWhat it grantsGitHub permissions
read_only (the default)Reads code and issues, changes nothing.metadata:read, contents:read, issues:read, pull_requests:read, checks:read, actions:read, deployments:read
reviewReads the code and writes about it: issues, comments, reviews.metadata:read, contents:read, issues:write, pull_requests:write, checks:read, actions:read, deployments:read
full_accessBranches, commits, pull requests, issues and checks. No administration.metadata:read, contents:write, issues:write, pull_requests:write, checks:write, actions:read, deployments:read
  • Empty means read_only. A seat nobody has thought about yet should have to ask for more rather than already hold it.
  • review keeps contents at read on purpose, so a reviewer cannot change what it is reviewing.
  • metadata: read is on every tier, because GitHub requires it for almost every read: without it a token cannot resolve a repository at all.
  • Each tier is an allow list. GitHub’s token endpoint takes the permissions to grant, so anything absent from a tier is simply not on the token. There is no catalogue to subtract from and therefore nothing to forget.
  • A typo is refused, not guessed at. tier: reviw fails config validation naming the field, and a reader that takes the value anyway falls back to read_only rather than to something wider. A hyphen is not a typo: full-access and full_access are the same tier.

None of the three asks for any of these, so no token this engine mints carries one. Each is a way out of the tier rather than a step up within it: administration (deleting a repository, dropping branch protection), secrets and variables (every credential the repository holds), and membership and organization settings (how an agent would widen its own access).

administration · organization_administration · organization_secrets · organization_self_hosted_runners · organization_user_blocking · members · organization_plan · secrets · actions_variables · organization_actions_variables · environments

What an app holds is a different question from what a token carries. A manifest can be edited in the browser before it is submitted, and an installation can be widened by a person afterwards; neither is the engine’s to decide. What is the engine’s is the mint: a token is issued with the tier’s own permission list and nothing else, so a read_only seat still cannot write on an installation that could. Where the seat names repos, the token is narrowed to those as well, and an empty list means every repository the installation covers, which is what the operator chose when they installed it.

The app is recorded on the seat, addressed by handle, so a company with ten agents keeps ten separate records:

roles:
- name: Senior Engineer
integrations:
github:
tier: review # read_only (default) | review | full_access
repos: [acme/api] # empty means every repository the installation covers
# Written by the engine, never typed in:
app_id: 1234567
app_slug: acme-senior-engineer
installation_id: 87654321
private_key: "${SENIOR_ENGINEER_GITHUB_APP_KEY}"
webhook_secret: "${SENIOR_ENGINEER_GITHUB_APP_WEBHOOK_SECRET}"

tier and repos are the two fields a person writes. app_id, app_slug, private_key and webhook_secret are written when the app is created, and the slug is what the bot login derives from, so it is what an @mention of this agent resolves through. installation_id is written as 0 at that moment, because creating an app and installing it are two acts and an app installed nowhere is a real state to report rather than a half-written record. A seat’s app can mint tokens only once app id, installation id and key are all present.

The credentials themselves never enter the document. Two entries are sealed in the secret store:

Sealed nameWhat it is
<HANDLE>_GITHUB_APP_KEYThe app’s PEM private key
<HANDLE>_GITHUB_APP_WEBHOOK_SECRETThe webhook secret GitHub generated for the app, when it returned one

<HANDLE> is the seat’s handle upper-cased, with every character outside A-Z, 0-9 and _ replaced by an underscore, so senior-engineer becomes SENIOR_ENGINEER. The names are per seat because the credentials are: one shared name would have the second agent’s key overwrite the first’s, and both seats would then authenticate as whichever app was created last.

An app has two names and nothing at GitHub relates them. A person writing a mention types the slug, so a body carries @acme-sre-lead; every payload reporting what that app did carries the account, which is the slug with [bot] appended. Both are registered against the seat, the slug where mentions resolve and the account in the companion namespace a payload’s sender resolves through, so an agent is routable under either.

Neither costs a request. A seat holding a personal access token still has its account learned with one GET /user, because a token says nothing about whose it is; an app’s account is its slug, which the engine wrote down when it created the app. The app wins where a seat has both, because the app is what the agent acts as; a credential nobody cleaned out of mcp_env would otherwise take the mapping and the app’s own deliveries would reach a stranger.

Logins are folded to lower case on the way in, because GitHub treats them as case-insensitive and a mention carries whatever a person typed.

An app created this way delivers to POST /webhooks/github/<handle>, which is the ordinary GitHub route addressed to one seat (see Configuration).

GitHub signs an app’s deliveries with that app’s own webhook secret, which it generates at conversion time and returns once. The engine seals it as <HANDLE>_GITHUB_APP_WEBHOOK_SECRET and writes the ${VAR} onto the seat, and the route verifies that seat’s deliveries against it. Nothing has to be set at GitHub by hand, and no two apps share a secret.

A seat with no secret of its own falls back to integrations.github.webhook_secret, which is what a single organization app pointed at a seat signs with. A seat with neither is a route with nothing to verify against, and it answers 503 rather than accepting the delivery.

RouteCalled byWhat it does
POST /setup/integrations/github/appThe dashboard, authenticatedAnswers with one seat’s manifest, the address to POST it to, and a signed state
GET /webhooks/github-appGitHub’s redirect, unauthenticatedConverts the one-time code, seals the key, records the app; also the page an install returns to

Creating an app and installing it are two clicks at GitHub, and an operator who has just done the first is already going to do the second, so the created-app page counts down five seconds and follows the install link itself. The button stays for anyone who would rather not wait, and it is the whole flow with scripting off: the countdown is hidden until the script owns it, so the page never promises a redirect it cannot make.

The callback carries no engine credential, because a browser redirect from GitHub has none to carry. What stands in its place is the state: a signed token naming the seat, minted by the begin route, valid for 15 minutes, and validated before anything else happens. It is scoped to this flow, so a token minted for another signed URL this engine issues cannot be replayed here.

Across a fleet the state signer is keyed from the Tier A keyring (secrets.keys), so a creation begun on one node can be finished on another. A deployment with no keys configured falls back to a per-process key, which is correct for a single node and cannot work across two; the engine says so at startup with github_app_state_key_is_per_process.

Request and response shapes are in API Endpoints.

Both of the acts that build a seat’s app happen at GitHub, in a browser, and GitHub tells the engine about neither. So the loop reads the app back on every pass and corrects the document from what it finds.

What it readsWhat it writesWhat the screen then says
An installation the app has and the seat does not nameinstallation_id on the seatThe seat is finished
A stored installation_id GitHub answers 404 toinstallation_id: 0Install it, with the link
An app id GitHub answers 404 toClears app_id, app_slug, installation_id, private_key and webhook_secretCreate an app for this seat
A disconnect uninstalled the appinstallation_id: 0Install it, with the link

An agent with no app at all is reported too, and it used to be invisible. The loop builds its seat list from the seats carrying an integrations.github block, because that block is where an app’s id, slug and key are recorded — so a seat that has never had one was absent from the pass’s input and produced no finding. Measured on a live connect: a company with one agent, no app, phase: ready, findings: [], and the seat’s own row three screens away saying “no app of its own yet, so this agent acts as nobody on GitHub”. They are reported as one approval_required naming the count and up to three handles, because creating an app is the same act for every one of them and a fifty-agent company does not need fifty rows saying it; the whole list travels beside the sentence. approval_required rather than identity_missing because the engine can never do it — an app is created by a form POST from a page carrying your own GitHub session — so a card reading Setting up agents would wait for an act nobody is performing.

And the card is not Connected while no agent can act. One GitHub App is one bot identity, so an agent without its own app acts as nobody there. The satisfaction check asks only about seats that have started, which is what lets a company running GitHub for three of its ten agents be finished when those three are — and a company where nobody had started passed it vacuously: satisfied: true beside seats_required: true, over an agent that could do nothing.

Every one of these reads as Action needed, waiting on a person at GitHub. The engine cannot create an app or install one for anybody: both are acts in a browser, carrying the operator’s own session.

The last row is the one that needs the extra call. An app installed nowhere and an app somebody deleted both answer 404 from the installation endpoints, and they call for opposite things, so the app’s own identity (GET /app, signed with its own key) is asked for before an operator is sent anywhere. Read as “installed nowhere”, a deleted app pointed an operator at an install page GitHub itself 404s, on a card reporting the app as present.

The sealed key is left in the store when a record is cleared: it is named per seat, so the next app’s conversion overwrites it, and deleting a credential on the strength of one remote 404 is a destructive answer to a question only GitHub can settle.

A check (POST /setup/integrations/github/check) writes none of this. It reads and reports, and the loop makes the correction on its next pass.

An applied revision makes every surface due, now. The loop’s wait is for asking GitHub again, not for asking the company document again, so a change here is reconciled immediately rather than at the settled cadence, and rather than at the end of the tick interval. Without it, an operator who installed an agent’s app was redirected back to a card still holding the previous pass’s finding, printed above the same card’s roster reporting that agent installed and ready.

  • The private key and the webhook secret come back exactly once. GitHub has no endpoint that reissues either, so the callback seals both before doing anything else that can fail. A failure after the seal costs a retry; a failure before it costs the app, and the only way forward is to delete it at GitHub and create it again. It is also why an error here is worded by the engine and never quotes GitHub’s response body: that body carries the key.
  • The delivery address is baked in at creation. An app’s hook attributes, redirect and setup URLs are set from the manifest and changed afterwards only by a person editing the app at GitHub, which is why the begin route refuses to run before the engine knows its own public base.
  • An app name is globally unique and capped at 34 characters. A name built from the seat alone would collide the second time two companies both have an sre-lead, so the company name leads and the seat’s role name follows. A name past the limit shortens the company and keeps the seat whole, and ends in six hex characters of a digest of the full name, so two seats of a company whose name alone fills the limit never share one (a name sliced through a multi-byte character is refused as malformed rather than as too long, so every shortening lands on a character boundary). GitHub then slugifies the name and disambiguates a collision itself, so the app that exists may not carry the name that was asked for. That is why the install link is built from the slug the conversion returned rather than from the name that was requested.
  • Permissions are frozen at creation. Raising a seat’s tier afterwards means editing the app’s permissions at GitHub, where every installation has to approve the change before it takes effect. Choosing the tier before the app is created is the cheap moment to get it right.
  • The creation code is single use and lives one hour. A code that has been converted or has expired is reported as exactly that, and the flow starts again from the begin route. The engine’s own state is shorter still, at 15 minutes, so an abandoned attempt fails on the state rather than on a code nobody can do anything about.

POST /webhooks/github verifies HMAC-SHA256 over the raw body against X-Hub-Signature-256, and dedupes on X-GitHub-Delivery — GitHub sends a stable per-delivery uuid, the same on every retry and on a redelivery an operator triggers by hand, so a redelivery does not wake the seat again.

The event name arrives in the X-GitHub-Event header, not the body. GitHub puts only the action in the payload, so {"action": "created"} is the whole discriminator a body-only reader gets — created what is not in there.

The hooks the provisioner registers subscribe to exactly what the parser reads, never *:

issues · pull_request · issue_comment · pull_request_review · pull_request_review_comment · workflow_run

A wildcard hook delivers every push, star and fork — thousands a day on a busy repository, each one verified, stored, deduped and routed to nobody. check_run is deliberately excluded: it reports the same failing Actions run as workflow_run, once per job, so subscribing to both would wake one seat as many times as the workflow has jobs.

One organization hook, or one per repository

Section titled “One organization hook, or one per repository”

An organization hook covers every repository in the org, including ones created after the run — the difference between a new repository routing on day one and routing whenever somebody remembers to re-run the provisioner. It needs the admin:org_hook scope, which a fine-grained token cannot carry at all and a classic token carries only if whoever minted it ticked the box.

org_webhookBehaviour
auto (config default)Try one org hook; fall back to per-repository hooks if the credential may not, saying so in the run’s notes
trueDemand the org hook. A credential that cannot register it fails the run — an operator who asked for this arrangement must not silently get the other one
falseRegister no org hook. Every repository in repos is hooked; with repos empty, each agent’s own app carries its own webhook

A working org hook means the repos list is not hooked separately: two hooks on one repository deliver every event twice.

The dashboard form asks a different question, and offers two of these three. It asks what should reach your agents rather than where a hook is registered, because that is the question an operator has:

Answer on the formWritesNeeds
Repositories where an agent’s app is installed (default)org_webhook: falsenothing
Every repository in your org, including new onesorg_webhook: trueintegrations.github.token with admin:org_hook — required for this answer

auto is not offered there, because it is not an answer to that question: it means “try for the whole organization and quietly take less”, which leaves a company believing it has one hook when it has several, none of them covering a repository created tomorrow. false with a repos list is not offered either — it is a real arrangement and a rare one, needing a list of repositories a form cannot help you build. Neither is removed: YAML keeps both, org_webhook still accepts all three values, and the pass still hooks every repository a company names. The form’s job is deciding which questions are worth putting to somebody connecting from a dashboard.

org_webhook: true with no token does not block a company whose agents carry their own apps. It used to: the finding said ingress_blocked, the card read Action required, and the remedy on screen was to install the agents’ apps — which can never carry admin:org_hook, so doing exactly as the card asked changed nothing. Events were arriving through those apps the whole time. It now reports as coverage on a ready card (below), and the only thing that widens it is the token.

A company already holding org_webhook: "true" is not migrated. The form’s default is a suggestion and never a stored value, so changing it moves nobody who already answered — and rewriting an explicit answer would be the engine overruling a decision somebody may have made deliberately. What changed is the report, which is what was wrong.


Two layers, and the difference between them is what a seat is being asked for.

Directed events name their recipient in the payload. They route from the payload alone, need no reads, and survive a lapsed credential:

EventReachesReason stamped
pull_request review_requestedThe requested reviewerpull_request.review_requested
pull_request openedEvery reviewer the pull request already requests, every assignee, and anyone the body @-mentions — each under its own reason, so an opener who assigned and mentioned one person wakes them oncepull_request.review_requested / .assigned / .mention
pull_request / issues assignedThe named assigneepull_request.assigned / issue.assigned
pull_request_review submitted, changes requestedThe pull request’s authorpull_request.changes_requested
pull_request_review submitted, approvedThe authorpull_request.approved
pull_request closed with merged: trueThe author and assigneespull_request.merged
pull_request closed without itThe author and assigneespull_request.close
pull_request reopenedThe author and assigneespull_request.reopened
pull_request ready_for_reviewThe author and assigneespull_request.ready_for_review
pull_request converted_to_draftThe author and assigneespull_request.converted_to_draft
issues closedThe assigneesissue.close
A @login in any bodyWhoever was named…mention
workflow_run completed, conclusion failureThe run’s own actorworkflow_run.failed

The four state changes — closed, reopened, ready_for_review, converted_to_draft — take the author first. A pull request’s outcome is news to whoever opened it before it is news to anyone else, and GitHub gives the login rather than an opaque id, so it needs no lookup and works with no credential at all. closed splits on merged because to the author those are opposite outcomes: one means the work landed and the other means somebody decided it would not.

Thread activity — a comment, a close, a merge — concerns everyone taking part, which GitHub does not put in the payload. It costs one read per issue event and two per pull request, and without a token it degrades to the author and assignees the payload does carry. That degradation can only ever cost reach on the watching layer, never on the directed one.

Where several reasons name one person, the first wins, and the list arrives in priority order — so a mentioned author is woken once, as a mention, which is the stronger claim on their attention and the one the prompt renders differently.

  • Bookkeeping. A label, a milestone, a synchronize (new commits pushed), an auto-merge toggle. Each changes the item without asking anyone for anything, and routing them produces turns triaging “somebody added a label”.
  • An edit, beyond the names it added. GitHub’s own rule: re-saving a body does not re-notify the people it already named. Only newly-added mentions route, so a typo fix pings nobody.
  • A deleted comment. Whatever it said is gone, and a notification pointing at it sends the recipient to a 404.
  • A green, cancelled or timed-out run. A cancel is somebody deciding the run was unnecessary; a timeout is usually the runner rather than the diff.
  • A team review request. @acme/reviewers names no person, and the acme half is not one either — reading it as a login wakes whichever seat happens to share the organization’s name on every team ping. Expanding the team would mean a members lookup on the inbound path to produce a fan-out GitHub itself treats as weaker than a direct request. It is logged (github_team_review_request_not_routed) rather than dropped silently.
  • Anyone who is not a seat here. A repository has contributors who are not in this company; the registry is the single gate every fan-out passes through.

A seat is never told about its own actions — with one exception in the whole engine. A failed workflow run names the person whose push triggered it, and when it goes red they are the only one who can fix it. A build runs asynchronously, minutes after the push, and reports a result nobody could have predicted, so suppressing it means the person who can act never learns.

The prompt says so out loud: a seat that has learned “I am not told about my own actions” reads its own name as a routing mistake otherwise.

GitHub has no participants endpoint. Its own subscription rule is that you are subscribed once you author, are assigned, are mentioned, comment or review — and of those five, three are in the webhook payload and one is in the text. What is left, and what the engine reads, is the two that are only in the API:

  • GET /repos/{owner}/{repo}/issues/{n}/comments — who has commented.
  • GET /repos/{owner}/{repo}/pulls/{n}/reviews — who has reviewed, on a pull request. A reviewer who approved without writing anything appears in neither the other, and is exactly the person who should hear that the author pushed again.

Both are one page of 100, read concurrently, never a cursor walk: a thread with more than a hundred commenters is one where notifying all of them is the wrong behaviour anyway, and the call sits on the inbound consumer’s hot path.

They are read through the agents’ own apps. An agent that acts as itself already holds a credential that can answer: its app is installed on the repositories it works in, and every tier grants issues:read and pull_requests:read. The first installed seat whose token mints is asked, and the next is tried when one refuses, which is what keeps the answer available while an operator is mid-rollout. Tokens are cached per installation for the hour GitHub issues them for, because this is the inbound hot path.

This took a shared organization token once. The token was scoped to whatever the person who minted it could reach, had to be rotated by hand, and was the subject of a note on every card that had not set one.

A pull request’s conversation comments arrive as issue_comment, because GitHub models a pull request as an issue with a diff. The engine reads that from the payload rather than the event name, so a pull-request comment is never filed as an issue — which would ask the wrong collection for its participants and lose every reviewer.


Terminal window
crewlet github provision company.yaml \
-public-url https://crewlet.example.com \
-env-file .env

It reports more than it changes, and that is GitHub’s shape. GitHub issues no user account and no personal access token on a provisioner’s behalf: there is no API that creates a user, and the API that once minted a token for somebody else was withdrawn in 2020. A command that offered to provision accounts would print instructions dressed as actions.

So it does the two things GitHub genuinely allows:

  1. Reports which account each seat’s credential authenticates as — the finding an operator acts on, because a seat with no login receives nothing and its inbound routing is simply silent.
  2. Registers the webhooks, on the organization where the credential may and on each named repository where it may not.
FlagEffect
-public-url URLThis deployment’s public base. Without it no webhook is registered — a hook pointing at the wrong host is worse than no hook, because GitHub then reports a healthy integration delivering into the void
-secret-store / -env-file PATH / -printWhere a minted webhook secret goes. Required for a real run — a run with nowhere to put what it mints creates a live secret and prints none of it
-recreate-webhooksDelete and remake every hook to mint a fresh secret. Destructive: it invalidates the secret every other deployment of this company holds
-dry-runRead and report; register nothing, and do not open the secret store

A hook the company DEMANDED and could not get is reported; the fallback is not. integrations.github.token is optional and the connect form does not ask for it, both deliberately — routing needs nothing from it, because each agent’s own app answers who is participating in a thread. With no token the pass reads nothing and writes nothing at the organization, and it used to say so only in its notes, which are not findings, so a surface that had authenticated with nobody reported ready with an empty finding list. Measured on a live connect: phase: ready, routes: true, and exactly one webhook on the organization, belonging to a different deployment and never triggered. It is now ingress_blocked against integrations.github.token.

But only where the company actually asked for a hook this credential must register, and has no other coverage. Two clauses, and both were learned the same way. The first is org_webhook: true (no fallback) or a non-empty repos list (no agent’s own app covers a named repository): the form requires provisioning.org — that is where the agents’ apps are installed — so reading “the block names an organization” as “this company wants an organization-wide webhook” put a permanent finding on every company that connects GitHub from the dashboard.

The second is that an agent’s own app is a registrar too. Even a company that did ask for an organization-wide hook is receiving events if its agents carry their own apps, so the honest report there is coverage rather than a block. Measured: an operator connected GitHub, was told Action required: no webhook on crewbed, installed the per-agent app exactly as the card was asking, and the warning stayed — because no App installation carries admin:org_hook at any permission. The one instruction on screen could not clear the one warning on screen.

A company whose events arrive through its agents’ own apps gets one sentence on a ready card: covering the repositories your agents’ apps are installed on. Not covering the rest of <org> — supply integrations.github.token to add one organization-wide hook.

It is a coverage_partial finding, whose verdict is ready and whose actor is the operator: nothing is broken, and widening it is a value in this company’s own configuration rather than a grant somebody at GitHub has to make. The two alternatives were both worse. Reported as ingress_blocked it read as Action required over agents that were working. Reported as a note it reached nobody at all — the engine’s pass returns findings and discards notes, so a person would learn the limits of their coverage only by noticing the first repository nobody hears about.

The same sentence covers the company that chose this arrangement and the one left on org_webhook: true with no token, because the two are the same arrangement whatever the mode field says. And it never prescribes installing an app: that cannot widen it.

A working secret is never reminted. The engine is running with the old one, so re-registering with a fresh secret would have GitHub sign every delivery with a key the running engine does not hold — every webhook refused at the edge, from a command whose whole promise is that it is safe to re-run. A secret that already resolves is used as it is; one that resolves to nothing is minted into the ${VAR} the config already points at, and the run says where it went — unless this deployment has already sealed a value under that variable, which is read back and reused instead. That read-back matters to the reconcile loop rather than to this command: a ${VAR} resolves from a snapshot taken at apply time, so in the window between a pass sealing a secret and something rebuilding that snapshot the resolver answers empty for a variable the fleet already holds — and without the read-back the loop minted again on every tick, rotating the key GitHub signs with until the snapshot caught up. On a node with no keyring at all (secrets.keys unset) nothing is minted and no hook is registered: the surface reports ingress_blocked against integrations.github.webhook_secret, naming secrets.keys, rather than failing every pass as though the engine were working on it.

A repository that cannot be hooked is reported, not raised. A company’s list will contain one that was renamed, archived, or made private to a team this credential is not in. Failing the whole run over it would leave every other repository unhooked to punish one typo. Note that GitHub answers 404 for both “absent” and “invisible to this credential” — deliberately, so a probe cannot enumerate what exists — so the report says both.


Declare the GitHub MCP server once as a shared: false http server; each agent supplies its own token:

mcp_servers:
- name: github
transport: http
shared: false
url: "https://api.githubcopilot.com/mcp/"
roles:
- name: Senior Engineer
mcp_env:
github:
Authorization: "Bearer ${GITHUB_TOKEN_SENIOR}" # per-agent PAT
goal: "Implement backend features"
- name: Tech Lead
goal: "Coordinate the team" # no github creds → no GitHub tools

The Authorization header is the same credential the engine resolves the seat’s login from — one secret, named once. See Tools & MCP.

CategoryTools
Issuesissue_read, issue_write, add_issue_comment, list_issues, search_issues
Pull Requestscreate_pull_request, list_pull_requests, pull_request_read, merge_pull_request, update_pull_request
Repositoriesget_file_contents, create_or_update_file, push_files, search_code, list_branches
Actionsactions_list, actions_run_trigger, get_job_logs
Code Securitylist_code_scanning_alerts, list_secret_scanning_alerts

The remote server also exposes GitHub Copilot tools (create_pull_request_with_copilot, assign_copilot_to_issue, request_copilot_review). They stay reachable and an agent may call any of them; Crewlet’s own code-authoring path is the code sandbox, which is what run_sandbox drives. The Copilot tools are only on the remote server, not a self-hosted one.


A seat the founder has gated with role.sandbox.enabled authors code through the code sandbox. run_sandbox is on the executor’s surface; it calls it, and a coding agent runs in an isolated box and opens a pull request as the agent’s own GitHub identity — the token the role declares in role.sandbox.env, by convention the same one as its mcp_env.github header. The call is detached: the executor loop suspends and resumes with the result, so the agent reports the pull request in the same turn.

GitHub stays in the picture on the read/review/track side. Once the pull request exists, its review_requested event wakes the reviewer through exactly the path above — there is nothing special about a pull request an agent opened.

An agent that kicks off async work whose result returns later should capture the context with reflect_and_persist(ttl_days=30): what was kicked off, the repository and number, and where the original request came from. That is the SHORT-tier personal memory shape — see agent-learning.md.

When the review request arrives later, the turn-start prefetch filters that diary against the incoming trigger, so the original ask shows up in the agent’s ## Personal memory block and it can report back to whoever asked rather than silently reviewing.

Roles with GitHub credentials also see the bundled mcp:github Tool Skill in their executor prompt, which frames the GitHub tools as read/review/track tools with authoring pointed at the sandbox.

For team-shared conventions — “Engineering uses semantic commits” — edit the knowledge-base page instead. The knowledge base is the single source of truth for shared procedural content; a diary entry is private to one seat.


integrations:
github:
enabled: true
webhook_secret: "${GITHUB_WEBHOOK_SECRET}"
token: "${GITHUB_ENGINE_TOKEN}"
provisioning:
org: acme
repos: [acme/api]
org_webhook: auto
mcp_servers:
- name: github
transport: http
shared: false
url: "https://api.githubcopilot.com/mcp/"
units:
- name: Backend
type: team
lead: Tech Lead
roles:
- name: Tech Lead
goal: "Ship backend features on time with high quality"
manages: ["Senior Engineer", "Junior Engineer"]
- name: Senior Engineer
mcp_env:
github: { Authorization: "Bearer ${GITHUB_TOKEN_SENIOR}" }
goal: "Implement complex backend features"
- name: Junior Engineer
mcp_env:
github: { Authorization: "Bearer ${GITHUB_TOKEN_JUNIOR}" }
goal: "Implement straightforward features and write tests"

  • Team mentions and team review requests reach nobody. Both name a team rather than a person; see What deliberately does not route.
  • The provisioner creates no accounts. GitHub has no API for it. Machine users and their tokens are created by hand, and the command reports which account each one turned out to be.
  • Agents cannot share one GitHub App and keep distinct identities. An app has exactly one bot identity, so a shared app makes every agent the same account. One app per agent is the only arrangement that works, which is why the flow is per seat.
  • Creating and installing an app is a person’s job, twice per agent. The manifest is submitted with an operator’s own GitHub session and there is no server-to-server equivalent, so nothing in the engine can create or install an app unattended.
  • An app’s private key cannot be recovered. GitHub returns it once, at conversion time, and reissues it never. A key lost between the conversion and the seal means deleting the app at GitHub and creating it again.
  • An installation token cannot hold a seat’s identity. It authenticates as an app rather than a person, so GET /user names nobody and the seat is reported unresolved.
  • Code authoring is the sandbox’s job. A role without role.sandbox.enabled and an engine-level providers.sandbox can still read, review and track through the GitHub tools; it has no engine-supported path to author a pull request.

Part of Crewlet. Generated from crewlet/crewlet main at f665f5a. This is not the current version — see the latest docs.