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

# Runner commands

> Start, stop and inspect the loop that takes work on this machine.

`outerlayer runner` claims queued work items and builds them on this machine.
The guide is [Run work on your own machines](/run-work-on-your-machines).

Every subcommand takes `--config <path>` (default `~/.outerlayer/config.json`),
the config file whose `runner` block it reads. A command that fails prints one
line on standard error and exits 1.

## outerlayer runner start

Runs the runner until it is stopped.

```bash theme={"system"}
outerlayer runner start [--config <path>]
```

On start it sends again any release an earlier attempt could not
land. A pid file left by a crashed runner is overwritten. The new runner
adopts a left-behind build whose command still runs, and releases one whose
command is gone with outcome `interrupted`.

It exits 1, naming the problem, when:

* the config file is not valid JSON, or lacks `url`, `apiKey` or `appId`
  (pipe a factory key into `outerlayer login` for it first);
* the `runner` block is absent, a queue it enables has no command, or a
  required hook key is missing;
* `runner.queues` names a queue other than `implement` or `amend`;
* a key inside `runner`, `runner.build`, `runner.housekeeping` or
  `runner.checks` is unknown or out of range;
* a hook does not exist, is not a file, or is not executable;
  `builtin:container`, `builtin:process` and `builtin:vm` must be given for
  both the provision and the cleanup hook or for neither;
* a destination's header names a variable the runner's environment does not
  hold;
* another runner is running on the same config (the message names its pid);
* the gateway refuses the API key (401 or 403).

A gateway that does not answer is not a refusal: the runner logs `the gateway
did not answer at start` and starts anyway. A gateway that fails later does
not end the runner either: it logs the failure and retries on the next tick.
See [Troubleshoot
builds](/troubleshoot-builds).

