> ## Documentation Index
> Fetch the complete documentation index at: https://docs-stg.outerlayer.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# What a build gets

> What a build gets when the runner starts it in a container or a microVM, and how to size, limit and stop it.

A container build runs each item in a locked-down container of its own. The
runner starts it, serves its only network exit, and holds every credential
the build uses.

Set both the provision and the cleanup hook to `builtin:container`:

```json theme={"system"}
{
  "runner": {
    "commands": { "implement": "claude -p \"/build\" --permission-mode bypassPermissions" },
    "hooks": { "provision": "builtin:container", "cleanup": "builtin:container" },
    "build": { "memory": "8g", "cpus": 4 }
  }
}
```

Name both hooks or neither. The host needs Docker Engine 26 or later. If it
is older or Docker is not running, the build fails at provision and says
which.

## MicroVM builds

With both hooks set to `builtin:vm`, each build runs in a Firecracker
microVM with its own kernel. The host must be x86\_64 Linux, with Docker
Engine 26 or later and Docker Buildx 0.18 or later. The runner's account
must be able to open `/dev/kvm`. `runner init` names
`builtin:vm` on such a host; the full rule is in
[`outerlayer runner init`](/reference/cli-runner#outerlayer-runner-init).
`outerlayer doctor` fails `builtin:vm` hooks on any other host, and says
why.

The microVM is made from the repository's build image, with the same tunnel
and tokens a container build has. The tunnel, the destinations, the branch
rule and the [outcomes and reasons](/troubleshoot-builds#outcomes-and-reasons)
apply to it too, except `disk_limit_exceeded` and `disk_check_failed`. Its
`build` limits work differently: see [The `build` block](#the-build-block). It has no network device: its connections leave over vsock to the
runner's tunnel. A recipe that adds the docker-in-docker feature gets its
own Docker daemon inside the microVM. That is how a build that needs Docker
runs.

Containers and `docker build` steps reach public hosts through the tunnel
too. Provision writes the build user's Docker client configuration. The
Docker CLI and Compose use it to give each container and each
`docker build` step proxy variables. The variables name the tunnel at
`http://172.17.0.1:3128`. The same `allowHosts` list and refusals apply. The
tunnel answers `403` to a host the list omits, and each host a container
reaches is among the hosts reached. A direct connection from a container
fails, because the microVM has no network device.

Three limits apply:

* A container that calls another container by service name over HTTP must
  list that name in `NO_PROXY`. The defaults skip the proxy for
  `localhost`, `127.0.0.1`, `::1`, `10.0.0.0/8`, `172.16.0.0/12` and
  `192.168.0.0/16` only.
* A tool that ignores proxy variables needs its own proxy setting. Node's
  `fetch` is one: set `NODE_USE_ENV_PROXY=1` in the container.
* A container that you start through the Docker API, not the Docker CLI or
  Compose, gets no proxy variables. The Supabase CLI and Testcontainers do
  this.

The first microVM build downloads a pinned Firecracker release, a guest
kernel and a small guest helper from github.com. The runner checks each
file against a sha256 pinned in the CLI, and fails the build at provision
if one differs.

## The build's container

The runner builds the repository's build image, as
[`outerlayer runner image`](/reference/cli-runner#outerlayer-runner-image)
does, and starts one container from it. The container has no network but
loopback, a read-only root filesystem, and one volume for the clone. Your
home directory, other builds and the Docker socket are out of reach. The
runner mounts no folder of its own into the container: the build's files are
copied into `/outerlayer/job` through `docker exec`.

Commands run as the recipe's `remoteUser`, else as the build image's own user.
When that is root, or neither names one, they run as `ol-agent`. The name
`ol-relay` and the user and group id 60100 belong to the relay that carries
the build's connections to the runner. A build image that already uses
that id fails its image build, and a `remoteUser` named `ol-relay` or
with that id fails the build.

A lifecycle command that exits non-zero fails the build at `provision`,
with a reason naming the command.

## The repositories in a build

A container or microVM build checks out every repository connected to the
factory, each on the item's branch, so one session can change several. The
item's own repository is the primary. In a build with more than one
repository, every repository is at `/build/work/repos/<owner>/<repo>`, with
the owner and repository written in lower case, such as
`/build/work/repos/acme/web` for `Acme/Web`. The primary is one of them, and is also at
`/build/work/checkout`. A factory with one repository gets one checkout at
`/build/work/checkout` and no `repos` directory.

In each repository the build starts on the branch the claim named there. That
is the head branch of the item's open pull request in that repository. When
there is no such pull request, it is `outerlayer/<factory>/<item number>`. See [The branch a claim
names](/reference/build-reference#the-branch-a-claim-names).

`OUTERLAYER_REPOSITORIES` is a JSON list of the checkouts. Each entry has
`repository`, `path`, `branch` and `primary`:

```json theme={"system"}
[
  { "repository": "acme/api", "path": "/build/work/checkout", "branch": "outerlayer/acme/7", "primary": true },
  { "repository": "acme/web", "path": "/build/work/repos/acme/web", "branch": "outerlayer/acme/7", "primary": false }
]
```

The agent decides from the issue which repositories to change. It then opens
one pull request per changed repository with
[`outerlayer work open-pr --repository <owner/name>`](/reference/cli-work#outerlayer-work-open-pr).

The primary decides where the agent starts. The governed context is delivered
to a directory outside its checkout, and `GH_REPO` and `OUTERLAYER_BRANCH` name
the checkout. `gh` holds a
read token for the primary only. Each repository's push token reaches the
build only through the runner, which limits it to that repository.

The build image and the setup commands do not come from the primary. They come from
the repository that governs the build: the control plane, or the repository
itself when it governs itself. The setup commands run once, in that
repository's checkout. See [Build a repository's image from its devcontainer
file](/build-image-from-devcontainer#which-repositorys-file-it-reads).

## Host checks across repositories

After a build, the runner runs the host checks once, in a new container or
microVM started from the build image. The checks are the `where: host`
checks of the item's own repository, or of its control plane. Checks that the
factory's other repositories declare do not run.

Every repository of the claim is checked out there, at the same fixed paths as
in the build. Each is at the branch the build pushed there, or at the
repository's default branch where it pushed none. Host checks therefore run
with every pushed branch checked out. Each check's command runs in the
primary checkout, so it can test them all. A failing test in any of them
fails the item.

The setup commands run first, read at the commit the build's instructions came
from. A pull request that edits the control plane's devcontainer changes
nothing until it merges. A script a setup command calls is read from the
checkout, which can be a build's branch.

A build that pushed no branch in any repository fails every check, with a
reason that says the build pushed no branch.

A runner older than protocol 16 cannot build in more than one repository. A
factory with more than one connected repository refuses its claim with
`runner_protocol_too_old`, and the item stays available to a newer runner. See
[Runner protocol](/reference/build-reference#runner-protocol). Builds that run your
own hooks, and `builtin:process`, work in one repository.

## The `build` block

| Key | Default | Meaning |
| - | - | - |
| `memory` | `8g` | Memory limit, with no swap. |
| `cpus` | `4` | CPU limit. |
| `pids` | `4096` | Process limit. |
| `disk` | `40g` | The most the build's volume may hold. Checked every thirty seconds. |
| `variables` | `[]` | Names of host variables passed into the build. |
| `allowHosts` | none | The public hosts a build may reach. With none, every public host. See [Limiting the hosts a build reaches](#limiting-the-hosts-a-build-reaches). |

A microVM build uses the first four keys in its own way:

* `memory` is the microVM's memory. Under the service that
  `outerlayer runner install --service` writes, the host also limits the
  microVM to `memory` plus 1 GiB, with no swap. See
  [Memory limits](/run-a-host-as-a-service#memory-limits).
* `cpus` is the number of virtual CPUs the microVM gets. Give a whole number.
  Any other value stops the microVM from starting, and the build fails at
  provision.
* `pids` does not apply. The runner sets no process limit in a microVM build.
* `disk` is the size of the microVM's build disk, a sparse file on the host.
  The disk holds `/build`, `/tmp` and the data of the build's Docker daemon,
  if it has one.
  A write past it fails with "No space left on device". The runner does not
  measure the disk, so it does not end the build with `disk_limit_exceeded`
  or `disk_check_failed`.

`variables` is the only way a host variable enters a build. Claude's
credential reaches it only as a placeholder. Repository tokens (`GH_TOKEN`,
`GITHUB_TOKEN`, `GH_ENTERPRISE_TOKEN`, `GITHUB_ENTERPRISE_TOKEN`) are never
passed; the runner logs each name it skipped. A name the runner sets itself,
such as `HOME`, `PATH` or a proxy variable, is refused.

To size the host for these limits, see
[Memory limits](/run-a-host-as-a-service#memory-limits).

## The gateway and Claude

Every build the runner provisions with a built-in hook (`builtin:vm`,
`builtin:container` or `builtin:process`) gets two built-in
[destinations](/destinations-and-secrets). The build holds neither the item
key nor Claude's credential.

| Destination | Forwards to | The runner sets | The build sees |
| - | - | - | - |
| `gateway` | The `url` in the runner's config file | `Authorization: Bearer <item key>` | A local `OUTERLAYER_URL` and a placeholder `OUTERLAYER_API_KEY` |
| `claude` | `https://api.anthropic.com` | The host's Claude credential | A local `ANTHROPIC_BASE_URL` and a placeholder credential |

The runner reads Claude's credential once, when it starts:
`CLAUDE_CODE_OAUTH_TOKEN` (from `claude setup-token`), or else
`ANTHROPIC_API_KEY`. It reads them from its own environment. When that holds
neither, it reads the `claude` file in `$CREDENTIALS_DIRECTORY`, where a
service on systemd 247 or later loads `claude.env`; see
[What the service sets](/run-a-host-as-a-service#what-the-service-sets).
Restart the runner after a change. A terminal `claude` login is not
forwarded. With neither set, each build fails
at `provision` as `agent_credential_missing`. See
[Give the runner Claude's credential](/set-up-a-host#set-it-up).

A build your own hooks run gets no built-ins. It holds the item key as
`OUTERLAYER_API_KEY`; see [The build's variables](/write-your-own-hooks#the-builds-variables).

## The tunnel

The container's only way out is a tunnel the runner serves for that build.
A container build reaches the runner through `docker exec`, so the tunnel is
not a socket in the build's directory. The runner sets the standard proxy variables to it, `http://127.0.0.1:3128`.
A tool that ignores them cannot connect; set pip's `--proxy` or Maven's
`settings.xml` to that address in the recipe.

Not every tool reads those variables. A tool's own documentation says where
it takes its proxy from. A tool that ignores them fails with a connection or
name-resolution error, because the build has no other way out. A host the
tunnel refuses gets a `403` instead. So a download that fails without a `403`
usually means a tool that needs its own proxy setting.

A task runner that filters the environment hides the proxy variables from
every task it starts. Turborepo does this by default. List `HTTP_PROXY`,
`HTTPS_PROXY`, `NO_PROXY`, their lowercase forms and `NODE_USE_ENV_PROXY` in
`globalPassThroughEnv` in `turbo.json`, or a task that downloads anything,
such as a Next.js build that fetches Google Fonts, cannot connect.

<Frame>
  <img className="block dark:hidden" src="https://mintcdn.com/outerlayer-stg/M2z6K_ww2sAtqE1w/images/build-network-light.svg?fit=max&auto=format&n=M2z6K_ww2sAtqE1w&q=85&s=8cacfe5124457e17ba6f1d24ba13ac6f" alt="A build's only exits are four ports on 127.0.0.1. Port 3126 reaches the gateway destination and the OuterLayer gateway, 3127 the claude destination and api.anthropic.com, 3129 and up your destinations and services, and 3128 the runner's tunnel. The tunnel carries git and gh to github.com with the build's tokens added, and reaches public hosts, limited by allowHosts when set. The host itself and private, loopback, link-local, Tailscale and several other special-purpose addresses are always refused with a 403." width="660" height="530" data-path="images/build-network-light.svg" />

  <img className="hidden dark:block" src="https://mintcdn.com/outerlayer-stg/M2z6K_ww2sAtqE1w/images/build-network-dark.svg?fit=max&auto=format&n=M2z6K_ww2sAtqE1w&q=85&s=5d11570672284d0c524ef817c2889dd9" alt="A build's only exits are four ports on 127.0.0.1. Port 3126 reaches the gateway destination and the OuterLayer gateway, 3127 the claude destination and api.anthropic.com, 3129 and up your destinations and services, and 3128 the runner's tunnel. The tunnel carries git and gh to github.com with the build's tokens added, and reaches public hosts, limited by allowHosts when set. The host itself and private, loopback, link-local, Tailscale and several other special-purpose addresses are always refused with a 403." width="660" height="530" data-path="images/build-network-dark.svg" />
</Frame>

A build cannot reach these addresses:

* the host's own addresses
* private and loopback addresses
* link-local addresses, including the cloud metadata address `169.254.169.254`
* Tailscale addresses
* the Azure wire server `168.63.129.16`
* the ranges `192.0.0.0/24` and `198.18.0.0/15`
* NAT64 addresses
* 6to4 addresses (`2002::/16`) that carry a refused IPv4 address

A refusal is a `403` naming the range. Listing such a
host in `allowHosts` does not change that. To reach a private service, make it
a [destination](/destinations-and-secrets).

## Limiting the hosts a build reaches

<Warning>
  By default a build can reach every public host. It can send anything it
  reads to any of them, such as a `build.variables` value or its read token.
  Set `build.allowHosts` to close that.
</Warning>

```json theme={"system"}
{
  "runner": {
    "build": {
      "allowHosts": ["registry.npmjs.org", "registry.yarnpkg.com", "github.com", "*.githubusercontent.com"]
    }
  }
}
```

An entry is a host name, an IP address, or a pattern such as
`*.githubusercontent.com`, which matches names under the domain but not the
domain itself. An empty list refuses every public host. An unlisted name gets
a `403` with the range `not-allowed`. The list does not limit the gateway,
Claude, your destinations or the git remote. It does not limit the image
build either, which runs outside the tunnel. See
[What an image build reaches](/build-security#what-an-image-build-reaches).

To find the hosts your builds need, run a few without the list, then run
`outerlayer doctor` on the host. While the list is unset, its "Build hosts"
check warns and lists every host past builds reached, ready to paste into
`allowHosts`. Review it first: a host a build should never have reached is
in it too. Each build's own list is `hosts` in its `environment` (see
[What a build records](#what-a-build-records)).

## The git remote and the build's tokens

A container build holds no credential that can write to its repository. Git
and `gh` reach GitHub through the runner, which adds short-lived read and push
tokens the gateway issues
([GitHub App permissions and build tokens](/github-app-and-build-tokens)).
`gh` can read, such as `gh pr view`, but not write.

**The branch rule.** A push may update only the claim's branch in the
repository it goes to. A push to a repository the claim does not name is
refused before it reaches GitHub. On an
`outerlayer/…` branch any update goes through, force-pushes included;
elsewhere only creating the branch and fast-forwards. A refused push updates
no ref, the runner logs `push refused: <refs>`, and git prints:

| Push refused | Git prints |
| - | - |
| Another ref | `this build may push only to <branch>` |
| Force-push or delete outside `outerlayer/` | `<branch> is not an outerlayer/ branch, so this build may only fast-forward it` |
| Over 2 GiB, outside `outerlayer/` | `the push is larger than the <bytes> bytes the runner holds to check it` |
| Check failed, outside `outerlayer/` | `the runner could not check whether this push fast-forwards <branch>` |
| Unreadable body | `the runner refused this push: <why>` |

<Warning>
  A gateway with no GitHub App configured refuses every read token with
  `repository_tokens_unavailable`, so every container build fails at
  provision. Configure the App first.
</Warning>

## A governed repository's context

When a control-plane repository governs the item's repository, a container
build receives the control plane's instructions, skills, agents and hooks.
A governed build reads its control plane from a directory outside the
checkout. Nothing from the control plane is written into the checkout, so a
commit cannot pick it up.

The runner fetches the control plane on the host and copies it,
write-protected, to `~/.outerlayer/context/<owner>/<repo>/<commit>/` in the
build user's home. The
directory holds the control plane's `AGENTS.md`, `.claude/` and `.outerlayer/`,
the maintained skills, a `CLAUDE.md` and a `settings.json`. It also holds the
control plane's `.agents/` folder, and each skill in `.agents/skills/` is
delivered under `.claude/skills/` too. Paths that start
with `.outerlayer/` or `.claude/` in the delivered `CLAUDE.md`, skills and
agents point into that directory.

In a governed build, `claude` runs `outerlayer claude`. The runner puts a
`claude` program ahead of the build image's Claude Code on the command's `PATH`.
That program starts Claude Code with the directory attached. The main session
and its subagents then follow the control plane. A configured command needs no change.
A command such as `claude -p "Build the issue"` works as it is.

Every build puts that `claude` program first, including a build of a
repository that governs itself. There it starts the build image's Claude Code with
the arguments alone, asks the gateway nothing, and does not check the Claude
Code version. It adds the checkout's own `.agents/skills/` skills, so Claude
Code loads them under their own names.

The release records the control-plane repository and the commit that was
delivered. The work item then shows which version of the shared rules a build
ran under.

The build is refused before the agent starts in two cases:

* The repository has a skill in `.claude/skills/` or `.agents/skills/` with the
  same name as a shared skill, so a repository could replace a shared one.
  Rename or remove it.
* The build image's Claude Code is older than 2.1.284. Rebuild the build
  image.

A build is also refused when the control plane declares a layout the
runner's CLI does not read. See `context_layout_unsupported` in
[Outcomes and reasons](/troubleshoot-builds#outcomes-and-reasons).

The runner reads the control plane through the GitHub App. The gateway issues
a read-only token for that one repository, and the runner uses it for the
fetch. The token is never written to a file or a log, and the build never sees
it. The App's installation must include the control plane.

A host git login is needed only without a GitHub App. A gateway that has none
issues no token, so the runner reads the control plane with the host's git
login. The host also needs a git login for `outerlayer runner image` on a
private repository. `outerlayer doctor` checks all of this as
`Control plane access`.

Each refusal, and what to do about it, is in
[Outcomes and reasons](/troubleshoot-builds#outcomes-and-reasons).

## Stopping a build

Stopping a build stops its container. Cleanup removes the container and
volume; a runner that crashed finishes it on restart.

A container build keeps running when its runner restarts or crashes, and
stops when its claim expires with no runner to renew it. See
[Start and stop the runner](/run-a-host-as-a-service#start-and-stop-the-runner).
Its command keeps its output and exit code in the build's volume. If the
command ended while no runner was there, the build ends with the exit code
the volume holds, or as `interrupted` when it holds none. The item can then
be built again.

If the runner logs
`cleanup failed`, its next [housekeeping](/run-a-host-as-a-service#housekeeping)
pass removes what the build left, within the hour. To free the space
sooner, remove what it names with `docker rm --force` and
`docker volume rm`.

## What a build records

`outerlayer work status --json` shows each build's `environment` under
`claims`: the isolation, image, hosts reached and refused, variable and
destination names, and CLI versions. It never holds a secret.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.