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 except container-start takes --config <path> (default ~/.outerlayer/config.json), the config file whose runner block it reads. A command that fails prints one line on standard error and exits 1.

outerlayer runner start

Runs the runner until it is stopped.
On start it sends again any release an earlier attempt could not land. A pid file left by a crashed runner is overwritten. The new runner adopts a left-behind build whose command still runs. For a container build, it reads the exit code from the build’s volume. It releases a build whose command is gone, and whose volume holds no exit code, with outcome interrupted. It exits 1, naming the problem, when:
  • the config file is not valid JSON, or lacks url, apiKey or appId (outerlayer runner init asks for a runner key and writes all three; see Give the runner its key);
  • the runner block is absent, a queue it enables has no command, or a required hook key is missing;
  • runner.queues names a queue other than implement or amend;
  • a key inside runner or its build, housekeeping, checks or update block is unknown or out of range;
  • a hook does not exist, is not a file, or is not executable; builtin:container, builtin:process and builtin:vm must be given for both the provision and the cleanup hook or for neither;
  • a destination’s header names a variable the runner’s environment does not hold;
  • another runner is running on the same config (the message names its pid);
  • the gateway refuses the API key (401 or 403).
A gateway that does not answer is not a refusal: the runner logs the gateway did not answer at start and starts anyway. A gateway that fails later does not end the runner either: it logs the failure and retries on the next tick. See Troubleshoot builds. When the gateway names a higher CLI version, start installs it, switches to it and exits with status 75, so the service starts it again. See Upgrading the runner. A service runner restarts at once when every running build carries on through the restart. A container build does, and so does a microVM build that the runner can adopt. Otherwise it waits for the running builds to finish. A runner started by hand waits for them too. 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 container-start

The entrypoint of the runner container image. You do not run it by hand.
It reads OUTERLAYER_URL, OUTERLAYER_APP_ID, OUTERLAYER_API_KEY, OUTERLAYER_RUNNER_NAME and one Claude credential from the environment. OUTERLAYER_AGENT is optional and defaults to claude. Then it:
  1. Checks that /var/run/docker.sock is in the container and leads to Docker Engine or Docker Desktop. It refuses an engine older than version 26 and names the version.
  2. On a first start in an empty volume, writes the config as outerlayer runner init --agent claude does, with builtin:container hooks and runner.name set from OUTERLAYER_RUNNER_NAME (or from the earlier OUTERLAYER_HOST). It never writes the Claude credential to the volume.
  3. On a later start, uses the config in the volume. It exits 1 when a variable disagrees with the config’s factory, key or runner name.
  4. Starts the runner in image mode.
It exits 1, naming each missing variable, when a required variable is not set. In image mode:
  • The runner never updates itself. It writes the version the factory names to update-status.json in its folder, and logs once the image tag to pull.
  • SIGTERM, which docker stop sends, takes no more work, leaves every build running, and exits 0.
  • runner stop sends SIGUSR2: the runner takes no more work and keeps managing its builds. It returns at once, and the container stays up.
  • runner status, runner status --json and doctor name the tag to pull when the factory names a newer version or refuses the runner as too old. --json adds an image object with the version the container runs and an update with the tag and a reason of newer or refused.

outerlayer runner stop

Stops the runner on this config.
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. To end running builds at once:
Running builds end now with outcome stopped, their cleanup still runs, and their claims are released. Ctrl-C on a foreground runner drains; a second Ctrl-C stops it now. A container build keeps running when its runner restarts or crashes, so stopping or restarting the runner does not end it. The runner that starts next picks the build up. Use --now to end a container build on purpose. A build whose runner never comes back stops when its lease expires. A microVM build keeps running when its runner restarts or crashes. This needs the root helper and systemd 254 or later. A service stop or restart (systemctl stop or systemctl restart) leaves such a build running, and the runner that starts next adopts it. outerlayer runner stop still drains the runner, and --now ends the build on purpose. A microVM build on a host without those ends with its runner. See A microVM build outlives its runner.

outerlayer runner status

