Skip to main content
The GitHub App reads your repository and posts results to it. For a build, the gateway also issues a short-lived token limited to the item’s repository. In a container build the runner asks for these tokens and holds them, so the build never does. See the git remote and the build’s tokens. A build that runs with hooks of your own fetches and pushes with the GitHub login already on the host. Every host build still opens its pull request as the App: see Opening a build’s pull request.
A host build never holds a real key. Claude Code, git and gh, and the outerlayer CLI send their requests without one. The runner adds the host's Claude credential for Anthropic, a GitHub token that lasts about an hour and pushes to one branch, and an item key that works only on this work item for OuterLayer. With builtin:process or your own hooks, the build can read the item key.A host build never holds a real key. Claude Code, git and gh, and the outerlayer CLI send their requests without one. The runner adds the host's Claude credential for Anthropic, a GitHub token that lasts about an hour and pushes to one branch, and an item key that works only on this work item for OuterLayer. With builtin:process or your own hooks, the build can read the item key.

Permissions the App asks for

If GitHub’s list for the App differs from this table, GitHub is right.

Accepting a permission the App asks for

An installation keeps the permissions it accepted. When the App asks for more, GitHub does not widen the installation on its own. An owner of the installation must accept the request. Until then, a token that needs a missing permission is refused with repository_permissions_pending. Everything else keeps working. Opening or editing a build’s pull request needs pull requests write (pull_requests: write on GitHub). A new installation gets it when it is installed. An older installation that never accepted it must accept it first. Until then, the route answers repository_permissions_pending and opens nothing. To accept, open the installation’s settings link that outerlayer doctor prints. On GitHub the request shows as Review request. One acceptance covers every repository in the installation.

Check what each repository is missing

Run outerlayer doctor on any signed-in machine, with a factory key or a login. It adds one GitHub App: owner/name check per repository connected to the factory:
  • It warns when the installation has not accepted a permission builds need, and names each one. The fix says who acts. A permission the App’s own settings request is accepted by an owner of the installation, on the installation’s settings page. A permission the App’s settings do not request cannot be accepted yet: the App’s settings must add it first. The response of GET /v1/repositories/access marks each as requestedByApp.
  • It warns when the repository’s default branch does not require a pull request. See Protect the default branch.
  • It passes otherwise.
Builds need contents, pull requests and issues write, and actions, checks, commit statuses and metadata read. A permission held at read does not count where write is needed. doctor only reads. It changes nothing on GitHub or in your factory. It asks the gateway with GET /v1/repositories/access, which any key holding git.read can call; the API reference has the response.

Opening a build’s pull request

A host build opens its pull request as the App, not with the GitHub login on the host. A claim hands the build a short-lived item key (prefixed olitem_). outerlayer work open-pr sends PUT /v1/work-items/{workItemId}/pull-request with that key. Only an item key may call this route. In your own session, work open-pr runs gh under your login instead. The route asks GitHub for a token limited to the item’s repository, with pull requests write and contents read. It opens or edits the pull request on the branch the claim named. It revokes the token when the call ends. The token is never returned or stored. See the branch a claim names for the branch rule and the refusals.

Who a build’s commits are authored by

Every commit an unattended build makes is authored and committed by the App’s bot account, <app-slug>[bot]. GitHub links it to the App, so a reviewer can tell a factory commit from a person’s. The runner sets the author for every process the build starts, and git prefers it to any identity configured on the host. The host owner’s own git identity never appears on a factory commit. The member who asked for the build is credited as co-author. Each commit message ends with a Co-authored-by: <name> <email> line for them, so the commit counts on their GitHub profile. The requester is the same person the token rules use. For an implement build, it is whoever ran outerlayer work build or chose Build on the Work page. For an amend build, it is the author of the comment that started it. For an amend build that a merge conflict started, it is the member who asked for the item’s newest build, if still active. Otherwise the commits carry no co-author line. The co-author comes from the GitHub account the member signed in with, not from their profile:
  • The email is <github-user-id>+<login>@users.noreply.github.com, built from that sign-in. A member cannot change it by editing their profile.
  • The name is the member’s profile name, or their GitHub login when the profile has none. The login is also used when the profile name is over 200 characters or holds a line break or an angle bracket, which a commit trailer cannot carry.
  • A member who signed in without GitHub gets no co-author line. The commit is still authored by the bot.
Both addresses are GitHub noreply addresses. No personal email address is written into a commit. This needs two things: a gateway that holds the App’s keys, and a runner at protocol 8 or newer.
  • A gateway without the App’s keys sends no identity. Commits keep the host’s own git identity.
  • For an older runner, the gateway skips the bot lookup, because that runner ignores the identity. The claim succeeds even when GitHub cannot be reached.
  • If GitHub rate limits the bot lookup, the claim answers 503 at once and the runner claims again.
The release of each attempt records the result as environment.commitIdentity: author is app or host, and coAuthor is credited or none. GET /v1/work-items/{workItemId} returns it on each claim. The runner adds the co-author line with git hooks of its own. Your repository’s own git hooks still run first, and a hook that refuses a commit still refuses it. A build that a runner’s own provision hook runs through an exec gets the bot as author and no co-author line. Its commands may run inside a virtual machine, where the runner’s hooks directory does not exist. Commits from your own local session are not affected.

