Skip to main content
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. 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.
  • 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

outerlayer runner init ends by setting up the service. You write no unit. To set it up again, run:
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. 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.
  • 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.
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. On WSL2 without systemd, setup turns systemd on in /etc/wsl.conf and stops. It asks you to run wsl --shutdown once from Windows. Run the command again after that. If schtasks.exe cannot start, setup prints schtasks /Query /TN outerlayer-runner and the schtasks /Create /F command to run in Windows. Once systemd runs, setup also turns off Windows interop, the Windows PATH and drive automount in /etc/wsl.conf. They take effect when you restart the distribution. See Run builds on Windows. The scheduled task names your distribution, which WSL2 gives in WSL_DISTRO_NAME. sudo drops that variable, so run setup as yourself, not under sudo. Without the name, setup makes no task. It prints the schtasks command with a placeholder for the name wsl -l shows.

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

Move an existing host to claude.env

A service that an earlier release set up keeps Claude’s credential in runner.env. Update the CLI, then run this command:
The command asks for sudo. It does these steps in order:
  1. It copies the credential line from runner.env into claude.env.
  2. It writes the new unit and reloads systemd.
  3. It removes the credential line from runner.env. Every other line stays.
You enter nothing again. The service restarts when no build runs. Otherwise the new unit applies at the next restart. A host that updates itself before you run the command already starts Firecracker with an empty environment. Its runner also removes the credential from its own environment at start. Its old unit still loads the credential into the runner’s environment until you run the command.

The microVM helper

For builtin:vm hooks on Linux, outerlayer runner install --service also installs a second unit, named <unit>-helper, beside the runner’s. The helper runs as root. Its one job is to run each microVM build under Firecracker’s jailer. It starts, stops and reports microVM builds, and lists the builds it holds. It does nothing else. A request names a build id, the kind of microVM and the runner’s time limit. The helper works out the rest:
  • It takes the build’s files only from under the runner’s builds directory. It opens each file without following a link.
  • It runs the jailer and Firecracker only from root-owned copies. Each must match the SHA-256 this CLI release pins. The helper checks both before each start.
  • It picks the build’s user from a range of ids that no account on the host uses. Each running build gets a different user.
The jailer runs Firecracker as that user, in a chroot that holds only the build’s kernel, disks, configuration and sockets. The build’s unit, outerlayer-build-<build id>, has the build’s memory limits and no network. The unit runs outside the runner’s service. The unit also has a runtime limit. It is runner.timeLimitMinutes plus 10 minutes. The runner ends a build at runner.timeLimitMinutes, and the extra 10 minutes are for its cleanup. The limit ends a build that no runner manages. A helper from an earlier release ignores the time limit, so its units have no limit. Run outerlayer runner install --service --replace to install this release’s helper. The helper also writes the CPU template that hides VMX and SVM from each guest. A helper from an earlier release does not write the template, so its guests still see VMX and SVM. The same command installs the helper that does. A microVM build powers off when its lease runs out with no runner renewing it. The runner tells the microVM how much time is left on the lease. It sends the time when the microVM starts and after each renewal. The microVM counts the time down. When the time runs out, the microVM powers off. A microVM that the runner told nothing never powers off. The helper listens on a Unix socket, /run/<unit>-helper/helper.sock. Only the runner’s account can open it. The helper also checks who connects, and refuses any other account, root included. The helper’s program, the jailer and Firecracker are in /usr/local/libexec/outerlayer/<unit>-helper/. Its config is in /etc/outerlayer/. Root owns all of them. The install checks that the runner’s account cannot write them, and it refuses the helper if the account can. The runner finds the helper through OUTERLAYER_HELPER_SOCKET in the unit. A runner started by hand, or from a unit written before the helper existed, has no helper. It runs Firecracker itself, as the runner’s account. To turn the helper off, set OUTERLAYER_HELPER_SOCKET= in runner.env and restart the service. To update the helper, run outerlayer runner install --service --replace again after you update the CLI. The runner’s self-update cannot do this, because the helper needs root. A runner works with older helpers whose protocol it knows. For any other helper, the runner logs that the helper needs reinstalling and builds without it. outerlayer doctor says the same on its MicroVM jailer line. outerlayer runner uninstall --service removes the helper with the service. See What a microVM escape reaches.

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 lease, 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. 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 build that no runner manages stops when its lease runs out. The unit’s time limit stops it at the latest.

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.
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.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.
  • 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.
  • 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 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. 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. Each new 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 image’s tools layer once. A copy of each 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:
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.