Shows the runner’s state and its builds, read from the job files.
To see finished builds as well as running ones:
This lists finished builds, newest first, under the running ones.
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. While an update is pending, a line says which version. When the update waits for a build, a line under it names the build and why. A build is a reason to wait when it does not carry on through a restart. The runner holds new work for the update only when the gateway refuses its version or the update has waited longer than runner.update.maxWaitHours. Then the line also names the builds it waits for. See When the runner holds work for an update.
After runner install, a runner that waits for builds says so on a line that starts update to 0.10.1 installed. --json carries the same facts as pendingUpdate: version, since, via (self-update or install), waits with the item and reason of each build, and holding with the builds it waits for. status still answers when the config no longer validates.

outerlayer runner check

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

outerlayer runner install

Moves a host onto the install layout a runner needs to update itself. With --service, it also sets up the service that keeps the runner running.
Without --service, it copies the CLI that is running into <config dir>/runner/versions/<version>/, points current at it, writes the launcher ~/.local/bin/outerlayer, and prints the ExecStart= line that starts the runner from current. It works offline. It exits 1, changing nothing, when the CLI is not installed in a node_modules directory, as in a source checkout. Running it again re-copies the same version. When it moves current to a different version, it prints when the service restarts on the new version. It restarts nothing itself. A runner its service started through current sees the switch on its next poll. 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, 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. The unit runs on Linux, inside the Mac’s VM and inside WSL2. On WSL2 the command also sets up a Windows scheduled task. See What the service sets. 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 and sets nothing up when the host has no systemd, or when the hooks are your own executables. It does the same when concurrency times build.memory does not fit in the memory the runner can use. It names the reason, and in the last case it names both figures.

outerlayer runner uninstall

Removes what runner install --service set up.
--unit-name <name> names the unit to remove, when it is not the default outerlayer-runner. It stops and disables the unit, removes the unit file and reloads systemd. On a Mac it also removes the launch agent, and on WSL2 the scheduled task, when schtasks.exe can start. Otherwise it prints schtasks /Delete /F /TN <task> to run in Windows. It leaves a unit it did not write alone.

outerlayer runner init

Adds a runner block to the config file and makes the host key. With no runner key in the config file, it first asks you to paste one. See The runner key.
It keeps every other key, writes placeholder command lines unless --agent is given, writes no hook script, and prints the host key’s fingerprint. It ends by running outerlayer runner install --service. A refusal there is a note and the command still exits 0, because the config it wrote is useful. The hooks it names: 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 and no login (run outerlayer login first), or the file is not valid JSON;
  • the config has no apiKey, and no login is saved;
  • a runner key is to be asked for and no factory is named, there is no terminal to paste it in, or the gateway refuses the pasted key. See The runner key;
  • the config’s url, apiKey or appId is missing, empty or blank, or apiKey or appId still holds a <...> placeholder such as <factory-id>. The refusal names the field and the config file, and comes before any VM or service step;
  • a runner block exists;
  • the machine cannot isolate a build and --allow-process-builds is absent (Docker Desktop alone is not enough);
  • on macOS, neither --vm nor --no-vm is given and there is no terminal (the refusal names the runner image on Docker Desktop, then --vm);
  • --vm is given and limactl is missing;
  • --agent names an agent other than claude;
  • --agent claude is given, nothing is piped on stdin, and neither the claude.env nor the runner.env beside the config holds a Claude credential (the refusal shows the piped form). With --vm the new VM has no claude.env, so the credential must be piped;
  • the value on stdin is not a Claude credential (the refusal does not repeat it).