When the gateway names a higher CLI version and no build is running, `start`
installs it, switches to it, and exits with status 75 so the service starts
it again. See [Upgrading the runner](#upgrading-the-runner).

The built-in hooks are described in [Container builds](/container-builds),
[MicroVM builds](/container-builds#microvm-builds) and [The process
fallback](/write-your-own-hooks#the-process-fallback). Their `runner.build`
limits and defaults are in the [config file reference](/reference/config-file).

## outerlayer runner stop

Stops the runner on this config.

```bash theme={"system"}
outerlayer runner stop [--now] [--config <path>]
```

| Flag | Default | What it does |
| - | - | - |
| `--now` | off | Sends `SIGUSR1`: running builds end now with outcome `stopped`, their cleanup hooks still run, and their claims are released. |

Plain `stop` sends `SIGTERM` and drains: the runner takes no more work and
waits for running builds, up to `runner.timeLimitMinutes`. It prints `sent
SIGTERM to runner pid <pid>, draining`, then `waiting on #<item> (<stage>, <elapsed>)` every five seconds, then `runner exited`. With no runner it prints
`no runner is running on this config`. A stale pid file is removed. Exits 0 in
every case.

Ctrl-C on a foreground runner drains; a second Ctrl-C stops it now.

## outerlayer runner status

Shows the runner's state and its builds, read from the job files.

```bash theme={"system"}
outerlayer runner status [--recent] [--json] [--config <path>]
```

| Flag | Default | What it does |
| - | - | - |
| `--recent` | off | Also list finished builds, newest first, up to 20. |
| `--json` | off | Print the runner's state and every job file as JSON, without the item key. |

```text theme={"system"}
runner ci-3 running, pid 4242 · config /etc/outerlayer/config.json · 1 of 1 slots in use

  ITEM  QUEUE      STAGE    ELAPSED  STARTED    LEASE          JOB DIR
  #3    implement  command  1m 11s   14:46 UTC  renews 4m 00s  jobs/implement-3-c39386da-5b41-4f…

  ITEM  QUEUE      OUTCOME      ENDED      DURATION
  #2    implement  ok           13:53 UTC  2m 39s
```

| Value | Meaning |
| - | - |
| `STAGE` | `starting`, `provision`, `command`, `checks`, `sync`, `cleanup` or `report`. |
| `not running (pid file absent)`, `not running (stale pid file for pid <pid>)` | No runner serves the config. Its unfinished builds read `orphaned`. |
| `not attempted` | The provision hook exited 75 and the claim was released with no outcome. |
| `(release failed)` | The release never landed. |
| `(already released)` | Somebody released the claim first. The platform keeps the outcome stored first, and the runner does not retry. |

On a Linux host with a systemd unit for the runner, a line under the header
gives the service's state:

```text theme={"system"}
service outerlayer-runner running
```

It reads `stopped` when the service's main process is gone. It ends with
`not delegated: microVM builds have no memory limit of their own` when the
unit does not delegate its cgroup. `--json` carries the same facts as
`service`.

When housekeeping has run, a line under the header says when it last ran and
the space it freed. While the runner holds off new work for want of disk, a
second line gives the free and the needed space:

```text theme={"system"}
housekeeping last ran 12m 04s ago (14:34 UTC), freed 3.2 GB
holding off new work for disk: 2.0 GB free, 40.0 GB needed (since 14:40 UTC)
```

`--json` carries the same facts as `housekeeping`.

`status` still answers when the config no longer validates.

## outerlayer runner check

Checks the config, hooks and credentials without taking work.

```bash theme={"system"}
outerlayer runner check [--config <path>]
```

It writes no pid file, so it is safe beside a running runner. It prints the
settings the runner would use, and exits 1 when:

* the config or a hook fails a check `start` runs;
* an enabled queue's command is still the placeholder `runner init` wrote;
* built-in hooks find neither `CLAUDE_CODE_OAUTH_TOKEN` nor
  `ANTHROPIC_API_KEY`, in the shell or in the `runner.env` beside the
  config. When the credential comes from that file, the output names it;
* a `claude` command's `--permission-mode` is not one Claude Code accepts:
  `acceptEdits`, `auto`, `bypassPermissions`, `default`, `dontAsk`,
  `manual` or `plan`. The message names the command and the value;
* the gateway refuses the key, or the key lacks `work.claim`.

A hook must be an executable path, not a shell line: nothing expands `$HOME`
or `~`. A config file that is not valid JSON is reported as that, with the
parser's message. Any other failed gateway call prints `credentials
unverified` and exits 0. The settings it prints include `auto update`, `on`
or `off`, from `runner.autoUpdate`.

## outerlayer runner install

Moves a host onto the install layout a runner needs to update itself. With
`--service`, it also sets up the service that keeps the runner running.

```bash theme={"system"}
outerlayer runner install [--service [--print] [--user <account>] [--replace]] [--allow-downgrade] [--config <path>]
```

Without `--service`, it copies the CLI that is running into
`<config dir>/runner/versions/<version>/`, points `current` at it, writes the
launcher `~/.local/bin/outerlayer`, and prints the `ExecStart=` line that
starts the runner from `current`. It works offline. It exits 1, changing nothing, when the CLI is not installed in a
`node_modules` directory, as in a source checkout. Running it again re-copies
the same version.

When it moves `current` to a different version, it prints that the service
restarts on the new version once its running builds finish. It restarts
nothing itself. A runner its service started through `current` sees the
switch on its next poll, takes no new work, waits for its builds, and exits
with status 75, so the service starts the new version. A runner started by
hand keeps running and logs that a restart picks the new version up. See
[Upgrade by hand](/run-a-host-as-a-service#upgrade-by-hand).

It also exits 1, changing nothing, when the running CLI is older than the
version `current` names. That happens when a runner has updated itself past the
CLI you installed with npm. The message names both versions and the launcher
command to run instead: `~/.local/bin/outerlayer runner install`, which runs
the version in `current`. A same or newer version installs as before.
`runner init` leaves such a layout alone and sets up no service.

With `--service`, it does that and then sets up one systemd unit, on Linux,
inside the Mac's VM and inside WSL2, with a Windows scheduled task on WSL2.
See [What the service sets](/run-a-host-as-a-service#what-the-service-sets).
`runner init` runs it for a new host.

| Flag | What it does |
| - | - |
| `--service` | Write, enable and start the unit. With an unchanged config it changes nothing and says the service is current. |
| `--print` | With `--service`, write the unit to stdout and set up nothing. It works where setup refuses: a host without systemd, and hooks that are your own executables. |
| `--user <account>` | With `--service`, the account the service runs as. The default is the account running the command. |
| `--unit-name <name>` | With `--service`, the unit's name. The default is `outerlayer-runner`. |
| `--replace` | Replace a launcher file at `~/.local/bin/outerlayer`, and with `--service` a unit at its path, that this command did not write. Without it, the command leaves the file alone and says so. |
| `--allow-downgrade` | Install the running CLI even when it is older than the version `current` names, moving the runner back to it. |

The launcher runs the CLI that `<runner dir>/current` names, so a self-update
changes what it runs and the file stays as it is. When `~/.local/bin` is not on
`PATH`, or another `outerlayer` comes first, the command prints the line
`export PATH="$HOME/.local/bin:$PATH"`. It never edits a shell startup file.
See [The outerlayer command](/run-a-host-as-a-service#the-outerlayer-command).

It exits 1, setting nothing up, and names the reason when the host has no
systemd, when the hooks are your own executables, or when `concurrency` times
`build.memory` does not fit in the memory the runner can use. In the last case
it names both figures.

## outerlayer runner uninstall

Removes what `runner install --service` set up.

```bash theme={"system"}
outerlayer runner uninstall --service [--config <path>]
```

It stops and disables the unit, removes the unit file and reloads systemd. On
a Mac it also removes the launch agent, and on WSL2 the scheduled task. It
leaves a unit it did not write alone.

## outerlayer runner init

Adds a `runner` block to an existing config file and makes the host key.

```bash theme={"system"}
outerlayer runner init [--vm | --no-vm] [--vm-cli-package <path>] [--allow-process-builds] [--unit-name <name>] [--config <path>]
```

| Flag | Default | What it does |
| - | - | - |
| `--vm` | asked on a terminal | macOS only. Run the runner in a Lima Linux VM, with container builds. Refused on any other machine. |
| `--no-vm` | asked on a terminal | macOS only. No VM; builds run as unisolated processes under this user. |
| `--vm-cli-package <path>` | the published version | With `--vm`, install this packed CLI tarball (from `npm pack`) in the VM instead of the version from the registry. Use it to try a build that is not published. A runner on an unpublished build cannot verify its own updates. |
| `--allow-process-builds` | off | Where builds cannot be isolated, write `builtin:process` instead of refusing. |
| `--unit-name <name>` | `outerlayer-runner` | The name of the service this command sets up. Give a second runner on one machine its own name. Applies without `--vm` only: the VM's service is always `outerlayer-runner`. |

It keeps every other key, writes placeholder command lines and no hook script,
and prints the host key's fingerprint. It ends by running
`outerlayer runner install --service`. A refusal there is a note and the
command still exits 0, because the config it wrote is useful. The hooks it
names:

| Machine | Hooks |
| - | - |
| x86\_64 Linux, not WSL2, with read and write access to `/dev/kvm`, and Docker Engine 26 or later | `builtin:vm` |
| Linux or WSL2 with Docker Engine 26 or later, where `builtin:vm` does not fit | `builtin:container` |
| Anything else, with `--allow-process-builds` | `builtin:process` |

`builtin:vm` and `builtin:container` also need Docker's Buildx plugin 0.18 or
later for the runner's image builder. `init` does not check it. Until it works,
the runner claims nothing. See [When the runner claims
nothing](/run-a-host-as-a-service#when-the-runner-claims-nothing).

It writes nothing and exits 1 when:

* there is no config file (run `outerlayer login` first), or it is not valid
  JSON;
* a `runner` block exists;
* the machine cannot isolate a build and `--allow-process-builds` is absent
  (Docker Desktop alone is not enough);
* on macOS, neither `--vm` nor `--no-vm` is given and there is no terminal
  (the refusal names `--vm`);
* `--vm` is given and `limactl` is missing.

Answering no to the macOS question is the same as `--no-vm`.

`outerlayer doctor` on a runner host reports the isolation on its `Build
isolation` line, the variable holding Claude's credential on its `Claude
credential` line, and whether the runner key is bound to this host's key on
its `Host key binding` line. See [Run builds on
macOS](/run-builds-on-macos) and [Run builds on
Windows](/run-builds-on-windows).

## outerlayer runner image

Builds the image a repository's builds run in, from the devcontainer file on
its default branch.

```bash theme={"system"}
outerlayer runner image --repository <owner/name> [--remote <url>] [--json] [--config <path>]
```

| Flag | Default | What it does |
| - | - | - |
| `--repository <owner/name>` | required | The repository to build the image for. |
| `--remote <url>` | `https://github.com/<owner>/<name>.git` | Where to fetch from, with this host's git credentials. |
| `--json` | off | Print the result, or the error, as JSON. |

The image builds in the runner's own builder at the `runner.build.memory` of the runner config, or `8g` when there is none. See [Image builds](/run-a-host-as-a-service#image-builds).

A private repository needs a git login on this host. Exits 0 when the image
is built or reused. Otherwise it exits 1 with a code:

| Code | Meaning |
| - | - |
| `repository_unreadable` | The fetch failed. The message gives git's reason. |
| `recipe_needs_docker` | The recipe uses `dockerComposeFile`. |
| `recipe_ambiguous` | The repository has only named configurations. |
| `recipe_context_outside` | The recipe's `build` reads files outside `.devcontainer/`, or `.devcontainer` is a symbolic link. |
| `default_branch_unknown` | The remote names no default branch. |
| `devcontainer_cli_missing` | The devcontainer CLI is not installed beside this CLI. |
| `build_in_progress` | Another image build for the repository is running. |
| `build_failed` | A build exited non-zero. The message names the log. |
| `image_build_out_of_memory` | The kernel stopped a build step at the limit of `runner.build.memory`. See [When an image build runs out of memory](/troubleshoot-builds#when-an-image-build-runs-out-of-memory). |
| `image_builder_unavailable` | Buildx cannot make the runner's image builder. See [When the Image build limit check fails](/troubleshoot-builds#when-the-image-build-limit-check-fails). |
| `invalid_repository` | The value is not `owner/name`. |

See [Build a repository's image from its devcontainer
file](/build-image-from-devcontainer).

## outerlayer runner logs

Prints the newest build's log for one item: the command's output, or a
running hook's.

```bash theme={"system"}
outerlayer runner logs <item> [-f] [--config <path>]
```

| Flag | Default | What it does |
| - | - | - |
| `-f`, `--follow` | off | Keep printing as the log grows. |

Exits 1 when `<item>` is not a number, no build on this config worked the
item, or the log does not exist yet.

## 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 host runs itself.
See [Policy and validators](/policy-and-validators#checks-that-run-on-your-host)
for the file format. The runner runs them in the `checks` stage of finishing a
build, only when the build's command exited 0. The stage comes before the sync,
so `outerlayer runner status` and `runner logs` show `checks` while it runs.
`builtin:container` and `builtin:vm` builds run their checks in a new
environment. For a `builtin:vm` build, the runner uploads the build's sessions
and stops the build's microVM first, so a host never runs two microVMs for one
build slot.

For each build the runner:

1. Reads the definitions from the repository that governs the item's, or from
   the repository at the commit the image's recipe was read from. It never
   reads them from the build's checkout.
2. Starts a new container from the build's image, or for a `builtin:vm` build a
   new microVM booted from the build's root disk, with the same limits, tunnel
   destinations and `allowHosts` as the build. It holds no OuterLayer key, no
   gateway address and no model credential. Its git remote serves fetches
   only.
3. Checks out the branch the claim names, and runs the recipe's lifecycle
   commands again.
4. Writes the folder `OUTERLAYER_EVAL_INPUT` names: the work item, the branch's
   diff and the build's sessions, which it copied out of the build's own
   environment before that environment was stopped or removed. The folder is
   read-only to the commands. See
   [what a host check can read](/policy-and-validators#what-a-host-check-can-read).
5. Runs each command, one at a time, and records its result with the runner's
   own key.
6. Removes the container, or stops the microVM and deletes its build disk.

The gateway stores each result with the recorder `host` and the host name
from the claim. It decides this from the runner key and the item's live
claim, and a request cannot choose it. The runner key needs no permission
beyond the four [below](#permissions). `POST /v1/emitted-results` accepts
`work.claim` from the key that holds the item's claim, and refuses any other
key.

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. |
| `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.

While the stage runs, the runner keeps renewing the claim's lease, so checks may
run longer than one lease. Each command's output is in
`checks/<name>.log` in the job directory, and the environment's own steps are in
`checks/environment.log`.

| Key | Default | Meaning |
| - | - | - |
| `runner.checks.timeLimitMinutes` | 10 | The longest the checks' environment may take to set up (container or microVM start, clone and lifecycle commands, together), and the longest one check command may run. Runs from 1 to 240 minutes. A command past it is ended with its container or microVM and recorded as a fail. A setup past it records every check as a fail. |

Checks cost one more run of the recipe's setup commands for each build. A
`builtin:vm` build also boots a second microVM, after the build's own has
stopped.

## The branch a claim names

Every claim names the branch its build works on, passed to the build as
`OUTERLAYER_BRANCH`:

* **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 the item's 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 workdir that is not a git
checkout is left as it is. 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).

### 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, or one outside the item's repository. Close the extras and ask again. |
| 409 | `branch_claimed_elsewhere` | Another item's live claim names the branch. Ask again when it ends. |
| 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. 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. |
| 422 | `work_item_has_no_repository`, `repository_not_connected`, `repository_not_in_installation` | The item's 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. |

## Gateway contract

These are the calls between the runner and the gateway. The [runner
protocol](#runner-protocol) numbers their version.

### Heartbeat

After every poll the runner sends `POST /v1/hosts/heartbeat` with:

* `host`: the `runner.host` name, which defaults to this machine's hostname.
* `cliVersion`: the CLI version the runner is running.
* `runnerProtocol`: the [runner protocol](#runner-protocol).
* `pollSeconds`: the poll interval.
* `slotsUsed` and `slotsTotal`: the slots in use and the total.
* `workRequest`: the result of this poll's own request for work. That is
  success, or the error code and message. Left out when the poll made no
  request.
* `lastUpdate`: the runner's 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`, when it ended. Left out until
  the runner has made an update.
* `lowDisk`: the free and needed bytes, sent only while the runner holds off for disk,
  meaning it claims no new work. Left out otherwise, which clears what the
  gateway stored. See [Housekeeping](/run-a-host-as-a-service#housekeeping).
* `blocked`: the `reason`, sent only while the runner claims nothing because
  a check of its own failed. At most 500 characters. Left out otherwise,
  which clears what the gateway stored. The causes are in [When the runner
  claims nothing](/run-a-host-as-a-service#when-the-runner-claims-nothing).

The reply carries `runnerVersion`: the `@outerlayer/cli` version the gateway was built from.
The runner moves itself to it when it is higher.

### Host status

`GET /v1/hosts` (needs `hosts.read`) lists each host with those fields and:

* the last heartbeat time;
* the last successful request for work;
* the last error, with its code, message and time;
* `lastUpdate`: the last self-update, with the versions, outcome, reason and
  time above, or `null` before the host reports one;
* `lowDisk`: the free and needed bytes while the host holds off for disk, or `null`.
  The status does not change: a host that holds off for disk can still be `ok`;
* `blocked`: the reason while the host claims nothing because a check of its
  own failed, or `null`. A host with `blocked` set is not taking work. The
  status does not change here either;
* a status.

The status is the first of these that holds:

* **`stale`**: the last heartbeat is older than three times the host's
  `pollSeconds`.
* **`outdated`**: the last heartbeat carried a runner protocol below the
  gateway's minimum, or none. See [Upgrading the runner](#upgrading-the-runner).
* **`failing`**: the last request for work failed and no later success has
  cleared it.
* **`ok`**: none of the above.

### Runner protocol

A whole number compiled into the CLI, sent on every claim and heartbeat. It
is not the package version. The gateway's minimum is 7. A claim below it is
refused with `426` and `runner_upgrade_required`; other calls, the heartbeat
included, are still accepted.

### Upgrading the runner

A runner started from its install layout updates itself to the gateway's
`runnerVersion`. A host that cannot update itself, because it has no install
layout, moves onto it with `outerlayer runner install`. How updates work and
their outcomes are in [Keep the runner up to
date](/run-a-host-as-a-service#keep-the-runner-up-to-date).

A host whose status is `outdated` keeps polling but takes no work. Its log
says, and repeats every tenth poll:

```text theme={"system"}
this runner speaks protocol 2 and the gateway needs 7 or newer — upgrade the runner: https://docs.outerlayer.ai/reference/cli-runner#upgrading-the-runner
```

To upgrade a host by hand, with `runner.autoUpdate` off:

1. Install a current CLI the way you installed the first, such as
   `npm install -g @outerlayer/cli`.
2. Run `runner install --service` from the CLI you just installed, such as
   `"$(npm prefix -g)/bin/outerlayer" runner install --service`. The
   `outerlayer` launcher in `~/.local/bin` runs the old version, so do not
   use it here. The service starts the runner from `runner/current`, and a
   new npm install changes nothing until this command copies it into the
   layout and points `current` at it.
3. Wait. The runner sees the new `current` on its next poll, takes no new
   work, waits for its running builds to finish, and exits so its service
   starts the new version. No `sudo systemctl restart` is needed. See
   [Upgrade by hand](/run-a-host-as-a-service#upgrade-by-hand).

A runner without the install layout or a service: stop it with
`outerlayer runner stop`, install the CLI, and run `outerlayer runner start`.

After the restart its status returns to `ok` and it takes work again.

To pause or hold updates for every host of a factory, see [Pausing or holding updates for a factory](/run-a-host-as-a-service#pausing-or-holding-updates-for-a-factory).

## Paths

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

| Path | What it holds |
| - | - |
| `runner/runner.pid` | The running runner's pid and start time. |
| `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/update-marker.json` | Present from a switch until the new version passes its checks. |
| `runner/last-update.json` | The last update the heartbeat reports. |
| `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>/` | Sockets for the tunnel and destinations, and a container build's `gh/hosts.yml` with its read token. Mounted read-only into the build. |
| `runner/images/` | A record per built image. |
| `runner/repos/` | The cached checkout of each repository's default branch. |
| `runner/mirrors/` | One bare mirror per repository. Never removed. |
| `runner/spool/` | Push bodies being checked. |
| `runner/housekeeping.json` | When housekeeping last ran, what it freed, and whether the runner holds off for disk. `status` reads it. |
| `runner/context-home/` | The cached context source and its record, for container and microVM builds of a governed repository. |
| `runner/vm/` | MicroVM builds' guest assets (`assets/`), root disks made from images (`rootfs/`) and disk templates (`templates/`). |

The service also reads `runner.env` beside the config file, not under
`runner/`. It holds the runner's Claude credential, `CLAUDE_CODE_OAUTH_TOKEN`
or `ANTHROPIC_API_KEY`, and is read only when the service starts.

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, [item key](#item-key), [branch](#the-branch-a-claim-names), stage, workdir, command pid, outcome, exit status, and any artifacts left unsynced. |
| `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. 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 [session sender](#item-key)'s output, 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. |

## 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, so
`runner.timeLimitMinutes` is capped at 1320. It can read the item, its
threads, checks and changes; link and sync sessions; post comments; declare,
open or edit the pull request; and emit artifacts, results and findings. See [Build
security](/build-security).

While the build runs, the runner also starts a session sender beside the
command, in the same place: the microVM's guest, the container, the host's
`exec`, or a child of the runner. The build's sessions stream to the work item
while the build runs, so the item shows the session and its cost so far within
a minute of each turn. The sender sends under the item key and only sessions
this build started. It writes to `sender.log` in the job directory, and a
sender that fails never changes the attempt's outcome.

Before releasing, the runner stops the sender and 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: a session streamed during the
build ends with the same spans and cost as one uploaded once. Turns written
after the sender's last acknowledged upload of a killed build 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.

## Permissions

A runner's key needs four:

| Permission | Why |
| - | - |
| `git.read` | Prerequisite of `work.read`. |
| `work.read` | Every tick lists work items. |
| `work.claim` | Taking, renewing and releasing a claim. |
| `hosts.ingest` | Sending the heartbeat. |

A key with only `work.claim` fails on the first tick. `work.claim` is on no
dashboard role by default.

A key without `hosts.ingest` still claims work, but its heartbeat is refused
with 403 and the runner keeps polling. The runner logs the refusal once, then again every tenth poll
while it continues, and the host is missing from `GET /v1/hosts`. Keep the
runner's config file out of a build's reach.


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