> ## Documentation Index
> Fetch the complete documentation index at: https://docs-stg.outerlayer.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Build reference

> What the runner does around a build's command: host checks, the judge, branches, keys, tokens, files and host status.

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](/reference/cli-runner). The guide is [Run work on your own machines](/run-work-on-your-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](/run-work-on-your-machines#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](/write-a-validator#checks-that-run-on-your-host)
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](/write-a-validator#what-a-host-check-can-read).
* `OUTERLAYER_CHECK_ITEMS` names a file the command can write
  [check items](/policy-and-validators#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:

| Reason | Cause |
| - | - |
| `exited <status>: <last line>` | The command exited non-zero. The last line of its output is scrubbed like a release reason. |
| `<n> of <m> check items not satisfied: <ids>` | The command exited 0, and its check items file holds a failing check item. |
| `its check items file (OUTERLAYER_CHECK_ITEMS) is not JSON` | The command wrote a check items file that does not parse. |
| `its check items file does not match the check item shape: <field>: <why>` | A check item in the file has a missing or bad field. |
| `its check items file is longer than 1048576 bytes` | The check items file is over 1 MiB. |
| `ran out of time after <n> minutes` | The command ran past `runner.checks.timeLimitMinutes`. |
| `the check environment setup ran out of time after <n> minutes` | Starting the container or microVM, the clone and the lifecycle commands together ran past `runner.checks.timeLimitMinutes`. |
| `the check environment could not be prepared` | The clone or a lifecycle command failed in the new container or microVM. |
| `the host has no clean environment for checks` | The build ran under `builtin:process` or a provision hook of your own. Only `builtin:container` and `builtin:vm` builds have a new environment to run checks in. |
| `the build pushed no branch` | The claim's branch does not exist on the repository. |

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](/reference/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`](/write-a-validator#a-judge-on-your-host) 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.

<Warning>
  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.
</Warning>

The check is recorded as a fail, with a one-line reason, when:

| Reason | Cause |
| - | - |
| `the brief <path> is not in the definitions repository` | The brief the check names does not exist in the definitions repository. |
| `the image has no Claude Code for the judge` | The build's image does not contain Claude Code. The reason also reads `: this build ran outside a container or microVM` for a build with no image, or ends with why the program could not be hashed. |
| `the build's repository tokens are no longer held, so the branch cannot be fetched` | The runner no longer holds the tokens it needs to fetch the build's commits. |
| `the host holds no Claude credential for the judge` | The host has no Claude credential to give the judge. |
| `the build pushed no branch: the claim names none` | The claim named no branch for the build. |
| `the build pushed no branch: <branch> does not exist on <repository>` | The branch is absent from the remote. |
| `the branch <branch> could not be read: <why>` | Reading the branch from the remote failed. |
| `the build's processes could not be stopped: <why>` | The runner could not stop the build's processes before the judge started. |
| `the judge program changed during the build` | The Claude Code program differs from the one the build started with. |
| `the item's criteria could not be read: <why>` | The gateway request for the item's criteria failed. |
| `no criteria are recorded for the item` | The work item holds no recorded criteria. |
| `the build's tests could not be fetched: <why>` | Fetching the build's commits failed. |
| `the build's diff could not be read: <why>` | The runner could not make the branch's diff. |
| `the judge could not be run: <why>` | Starting the judge failed. |
| `the judge ran out of time after <n> minutes (runner.checks.timeLimitMinutes)` | The judge ran past `runner.checks.timeLimitMinutes`. |
| `the judge exited <status> and printed no result` | The judge ended without readable output. The first line of its error output follows after a colon when there is one. |
| `the judge reached the turn limit of 60 turns before it gave a result` | The judge used 60 turns without a result. |
| ``the judge gave no `result:` line`` | The judge ended without a final `result:` line. |
| `the judge's items.json is not JSON` | The judge wrote an answer that does not parse. |
| `the judge's items.json does not match its shape: <field>: <why>` | The judge's answer has a missing or bad field. |
| `<n> of <m> criteria not satisfied: <ids>` | `<n>` criteria failed: the judge found them not proven, gave no result for them, or they have no proof and no citing test. `<ids>` names every one, in the criteria's order. Each check item's reason says why. |
| `<n> of <m> check items not satisfied: <ids>` | A `subjects: none` judge failed one or more of the parts it judged. |
| `the judge failed the change` | A `subjects: none` judge ended with `result: fail` and failed no check item. |

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](/container-builds#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](/container-builds#the-git-remote-and-the-builds-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>`.

| Status | Code | Fix |
| - | - | - |
| 422 | `pull_request_from_fork` | The open pull request's head is in another repository. Close it or use a branch of the repository, then ask again. |
| 422 | `pull_request_on_default_branch` | The head is the default branch. Close it and ask again. |
| 422 | `pull_request_ambiguous` | Two open pull requests in one repository. The refusal names it. Close the extras and ask again. |
| 422 | `pull_request_repository_not_connected` | An open pull request is in a repository the factory has not connected. The refusal names it. Connect the repository or close the pull request, then ask again. |
| 422 | `repository_not_reachable` | The item's own repository is in a GitHub organization whose App installation is suspended. The refusal names the repository and the organization. An owner of the organization unsuspends the App on GitHub, then ask again. |
| 409 | `branch_claimed_elsewhere` | Another item's live claim names a branch this claim would name in the same repository. The refusal names it. Ask again when that claim ends. |
| 426 | `runner_protocol_too_old` | The factory has more than one connected repository and this runner speaks a protocol below 16. The request is not used up. Upgrade the runner. |
| 503 | `github_unavailable` | GitHub was failing. No build is recorded; the runner claims again on its next poll. |

The log line `the gateway named no branch` means the gateway is too old.
Upgrade it.

### Opening the pull request

[`outerlayer work open-pr`](/reference/cli-work#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](#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:

| Status | Code | Meaning and fix |
| - | - | - |
| 400 | `invalid_request_body` | The title is empty or the body is not text. Give `--title` a value. |
| 403 | `item_key_out_of_scope` | The credential is not this item's key. Run it from the build the claim started. |
| 404 | `work_item_not_found` | No work item has that number. |
| 409 | `work_item_withdrawn` | The item was withdrawn. Stop the build. |
| 409 | `claim_has_no_branch` | The claim names no branch. Release it and claim again. |
| 409 | `repository_permissions_pending` | The installation has not accepted the App's pull request write permission. An owner accepts it, then run it again. |
| 410 | `claim_expired` | The claim is gone. Claim the item again. |
| 422 | `branch_has_no_commits` | The branch has nothing the default branch lacks. Push a commit first. |
| 422 | `branch_not_pushed` | The branch is not on the remote. Push it first. |
| 403 | `repository_not_in_claim` | `--repository` names a repository the claim does not name. Use one from `OUTERLAYER_REPOSITORIES`. |
| 422 | `work_item_has_no_repository`, `repository_not_connected`, `repository_not_in_installation` | The repository is missing, not connected, or not in the App's installation. |
| 503 | `repository_tokens_unavailable` | The gateway has no GitHub App configured. |
| 503 | `github_unavailable` | GitHub was failing. Run it again shortly. |

## 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](/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.

| Status | Code | Meaning |
| - | - | - |
| 403 | `item_key_out_of_scope` | The call is outside the item key's scope, or names another item. |
| 401 | `item_key_expired` | The claim has ended. |
| 401 | `item_key_invalid` | The key fails its signature check. |
| 503 | `item_key_unavailable` | The gateway cannot sign item keys. The claim records nothing. |

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](/github-app-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](/concepts).** 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](/review-the-work#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.

| Access | Permissions on the repository |
| - | - |
| `read` | Contents, issues, pull requests, actions, checks and commit statuses, all read only. |
| `push` | Contents write. Workflows write is added when [the repository allows it](/github-app-and-build-tokens#letting-a-build-change-workflows). |

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

| Status | Code | Meaning |
| - | - | - |
| 400 | `invalid_request_body` | The request named no valid access, or asked for a push token on the control plane. |
| 401 | `runner_key_signature_required`, `runner_key_signature_invalid`, `runner_key_signature_stale`, `runner_key_signature_replayed` | The request was not signed by the host key the runner key is bound to. See [The host key](/run-a-host-as-a-service#the-host-key). |
| 403 | `forbidden` | The key does not hold `work.claim`. |
| 403 | `claim_not_held` | The caller is not the key that holds the item's newest claim. |
| 403 | `build_request_not_from_member` | A member of the organization did not ask for this build. |
| 403 | `conflict_resolution_off` | The build answers a merge conflict, and the factory turned off **Resolve merge conflicts automatically** after the claim. |
| 403 | `repository_not_in_claim` | The request names a repository the claim does not name. |
| 404 | `work_item_not_found` | No such work item. |
| 409 | `work_item_withdrawn` | The item was withdrawn. |
| 409 | `repository_permissions_pending` | GitHub refused because the installation has not accepted a permission the token needs. The message names the organization whose owners have to accept it, because GitHub asks each organization separately. |
| 410 | `claim_expired` | The item has no live claim. It was released, or it lapsed. |
| 422 | `work_item_has_no_repository` | The item names no repository. |
| 422 | `repository_not_connected` | The factory has not connected the item's repository. See [Connect a repository](/connect-repository). |
| 422 | `repository_not_in_installation` | The installation does not include the repository. Grant it on GitHub. |
| 422 | `no_control_plane` | No control plane governs the item's repository. |
| 422 | `control_plane_not_in_installation` | No installation of the GitHub App can read the item's control plane. Grant the App access to it. |
| 502 | `github_request_failed` | GitHub refused a request for a reason other than load, such as a rejected App credential. Check the GitHub App's credentials. |
| 503 | `repository_tokens_unavailable` | This gateway has no GitHub App configured. On a self-hosted gateway, set the App's id and private key. |
| 503 | `github_unavailable` | GitHub was rate limiting or failing. Ask again shortly. |

## Paths

The runner's files live in `<config dir>/runner/`, by default
`~/.outerlayer/runner/`, owner-only.

| Path | What it holds |
| - | - |
| `runner/versions/<version>/` | Each installed CLI version. The current and the previous one are kept. |
| `runner/current` | A link to the version the service starts. |
| `runner/host-key.pem` | The host key (Ed25519, mode `0600`). Never overwritten. See [The host key](/run-a-host-as-a-service#the-host-key). |
| `runner/jobs/<dir>/` | One directory per build, named for the queue, the item, the claim id and the start stamp. |
| `runner/builds/<id>/` | The runner's copy of a container build's `gh/hosts.yml` with its read token, and of its staged files. These files are copied into `/outerlayer/job` in the container. Nothing from the runner's disk is mounted into the build. A container build reaches the runner through `docker exec`, so the folder holds no socket. A build an older runner started can still hold sockets and its token there until it ends. |
| `runner/mirrors/` | One bare mirror per repository. Never removed. |
| `runner/vm/` | MicroVM builds' guest assets, root disks and disk templates. |

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](/run-a-host-as-a-service#several-factories-one-machine).

Inside each job directory:

| File | What it holds |
| - | - |
| `checks/` | Each host check's output as `<name>.log`, and `environment.log` for the new container's or microVM's clone and lifecycle commands. |
| `job.json` | The item, queue, claim id, [branch](#the-branch-a-claim-names), stage, workdir, command pid, outcome, exit status, and any artifacts left unsynced. It holds no credential, so it is safe to share when asking for help. |
| `item-key` | The build's [item key](#item-key), readable by the runner's user alone. Never share it. |
| `provision.out` | What the provision hook reported. See [provision.out](/write-your-own-hooks#provisionout). |
| `command.log` | The command's combined output. |
| `sync.log` | The output of the `outerlayer sync` that uploads the build's sessions before release. |
| `vm-console.log` | A `builtin:vm` build's microVM console. It keeps the first 8 MiB of the console, then one line that says the runner cut it. A host check's microVM writes its own under `checks/`. |
| `provision.log`, `cleanup.log`, `report.log` | Each hook's combined output. For `builtin:container`, `provision.log` holds the clone's and each lifecycle command's output. |
| `hook-errors.log` | A container build's failed `outerlayer hook` commands. |
| `sender.log` | The output of the [session streaming](#item-key), including each upload it could not make. |
| `tunnel.json` | The hosts the build reached, refused addresses and destinations called. |
| `exit-code` | The command's exit status, used by a runner that adopted the job after a restart. Absent when the command was killed. |
| `reason` | One line on how the build went, written by the cleanup hook or the report hook; the runner deletes it before cleanup runs, and a decline (exit 75) never sends it. The runner scrubs it and cuts it to 200 characters before releasing the claim. |

## Host status

`GET /v1/hosts` (needs `hosts.read`) lists each runner that sends heartbeats.
A runner sends one after every poll. Each entry holds:

| Field | What it holds |
| - | - |
| `host` | The `runner.name` value, which defaults to the machine's hostname. |
| `cliVersion`, `runnerProtocol` | The CLI version the runner runs, and its [runner protocol](#runner-protocol). |
| `pollSeconds`, `slotsUsed`, `slotsTotal` | The poll interval, and the slots in use and in total. |
| `lastHeartbeatAt`, `lastSuccessAt` | The last heartbeat, and the last successful request for work. |
| `lastError` | The last failed request for work, with its `code`, `message` and `at`. |
| `lastUpdate` | The last self-update: `from` and `to` versions, an `outcome` of `updated`, `rolled_back`, `verification_failed`, `not_published` or `retrying`, a `reason` (`null` for `updated`) and `at`. `null` before the runner reports one. |
| `lowDisk` | The `free` and `needed` bytes while the runner holds off new work for disk, or `null`. See [Housekeeping](/run-a-host-as-a-service#housekeeping). |
| `blocked` | The `reason` while the runner claims nothing because a check of its own failed, or `null`. See [When the runner claims nothing](/troubleshoot-builds#when-the-runner-is-blocked). |
| `state` | What the runner says it is doing: `draining` after `outerlayer runner stop`, `restarting` onto an installed version, `holding-for-update`, `blocked`, `low-disk` or `taking-work`. `null` for a runner older than protocol 21. |
| `waitingItems` | The item numbers a draining runner waits on, or `null`. |
| `takes` | The `queues` the runner polls and the `repos` patterns it applies. `truncated` is `true` when the lists were cut. `null` for a runner older than protocol 21. |
| `status` | One of the statuses below. |

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](/reference/cli-runner#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.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.