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

# 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).
The [Build reference](/reference/build-reference) covers what the runner does
around a build's command. It has the host checks, the branch, the item key and
the runner's files.

Every subcommand except `container-start` 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. For a container build, it
reads the exit code from the build's volume. It releases a build whose command
is gone, and whose volume holds no exit code, with outcome `interrupted`.

It exits 1, naming the problem, when:

* the config file is not valid JSON, or lacks `url`, `apiKey` or `appId`
  (`outerlayer runner init` asks for a runner key and writes all three; see
  [Give the runner its key](/set-up-a-host#give-the-runner-its-key));
* 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` or its `build`, `housekeeping`, `checks` or
  `update` block 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, `start` installs it, switches to
it and exits with status 75, so the service starts it again. A service runner
restarts at once when every running build carries on through the restart. A
container build does, and so does a microVM build that the runner can adopt.
Otherwise it waits for the running builds to finish. A runner started by hand
waits for them too. See [When a build carries on through an
update](/run-a-host-as-a-service#when-a-build-carries-on-through-an-update).

The built-in hooks are described in [What a build gets](/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 container-start

The entrypoint of the [runner container image](/run-the-runner-in-a-container).
You do not run it by hand.

```bash theme={"system"}
outerlayer runner container-start
```

It reads `OUTERLAYER_URL`, `OUTERLAYER_APP_ID`, `OUTERLAYER_API_KEY`,
`OUTERLAYER_RUNNER_NAME` and one Claude credential from the environment.
`OUTERLAYER_AGENT` is optional and defaults to `claude`. Then it:

1. Checks that `/var/run/docker.sock` is in the container and leads to Docker
   Engine or Docker Desktop. It refuses an engine older than version 26 and
   names the version.
2. On a first start in an empty volume, writes the config as
   `outerlayer runner init --agent claude` does, with `builtin:container`
   hooks and `runner.name` set from `OUTERLAYER_RUNNER_NAME` (or from the earlier `OUTERLAYER_HOST`). It never writes the
   Claude credential to the volume.
3. On a later start, uses the config in the volume. It exits 1 when a variable
   disagrees with the config's factory, key or runner name.
4. Starts the runner in image mode.

It exits 1, naming each missing variable, when a required variable is not set.

In image mode:

* The runner never updates itself. It writes the version the factory names to
  `update-status.json` in its folder, and logs once the image tag to pull.
* `SIGTERM`, which `docker stop` sends, takes no more work, leaves every build
  running, and exits 0.
* `runner stop` sends `SIGUSR2`: the runner takes no more work and keeps
  managing its builds. It returns at once, and the container stays up.
* `runner status`, `runner status --json` and `doctor` name the tag to pull
  when the factory names a newer version or refuses the runner as too old.
  `--json` adds an `image` object with the `version` the container runs and
  an `update` with the `tag` and a `reason` of `newer` or `refused`.

## 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 | End running builds now. Sends `SIGUSR1`. |

Plain `stop` drains: the runner takes no more work and waits for its running
builds to end. A build ends `timed_out` at `runner.timeLimitMinutes`. It sends
`SIGTERM`, or `SIGUSR2` to a service runner that leaves microVM builds on a
service stop. It prints `sent <signal> to runner pid <pid>, draining`, then
`waiting on #<item> (<stage>, <elapsed>)` every five seconds, then `runner
exited`. In the runner container it sends `SIGUSR2` and returns at once. With
no runner it prints `no runner is running on this config`. A stale pid file is
removed. Exits 0 in every case.

To end running builds at once:

```bash theme={"system"}
outerlayer runner stop --now
```

Running builds end now with outcome `stopped`, their cleanup still runs, and
their claims are released.

### What ends or keeps a build

| Event | Container build | MicroVM build | Process build |
| - | - | - | - |
| `outerlayer runner stop` | The runner drains. The build runs to its end. | The runner drains. | The runner drains. |
| `outerlayer runner stop --now` | Ends now as `stopped`. | Ends now as `stopped`. | Ends now as `stopped`. |
| Ctrl-C once | The runner drains. | The runner drains. | The runner drains. |
| Ctrl-C twice | Ends now as `stopped`. | Ends now as `stopped`. | Ends now as `stopped`. |
| `systemctl stop` | The runner drains. | Keeps running when it carries on through a restart, and the next runner adopts it. Otherwise the runner drains. | The runner drains. |
| `systemctl restart` | The runner drains, then starts again. | Keeps running when it carries on through a restart, and the new runner adopts it. Otherwise the runner drains. | The runner drains, then starts again. |
| The runner crashes | Keeps running. The next runner adopts it. | Keeps running on a host with the root helper and systemd 254 or later, and the next runner adopts it. Otherwise it ends, and the next runner releases it `interrupted`. | Under a service, it ends with the runner, and the next runner releases it `interrupted`. Started by hand, it keeps running and the next runner adopts it. |
| `docker stop` of the runner container | Keeps running. The next container on the same volume adopts it. | The runner container runs container builds only. | The runner container runs container builds only. |
| `outerlayer runner stop` in the runner container | The runner takes no more work. The build runs to its end, and the container stays up. | The runner container runs container builds only. | The runner container runs container builds only. |
| `outerlayer work stop` | Ends as `stopped` after the runner's next heartbeat. | Ends as `stopped` after the runner's next heartbeat. | Ends as `stopped` after the runner's next heartbeat. |

A build that ends as `stopped` is cleaned up as with `--now`. When the runner
drains under systemd for longer than `runner.timeLimitMinutes` plus 10
minutes, systemd kills it. That is the same as a crash.

A container build keeps running when its runner restarts for an update or
crashes, and the next runner adopts it. A microVM build keeps running when its
runner restarts or crashes. This needs the root helper and systemd 254 or
later. See [A microVM build outlives its
runner](/run-a-host-as-a-service#a-microvm-build-outlives-its-runner) and
[When a build carries on through an
update](/run-a-host-as-a-service#when-a-build-carries-on-through-an-update).
How to stop and start a service runner is in [Start and stop the
runner](/run-a-host-as-a-service#start-and-stop-the-runner).

A build whose runner never comes back loses its claim. A container build stops
when its lease expires, and a microVM build powers off. A process build keeps
running until something ends it.

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

To see finished builds as well as running ones:

```bash theme={"system"}
outerlayer runner status --recent
```

This lists finished builds, newest first, under the running ones.

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

While an update is pending, a line says which version. When the update waits
for a build, a line under it names the build and why. A build is a reason to
wait when it does not carry on through a restart. The runner holds new work
for the update only when the gateway refuses its version or the update has
waited longer than [`runner.update.maxWaitHours`](/reference/config-file). Then the line also names the
builds it waits for. See
[When the runner holds work for an update](/run-a-host-as-a-service#when-the-runner-holds-work-for-an-update).

```text theme={"system"}
update to 0.10.1 pending · updating when no build is running
  #255 is running its checks
update to 0.10.1 pending · claiming nothing: waiting for #255 to finish
  #255 is running its checks
```

After `runner install`, a runner that waits for builds says so on a line that
starts `update to 0.10.1 installed`.

`--json` carries the same facts as `pendingUpdate`: `version`, `since`, `via`
(`self-update` or `install`), `waits` with the `item` and `reason` of each
build, and `holding` with the `builds` it waits for.

`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, including an empty or
  `<...>` placeholder `apiKey` or `appId`;
* 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, in the service's credentials
  directory, or in the `claude.env` or `runner.env` beside the config. When
  the credential comes from a 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] [--unit-name <name>]] [--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 when the service
restarts on the new version. It restarts nothing itself. A runner its service
started through `current` sees the switch on its next poll. It then restarts
as [`runner start`](#outerlayer-runner-start) does after a self-update. While
it waits, it takes no new work. 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. The unit
runs on Linux, inside the Mac's VM and inside WSL2. On WSL2 the command also
sets up a Windows scheduled task.
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 and sets nothing up when the host has no systemd, or when the
hooks are your own executables. It does the same when `concurrency` times
`build.memory` does not fit in the memory the runner can use. It names the
reason, and 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 [--unit-name <name>] [--config <path>]
```

`--unit-name <name>` names the unit to remove, when it is not the default
`outerlayer-runner`.

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, when
`schtasks.exe` can start. Otherwise it prints `schtasks /Delete /F /TN <task>`
to run in Windows. It leaves a unit it did not write alone.

## outerlayer runner init

Adds a `runner` block to the config file and makes the host key. With no
runner key in the config file, it first asks you to paste one. See
[The runner key](#the-runner-key).

```bash theme={"system"}
outerlayer runner init [--vm | --no-vm] [--vm-cli-package <path>] [--allow-process-builds] [--unit-name <name>] [--agent claude] [--factory <org>/<factory>] [--app-id <id>] [--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`. |
| `--agent claude` | none | Write commands that start Claude Code instead of the placeholder, and store Claude's credential from stdin in `claude.env`. See [The agent](#the-agent). |
| `--factory <org>/<factory>` | the only factory your login can use | The factory the runner key is for, named as in its dashboard address. A factory id also works. Refused with a key already in the config. |
| `--app-id <id>` | `OUTERLAYER_APP_ID`, the config's `appId`, then the current repository's connection | The factory the runner key is for, by the **Factory Id** on its **Settings → General** tab. With a key already in the config, it must match the config's `appId`. Given with `--factory`, it must name the same factory. |

It keeps every other key, writes placeholder command lines unless `--agent` is given, writes 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](/troubleshoot-builds#when-the-runner-is-blocked).

It writes nothing and exits 1 when:

* there is no config file and no login (run `outerlayer login` first), or
  the file is not valid JSON;
* the config has no `apiKey`, and no login is saved;
* a runner key is to be asked for and no factory is named, there is no
  terminal to paste it in, or the gateway refuses the pasted key. See
  [The runner key](#the-runner-key);
* the config's `url`, `apiKey` or `appId` is missing, empty or blank, or
  `apiKey` or `appId` still holds a `<...>` placeholder such as
  `<factory-id>`. The refusal names the field and the config file, and comes
  before any VM or service step;
* 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 the runner image on Docker Desktop, then `--vm`);
* `--vm` is given and `limactl` is missing;
* `--agent` names an agent other than `claude`;
* `--agent claude` is given, nothing is piped on stdin, and neither the
  `claude.env` nor the `runner.env` beside the config holds a Claude
  credential (the refusal shows the piped form). With `--vm` the new VM has
  no `claude.env`, so the credential must be piped;
* the value on stdin is not a Claude credential (the refusal does not
  repeat it).

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

`outerlayer doctor` on a runner host checks the build isolation, Claude's
credential, the host key binding and more. See [On a runner
host](/reference/cli-capture#on-a-runner-host), [Run builds on
macOS](/run-builds-on-macos) and [Run builds on
Windows](/run-builds-on-windows).

### The runner key

When the config file has no `apiKey` and a login is saved, `runner init`
asks you for the runner key. A login never creates a key, so you create it
in the dashboard. The login is the config file's own, else the one
`outerlayer login` saved in `~/.outerlayer/config.json`. It names the
factory, and is never copied into the runner's config file.

1. It finds the factory: `--factory`, then `--app-id`, then
   `OUTERLAYER_APP_ID`, then the config's `appId`, then the connection of
   the repository it runs in. When none names one, it lists the factories
   your login can use. If there is one, it uses it and prints `Using
   factory <org>/<factory>, the only one this login can use.` If there are
   several, it refuses and lists each as a `--factory` value to pass.
2. It prints the factory's API keys page, which is
   `<dashboard>/orgs/<org>/factories/<factory>/settings/api-keys`, and says
   to create a key with the **Runner** preset.
3. It asks for the key with nothing shown as you paste. Press Enter when you
   are done. It asks on the terminal even when stdin is piped, as in
   `printf ... | outerlayer runner init --agent claude`. With no terminal to
   ask on, it stops before any VM or service step and prints the same link and
   the config-file lines to use instead.
4. It checks the key with one unsigned request, `GET /v1/runner/host-key`.
   That request binds the key to no host. A key the gateway refuses, or that
   is already bound to a host, stops the command before any VM or service
   step.
5. Without `--vm`, it writes `url`, `apiKey` and `appId` into the config
   file, creating the file and its directory, owner only, when they do not
   exist. With `--vm`, it hands the key to the VM on the stdin of the VM's
   own `runner init`, and never writes it on the Mac.

If you cannot create the key, a member creates it with the Runner preset.
You put it in the config file as `apiKey`, with the Factory Id as `appId`. A
config file that already holds an `apiKey` is used as it is, and nothing is
asked.

### The agent

`--agent claude` writes these commands, in place of the placeholder:

| Queue | Command |
| - | - |
| `implement` | `claude -p "/build" --permission-mode bypassPermissions` |
| `amend` | `claude -p "/outerlayer-amend" --permission-mode bypassPermissions` |

It reads Claude's credential from stdin:

```bash theme={"system"}
printf '%s' "$CLAUDE_CODE_OAUTH_TOKEN" | outerlayer runner init --agent claude --factory <org>/<factory>
```

* The value's shape decides the variable. A token from `claude setup-token`
  starts `sk-ant-oat` and is stored as `CLAUDE_CODE_OAUTH_TOKEN`. An API key
  starts `sk-ant-api` and is stored as `ANTHROPIC_API_KEY`. Anything else,
  including text around the value, is refused.
* The credential is never printed. The output names the variable and the
  file.
* `claude.env` holds the one new line. It replaces what the file held,
  because the runner uses `CLAUDE_CODE_OAUTH_TOKEN` when both are set.
* Every line of `runner.env` that does not set `CLAUDE_CODE_OAUTH_TOKEN` or
  `ANTHROPIC_API_KEY` is kept, in order, such as a destination's secret.
  Every line that sets either one is removed.
* Both files are mode 600 afterwards, whatever their mode was before.
* With nothing piped, a credential already in `claude.env` or `runner.env`
  is used and the files are left as they are.
* The file is written before the service is set up, so the service starts
  with the credential. A service that was already running with an
  unchanged unit is restarted, unless a build is running; then it reads
  the file at its next restart. A unit `runner init` did not write is left
  alone, and `runner init` prints `sudo systemctl restart <unit>`.
* With `--vm`, the agent and the credential go to the VM on the stdin of
  the VM's own `runner init`, beside the runner key. The credential is
  stored in the VM's `claude.env` before its service starts.

Without `--agent`, nothing is read from stdin, and the placeholder commands
are written as before.

## outerlayer runner image

Builds the image a repository's builds run in, from the devcontainer file of
the repository that governs it. A repository governed by a control plane builds
the control plane's image, at the commit the gateway stored for it. A
repository that governs itself builds from its own 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 the repository from, with this host's git credentials. Ignored when a control plane governs the repository. |
| `--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).

The command asks the gateway named in the runner config which repository governs. Without a runner config it builds the repository's own devcontainer. 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. |

This prints item 42's newest build log and keeps printing until interrupted:

```bash theme={"system"}
outerlayer runner logs 42 -f
```

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

## Upgrading the runner

A runner whose status is `outdated` keeps polling but takes no work. Its log
says so, and repeats it 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
```

Upgrade it the way it runs:

* **A service runner:** with `runner.autoUpdate` on, it updates itself. See [Keep the runner up to date](/run-a-host-as-a-service#keep-the-runner-up-to-date). To upgrade it yourself, see [Upgrade by hand](/run-a-host-as-a-service#upgrade-by-hand).
* **The runner container image:** pull the tag the runner names with `docker pull`, then start a new container. See [Run the runner in a container](/run-the-runner-in-a-container).
* **A runner in a Lima VM on a Mac:** install the new CLI in the VM. See [Run builds on macOS](/run-builds-on-macos).
* **A runner started by hand, with no install layout:** stop it with `outerlayer runner stop`, install the new CLI, and run `outerlayer runner start`.

A runner that cannot update itself, because it has no install layout, moves
onto it with `outerlayer runner install`. A new version that fails its first
checks is rolled back to the previous one, and the update records
`rolled_back`. After the restart its status returns to `ok` and it takes work
again. The
[runner protocol](/reference/build-reference#runner-protocol) says which
versions the gateway accepts. To pause or hold updates for every runner of a
factory, see [Pausing or holding updates for a
factory](/run-a-host-as-a-service#pausing-or-holding-updates-for-a-factory).

## 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 runner 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.