Answering no to the macOS question is the same as --no-vm. outerlayer doctor on a runner host reports the isolation on its Build isolation line. It reports the variable holding Claude’s credential on its Claude credential line. Its Host key binding line says whether the runner key is bound to this host’s key. See Run builds on macOS and Run builds on Windows. outerlayer doctor on a runner host checks a Runner key with the route that the runner uses to check its host key, /v1/runner/host-key. The Runner preset does not hold factory.read, so doctor does not read the factory with the key. A key that the gateway refuses fails the Factory key line. A factory that does not exist also fails the Factory key line, because the gateway refuses a key that is sent for another factory. A key without work.claim fails the Factory line. On a runner host with builtin:container or builtin:vm hooks, outerlayer doctor leaves out the Claude Code version line and the Cloud sync line. Each build runs Claude Code and sends its sessions from inside its own image. With builtin:process hooks, or hooks that you wrote, doctor keeps both lines. On a host with builtin:vm hooks, the MicroVM jailer line says whether the root helper runs each microVM build under Firecracker’s jailer. It passes when the helper answers. It warns when the helper is missing or too old for this CLI. The warning says that a build that escaped its microVM would reach the runner’s account. For a missing or old helper, the fix is outerlayer runner install --service --replace. See The microVM helper. The line also warns that microVM builds will not survive a restart. It does so when the helper is missing. It does so too when the service’s unit sets no descriptor store. The fix is outerlayer runner install --service --replace. The line also warns when an account other than the runner’s can write the runner’s directory of builds. The helper then refuses every microVM build. An earlier release could leave that directory writable by the runner’s group. The runner makes it private before its next microVM build. To fix it at once, run chmod 700 on the directory the warning names. The line also warns when the helper runs a Firecracker other than the one this release pins. A helper keeps the Firecracker it was installed with when the CLI updates. Builds still run under the jailer, with the helper’s Firecracker, and each one logs the same warning. The fix is outerlayer runner install --service --replace.

The runner key

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

The agent

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

outerlayer runner image

Builds the image a repository’s builds run in, from the devcontainer file of the repository that governs it. A repository governed by a control plane builds the control plane’s image, at the commit the gateway stored for it. A repository that governs itself builds from its own default branch.
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. The command asks the gateway named in the runner config which repository governs. Without a runner config it builds the repository’s own devcontainer. A private repository needs a git login on this host. Exits 0 when the image is built or reused. Otherwise it exits 1 with a code: 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.
This prints item 42’s newest build log and keeps printing until interrupted:
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.

Update settings

The control plane in a governed build

outerlayer claude [args…] starts Claude Code with the control plane that governs the checkout attached. On a laptop, outerlayer claude asks OuterLayer which control plane governs the checkout and which commit it stored. It keeps a composed copy of that commit in ~/.outerlayer/context/<owner>/<repo>/<commit>/, records the copy in the checkout’s git directory, and starts Claude Code with the options below. Nothing from the control plane is written into the working tree. With OuterLayer unreachable it starts with the last copy and says the copy may be out of date. See Share instructions across repositories. A governed build reads its control plane from a directory outside the checkout. The runner fetches the control plane on the host. It copies it write-protected to ~/.outerlayer/context/<owner>/<repo>/<commit>/ in the build user’s home. It records the delivery in the checkout’s git directory, which is not part of the working tree. Nothing from the control plane is written into the checkout, at any point in the build. In a governed build, claude runs outerlayer claude. The runner writes a claude program to ~/.outerlayer/bin/ and puts that directory first on the PATH of the build’s command, in every build. outerlayer claude [args…] finds the delivery record and starts the image’s Claude Code with --add-dir <directory>, --settings <directory>/settings.json, CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD=1 and OUTERLAYER_CONTROL_PLANE_DIR=<directory>, followed by your arguments unchanged. A configured command needs no change. A command such as claude -p "Build the issue" works as it is. Where no control plane governs the checkout, it starts Claude Code with your arguments alone. A build of a repository that governs itself is an example. The runner sets OUTERLAYER_GOVERNANCE=self in such a build, so the launcher asks the gateway nothing and does not check the Claude Code version. When the checkout keeps skills in .agents/skills/, the launcher also adds --add-dir <directory> for a folder under ~/.outerlayer/context/own/. That folder links each of those skills, so Claude Code loads them under their own names. The launcher refuses Claude Code older than 2.1.284 in a governed checkout. The runner refuses the build earlier, at provision, from the version in the image. outerlayer hook SessionStart copies nothing into the checkout when OUTERLAYER_CONTROL_PLANE_DIR is set. It still records the session start, renews the lease and uploads the session. The runner refuses a build, before the agent starts, whose repository has a skill in .claude/skills/ or .agents/skills/ with the same name as a shared skill. It also refuses a build whose control plane declares a layout the runner’s CLI does not read. See Failure reasons. A control plane’s hooks name their scripts as ${OUTERLAYER_CONTROL_PLANE_DIR:-$CLAUDE_PROJECT_DIR}/.claude/…. The delivery also rewrites $CLAUDE_PROJECT_DIR/.claude, "$CLAUDE_PROJECT_DIR"/.claude and ${CLAUDE_PROJECT_DIR}/.claude in the delivered settings.json. Run outerlayer doctor in a control plane to name any hook command that reaches .claude/ another way.