When the runner claims nothing

At start and on every poll, the runner checks itself, as its own account and never as the person who set it up. It checks in this order:
  1. No queue’s command is still the placeholder runner init writes.
  2. For container and microVM hooks, concurrency times build.memory fits in memory, as in Memory limits.
  3. For builtin:vm hooks, /dev/kvm exists and the runner can open it.
  4. For container and microVM hooks, its image builder exists and starts, as in Image builds.
  5. For any built-in hooks, it has a Claude credential.
When one fails, the runner claims nothing and logs why once. Its heartbeat carries blocked with the reason, and GET /v1/hosts lists the runner as not taking work. Once the check passes, it takes work again on the next poll. Right after a start, the runner says it is still preparing its image builder. That is not a fault. It claims nothing until the builder is ready, then takes work with no action from you. An account added to the kvm group, or a credential added to claude.env, needs a service restart first. The runner reads both only when it starts. See When the runner is blocked. outerlayer doctor reports what the running service has: the groups of its main process and the credential variables in its claude.env and runner.env. It does not report your shell’s. A runner short of disk also claims nothing; see Housekeeping.

Which work a runner takes

A runner polls for work and takes one item for each free slot. It reads the queues in a fixed order:
  1. The amend queue, if the runner takes amend. This is work on a pull request that a person reviewed or that has a merge conflict.
  2. The implement queue, if the runner takes implement.
The runner takes amend work before implement work, whenever both wait. Each queue is oldest request first. The implement queue orders items by the time of their oldest unused build request. The amend queue orders items by the time of their amend trigger. Items with the same time come in id order. In the implement queue, a change to an item, such as a comment or a new pull request commit, does not move it. A second build request on an item that already waits does not move it either. An item whose build ended interrupted or lease_lost keeps its place. In the amend queue, a new review failure or a new build request sets a new trigger time. The item then moves back in the queue. The gateway lists a queue in pages of 100 items. A runner with repos.include or repos.exclude rules reads the next page when it claims none of the items on the page. This happens when no item is for a repository its rules allow, or when other runners claimed every allowed item first. It stops reading when it claims an item or the pages run out. The runner log says how many listed items its rules skipped.

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. A service runner with microVM builds is the exception: see the next point.
  • 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. runner stop --now ends it on purpose.
  • A container build keeps running when its runner restarts or crashes. This holds when the service stops, when the host reboots the runner’s container, and when the runner process is killed. 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 lease, joins the build again, and goes on reading its output and exit code.
  • A container build stops when its lease expires with no runner to renew it. The runner tells the build’s container when the lease 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.

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. 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 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. 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: 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 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:
When the update waits for a build that does not carry on, the log names the build and why:
When it starts holding work, the line names the builds it waits for:
outerlayer runner status shows the pending version and, while the runner holds work, the same builds. See 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 keeps polling but takes no work until you do.
  1. Install the new CLI with npm:
  2. Put it in the install layout with the npm-installed CLI, not the launcher in ~/.local/bin, which still runs the old version:
    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. 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. 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 (below).
  • 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.

Move an existing host onto the install layout

A runner installed without the layout, or whose service starts a version directory instead of current, cannot update itself. It logs once that it cannot, naming outerlayer runner install, and keeps taking work. To move it:
  1. Run outerlayer runner install --service [--config <path>]. It puts the CLI in the layout and rewrites the unit to start current.
  2. If it says the unit differs, the unit was not written by this command. Run it again with --replace.
outerlayer runner init does this for a new host. runner install needs the CLI installed with npm, not a source checkout. For a runner with its own executables as hooks, run outerlayer runner install without --service. It prints the ExecStart= line for its own unit.

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 lease 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: 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. 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.
  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.
  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.