Skip to main content
This page is the lookup for what happens around a runner build’s command. The commands that start and inspect the runner are in Runner commands. The guide is Run work on your own machines.

When the command exits 0

An exit of 0 says only that the agent’s session ended normally. How the runner then decides between ok and incomplete is in When the command exits 0. A build whose command exits 0 but that opens no pull request is released incomplete. A runner build opens its pull request with outerlayer work open-pr.

Host checks

A repository’s where: host validators declare commands the runner runs itself. See Policy and validators for the file format. The runner runs them in the checks stage, only when the build’s command exited 0. outerlayer runner status and runner logs show checks while it runs. Only builtin:container and builtin:vm builds run host checks. Each build’s checks run in a new container or microVM made from the build’s image, with the build’s limits, tunnel destinations and allowHosts. That environment holds no OuterLayer key, no gateway address and no model credential. Its git remote serves fetches only. The runner reads the check definitions from the repository that governs the item, never from the build’s checkout. So a build cannot change its own checks. It checks out the claim’s branch, runs the recipe’s lifecycle commands again, and then runs each command, one at a time. A command can use two paths:
  • OUTERLAYER_EVAL_INPUT names a read-only folder with the work item, the branch’s diff and the build’s sessions. See what a host check can read.
  • OUTERLAYER_CHECK_ITEMS names a file the command can write check items to. The runner records them with the result.
The runner records each result with its own key, with the recorder host and the runner’s name. A build cannot record a host result. A check is recorded as a fail, with a one-line reason, when: If the definitions cannot be read, the runner records nothing and logs why. The row then reads waiting while the claim is live and failed once it is released. Each command’s output is in checks/<name>.log in the job directory, and the environment’s setup is in checks/environment.log. runner.checks.timeLimitMinutes limits the setup and each check command. See the config file. Checks run the recipe’s setup commands once more for each build, and a builtin:vm build boots a second microVM.

The judge stage

A where: host check that declares a judge is run by the judge stage, not by a command. The stage runs after a build ends ok and before the host checks. outerlayer runner status and runner logs show judge while it runs. The judge is a fresh Claude Code agent with the check’s brief and no shell. For each criterion it reads the criterion’s text, its declared proof and each test file that cites it. It reads the tests from the pushed commit, never from the build’s checkout. A file the build changed and did not push is not what it judges. The runner records the result with its own key under the recorder host, with one check item per criterion and a report as its proof. Some criteria are settled without the judge:
  • A criterion with an artifact proof is skipped.
  • A criterion with no proof and no citing test fails.
  • A criterion keeps its last result when its text, its proof, its citing tests and the brief did not change. A change to a helper file that a citing test imports does not count, so a person still reviews such a change.
A judge with subjects: none has no criteria, and judges the whole change every time. The stage needs the built-in Claude relay and a host credential. The judge runs inside the build’s image, so the image must hold Claude Code.
The judge runs in the build’s own environment, not a fresh one. A build that can become root, through sudo or Docker, can replace programs the judge relies on. Treat the judge’s verdict on such a build as the build’s own.
The check is recorded as a fail, with a one-line reason, when: If the runner cannot read the repository’s definitions, it logs judge not run. It then records a fail on every row whose newest host result is a pass, with the reason the definitions could not be read: <why>. A row no runner has recorded keeps the result CI or a session gave it.

The branch a claim names

A claim names every repository the factory connected, with the branch its build works on in each. The item’s own repository is the primary. The build gets the primary’s branch as OUTERLAYER_BRANCH, and all of them as OUTERLAYER_REPOSITORIES (see The repositories in a build). In each repository:
  • No open pull request: outerlayer/<factory>/<item number>. The factory name is lowercased and cut to letters, digits, ., _ and -; if that changes it, 8 hex characters of a hash are added. acme item 7 builds on outerlayer/acme/7; Acme Prod on outerlayer/acme-prod-<hash>/7.
  • One open pull request in that repository: its head branch.
  • Anything else is refused.
An existing outerlayer/… branch that is not merged is continued; a missing or merged one starts from the default branch. Any other branch must exist on the remote, or the build fails at provision. The runner checks the branch out before the command starts, in a container build too, and logs on <branch> at <commit>, or why it could not. A git worktree whose branch another worktree has checked out fails the build, so have a custom hook create a detached worktree. A container build may push only that branch. See The git remote and the build’s tokens. A repository other than the primary whose GitHub App installation is suspended is left out of the claim. The runner logs one line for it and builds in the repositories it was given.

