Skip to main content
outerlayer runner claims queued work items and builds them on this machine. The guide is Run work on your own machines. Every subcommand takes --config <path> (default ~/.outerlayer/config.json), the config file whose runner block it reads. A command that fails prints one line on standard error and exits 1.

outerlayer runner start

Runs the runner until it is stopped.
On start it sends again any release an earlier attempt could not land. A pid file left by a crashed runner is overwritten. The new runner adopts a left-behind build whose command still runs, and releases one whose command is gone with outcome interrupted. It exits 1, naming the problem, when:
  • the config file is not valid JSON, or lacks url, apiKey or appId (pipe a factory key into outerlayer login for it first);
  • the runner block is absent, a queue it enables has no command, or a required hook key is missing;
  • runner.queues names a queue other than implement or amend;
  • a key inside runner, runner.build, runner.housekeeping or runner.checks is unknown or out of range;
  • a hook does not exist, is not a file, or is not executable; builtin:container, builtin:process and builtin:vm must be given for both the provision and the cleanup hook or for neither;
  • a destination’s header names a variable the runner’s environment does not hold;
  • another runner is running on the same config (the message names its pid);
  • the gateway refuses the API key (401 or 403).
A gateway that does not answer is not a refusal: the runner logs the gateway did not answer at start and starts anyway. A gateway that fails later does not end the runner either: it logs the failure and retries on the next tick. See Troubleshoot builds. When the gateway names a higher CLI version and no build is running, start installs it, switches to it, and exits with status 75 so the service starts it again. See Upgrading the runner. The built-in hooks are described in Container builds, MicroVM builds and The process fallback. Their runner.build limits and defaults are in the config file reference.

outerlayer runner stop

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

outerlayer runner status

Shows the runner’s state and its builds, read from the job files.
On a Linux host with a systemd unit for the runner, a line under the header gives the service’s state:
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:
--json carries the same facts as housekeeping. status still answers when the config no longer validates.

outerlayer runner check

Checks the config, hooks and credentials without taking work.
It writes no pid file, so it is safe beside a running runner. It prints the settings the runner would use, and exits 1 when:
  • the config or a hook fails a check start runs;
  • an enabled queue’s command is still the placeholder runner init wrote;
  • built-in hooks find neither CLAUDE_CODE_OAUTH_TOKEN nor ANTHROPIC_API_KEY, in the shell or in the runner.env beside the config. When the credential comes from that file, the output names it;
  • a claude command’s --permission-mode is not one Claude Code accepts: acceptEdits, auto, bypassPermissions, default, dontAsk, manual or plan. The message names the command and the value;
  • the gateway refuses the key, or the key lacks work.claim.
A hook must be an executable path, not a shell line: nothing expands $HOME or ~. A config file that is not valid JSON is reported as that, with the parser’s message. Any other failed gateway call prints credentials unverified and exits 0. The settings it prints include auto update, on or off, from runner.autoUpdate.

outerlayer runner install

Moves a host onto the install layout a runner needs to update itself. With --service, it also sets up the service that keeps the runner running.
Without --service, it copies the CLI that is running into <config dir>/runner/versions/<version>/, points current at it, writes the launcher ~/.local/bin/outerlayer, and prints the ExecStart= line that starts the runner from current. It works offline. It exits 1, changing nothing, when the CLI is not installed in a node_modules directory, as in a source checkout. Running it again re-copies the same version. When it moves current to a different version, it prints that the service restarts on the new version once its running builds finish. It restarts nothing itself. A runner its service started through current sees the switch on its next poll, takes no new work, waits for its builds, and exits with status 75, so the service starts the new version. A runner started by hand keeps running and logs that a restart picks the new version up. See Upgrade by hand. It also exits 1, changing nothing, when the running CLI is older than the version current names. That happens when a runner has updated itself past the CLI you installed with npm. The message names both versions and the launcher command to run instead: ~/.local/bin/outerlayer runner install, which runs the version in current. A same or newer version installs as before. runner init leaves such a layout alone and sets up no service. With --service, it does that and then sets up one systemd unit, on Linux, inside the Mac’s VM and inside WSL2, with a Windows scheduled task on WSL2. See What the service sets. runner init runs it for a new host. 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. It exits 1, setting nothing up, and names the reason when the host has no systemd, when the hooks are your own executables, or when concurrency times build.memory does not fit in the memory the runner can use. In the last case it names both figures.

outerlayer runner uninstall

Removes what runner install --service set up.
It stops and disables the unit, removes the unit file and reloads systemd. On a Mac it also removes the launch agent, and on WSL2 the scheduled task. It leaves a unit it did not write alone.

outerlayer runner init