Host checks

A repository’s where: host validators declare commands the runner 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. As a result, a runner 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. Before each command it removes the file OUTERLAYER_CHECK_ITEMS names. After the command it reads that file, and records the check items with the result.
  6. Removes the container, or stops the microVM and deletes its build disk.
The gateway stores each result with the recorder host and the runner’s 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 judge stage

A where: host check that declares a judge is run by the judge stage, not by a command. The stage runs after a build ends ok and before the host checks. outerlayer runner status and runner logs show judge while it runs. For each build the runner:
  1. Stops the build’s processes, then checks that the Claude Code program is the one it started the build with.
  2. Reads the item’s recorded criteria and the check items the runner recorded last on the check.
  3. Settles each criterion it can without the judge. A criterion keeps its last result when nothing the judge reads about it changed. A criterion with an artifact proof is skipped. A criterion with no proof and no citing test fails. When it settles every criterion, no judge runs.
  4. Copies criteria.json (the criteria left to judge), citations.json and diff.patch into /outerlayer/judge, and the files of the pushed commit into /outerlayer/judge/tree. The judge reads the tests there, never the build’s checkout, so a file the build changed and did not push is not what it judges.
  5. Starts a fresh Claude Code agent with the check’s brief, and a section after it that says how to answer. The judge has no shell. It writes its answer to items.json, and ends with a line result: pass or result: fail.
  6. Records the result with the runner’s own key under the recorder host, with one check item per criterion and a report made from them as its proof.
The stage needs the built-in Claude relay and a host credential. The judge runs inside the build’s image, so the image must hold Claude Code. The judge reads four things about a criterion: its text, its declared proof, each test file that cites it, and the brief. Their hash is the check item’s inputs. A change to a helper file that a citing test imports does not count, so a person still reviews such a change. A judge with subjects: none has no criteria, and the runner judges the whole change every time.
The judge runs in the build’s own environment, not a fresh one. A build that can become root, through sudo or Docker, can replace programs the judge relies on, such as the Node that hashes Claude Code. The hash check does not catch that. Treat the judge’s verdict on such a build as the build’s own until the judge gets a fresh environment.
The check is recorded as a fail, with a one-line reason, when: If the runner cannot read the repository’s definitions, it logs judge not run, because it cannot tell which checks are the host’s. It then records a fail on every row whose newest host result is a pass, with the reason the definitions could not be read: <why>. So an earlier attempt’s pass does not stand for the new commit. A row no runner has recorded keeps the result CI or a session gave it.

The branch a claim names

A claim names every repository the factory connected, with the branch its build works on in each. The item’s own repository is the primary. The primary’s branch is passed to the build as OUTERLAYER_BRANCH, and all of them as OUTERLAYER_REPOSITORIES (see The repositories in a build). In each repository:
  • 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 that 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. A repository whose GitHub App installation is suspended is left out of the claim when it is not the primary. The claim answer lists it under unreachable, as { "repository": "acme-labs/api", "reason": "installation_suspended" }, and the field is an empty list when nothing was left out. The runner logs one line for each entry and builds in the repositories it was given.

Refusals