Who can get a token

A token is issued only when both of these hold at the moment it is asked for:
  • The caller holds the item’s live claim. Error codes call a claim a lease. The API key that claimed the item is the only key that can ask. A released or lapsed claim gets nothing, and neither does a different key.
  • A member of your organization asked for the build. For an implement build, that is whoever ran outerlayer work build or chose Build on the Work page. For an amend build, it is the person whose newest comment or fail on the item started it.
A request made with an API key counts as a member’s only while all of these are true:
  • The key still exists and has not expired.
  • The key is bound to a member.
  • That member is still active in the organization.
  • The key does not hold work.claim. A key that holds it is a runner’s key, so a build requested with one is a machine’s.
A build started from a local session gets no token. POST /v1/work-items/build still accepts a request from a key that fails the member test, with no member bound or holding work.claim. This is because a runner that runs builds with its own hooks needs no tokens. The response then carries repositoryTokens with earnable: false and the reason, and outerlayer work build prints a warning that a runner with container builds will fail the build. These checks run against the current records every time, not against what was true when the claim began. A member removed from the organization stops earning tokens at once.

A merge conflict needs no member

One kind of build gets a token with no member behind it. A merge conflict has no person who asked for it, so the token rests on the factory’s authority. See When a pull request conflicts with its base branch. The gateway issues the token only when all of these hold at the moment it is asked for:
  • The build is an amend build by a runner, and its newest trigger is a live merge conflict. The gateway recorded the conflict, and the pull request’s head has not moved since.
  • The item was built by a runner. Its newest implement claim belongs to a runner, not to a local session.
  • The factory’s Resolve merge conflicts automatically setting is on. If you turn it off after the claim, the gateway refuses the token with conflict_resolution_off.
A person’s fail or a member’s rerun that is newer than the conflict is the newest trigger instead. The member rule then applies, as above. The exception never widens the member rule for any other build. No API caller can create the trigger, because the gateway writes it from its own read of GitHub. The build can only merge the base branch into the pull request’s branch, and the runner refuses a push anywhere else. The audit event for the token records authority with kind factory and reason merge_conflict. A token issued under the member rule records kind member.

The two kinds of token

A factory holds one GitHub App installation per GitHub organization it links repositories from. Each repository is reached through the installation stored on its connection. A token is issued through the installation that covers the repository it is for. So a claim over acme/web and acme-labs/api gets its two tokens from two installations. GitHub mints a token for one installation only, so no token covers both. See Repositories from more than one GitHub organization. Ask with POST /v1/work-items/{workItemId}/claim/tokens and a body of { "access": "read" } or { "access": "push" }. A claim can name several repositories. Add "repository": "owner/name" to ask for the token of one of them, which limits the token to that repository. Without it the token is the claim’s primary repository’s. A repository the claim does not name is refused with repository_not_in_claim. The API reference has the full request and response. A third body, { "access": "read", "target": "control_plane" }, asks for a token on the repository that governs the item’s repository. The caller names no repository. The gateway uses the control plane stored for the item’s repository. That is the factory’s control plane when the item’s repository is another one the factory links. It reads nothing from the control plane to decide that. The gateway first tries the installation stored on the control plane’s own connection. Then it tries each other GitHub App installation in the organization, until one can read the control plane. It issues a token with contents read and no other permission. The runner uses it to fetch the build’s context on the host. It is refused with no_control_plane when no control plane governs the repository. It is refused with control_plane_not_in_installation when no installation can read the control plane. It is refused with github_request_failed when GitHub refuses the App’s credentials. The runner treats no_control_plane as the answer that nothing governs the repository and builds without a context. It ends the attempt as failed for github_request_failed, which a retry does not cure. It releases the claim to try again only when GitHub is unavailable (github_unavailable). Either token names only the item’s repository. It lasts as long as GitHub says in expiresAt, about an hour. The response carries Cache-Control: no-store.

Letting a build change workflows

A build cannot change files under .github/workflows/ unless the repository allows it. Set this in .outerlayer/policy.yaml:
The gateway reads the file from the repository’s default branch. A change on the item’s own branch does not count, so a build cannot grant itself the permission. See Policy and validators.

When a token is refused

Protect the default branch

A push token can write to any branch of the item’s repository, and a push runs that repository’s CI. Two settings keep a build from reaching further than its own branch:
  • Require a pull request before merging on the default branch. Use a ruleset with the Require a pull request before merging rule, or classic branch protection with required reviews. A build then cannot push to the default branch directly; its change lands only through a pull request someone approves.
  • Keep CI secrets in protected environments. Put deployment keys and other secrets in a GitHub environment whose deployment branches are limited to the default branch, and reference that environment from the workflow jobs that need them. A workflow run on a build’s branch then gets no secret it could print or send.
outerlayer doctor warns about a default branch that requires no pull request. It reads rulesets with the installation’s own access. Reading classic branch protection needs the Administration permission, which the App does not ask for. So for a branch protected the classic way, doctor cannot tell whether reviews are required, and says so instead of warning. A ruleset is readable either way.

What the gateway records

For every token it issues, the gateway records which claim, repository, access and key it was for, and the permissions it asked GitHub for. It also writes a Repository Token Issued event to the organization audit log. It never stores the token.