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.interrupted.
It exits 1, naming the problem, when:
- the config file is not valid JSON, or lacks
url,apiKeyorappId(pipe a factory key intoouterlayer loginfor it first); - the
runnerblock is absent, a queue it enables has no command, or a required hook key is missing; runner.queuesnames a queue other thanimplementoramend;- a key inside
runner,runner.build,runner.housekeepingorrunner.checksis unknown or out of range; - a hook does not exist, is not a file, or is not executable;
builtin:container,builtin:processandbuiltin:vmmust 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).
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:
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.- the config or a hook fails a check
startruns; - an enabled queue’s command is still the placeholder
runner initwrote; - built-in hooks find neither
CLAUDE_CODE_OAUTH_TOKENnorANTHROPIC_API_KEY, in the shell or in therunner.envbeside the config. When the credential comes from that file, the output names it; - a
claudecommand’s--permission-modeis not one Claude Code accepts:acceptEdits,auto,bypassPermissions,default,dontAsk,manualorplan. The message names the command and the value; - the gateway refuses the key, or the key lacks
work.claim.
$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.
--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 whatrunner install --service set up.
outerlayer runner init
Adds arunner 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 loginfirst), or it is not valid JSON; - a
runnerblock exists; - the machine cannot isolate a build and
--allow-process-buildsis absent (Docker Desktop alone is not enough); - on macOS, neither
--vmnor--no-vmis given and there is no terminal (the refusal names--vm); --vmis given andlimactlis missing.
--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 betweenok 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’swhere: 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:
- 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.
- Starts a new container from the build’s image, or for a
builtin:vmbuild a new microVM booted from the build’s root disk, with the same limits, tunnel destinations andallowHostsas the build. It holds no OuterLayer key, no gateway address and no model credential. Its git remote serves fetches only. - Checks out the branch the claim names, and runs the recipe’s lifecycle commands again.
- Writes the folder
OUTERLAYER_EVAL_INPUTnames: 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. - Runs each command, one at a time, and records its result with the runner’s own key.
- Removes the container, or stops the microVM and deletes its build disk.
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 asOUTERLAYER_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.acmeitem 7 builds onouterlayer/acme/7;Acme Prodonouterlayer/acme-prod-<hash>/7. - One open pull request in the item’s repository: its head branch.
- Anything else is refused.
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 sendsPOST /v1/hosts/heartbeat with:
host: therunner.hostname, which defaults to this machine’s hostname.cliVersion: the CLI version the runner is running.runnerProtocol: the runner protocol.pollSeconds: the poll interval.slotsUsedandslotsTotal: 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:fromandtoversions, anoutcomeofupdated,rolled_back,verification_failed,not_publishedorretrying, areason(nullforupdated), andat, 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: thereason, 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.
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, ornullbefore the host reports one;lowDisk: the free and needed bytes while the host holds off for disk, ornull. The status does not change: a host that holds off for disk can still beok;blocked: the reason while the host claims nothing because a check of its own failed, ornull. A host withblockedset is not taking work. The status does not change here either;- a status.
stale: the last heartbeat is older than three times the host’spollSeconds.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 with426 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’srunnerVersion. 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:
runner.autoUpdate off:
- Install a current CLI the way you installed the first, such as
npm install -g @outerlayer/cli. - Run
runner install --servicefrom the CLI you just installed, such as"$(npm prefix -g)/bin/outerlayer" runner install --service. Theouterlayerlauncher in~/.local/binruns the old version, so do not use it here. The service starts the runner fromrunner/current, and a new npm install changes nothing until this command copies it into the layout and pointscurrentat it. - Wait. The runner sees the new
currenton its next poll, takes no new work, waits for its running builds to finish, and exits so its service starts the new version. Nosudo systemctl restartis needed. See Upgrade by hand.
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.