A refused claim records a failed build with the code as its reason, uses up the build request, and logs #<item> skipped: <code>. A runner below protocol 16 can also get runner_protocol_too_old, which the gateway answers without recording a lease, so it uses up nothing. 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.name value, 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.
  • state: what the runner is doing, the first of these that holds. Protocol 21 and newer send it. Left out otherwise, which clears what the gateway stored.
    • draining: outerlayer runner stop was run. The runner takes no new work, waits for running builds to end, and keeps sending heartbeats.
    • restarting: the runner is restarting onto an installed version.
    • holding-for-update: the runner holds new work for an update.
    • blocked: a check of its own failed. blocked carries the reason.
    • low-disk: the runner holds off for disk. lowDisk carries the bytes.
    • taking-work: none of the above.
  • waitingItems: the item numbers of the builds a draining runner waits on, at most 200. Sent only while state is draining and a build is running.
  • takes: the queues the runner polls, and the include and exclude repository patterns it applies, at most 50 patterns of 200 characters each. truncated is true when the lists were cut. The gateway uses it to tell whether any runner would take an item.
The reply carries runnerVersion: the @outerlayer/cli version the gateway was built from. The runner moves itself to it when it is higher. It also carries stops: one entry for each build of this runner that someone asked to stop through OuterLayer, with the claimId, the requestedByName and the note. The runner ends each build it holds in stops with the session synced and cleanup run. It then releases the claim with outcome stopped and a reason that names who stopped it and carries the note. It ignores a claim it does not hold and one it has already started to end. A runner older than protocol 21 ignores stops.

Host status

GET /v1/hosts (needs hosts.read) lists each runner 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 runner reports one;
  • lowDisk: the free and needed bytes while the runner holds off for disk, or null. The status does not change: a runner that holds off for disk can still be ok;
  • blocked: the reason while the runner claims nothing because a check of its own failed, or null. A runner with blocked set is not taking work. The status does not change here either;
  • state, waitingItems and takes: as the last heartbeat sent them. state and takes are null for a runner older than protocol 21, and waitingItems is null unless the runner named items;
  • a status.
The status is the first of these that holds:
  • stale: the last heartbeat is older than three times the runner’s pollSeconds.
  • draining: the last heartbeat said state is draining. A draining runner keeps heartbeating until its builds end, so it reads draining and not stale.
  • 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. Protocol 16 reads every repository a claim names and builds in all of them. A claim below 16 in a factory with more than one connected repository is refused with 426 and runner_protocol_too_old. A factory with one repository still serves older runners. Protocol 21 adds state, waitingItems and takes to the heartbeat and stops to its reply. The gateway’s minimum stays 7. An older runner still works. It cannot be stopped through OuterLayer and reports no state. Protocol 22 reads unreachable from a claim and logs one line for each repository listed. The gateway’s minimum stays 7. An older runner ignores the list.

Mixed versions

When the runner and the gateway are on different versions, the verdict read after an exit of 0 can be skipped:
  • A gateway too old to return a verdict is read as passing.
  • A gateway that refuses incomplete is sent the release again as ok, with the same reason.
  • A runner older than runner protocol 12 releases such a build ok.

Upgrading the runner

A runner started from its install layout updates itself to the gateway’s runnerVersion. A runner 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 runner whose status is outdated keeps polling but takes no work. Its log says, and repeats every tenth poll:
To upgrade a runner 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 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 runner 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 two files beside the config file, not under runner/, only when it starts:
  • claude.env holds the runner’s Claude credential, CLAUDE_CODE_OAUTH_TOKEN or ANTHROPIC_API_KEY. On systemd 247 or later the unit loads it with LoadCredential=. On older systemd it loads it with EnvironmentFile=.
  • runner.env holds destination secrets and settings.
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. For this reason, runner.timeLimitMinutes is capped at 1320. It can read the item, its threads, checks and changes. It can link and sync sessions and post comments. It can 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. The sender runs in the same place as the command: 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. 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 runner is missing from GET /v1/hosts. Keep the runner’s config file out of a build’s reach.