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.interrupted.
It exits 1, naming the problem, when:
- the config file is not valid JSON, or lacks
url,apiKeyorappId(outerlayer runner initasks for a runner key and writes all three; see Give the runner its key); - 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
runneror itsbuild,housekeeping,checksorupdateblock is 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, 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.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:
- Checks that
/var/run/docker.sockis in the container and leads to Docker Engine or Docker Desktop. It refuses an engine older than version 26 and names the version. - On a first start in an empty volume, writes the config as
outerlayer runner init --agent claudedoes, withbuiltin:containerhooks andrunner.nameset fromOUTERLAYER_RUNNER_NAME(or from the earlierOUTERLAYER_HOST). It never writes the Claude credential to the volume. - 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.
- Starts the runner in image mode.
- The runner never updates itself. It writes the version the factory names to
update-status.jsonin its folder, and logs once the image tag to pull. SIGTERM, whichdocker stopsends, takes no more work, leaves every build running, and exits 0.runner stopsendsSIGUSR2: the runner takes no more work and keeps managing its builds. It returns at once, and the container stays up.runner status,runner status --jsonanddoctorname the tag to pull when the factory names a newer version or refuses the runner as too old.--jsonadds animageobject with theversionthe container runs and anupdatewith thetagand areasonofnewerorrefused.
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:
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:
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.
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.
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.- the config or a hook fails a check
startruns, including an empty or<...>placeholderapiKeyorappId; - 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, in the service’s credentials directory, or in theclaude.envorrunner.envbeside the config. When the credential comes from a 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 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 whatrunner 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 arunner 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 loginfirst), 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,apiKeyorappIdis missing, empty or blank, orapiKeyorappIdstill 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
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 the runner image on Docker Desktop, then--vm); --vmis given andlimactlis missing;--agentnames an agent other thanclaude;--agent claudeis given, nothing is piped on stdin, and neither theclaude.envnor therunner.envbeside the config holds a Claude credential (the refusal shows the piped form). With--vmthe new VM has noclaude.env, so the credential must be piped;- the value on stdin is not a Claude credential (the refusal does not repeat it).
--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 noapiKey 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.
- It finds the factory:
--factory, then--app-id, thenOUTERLAYER_APP_ID, then the config’sappId, 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 printsUsing factory <org>/<factory>, the only one this login can use.If there are several, it refuses and lists each as a--factoryvalue to pass. - 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. - 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. - 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. - Without
--vm, it writesurl,apiKeyandappIdinto 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 ownrunner init, and never writes it on the Mac.
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-tokenstartssk-ant-oatand is stored asCLAUDE_CODE_OAUTH_TOKEN. An API key startssk-ant-apiand is stored asANTHROPIC_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.envholds the one new line. It replaces what the file held, because the runner usesCLAUDE_CODE_OAUTH_TOKENwhen both are set.- Every line of
runner.envthat does not setCLAUDE_CODE_OAUTH_TOKENorANTHROPIC_API_KEYis 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.envorrunner.envis 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 initdid not write is left alone, andrunner initprintssudo systemctl restart <unit>. - With
--vm, the agent and the credential go to the VM on the stdin of the VM’s ownrunner init, beside the runner key. The credential is stored in the VM’sclaude.envbefore its service starts.
--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:
<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.
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’swhere: 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:
- 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. Before each command it removes the file
OUTERLAYER_CHECK_ITEMSnames. After the command it reads that file, and records the check items with the result. - Removes the container, or stops the microVM and deletes its build disk.
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
Awhere: 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:
- Stops the build’s processes, then checks that the Claude Code program is the one it started the build with.
- Reads the item’s recorded criteria and the check items the runner recorded last on the check.
- 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. - Copies
criteria.json(the criteria left to judge),citations.jsonanddiff.patchinto/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. - 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 lineresult: passorresult: fail. - 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.
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 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 asOUTERLAYER_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.acmeitem 7 builds onouterlayer/acme/7;Acme Prodonouterlayer/acme-prod-<hash>/7. - One open pull request in that 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.
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 sendsPOST /v1/hosts/heartbeat with:
host: therunner.namevalue, 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.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 stopwas 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.blockedcarries the reason.low-disk: the runner holds off for disk.lowDiskcarries 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 whilestateisdrainingand a build is running.takes: the queues the runner polls, and theincludeandexcluderepository patterns it applies, at most 50 patterns of 200 characters each.truncatedistruewhen the lists were cut. The gateway uses it to tell whether any runner would take an item.
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, ornullbefore the runner reports one;lowDisk: the free and needed bytes while the runner holds off for disk, ornull. The status does not change: a runner that holds off for disk can still beok;blocked: the reason while the runner claims nothing because a check of its own failed, ornull. A runner withblockedset is not taking work. The status does not change here either;state,waitingItemsandtakes: as the last heartbeat sent them.stateandtakesarenullfor a runner older than protocol 21, andwaitingItemsisnullunless the runner named items;- a status.
stale: the last heartbeat is older than three times the runner’spollSeconds.draining: the last heartbeat saidstateisdraining. A draining runner keeps heartbeating until its builds end, so it readsdrainingand notstale.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.
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
incompleteis sent the release again asok, 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’srunnerVersion. 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:
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 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 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.envholds the runner’s Claude credential,CLAUDE_CODE_OAUTH_TOKENorANTHROPIC_API_KEY. On systemd 247 or later the unit loads it withLoadCredential=. On older systemd it loads it withEnvironmentFile=.runner.envholds destination secrets and settings.
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.