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

# Run the runner as a service

> Run the runner under an account and a service manager, move it between machines, upgrade it and remove it.

For unattended builds, a host runs `outerlayer runner start` under a
service manager, as an account of its own. Set the host up first with
[Set up your first host](/set-up-a-host).

The runner keeps its files beside its config file. Under an account of its
own, that file is `~/.outerlayer/config.json`, and the commands on this page
need no `--config`. On your own account, the runner's config file is
`~/.outerlayer-runner/config.json`, so your login stays separate. Then add
`--config ~/.outerlayer-runner/config.json` to each `outerlayer runner`
command on this page, and read `~/.outerlayer-runner/` for `~/.outerlayer/`
in each runner path.

## The account the runner runs as

The runner never prompts. Under the service's account:

* **Claude's credential** is in `claude.env` beside the config file. Built-in
  hooks use only that file. A terminal `claude` login is not used. See
  [Give the runner Claude's credential](/set-up-a-host#set-it-up).
* **git** must fetch with no credential prompt. The runner reads a governing
  control plane's context through the GitHub App, so it needs no git login
  for that. The account's own git login is needed only without a GitHub App
  (a gateway that has none issues no token) and for `outerlayer runner image`
  on a private repository. Your own hooks may use it too. Use a deploy key, a
  credential helper with a stored token, or an SSH agent the service can
  reach.
* **With your own hooks, the agent** must already be logged in under this
  account. Claude Code keeps its login per home directory.
* **The factory** must be reachable with the key in the config file.

A prompt makes the build wait until the idle limit ends it with outcome
`idle`. Run `outerlayer runner check`, and your command once by hand under
that account, to catch this early.

Hooks and the command run with the runner's own CLI first on `PATH`, so an
`outerlayer` call in a hook uses the runner's version.

## What the service sets

<Frame>
  <img className="block dark:hidden" src="https://mintcdn.com/outerlayer-stg/vtkewxrpx_NzS0BQ/images/runner-host-layout-light.svg?fit=max&auto=format&n=vtkewxrpx_NzS0BQ&q=85&s=1e65769589254559078b05b90c89ce8c" alt="What a runner host holds. The systemd system unit outerlayer-runner.service runs as the runner's account. It loads claude.env as a credential with LoadCredential, reads runner.env as an EnvironmentFile, and starts the CLI in runner/current. Both files sit beside config.json in the config directory, ~/.outerlayer/. Its runner/ folder holds host-key.pem, versions/, the current link to one version, and jobs/ with one folder per build. ~/.local/bin/outerlayer runs the CLI in current too. For microVM builds, the root helper unit outerlayer-runner-helper.service listens on a socket and runs each build in its own unit under the jailer and Firecracker. The unit requires Docker, where the runner starts the build containers and its BuildKit builder." width="660" height="568" data-path="images/runner-host-layout-light.svg" />

  <img className="hidden dark:block" src="https://mintcdn.com/outerlayer-stg/vtkewxrpx_NzS0BQ/images/runner-host-layout-dark.svg?fit=max&auto=format&n=vtkewxrpx_NzS0BQ&q=85&s=d0c2e9c24725948d7375125850229c9c" alt="What a runner host holds. The systemd system unit outerlayer-runner.service runs as the runner's account. It loads claude.env as a credential with LoadCredential, reads runner.env as an EnvironmentFile, and starts the CLI in runner/current. Both files sit beside config.json in the config directory, ~/.outerlayer/. Its runner/ folder holds host-key.pem, versions/, the current link to one version, and jobs/ with one folder per build. ~/.local/bin/outerlayer runs the CLI in current too. For microVM builds, the root helper unit outerlayer-runner-helper.service listens on a socket and runs each build in its own unit under the jailer and Firecracker. The unit requires Docker, where the runner starts the build containers and its BuildKit builder." width="660" height="568" data-path="images/runner-host-layout-dark.svg" />
</Frame>

`outerlayer runner init` ends by setting up the service. You write no unit.
To set it up again, run:

```bash theme={"system"}
outerlayer runner install --service
```

On Linux, it writes one systemd unit at
`/etc/systemd/system/outerlayer-runner.service`, then enables and starts it.
The runner starts at boot. It asks for `sudo` and names each `sudo` command
before it runs. The unit runs as the account that ran the command. Pass
`--user <account>` to name another.

| Where the runner lives | What it sets up |
| - | - |
| Linux | The unit, enabled and started. |
| macOS | The same unit inside the Linux VM `runner init --vm` makes, and the launch agent that starts the VM at login. |
| Windows (WSL2) | The same unit inside WSL2, and a Windows scheduled task that starts the distribution at login and keeps it running. |

It refuses on a Linux host without systemd, and for hooks that are your own
executables. The refusal names the reason. `--print` writes the unit to
stdout and sets up nothing, so you can edit it for those hosts.

The unit holds these lines:

* `ExecStart=` starts the runner from its install layout, so it can update
  itself. See [Keep the runner up to date](#keep-the-runner-up-to-date).
* `EnvironmentFile=-` names `runner.env` beside the config file, readable
  only by the runner's account. It holds every secret a destination's header
  reads and every value `build.variables` passes in. It also holds settings
  such as `OUTERLAYER_HELPER_SOCKET=`. Restart the service after you edit
  the file, because a service reads it only when it starts.
* `claude.env` beside the config file holds Claude's credential,
  `CLAUDE_CODE_OAUTH_TOKEN=<token>` or `ANTHROPIC_API_KEY=<key>`. Only the
  runner's account can read it. Without a Claude credential the runner
  claims nothing. The install always makes the file, empty when no
  credential is stored. Restart the service after you change it.

  * The unit loads the file with `LoadCredential=` on systemd 247 or later.
    The credential reaches the runner as a file in `$CREDENTIALS_DIRECTORY`.
    It is in no variable of the service, so no process the runner starts
    inherits it.
  * The unit loads the file with a second `EnvironmentFile=` line on older
    systemd, or when the install cannot read the version. The credential
    then stays in the service's environment. The install prints one line
    that says so. Run `systemctl --version` to see your version.

  With built-in hooks, the runner also removes the three Claude credential
  variables from its own environment when it starts. The credential stays
  in the runner's memory. The microVM that the runner starts itself gets an
  empty environment.
* `Restart=on-failure` and `RestartForceExitStatus=75` restart the runner
  after a crash and after the exit with status 75 that a self-update, or a
  version `runner install` put in place, asks for.
* `TimeoutStopSec=` is `timeLimitMinutes` plus ten minutes, because a stop
  waits for running builds.
* `Requires=docker.service` is set for container and microVM hooks.
* `OOMPolicy=continue` keeps one build's death from stopping the service.
  Without it, systemd stops the whole service, and every other build, when
  the kernel kills any process in it.
* `Delegate=yes` lets the runner give each microVM build a cgroup of its own.
* With the helper installed, `Wants=` and `After=` name the helper's unit, and
  `Environment=OUTERLAYER_HELPER_SOCKET=` names its socket. See
  [The microVM helper](/build-security#the-microvm-helper).

The unit has no `ManagedOOMMemoryPressure=`. The OOM daemon kills a whole
cgroup, which here is every build.

It is a system service, not a user service, so the account's groups are
read again at each start. An account added to `kvm` then needs only a
service restart.

What setup does in WSL2, and what it turns off there, is in
[Run builds on Windows](/run-builds-on-windows#keep-the-runner-running).

## Re-run and remove the service

The runner reads the config file again on every poll, so most edits need
nothing else. Re-run `outerlayer runner install --service` after a change to
`timeLimitMinutes`, to the hooks, or to the account. With a unit that still
matches, it changes nothing and says the service is current.

When the unit changes, it rewrites the unit and reloads systemd. It restarts
the service only while no build is running. Otherwise it says the new unit
applies at the next restart.

A unit at that path that the command did not write is left alone, and the
command says the unit differs. Pass `--replace` to replace it.

To stop the service and remove it:

```bash theme={"system"}
outerlayer runner uninstall --service
```

It removes the unit, and the launch agent on a Mac or the scheduled task on
Windows. On WSL2 it removes the task only while `schtasks.exe` can start.
Otherwise it prints `schtasks /Delete /F /TN outerlayer-runner` to run in
Windows.

## Memory limits

Every build has a memory limit of its own, and the runner never runs more
than `concurrency` builds. So the builds together use at most
`concurrency` times `build.memory`.

<Frame>
  <img className="block dark:hidden" src="https://mintcdn.com/outerlayer-stg/h46SL2WH_1X-gmFL/images/build-memory-light.svg?fit=max&auto=format&n=h46SL2WH_1X-gmFL&q=85&s=e938d90aae064b5b24a00407343f5d34" alt="Example: a 32 GiB host with concurrency 2 and build.memory 8 GiB. 2 GiB is kept for the runner and the system, the two builds take 16 GiB, and 14 GiB is left, so the builds fit. Zoomed in, one microVM build runs up to build.memory. Above build.memory plus 256 MiB the kernel slows its writes, and above build.memory plus 1 GiB the microVM is killed and the build is released failed." width="660" height="330" data-path="images/build-memory-light.svg" />

  <img className="hidden dark:block" src="https://mintcdn.com/outerlayer-stg/h46SL2WH_1X-gmFL/images/build-memory-dark.svg?fit=max&auto=format&n=h46SL2WH_1X-gmFL&q=85&s=4d71e5b9418ce5e5eba3baa7f98dfb5e" alt="Example: a 32 GiB host with concurrency 2 and build.memory 8 GiB. 2 GiB is kept for the runner and the system, the two builds take 16 GiB, and 14 GiB is left, so the builds fit. Zoomed in, one microVM build runs up to build.memory. Above build.memory plus 256 MiB the kernel slows its writes, and above build.memory plus 1 GiB the microVM is killed and the build is released failed." width="660" height="330" data-path="images/build-memory-dark.svg" />
</Frame>

* A container build runs with Docker's `--memory` at `build.memory`.
* Image builds run in the runner's own builder, whose container has the same
  limit. See [Image builds](#image-builds).
* Under a service with `Delegate=yes`, each microVM build runs in a cgroup
  of its own. Above `build.memory` plus 256 MiB (`memory.high`), the kernel
  reclaims the page cache of the microVM's disks and slows its writes. Above
  `build.memory` plus 1 GiB (`memory.max`), it kills the build's microVM.
  `memory.swap.max` is 0.
* A build killed at that limit is released `failed`, with a reason that
  says it ran out of memory. The other builds and the runner carry on.
* Without `Delegate=yes`, for example under a hand-written unit, microVM
  builds have no limit of their own, and `outerlayer doctor` warns. On
  cgroup v1 the same is true.

Setup refuses builds that cannot fit. It compares `concurrency` times
`build.memory` with the memory the runner can use, less 2 GiB. That memory
is the host's on Linux, the VM's on macOS and the distribution's on WSL2.
When it does not fit, setup sets up nothing and names both figures. A
runner started with such a config claims nothing, and its entry in the
factory says why.

## Image builds

The runner builds a repository's build image before its first build. It builds it
again when the recipe or the CLI changes. Those builds run in the
runner's own builder, a BuildKit container that Buildx makes for it. The
container's memory limit is `runner.build.memory`. A recipe step that uses
more is stopped there, so it cannot starve running builds. Docker's default
builder cannot be limited this way.

What the host needs:

* Docker's Buildx plugin, version 0.18 or later. It is `docker-buildx-plugin`
  in Docker's apt repository and `docker-buildx` in Ubuntu's own packages.
  Lima's `docker-rootful` template has it.
* Access to Docker Hub, to pull the BuildKit image once.
* A proxy, if any, that the builder can reach. The builder does not inherit
  the Docker daemon's proxy settings, so the runner passes its own
  `HTTP_PROXY`, `HTTPS_PROXY` and `NO_PROXY`. A proxy on the host's loopback
  address cannot be reached from the builder. A password in a proxy address
  shows in the host's process list, so use a proxy that needs none.

A runner without a usable Buildx claims no work. Its heartbeat says why, and
`outerlayer doctor` fails the "Image build limit" check. See
[When the Image build limit check fails](/troubleshoot-builds#when-the-image-build-limit-check-fails).

The runner makes the builder when it is missing. It replaces the builder
when `runner.build.memory` changes, after the image builds it is running
end. Replacing it clears its cache. `outerlayer runner image` never
replaces a builder; it stops and says so. A build that hits the limit is
released `failed` at `provision`. See
[When an image build runs out of memory](/troubleshoot-builds#when-an-image-build-runs-out-of-memory).

Each new build image is copied from the builder into Docker's image store, which
takes time in proportion to its size. A new CLI release rebuilds each
build image's tools layer once. A copy of each build image is also kept under
`runner/images/<owner>/<name>/layouts/`, and removed with the image.

## The outerlayer command

`outerlayer runner install` writes `~/.local/bin/outerlayer`, a short shell
script that runs the CLI in `<runner dir>/current`. A self-update switches
`current` to the new version. So the command a shell finds always runs the
version the service runs, and `outerlayer runner status`, `doctor` and
`logs` match the runner. The command prints the path it wrote.
`runner install --service` and `runner init` write it too.

The runner never edits a shell startup file. When `~/.local/bin` is not on
`PATH`, the command says so and prints the line to add:

```bash theme={"system"}
export PATH="$HOME/.local/bin:$PATH"
```

When another `outerlayer` comes first on `PATH`, for example one installed with
npm, `runner install` and `outerlayer doctor` name both paths and give the same
line. `doctor` shows this as the "outerlayer on PATH" check.

A file at that path that the runner did not write is left alone, and the
command says to run `outerlayer runner install --replace`. A launcher the
runner wrote earlier is rewritten without `--replace`, so the last install
decides which runner directory it follows.

## Start and stop the runner

* `outerlayer runner stop` takes no more work, waits for running builds to
  finish, and exits. Ctrl-C does the same. This is true for every runner.
* `outerlayer runner stop --now` ends running builds with outcome
  `stopped`. A second Ctrl-C does the same.
* A service stop or restart (`systemctl stop` or `systemctl restart`) leaves
  adoptable microVM builds running. The runner exits at once, and the next
  runner adopts the builds. If a build must be waited for, such as one that
  runs its checks, the runner drains as before. `outerlayer runner stop`
  always drains such a runner.
* A microVM build keeps running when its runner restarts or crashes, on a host
  with the root helper and systemd 254 or later. See
  [A microVM build outlives its runner](#a-microvm-build-outlives-its-runner).
  `runner stop --now` ends it on purpose.
* A container build keeps running when its runner restarts for an update,
  crashes or is killed, and when the runner image's container stops or
  restarts. A service stop or restart (`systemctl stop` or `systemctl
  restart`) is different: the runner waits for its container builds to end.
  The command runs inside the
  build's container, not as a process of the runner. When the runner starts again, it
  picks up each build that is still running. It renews the claim, joins the
  build again, and goes on reading its output and exit code.
* A container build stops when its claim expires with no runner to renew it.
  The runner tells the build's container when the claim ends. The container
  stops itself at that time, so the item is free before another build can start.
  The runner never adopts, stops or removes a build that another runner started.

### Let a restart through

A draining runner waits for its running builds to end before it exits. A long
build can hold a restart for hours. To let the restart through, stop the build
the runner waits on.

1. Find the build. `GET /v1/hosts`, the MCP tool `list_hosts` and the item page
   show a draining runner and the items it waits on. The item's **Building now**
   block says `<host> is draining`.
2. Stop it. On the item page, select **Stop build** and confirm. Or run
   `outerlayer work stop --item <n>`.
3. The runner reads the stop in the reply to its next heartbeat. It ends the
   build with the session synced and cleanup run, and releases the claim as
   `stopped`. The restart goes ahead once its last build has ended.

The stopped build's request is used up, so the item is not built again until
someone asks for another build. See [`outerlayer work stop`](/reference/cli-work#outerlayer-work-stop).

## A microVM build outlives its runner

A microVM build keeps running when its runner restarts or crashes. This holds when the runner process is killed and when an update restarts the runner. The build's unit runs outside the runner's service. The build's command runs in the microVM, not as a process of the runner. When the runner starts again, it adopts each build that still runs. It renews the claim, serves the build again, and goes on reading the build's output and exit code.

A microVM build needs four things to outlive its runner:

* The root helper runs the build; see [The microVM helper](/build-security#the-microvm-helper). Without the helper, Firecracker runs inside the runner's service, and the build ends with its runner.
* The host runs systemd 254 or later. The service keeps the build's sockets in systemd's descriptor store, and older systemd cannot store them.
* The service's unit sets `FileDescriptorStoreMax`. A unit that an earlier release wrote does not. Run `outerlayer runner install --service --replace` to write one that does.
* The running runner started under that unit. A process keeps the environment it started with, so a runner that was running when the unit was replaced cannot use the store. Restart the service once, while no build runs: `sudo systemctl restart outerlayer-runner`.

A build without these things ends with its runner, as before. An update then waits for every build to finish. On its `MicroVM jailer` line, `outerlayer doctor` warns about each of the four.

While no runner is up, a microVM build reaches nothing outside its microVM. The build's sockets wait in the descriptor store, and nothing serves them. A call from the build waits. When the runner starts again, the call meets the same allow-list and tokens as before. A call that is open during the restart fails, so a tool in the build must retry it.

A microVM build ends `interrupted` when its unit stopped while no runner managed it, unless the runner had already read its exit code. A reboot of the host does this.

A microVM build powers off when its claim runs out with no runner renewing it. The runner tells the microVM how much time is left on the claim. It sends the time when the microVM starts and after each renewal. The microVM counts the time down, and powers off when the time runs out. A microVM that the runner told nothing never powers off. The unit's time limit stops it at the latest.

## The host key

A runner key works only from the host that first used it. On first run,
the runner makes a host key at `runner/host-key.pem` beside its config file,
readable only by its account. The private half never leaves the machine.

The first claim binds the host key to the runner key, and the organization
audit log records it. From then on the gateway refuses that runner key unless
the host key signed the request. A leaked runner key is useless without
its host key. A runner too old to sign is refused: upgrade it.

Signatures are valid once, and only within five minutes of the gateway's
clock, so keep the host's clock in sync.

When the gateway refuses a signature, see
[When the gateway refuses a signature](/troubleshoot-builds#when-the-gateway-refuses-a-signature).

**Settings → API keys** shows each bound key's host key fingerprint and
when it was bound. `outerlayer doctor` says whether the runner key is bound
to this host.

### Moving a runner to a new host

To move a runner key to a new machine, or after you delete `host-key.pem`:

1. A member who may update API keys opens **Settings → API keys** and
   chooses **Clear host key** on the runner key. The organization audit log records
   `runner_key_binding_cleared`.
2. Start the runner on the new host. Its first claim binds the new key.

Until step 1, the runner on the new host is refused as
`runner_key_signature_invalid`, and the runner on the old host keeps
working. Do step 2 right after step 1, so nobody else holding the key can
bind it first.

A claim belongs to the runner key that took it. The runner's name
(`runner.name`) is only a label. A different key that sends the same name is
refused with `claim_not_held`.

## Several factories, one machine

One config file is one factory. To serve two from one host, run two runners.
Each has its own name and a config file in a directory of its own, such as
`/etc/outerlayer/acme/config.json`. The runner keeps its pid file, host key,
jobs and install layout in `runner/` beside its config file. Two config
files in one directory would share them.

**Give each config file its own runner key.** Each directory has its own
host key, so a key the first runner bound is refused from the second.

* Set `runner.name`, the runner's name, in each file, so the two runners have
  different names.
* Shared hook scripts should read `OUTERLAYER_URL` and `OUTERLAYER_APP_ID`
  rather than assume one factory.
* Each runner checks its builds against all of the machine's memory. Size
  the two runners' `concurrency` and `build.memory` together.
* `runner install --service` writes one unit, `outerlayer-runner`. Give the
  second runner a unit of its own with `--unit-name`, on `runner init`,
  `runner install --service` and `runner uninstall --service`:
  `outerlayer runner install --service --unit-name <name> --config <path>`.

## Keep the runner up to date

A runner started from its install layout updates itself to the CLI version
its gateway names. It never moves to a lower version. When a higher version
is named, the runner verifies it against the npm registry. It installs it
beside the current one, checks it, and restarts on it. A runner installed
without the layout cannot update itself until `outerlayer runner install`
moves it; see
[Move an existing host onto the install layout](/upgrade#move-an-existing-host-onto-the-install-layout).

A service runner restarts at once when its running builds carry on through
the restart; see
[When a build carries on through an update](#when-a-build-carries-on-through-an-update).
Otherwise it waits for the running builds to finish. It keeps taking work
while it waits, and updates the first time no
build is running. A runner started by hand waits for its builds in the same
way, because nothing would start it again after it exits.

Verifying means the tarball matches the registry's digest and passes
`npm audit signatures`. When the version has a provenance attestation, the
attestation must also name OuterLayer's release workflow and cover the same
tarball.

The install layout lives under `<config dir>/runner/`:

* `versions/<version>/` holds each installed version. The current and the
  previous one stay on disk.
* `current` links to the version the service starts.
* `last-update.json` holds the last update's outcome.

The runner's log says what each update did. The runner's heartbeat also
reports the last one, and `GET /v1/hosts` lists it as `lastUpdate`, with
one of these outcomes:

| Outcome | Meaning | What to do |
| - | - | - |
| `updated` | The new version runs and passed its checks. | Nothing. |
| `rolled_back` | The new version failed its checks once it ran, or started three times without finishing its first poll. The runner went back to the previous version. | Read the runner's log for the failing check. The runner does not try that version again until the gateway names another. |
| `verification_failed` | The version's checksum, provenance or signatures did not check out. Nothing was installed. | Nothing on the host. The runner waits for the gateway to name a different version. |
| `not_published` | The registry does not have the version yet. | Nothing. The runner asks again on its next poll. |
| `retrying` | Something on the host stopped the update: a Node version outside the CLI's range, npm older than 9.5, an unreachable registry, a failed `npm install`, or the new version's `runner check` failing. | Fix the cause in the log. The runner retries after 5 minutes, then waits twice as long each time, up to 6 hours. |

To turn updates off, set `runner.autoUpdate` to `false`. When it is not set,
updates are on if both hooks are built-in, and off if you supply your own
hooks.

### When a build carries on through an update

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. The running builds keep going, and
the new version takes them over.

A build carries on through a restart when all of these hold:

* It is a container build or a microVM build, and the runner tracks its
  command there. A container build is tracked inside the container. A microVM
  build is tracked in the microVM, through the guest's detached mode, and
  needs the root helper and systemd 254 or later. A build that an older
  runner started is not tracked there.
* Its command is running. A build in setup, in the criteria judge, in its
  checks, or in sync, cleanup or report does not carry on. These stages take
  minutes, and a new runner would run them again or skip them.
* The runner is not ending it.
* The new version reads the build's job state. Each job records the format of
  its state. Before the runner restarts, it asks the new version which
  formats it reads. It asks only after a downloaded version passes the checks
  that every update makes. A version that `runner install` put in place is
  asked without those checks. A version that gives no answer reads none.

A build that carries on has no network while the runner restarts. After the
restart, the new version serves it the same allow-list as before.

### When the runner holds work for an update

A build that does not carry on through a restart delays the update. The
runner does not leave slots empty meanwhile. It stops taking new work for an
update in two cases only:

* The gateway refuses a claim because the runner is too old
  (`runner_upgrade_required`).
* The update has been pending longer than `runner.update.maxWaitHours`.

Set `runner.update.maxWaitHours` in the `runner` block. It defaults to `6`.
`0` holds new work as soon as an update is pending, so the runner updates at the
first idle moment and no sooner. A negative or non-numeric value makes the
config invalid, and the error names the key.

The runner's log says each state once. When an update becomes pending:

```text theme={"system"}
update to 0.10.1 pending · updating when no build is running
```

When the update waits for a build that does not carry on, the log names the
build and why:

```text theme={"system"}
update to 0.10.1 waits: #255 is running its checks
```

When it starts holding work, the line names the builds it waits for:

```text theme={"system"}
claiming nothing: update to 0.10.1 waits for #255 to finish
```

`outerlayer runner status` shows the pending version and, while the runner
holds work, the same builds. See
[`outerlayer runner status`](/reference/cli-runner#outerlayer-runner-status).

### Upgrade by hand

A runner with updates off, or one that cannot update itself, is upgraded by
hand. A runner below the gateway's minimum
[runner protocol](/reference/build-reference#runner-protocol) keeps polling but
takes no work until you do.

1. Install the new CLI with npm:

   ```bash theme={"system"}
   npm install -g @outerlayer/cli
   ```
2. Put it in the install layout with the npm-installed CLI, not the
   launcher in `~/.local/bin`, which still runs the old version:

   ```bash theme={"system"}
   "$(npm prefix -g)/bin/outerlayer" runner install --service
   ```

   For a runner with its own hooks, run `runner install` without `--service`.
3. Wait. `runner install` points `current` at the new version and prints
   when the service restarts on it. It restarts nothing itself, so no
   `sudo systemctl restart` is needed.

On its next poll, a runner its service started through `current` sees the
switch. It restarts at once when its running builds carry on through the
restart, as
[When a build carries on through an update](#when-a-build-carries-on-through-an-update)
says. Otherwise it waits for the running builds to finish. While it waits, it takes no new work. It then
exits with status 75, and the service starts the new version. The
install can run as any account that can write the runner's directory,
because the runner only reads the link.

The new version starts under the update marker, as after a self-update. It
runs the runner's checks at its first start. If they fail, or it starts
three times without finishing its first poll, it puts the previous version
back as `current`. `last-update.json` then records `rolled_back`. Otherwise
it records `updated` once its first poll completes.

These runners do not restart:

* A runner started by hand with `outerlayer runner start`. Nothing would
  start it again, so it keeps running and logs that a restart picks the new
  version up. Run `outerlayer runner stop` and `outerlayer runner start`.
* A runner whose service starts a version directory instead of `current`.
  A restart would start the same version, so move the runner onto the layout
  first; see
  [Move an existing host onto the install layout](/upgrade#move-an-existing-host-onto-the-install-layout).
* A runner whose `current` names a version that is not installed. It logs an
  error and keeps running its own version.

`runner install` refuses to install a CLI older than the version `current`
names unless you pass `--allow-downgrade`, and a refused install changes
nothing.

### Pausing or holding updates for a factory

`runner.autoUpdate` is one runner's switch. An owner or admin can stop or
limit updates on every runner of a factory at once. They open the factory's
**Settings**, then **General**, and change **Runner updates**. The API takes
the same setting as `runner_updates` on `PATCH /v1/apps/{appId}`. It takes
effect on each runner's next heartbeat.

* **Automatic** (`follow`, the default): runners update to the version the
  gateway was built from.
* **Paused** (`paused`): no runner updates. The heartbeat names each runner's
  own version back to it.
* **Hold at a version** (`pinned`, with `version`): runners below it update to
  it, and none moves past it. The version is a plain `major.minor.patch` no
  higher than the gateway's own. A higher one is refused with 422
  `runner_version_above_gateway`.

A runner never moves down. Holding a factory at a version lower than a
runner's version leaves that runner where it is.
To resume, choose Automatic again.

## Housekeeping

A runner on `builtin:container` or `builtin:vm` hooks removes what its own
builds leave behind, so it needs no cleanup script. The runner does this
once when it starts and then once an hour, between polls. It never delays a
running build's claim renewals. Each removal is one log line that names
what went and why.

The runner removes:

* **Job directories** under `runner/jobs/` of finished builds, once the
  directory last changed more than `jobsKeepDays` ago. A build whose
  release has not landed yet is kept.
* **Containers and volumes** labelled `ai.outerlayer.build` and with this
  runner's own `ai.outerlayer.runner` id that no unfinished build names. A
  failed cleanup leaves these. Another runner's, on the same Docker daemon,
  are never touched. Objects made by an earlier version carry no runner id.
  The runner removes one of those only when a finished job in its own
  `runner/jobs/` names its build id.
* **Root disks** under `runner/vm/rootfs/` whose image Docker no longer has.
  A file still being written (`.partial-` in its name) is kept.
* **Spool files** under `runner/spool/` older than 24 hours, when no build is
  running.
* **Dangling images** that carry the runner's `ai.outerlayer.kind` label,
  except one an unfinished build's job file names.
* **Build cache** of the runner's own image builder, down to `buildCacheMax`.

The runner only removes what carries its labels or lives under its own
directory. It never touches anything that belongs to an unfinished build,
however old. When any job file does not parse, or Docker does not answer,
the runner removes nothing from Docker on that pass.

The cap applies to the cache of the runner's own builder only. Docker's
default builder is not touched. Cache that earlier runner versions left
there stays until you run `docker builder prune` once.

Set these under `runner.housekeeping` in the config file:

| Setting | Default | Meaning |
| - | - | - |
| `runner.housekeeping.jobsKeepDays` | `30` | Days to keep a finished build's job directory. |
| `runner.housekeeping.buildCacheMax` | `20g` | The most the runner's image builder keeps in its cache. `"off"` leaves the cache alone. |
| `runner.housekeeping.minFreeDisk` | `runner.build.disk` | Free space the runner needs before it claims work. |

**Free disk.** Before it claims, every runner checks free space on the file
system that holds its directory. Below `minFreeDisk` it claims nothing, logs
why once, and sends `lowDisk` with its free and needed bytes in its
heartbeat. `GET /v1/hosts` then shows the runner as not taking work. It claims
again as soon as there is room, with no restart. See
[When the runner holds off for disk](/troubleshoot-builds#when-the-runner-holds-off-for-disk).

`outerlayer runner status` shows when housekeeping last ran and what it
freed, and `outerlayer doctor` warns when free disk is below the minimum.
A runner whose hooks are its own executables gets no cleanup pass: its hooks
own what they create. The free-disk minimum still applies to it.

## Uninstall the runner

1. Stop the service and remove its unit. Run `outerlayer runner stop --now`
   first if you do not want to wait for running builds.

   ```bash theme={"system"}
   outerlayer runner uninstall --service
   ```
2. As the runner's account, delete these:
   * On your own account, `~/.outerlayer-runner/`. It holds the runner key,
     the host key, `claude.env`, `runner.env`, builds, logs and caches. Do
     not delete `~/.outerlayer/config.json`, because it holds your own login.
   * Under an account of its own, these files in `~/.outerlayer/`:
     `runner/`, which holds the host key, builds, logs and caches.
     `runner.env`, which holds destination secrets and settings.
     `claude.env`, which holds Claude's credential. `config.json`, which
     holds the runner key, or only its `runner` and `apiKey` entries.
   * `~/.local/bin/outerlayer`, the launcher that ran the CLI in
     `runner/current`.
   * `~/.outerlayer/cli`, if `outerlayer init` made it.
3. Remove the images, volumes and image builder the runner made. `docker
   buildx ls` lists the builder, named `outerlayer-` and an id.

   ```bash theme={"system"}
   docker image rm --force $(docker image ls -q --filter label=ai.outerlayer.kind)
   docker volume rm $(docker volume ls -q --filter label=ai.outerlayer.build)
   docker buildx rm <the outerlayer- builder>
   ```
4. Run `npm uninstall -g @outerlayer/cli`, if nothing else uses it.
5. Delete the runner key in **Settings → API keys**. The gateway refuses it
   within about five minutes.

On a Mac, `outerlayer runner uninstall --service` removes the service in the
VM and the launch agent. Then delete the `outerlayer-runner` VM and remove
`runnerVm` from the Mac's `~/.outerlayer/config.json`. The runner key and
host key exist only in the VM, so this removes both. Delete the runner key
in **Settings → API keys** as well.

```bash theme={"system"}
limactl delete --force outerlayer-runner
```


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