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

# Write your own hooks

> Replace the runner's built-in hooks with scripts of your own: the variables a build gets, and what each hook must do.

Most hosts need none of this page. `outerlayer runner init` names built-in
hooks: [`builtin:vm` or `builtin:container`](/container-builds) where the
machine can isolate a build, and [`builtin:process`](#the-process-fallback)
where it cannot and you allow it. The full rule is in
[`outerlayer runner init`](/reference/cli-runner#outerlayer-runner-init).

Write your own hooks to give builds an isolation of your own, such as a
cloud VM or a sandbox your platform team runs. The runner runs the
provision hook before the command, the cleanup hook after it, and an
optional report hook at both ends. See
[What happens to one build](/run-work-on-your-machines#what-happens-to-one-build).

Name your scripts in the `runner` block of `~/.outerlayer/config.json`:

```json theme={"system"}
"hooks": {
  "provision": "/opt/outerlayer/provision",
  "cleanup": "/opt/outerlayer/cleanup",
  "report": "/opt/outerlayer/report"
}
```

Each value is the absolute path of an executable, with no arguments; see
[Hooks are executables](#hooks-are-executables-commands-are-shell-lines).
`provision` and `cleanup` are required. `report` is optional and is never a
`builtin:` value. See [the config file](/reference/config-file).

## The build's variables

Every hook and the command receive these, and so does everything they
start. They also inherit the runner's own environment, except any
`OUTERLAYER_*` variable and the secrets a destination's header names. A
container build gets only the variables named in `runner.build.variables`.

| Variable | Holds |
| - | - |
| `OUTERLAYER_URL`, `OUTERLAYER_APP_ID` | The factory, from the config file's top-level `url` and `appId`. |
| `OUTERLAYER_API_KEY` | The item key the claim returned. It acts only on the claimed item while the claim is live; see [the item key](/reference/cli-runner#item-key). Built-in hooks pass a placeholder; see [The gateway and Claude](/container-builds#the-gateway-and-claude). |
| `OUTERLAYER_WORK` | The claimed item's number. The session-start hook that `outerlayer init` installs for Claude Code reads it to link the session. Another agent needs a hook like it. |
| `OUTERLAYER_JOB_DIR` | Where this build's files live. |
| `OUTERLAYER_BRANCH` | The branch the build works and pushes on, when the claim names one. |
| `GIT_AUTHOR_NAME`, `GIT_AUTHOR_EMAIL`, `GIT_COMMITTER_NAME`, `GIT_COMMITTER_EMAIL` | The identity the build commits as, when the claim names one. |

For anything else about the item, call the CLI:

```bash theme={"system"}
outerlayer work status --item $OUTERLAYER_WORK --json
outerlayer work threads --item $OUTERLAYER_WORK --json
```

Neither carries the issue's body.

## Hooks are executables, commands are shell lines

A hook path is run directly: no shell, no arguments, no variables expanded.
`"/opt/outerlayer/provision.sh"` works. `"$HOME/bin/provision.sh"` and
`"/opt/outerlayer/provision.sh --fast"` fail with an error naming the path.
Put arguments and expansion inside the script. Command lines run through
`/bin/sh -c`, so variables in them expand.

**A hook that goes quiet is ended.** A hook silent in its log for
`idleLimitMinutes` gets `SIGTERM`, then `SIGKILL`, logged as
`#9 provision idle for 30m, ended`. No hook may outlast `timeLimitMinutes`.
A provision hook ended this way fails the build. A cleanup or report hook
ended this way leaves the build's outcome as it was. Print progress.

## The provision hook

Runs before the command, in `$OUTERLAYER_JOB_DIR`. It receives
[the build's variables](#the-builds-variables) and these:

| Variable | Holds |
| - | - |
| `OUTERLAYER_BUILD_DIR` | This build's directory. It holds the tunnel's socket, `tunnel.sock`, and one socket per destination, `dest-<n>.sock`. The runner removes it after cleanup. |
| `OUTERLAYER_REPOSITORY` | The item's repository, as `owner/name`. Unset when the item records none. |
| `OUTERLAYER_RECIPE_COMMIT` | The commit the repository's default branch points at. Unset when it could not be read. |
| `OUTERLAYER_MEMORY`, `OUTERLAYER_CPUS`, `OUTERLAYER_PIDS`, `OUTERLAYER_DISK` | The limits of the [`build` block](/container-builds#the-build-block): by default `8g`, `4`, `4096` and `40g`. |

### provision.out

The hook writes `key=value` lines to `$OUTERLAYER_JOB_DIR/provision.out`:

| Key | Required | Meaning |
| - | - | - |
| `workdir` | Yes | The directory the command runs in. |
| `cgroup` | No | Absolute path of the control group the runner reads CPU use from for idle detection. |
| `isolation` | No | `container`, `vm` or `shared-user`: what the build records. Missing or unknown is recorded as `shared-user`. |
| `exec` | No | Absolute path of an executable that runs its arguments inside the build as the agent's user. The runner runs the command, lifecycle commands and `outerlayer sync` through it, and `workdir` is then a path inside the isolation. Without it the command runs on the host, with no proxy settings. |
| `broker` | No | `<ip>:<port>` where the tunnel also listens, for isolation with its own network. A literal IP other than `0.0.0.0` or `::`; IPv6 as `[fd00::2]:3128`. |
| `recipe` | No | Absolute path of a `recipe.json` in the form [`outerlayer runner image`](/reference/cli-runner#outerlayer-runner-image) writes. Its lifecycle commands run through `exec`, never on the host. |

A key the runner cannot honour, such as a relative path, fails the build at
provision and names the key.

### The tunnel and the proxy

The hook can mount `$OUTERLAYER_BUILD_DIR` into the isolation. With an
`exec`, `HTTPS_PROXY` and its siblings point at the `broker` address, or at
`127.0.0.1:3128` when there is none. Make that port reach the tunnel by
running the relay inside the build's network:

```bash theme={"system"}
outerlayer broker-relay --port 3128 --socket "$OUTERLAYER_BUILD_DIR/tunnel.sock" \
  --forward 3129="$OUTERLAYER_BUILD_DIR/dest-0.sock"
```

Each `--forward <port>=<socket>` relays one port to one destination; see
[Destinations](/destinations-and-secrets).

### Exit status

* **Exit 0** runs the command. A `workdir` that does not exist fails the job
  with outcome `failed`.
* **Exit 75** (`EX_TEMPFAIL`) declines the item: this host cannot take it
  now, for example with too little disk free. The runner releases the claim
  with no outcome, and the item stays queued for another host or a later
  poll.
* **Any other non-zero exit** fails the job with outcome `failed`.

```bash theme={"system"}
#!/bin/sh
set -e
if ! df -Pk . | awk 'NR==2 {exit ($4 < 6*1024*1024)}'; then
  exit 75  # not enough disk free right now
fi
if [ -z "$OUTERLAYER_REPOSITORY" ]; then
  echo "item $OUTERLAYER_WORK records no repository" >&2
  exit 1  # not 75: declining would retry an item that can never build
fi
workdir="$OUTERLAYER_JOB_DIR/work"
git clone --depth 1 "https://github.com/$OUTERLAYER_REPOSITORY.git" "$workdir"
echo "workdir=$workdir" > "$OUTERLAYER_JOB_DIR/provision.out"
```

The command then runs in `workdir`. It must not wait for input.

## The cleanup hook

Runs after the command, however it ends, and again on recovery for a job
the runner lost. It receives [the build's variables](#the-builds-variables)
plus:

* `OUTERLAYER_OUTCOME`: `ok`, `failed`, `timed_out`, `idle`, `lease_lost`,
  `interrupted` or `stopped`. Empty when provision exited 75.
  [Troubleshoot builds](/troubleshoot-builds#outcomes-and-reasons) explains
  each. It is `ok` for any command that exited 0: the runner reads the
  item's pull request and checks after cleanup, and only then can release
  the build `incomplete`.
* `OUTERLAYER_LOG`: the path of the command's output log.
* `OUTERLAYER_BUILD_DIR`: so the hook can unmount what it mounted there.

It must remove everything the build created, and running it twice must
change nothing.

The cleanup and report hooks may write one line to
`$OUTERLAYER_JOB_DIR/reason`. The runner sends it with the release, cut to
200 characters.

```bash theme={"system"}
#!/bin/sh
workdir="$OUTERLAYER_JOB_DIR/work"
if [ "$OUTERLAYER_OUTCOME" = "ok" ]; then
  rm -rf "$workdir"
else
  echo "kept $workdir for inspection" > "$OUTERLAYER_JOB_DIR/reason"
fi
```

## The report hook (optional)

Runs when a job starts, with `OUTERLAYER_PHASE=start`, and when it ends,
with `OUTERLAYER_PHASE=end`, `OUTERLAYER_OUTCOME` and `OUTERLAYER_LOG`. Use
it to comment on the issue. The log is always
`$OUTERLAYER_JOB_DIR/command.log`, though the start phase is not given it.

The end phase's `OUTERLAYER_OUTCOME` is the
outcome the release carries. So a command that exited 0 while the item has
no pull request, or its checks still fail, is `incomplete` here. See
[When the command exits 0](/run-work-on-your-machines#when-the-command-exits-0).

## The process fallback

A machine that cannot isolate builds can still run them through the tunnel
with both hooks set to `builtin:process`. `runner init --allow-process-builds` sets this on a machine without Docker Engine 26 or
later; without the flag, `runner init` refuses there.

```json theme={"system"}
{ "runner": { "hooks": { "provision": "builtin:process", "cleanup": "builtin:process" } } }
```

The runner clones the repository and runs the command as the runner's user,
with the same proxy variables a container build has. The build records
`shared-user`.

This is not a boundary. The build can read the runner user's files, the
runner's environment (including [destination](/destinations-and-secrets)
secrets and Claude's credential), the item key and the config file. It has
the host's network. `npm`, `git`, `curl` and Node 22.21 or later honour the
proxy variables; a tool that ignores them bypasses the tunnel and shows
nothing in the release. Use `builtin:container` where you can.


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