Adds a runner block to an existing config file and makes the host key.
It keeps every other key, writes placeholder command lines and no hook script, and prints the host key’s fingerprint. It ends by running outerlayer runner install --service. A refusal there is a note and the command still exits 0, because the config it wrote is useful. The hooks it names: 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. It writes nothing and exits 1 when:
  • there is no config file (run outerlayer login first), or it is not valid JSON;
  • a runner block exists;
  • the machine cannot isolate a build and --allow-process-builds is absent (Docker Desktop alone is not enough);
  • on macOS, neither --vm nor --no-vm is given and there is no terminal (the refusal names --vm);
  • --vm is given and limactl is missing.
Answering no to the macOS question is the same as --no-vm. outerlayer doctor on a runner host reports the isolation on its Build isolation line, the variable holding Claude’s credential on its Claude credential line, and whether the runner key is bound to this host’s key on its Host key binding line. See Run builds on macOS and Run builds on Windows.

outerlayer runner image

Builds the image a repository’s builds run in, from the devcontainer file on its default branch.
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. 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: See Build a repository’s image from its devcontainer file.

outerlayer runner logs

Prints the newest build’s log for one item: the command’s output, or a running hook’s.
Exits 1 when <item> is not a number, no build on this config worked the item, or the log does not exist yet.

When the command exits 0

An exit of 0 says only that the agent’s session ended normally. How the runner then decides between ok and incomplete is in When the command exits 0. A build whose command exits 0 but that opens no pull request is released incomplete. A runner build opens its pull request with outerlayer work open-pr.

Host checks

A repository’s where: host validators declare commands the host runs itself. See Policy and validators for the file format. The runner runs them in the checks stage of finishing a build, only when the build’s command exited 0. The stage comes before the sync, so outerlayer runner status and runner logs show checks while it runs. builtin:container and builtin:vm builds run their checks in a new environment. For a builtin:vm build, the runner uploads the build’s sessions and stops the build’s microVM first, so a host never runs two microVMs for one build slot. For each build the runner:
  1. Reads the definitions from the repository that governs the item’s, or from the repository at the commit the image’s recipe was read from. It never reads them from the build’s checkout.
  2. Starts a new container from the build’s image, or for a builtin:vm build a new microVM booted from the build’s root disk, with the same limits, tunnel destinations and allowHosts as the build. It holds no OuterLayer key, no gateway address and no model credential. Its git remote serves fetches only.
  3. Checks out the branch the claim names, and runs the recipe’s lifecycle commands again.
  4. Writes the folder OUTERLAYER_EVAL_INPUT names: the work item, the branch’s diff and the build’s sessions, which it copied out of the build’s own environment before that environment was stopped or removed. The folder is read-only to the commands. See what a host check can read.
  5. Runs each command, one at a time, and records its result with the runner’s own key.
  6. Removes the container, or stops the microVM and deletes its build disk.
The gateway stores each result with the recorder host and the host name from the claim. It decides this from the runner key and the item’s live claim, and a request cannot choose it. The runner key needs no permission beyond the four below. POST /v1/emitted-results accepts work.claim from the key that holds the item’s claim, and refuses any other key. A check is recorded as a fail, with a one-line reason, when: If the definitions cannot be read, the runner records nothing and logs why. The row then reads waiting while the claim is live and failed once it is released. While the stage runs, the runner keeps renewing the claim’s lease, so checks may run longer than one lease. Each command’s output is in checks/<name>.log in the job directory, and the environment’s own steps are in checks/environment.log. Checks cost one more run of the recipe’s setup commands for each build. A builtin:vm build also boots a second microVM, after the build’s own has stopped.

The branch a claim names

Every claim names the branch its build works on, passed to the build as OUTERLAYER_BRANCH:
  • No open pull request: outerlayer/<factory>/<item number>. The factory name is lowercased and cut to letters, digits, ., _ and -; if that changes it, 8 hex characters of a hash are added. acme item 7 builds on outerlayer/acme/7; Acme Prod on outerlayer/acme-prod-<hash>/7.
  • One open pull request in the item’s repository: its head branch.
  • Anything else is refused.
An existing outerlayer/… branch that is not merged is continued; a missing or merged one starts from the default branch. Any other branch must exist on the remote, or the build fails at provision. The runner checks the branch out before the command starts, in a container build too, and logs on <branch> at <commit>, or why it could not. A workdir that is not a git checkout is left as it is. A git worktree whose branch another worktree has checked out fails the build, so have a custom hook create a detached worktree. A container build may push only that branch. See The git remote and the build’s tokens.

Refusals

A refused claim records a failed build with the code as its reason, uses up the build request, and logs #<item> skipped: <code>. The log line the gateway named no branch means the gateway is too old. Upgrade it.

Opening the pull request

outerlayer work open-pr opens the pull request from the claim’s branch into the default branch, or edits the one already open. Its refusals:

Gateway contract

These are the calls between the runner and the gateway. The runner protocol numbers their version.

Heartbeat