Refusals

A refused claim records a failed build with the code as its reason, uses up the build request, and logs #<item> skipped: <code>. The log line the gateway named no branch means the gateway is too old. Upgrade it.

Opening the pull request

outerlayer work open-pr opens the pull request from the claim’s branch into the default branch, or edits the one already open. A host build opens its pull request as the factory’s GitHub App, not with the GitHub login on the host. It calls OuterLayer with its item key. OuterLayer uses a GitHub token limited to the item’s repository, with pull requests write and contents read, and revokes it when the call ends. The build never receives that token. In your own session, work open-pr runs gh under your login instead. Its refusals:

Item key

A build never holds the runner’s key. It gets an item key (olitem_…) as OUTERLAYER_API_KEY; a build under a built-in hook gets a placeholder and the runner adds the key to its gateway calls. The key acts only on its own item and stops working when the claim ends, at most 24 hours after it began. For this reason, runner.timeLimitMinutes is capped at 1320. It can read the item, its threads, checks and changes. It can link and sync sessions and post comments. It can declare, open or edit the pull request, and emit artifacts, results and findings. See Build security. The build’s sessions stream to the work item while the build runs. The item shows each session and its cost so far within a minute of each turn. Only sessions this build started are sent. The streaming output is in sender.log in the job directory, and a streaming failure never changes the attempt’s outcome. Before it releases the claim, the runner runs outerlayer sync under the item key, so the build’s sessions upload, a container build’s included. The end-of-attempt sync stays the complete upload. When a build is killed, the turns written after the last streamed upload are lost. The log line the gateway returned no item key means the claim was released unstarted.

Repository tokens

The gateway issues a build short-lived GitHub tokens through the factory’s GitHub App. The permissions the App holds are in GitHub App permissions and build tokens.

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. outerlayer work build with a key that fails the member test still records the build request, because a runner with hooks of its own needs no tokens. It 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 that the gateway recorded. 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. The exception never widens the member rule for any other build. No API caller can create a merge conflict 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

Each token names one repository the claim names, and comes from the GitHub App installation that covers it. It lasts as long as GitHub says, about an hour. The runner also asks for a contents-read token on the repository that governs the item’s repository, to fetch the build’s context on the host. With no_control_plane, it builds without a context. With github_request_failed, it ends the attempt as failed. It releases the claim to try again only for github_unavailable.

When a token is refused

Paths

The runner’s files live in <config dir>/runner/, by default ~/.outerlayer/runner/, owner-only. The service also reads two files beside the config file, only when it starts:
  • claude.env holds the runner’s Claude credential, CLAUDE_CODE_OAUTH_TOKEN or ANTHROPIC_API_KEY.
  • runner.env holds destination secrets and settings.
Two factories on one machine need two config files and two runners. See Several factories, one machine. Inside each job directory:

Host status

GET /v1/hosts (needs hosts.read) lists each runner that sends heartbeats. A runner sends one after every poll. Each entry holds: The status is the first of these that holds:
  • stale: the last heartbeat is older than three times the runner’s pollSeconds.
  • draining: the last heartbeat said state is draining. A draining runner keeps heartbeating until its builds end, so it reads draining and not stale.
  • outdated: the last heartbeat carried a runner protocol below the gateway’s minimum, or none. See Upgrading the runner.
  • failing: the last request for work failed and no later success has cleared it.
  • ok: none of the above.
A runner that holds off for disk, or is blocked, keeps its status. It is not taking work. When the gateway runs a newer CLI version than a runner, the runner moves itself to that version. When someone stops a build with outerlayer work stop, the runner ends it after its next heartbeat. It syncs the session, runs cleanup and releases the claim as stopped.

Runner protocol

A whole number compiled into the CLI. It is not the package version. The gateway’s minimum is 7. A runner below it keeps polling but takes no work, and its status is outdated.
  • A runner below protocol 16 cannot build in more than one repository. In a factory with more than one connected repository, its claim is refused with runner_protocol_too_old.
  • A runner below protocol 21 cannot be stopped through OuterLayer and reports no state.

Mixed versions

When the runner and the gateway are on different versions, a build whose command exits 0 can be released ok without its verdict. Upgrade both to the same release.