After every poll the runner sends POST /v1/hosts/heartbeat with:
  • host: the runner.host name, which defaults to this machine’s hostname.
  • cliVersion: the CLI version the runner is running.
  • runnerProtocol: the runner protocol.
  • pollSeconds: the poll interval.
  • slotsUsed and slotsTotal: the slots in use and the total.
  • workRequest: the result of this poll’s own request for work. That is success, or the error code and message. Left out when the poll made no request.
  • lastUpdate: the runner’s last self-update: from and to versions, an outcome of updated, rolled_back, verification_failed, not_published or retrying, a reason (null for updated), and at, when it ended. Left out until the runner has made an update.
  • lowDisk: the free and needed bytes, sent only while the runner holds off for disk, meaning it claims no new work. Left out otherwise, which clears what the gateway stored. See Housekeeping.
  • blocked: the reason, sent only while the runner claims nothing because a check of its own failed. At most 500 characters. Left out otherwise, which clears what the gateway stored. The causes are in When the runner claims nothing.
The reply carries runnerVersion: the @outerlayer/cli version the gateway was built from. The runner moves itself to it when it is higher.

Host status

GET /v1/hosts (needs hosts.read) lists each host with those fields and:
  • the last heartbeat time;
  • the last successful request for work;
  • the last error, with its code, message and time;
  • lastUpdate: the last self-update, with the versions, outcome, reason and time above, or null before the host reports one;
  • lowDisk: the free and needed bytes while the host holds off for disk, or null. The status does not change: a host that holds off for disk can still be ok;
  • blocked: the reason while the host claims nothing because a check of its own failed, or null. A host with blocked set is not taking work. The status does not change here either;
  • a status.
The status is the first of these that holds:
  • stale: the last heartbeat is older than three times the host’s pollSeconds.
  • outdated: the last heartbeat carried a runner protocol below the gateway’s minimum, or none. See Upgrading the runner.
  • failing: the last request for work failed and no later success has cleared it.
  • ok: none of the above.

Runner protocol

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

Upgrading the runner

A runner started from its install layout updates itself to the gateway’s runnerVersion. A host that cannot update itself, because it has no install layout, moves onto it with outerlayer runner install. How updates work and their outcomes are in Keep the runner up to date. A host whose status is outdated keeps polling but takes no work. Its log says, and repeats every tenth poll:
To upgrade a host by hand, with runner.autoUpdate off:
  1. Install a current CLI the way you installed the first, such as npm install -g @outerlayer/cli.
  2. Run runner install --service from the CLI you just installed, such as "$(npm prefix -g)/bin/outerlayer" runner install --service. The outerlayer launcher in ~/.local/bin runs the old version, so do not use it here. The service starts the runner from runner/current, and a new npm install changes nothing until this command copies it into the layout and points current at it.
  3. Wait. The runner sees the new current on its next poll, takes no new work, waits for its running builds to finish, and exits so its service starts the new version. No sudo systemctl restart is needed. See Upgrade by hand.
A runner without the install layout or a service: stop it with outerlayer runner stop, install the CLI, and run outerlayer runner start. After the restart its status returns to ok and it takes work again. To pause or hold updates for every host of a factory, see Pausing or holding updates for a factory.

Paths

The runner’s files live in <config dir>/runner/, by default ~/.outerlayer/runner/, owner-only. The service also reads runner.env beside the config file, not under runner/. It holds the runner’s Claude credential, CLAUDE_CODE_OAUTH_TOKEN or ANTHROPIC_API_KEY, and is read only when the service starts. Two factories on one machine need two config files and two runners. See Several factories, one machine. Inside each job directory:

Item key

A build never holds the runner’s key. It gets an item key (olitem_…) as OUTERLAYER_API_KEY; a build under a built-in hook gets a placeholder and the runner adds the key to its gateway calls. The key acts only on its own item and stops working when the claim ends, at most 24 hours after it began, so runner.timeLimitMinutes is capped at 1320. It can read the item, its threads, checks and changes; link and sync sessions; post comments; declare, open or edit the pull request; and emit artifacts, results and findings. See Build security. While the build runs, the runner also starts a session sender beside the command, in the same place: the microVM’s guest, the container, the host’s exec, or a child of the runner. The build’s sessions stream to the work item while the build runs, so the item shows the session and its cost so far within a minute of each turn. The sender sends under the item key and only sessions this build started. It writes to sender.log in the job directory, and a sender that fails never changes the attempt’s outcome. Before releasing, the runner stops the sender and runs outerlayer sync under the item key, so the build’s sessions upload, a container build’s included. The end-of-attempt sync stays the complete upload: a session streamed during the build ends with the same spans and cost as one uploaded once. Turns written after the sender’s last acknowledged upload of a killed build are lost. The log line the gateway returned no item key means the claim was released unstarted.

Permissions

A runner’s key needs four: A key with only work.claim fails on the first tick. work.claim is on no dashboard role by default. A key without hosts.ingest still claims work, but its heartbeat is refused with 403 and the runner keeps polling. The runner logs the refusal once, then again every tenth poll while it continues, and the host is missing from GET /v1/hosts. Keep the runner’s config file out of a build’s reach.