Self-hosted runner quickstart (plain Linux / Proxmox)¶
Editions: OSS, Cloud, Enterprise. Unless stated otherwise, everything on this page ships in OSS.
Installed the runner from the preloop login prompt on your own laptop? Start with
Your machine as a Preloop runner, which covers what it
reports, what it can run and how to remove it. This page is for setting up
and tuning a runner by hand. Copilot CLI, Cursor CLI, Claude Code and Devin
CLI get generated host profiles, so a hand-written profile for them is an
advanced override. Codex, OpenCode and DeepSeek native flows still need a
hand-written profile (details).
The Preloop CLI is the self-hosted runner. It registers itself with your Preloop control plane, holds an outbound WebSocket, leases flow executions for your account, and runs the agent in a local Docker container: same model as GitHub/GitLab self-hosted runners. No inbound ports, no Kubernetes.
Running on a developer laptop instead? The Windows quickstart and macOS quickstart cover those platforms, where host execution profiles are the primary mode.
Requirements¶
- Linux x86_64 or arm64 (bare metal, VM, or Proxmox guest).
- Docker Engine (
docker infomust succeed as the runner user). - Outbound HTTPS (443) to your Preloop control plane. Nothing inbound.
- systemd, if you want the managed service mode (
runner enable).
Proxmox notes¶
- VM (recommended): any Linux VM works as-is. Install Docker, done.
- LXC container: Docker-in-LXC needs a privileged container or an unprivileged one with nesting enabled:
If docker info fails inside the container after this, use a VM: the runner refuses jobs when Docker is unavailable and reports
docker is not available back to the execution log.
1. Install the CLI¶
curl -fsSL https://preloop.ai/install/cli | sh
# or, from source:
go install github.com/preloop/preloop/cli/cmd/preloop@latest
2. Authenticate against your control plane¶
export PRELOOP_URL=https://preloop.example.com # your control plane
preloop login --headless # prints a URL, paste the code
# CI/service accounts can skip login and set an API token instead:
export PRELOOP_TOKEN=<account-api-token>
Precedence: --token/--url flags > PRELOOP_TOKEN/PRELOOP_URL env >
~/.preloop/config.yaml (written by preloop login).
Install during login¶
On a terminal, preloop login asks whether to install a runner as a
background service. The default answer is no. The service is a systemd
user unit. It starts on login, connects outbound to the instance you just
signed in to, can run flow executions on this machine, and reports
installed agent harnesses (names, versions, signed in or not). It does
not read or forward harness credentials. Remote sessions stay off until
you enable them per harness.
Consent wording (what the runner may see and do): https://docs.preloop.ai/security/personal-runners/.
An account policy of required installs without a question and prints
why. forbidden prints one line and does not install. Skip the step with
--no-runner or PRELOOP_NO_RUNNER=1. Headless login, --token,
PRELOOP_TOKEN, and a shell that is not a terminal never ask and never
install. A service that is already installed is left as it is.
Run the same step later with preloop runner setup. A decline is
remembered in ~/.preloop/config.yaml (runner_prompt_answered_at).
preloop login --force asks again. Remove the service with
preloop runner disable.
The runner registers as the machine hostname with labels personal,
os:<os> and arch:<arch>.
3. Run the runner in the foreground (first test)¶
Add --concurrency N to run more than one execution at a time.
You should see Runner <name> (<id>) connecting... then
Connected. Waiting for jobs. The runner registers itself on first run
and stores its identity in ~/.preloop/runner.json; restarts resume the
same runner. If the WebSocket drops (proxy idle timeout, laptop sleep,
control-plane restart), the process reconnects with backoff instead of
exiting; a job already running in Docker keeps going and reports
complete on the new socket. The console Runners page updates online/offline
status over the account websocket without a refresh. Ctrl-C unregisters
cleanly.
4. Route a flow to the runner¶
Private runners are the default. Once this runner is online, a flow with
no runner_pool (and no account default of server) leases to any
online private runner. Pin a pool only when you want a specific machine
or label, or set server to opt into hosted compute:
The account default is on the console Runners page. Override per-run from CI / the CLI:
When stdin is not a TTY (CI), flow trigger waits by default, streams
execution logs to stdout, and exits non-zero on FAILED / STOPPED /
TIMEOUT. If the CI job is cancelled, the CLI stops the execution
before exiting (see cancelled CI jobs).
If no runner in the chosen private pool has a free slot, the job queues
for 15 minutes and then fails. Hosted compute is used only when no
private runner is online, or when the flow or account default is
server.
5. Install as a service (survives reboots)¶
preloop runner enable # writes a systemd user unit + enables it
preloop runner start
preloop runner status # service state + last heartbeat + current execution
The unit pins HOME to the home directory of the user who ran enable, so
the service always reads that user's ~/.preloop/config.yaml. Run enable
again after changing HOME.
preloop runner stop, restart, and disable do what they say.
preloop runner status prints running: <held>/<slots> and one line per
execution the runner currently holds.
Headless machines: the unit is a systemd user service, so enable lingering once or it stops when your SSH session ends:
The service reads credentials the same way the CLI does; make sure
~/.preloop/config.yaml exists (via preloop login) for the user that
runs the service, since the unit does not inherit your shell exports.
Headless hosts (no keychain)¶
A host with no system credential store (a container or a minimal server)
cannot keep a Copilot login from copilot login --with-token: Copilot
reports that login succeeded but the token was not saved, and the runner
then reports Copilot signed out. Start the runner with
COPILOT_GITHUB_TOKEN set. The full procedure is in
Headless hosts (no keychain).
Rotate the token or retire the runner¶
preloop runner rotate-token # new token in runner.json, service restarted
preloop runner disable --delete # stop the service, then delete the runner
preloop runner disable --delete --force # also halt executions it still holds
rotate-token asks the server for a new runner token. The old token is
rejected from that moment, and a runner still connected with it is
disconnected. The new token is written to ~/.preloop/runner.json and never
printed.
disable --delete stops and removes the service, then deletes the runner on
the server and removes runner.json. The server refuses while the runner
still holds an execution; --force halts those executions the way the kill
switch does and deletes the runner anyway. Flows routed to the runner's
labels fall back to their configured runner pool behaviour.
The console offers the same two actions on the Runners page. Rotating from
the console does not show the new token: run preloop runner restart on the
machine and the service reconnects with a fresh one.
The API behind both is DELETE /api/v1/runners/{runner_id} (with
?force=true to halt held executions) and
POST /api/v1/runners/{runner_id}/token. Both need the same permission as
registering a runner.
Ephemeral (CI) mode: one job, then gone¶
A CI job is not a machine. It appears, runs one execution, and is deleted, so a runner registered from inside it must not survive it:
--ephemeral registers a runner that belongs to this process alone. It
never reads or writes ~/.preloop/runner.json, so it cannot take over or
overwrite a persistent runner's identity on the same host, and it
unregisters on every exit path: a finished job, Ctrl-C, SIGTERM, SIGHUP
from a dying CI shell. If the job is SIGKILLed, the control plane deletes
the row once its heartbeat lapses (45 seconds) rather than leaving an
offline runner in the console forever. While it is connected, the Runners
page shows an ephemeral badge next to its status.
--once exits after the first leased execution reaches a terminal state
and prints the execution URL as soon as the job is leased. The process
status is the job's verdict, which is what a CI step needs:
| Exit code | Meaning |
|---|---|
0 |
the execution SUCCEEDED |
1 |
the execution FAILED, STOPPED or TIMEOUT, or the runner stopped mid-job |
2 |
no execution was leased within --wait-for-job (default 15m) |
--labels defaults to ci-<hostname>-<pid> under --ephemeral, a label
nothing else can match, so a flow triggered with --runner ci-<...>
reaches this process and no other. Pass --labels yourself when you want
a label the trigger side already knows.
Without --labels on the trigger, a flow with no runner_pool can lease
to any online private runner, including this one. Pin both sides when a
CI job must run its own work.
Constraints worth knowing before you wire this into a pipeline:
- Linux hosts only. Docker execution on the runner needs Linux; the mode itself runs anywhere the CLI does, but jobs will not.
- Codex and OpenCode flows only, the same as any private runner.
- The agent image is pulled fresh on every job. A cold CI host pays
that download on each run; expect the first minutes of the step to be
a
docker pull. A persistent runner amortizes it, an ephemeral one cannot. - The runner needs
PRELOOP_TOKEN(orpreloop login) and a reachable control plane, same as a long-lived runner.
The GitHub Actions guide wires this into a workflow: trigger flows from GitHub Actions.
How many executions one runner runs¶
A runner holds 2 executions at once by default. Each one gets its own
workspace, its own log stream and its own halt: stopping one execution
does not disturb the other. Set the number with --concurrency, the
PRELOOP_RUNNER_CONCURRENCY environment variable (useful for service
units), or the config file:
The console Runners page shows running / slots per runner and lets an
account owner edit the ceiling, up to 32. The two values are not the same
promise: the stored ceiling is what the account allows, and a runner
process that starts with a lower --concurrency lowers it while it is
connected. It cannot raise it, because capacity on someone else's machine
is not the runner's decision. A runner is dispatchable until its slots are
full; busy now means no free slot rather than "holds a job".
Pick a number the machine can actually serve. Concurrent agents share CPU,
memory, disk and the Docker daemon, and each one may start containers of
its own. Flows that bind fixed host ports (for example a docker compose
file with ports:) collide when two of them run together; reach services
by container-network name instead.
Runner presence¶
The runner sends a heartbeat every 15 seconds. The control plane turns those heartbeats into one presence state per runner, and the Runners page and the runner picker show it. Hover the state to see the last heartbeat time.
| State | Meaning | Gets new work |
|---|---|---|
online |
Heartbeats are fresh and a slot is free. | Yes |
busy |
Heartbeats are fresh and every slot is taken. | When a slot frees up |
idle |
Online, and the host reported no user input for 5 minutes or more. | Yes |
asleep |
The runner said it was going to sleep before it disconnected. | No |
offline |
No heartbeat for 45 seconds and no sleep signal. | No |
idle appears only for runners that report OS idle time; older runners show
online instead. The runner reports it on macOS (ioreg HIDIdleTime) and
Windows (GetLastInputInfo); Linux runners do not report it and show
online.
Sleep and wake¶
| Host | Before sleep | After wake |
|---|---|---|
| Linux with systemd-logind | The runner holds a short logind sleep delay (released after at most 3 seconds), sends going_to_sleep, and stops heartbeating, so the runner shows asleep. |
Reconnects at once on logind's wake signal. |
| macOS | No signal in this build: IOKit power notifications need a native callback. The runner shows offline once heartbeats are 45 seconds old. |
Reconnects at once. |
| Windows | No signal in this build, for the same reason. Shows offline. |
Reconnects at once. |
| Linux without logind (containers, minimal hosts) | No signal. Shows offline. |
Reconnects at once. |
The runner detects wake on every host: the wall clock keeps running through
sleep, so a wall-clock gap 20 seconds or more longer than the 5 second sample
interval means the host slept. It then drops the socket left
over from before sleep and reconnects with the backoff reset, so the runner
is back to online within seconds of the network coming up instead of
waiting out a read timeout. A runner that wakes up and reconnects returns to online on
its first heartbeat, and the change is pushed to open console tabs without a
reload. The thresholds live in one place on the server
(backend/preloop/services/runner_presence.py).
How runner work counts against your account¶
A Preloop instance bounds how many executions one account may have admitted
at once on shared hosted compute (FLOW_EXECUTION_MAX_RUNNING_PER_ACCOUNT,
default 5). Work assigned to one of your own runners is bounded by that
runner's capacity instead and does not count against the hosted allowance,
so adding runners adds throughput rather than competing with it.
What the runner executes¶
Private Docker execution supports Codex and OpenCode. Update both the control plane and CLI together: old or unknown launch protocol versions fail explicitly. Other harness types require a hosted executor until their private launch adapter is implemented.
Launches without a configured workspace mount get a writable /workspace
tmpfs, so nonroot images such as the default OpenCode image can start without
creating directories under /. This temporary workspace uses Docker-host
memory and is discarded with the container. Repository scripts and test
binaries can execute there. For larger or retained workspaces, configure
agent_config.runner.persist_workspace or an explicit /workspace mount;
the mounted directory must be writable by the image's configured user.
The default tmpfs masks any files a custom image puts in /workspace. Package
reusable tools and dependencies elsewhere (for example, /opt), and supply
pre-populated working files through an explicit workspace mount.
The control plane builds a versioned launch specification using the same Codex/OpenCode script and environment builders as hosted execution. The CLI runs a static Docker bootstrap that launches this script. Repository clone, setup commands, prompt, model routing, MCP configuration and the existing post-execution git wrapper therefore run inside the container.
The prompt and (on Kubernetes) the inner agent script are not one
environment variable. They arrive as base64 chunks (PRELOOP_AGENT_PROMPT_*,
PRELOOP_INNER_SCRIPT_*), reassembled to AGENT_PROMPT_FILE
(/tmp/preloop/prompt.txt) and /tmp/preloop/agent-script.sh. AGENT_PROMPT
is set only when the prompt is 64 KiB or less. Custom images must not treat
a missing AGENT_PROMPT as an empty task. See
Agent launch payload.
Scripts and credentials are transient. Persisted leases contain configuration and execution references; delivery after a queue wait or reconnect regenerates the model, git and MCP credentials from the execution's stored trigger and resolved prompt. Changes to the leased flow configuration cause redelivery to fail so a retry can select the new settings. The process environment carries secret values rather than Docker command-line arguments. Root on the runner host can still inspect the container environment. Gateway-enabled runs receive a scoped flow token; direct-provider runs receive the configured provider key.
A zero exit code is insufficient. The agent must write a nonempty JSON object
to /workspace/result.json with a recognized status (success, succeeded,
pass, passed, or completed-evaluation fail) or audit verdict (pass,
passed, pass_with_findings, or fail). Failure/error and incomplete reports
do not confirm success. The runner removes stale results before launch,
requires exit zero, and sends the bounded report (256 KiB maximum) separately
from ordinary logs. Valid reports are also retained when the process exits
nonzero or reports failure; retaining evidence never promotes a failed run to
success. Malformed, oversized, empty or duplicate result envelopes are rejected.
The API independently checks the completion contract. The versioned vocabulary
and precedence cases live in backend/tests/fixtures/runner_completion_vocabulary.json;
both Go and Python tests verify their complete tables against this shared
docker_v1 contract. Update the fixture and both implementations together.
Workspace source and evidence archives are not uploaded by this protocol.
This is an agent completion report, not independent verification of its tests.
Ordinary output is sent in bounded batches about once per second, with a final flush before completion. Unsent output stays with the running process across reconnects (at most 4 MiB / 8192 lines; individual ordinary lines are truncated to 64 KiB). Exceeding the queue or partial-line bound fails completion because execution markers may have been lost. Transport is best effort: a disconnect after a successful socket write but before server persistence can lose that batch. The runner does not maintain an unbounded replay queue.
When the flow omits image / docker_image, the control plane uses the hosted
Codex/OpenCode default. The runner keeps the entrypoint of
ghcr.io/preloop/preloop-codex-universal and
ghcr.io/openai/codex-universal (a tag or a digest), because that entrypoint
initializes language runtimes. Custom images normally run with /bin/bash as
the entrypoint. They must provide Bash, Python 3, Git, Node/npm, writable
/workspace, a writable home directory, and the dependencies required by
repository setup/tests. The shared bootstrap installs the configured CLI
version. An image whose own entrypoint initializes its environment and
delegates arguments to Bash can opt into
agent_config.runner.preserve_image_entrypoint: true (use this for a mirror
under another name). Images that cannot execute this bootstrap fail
explicitly; an idle shell cannot be reported as successful work.
Harnesses are detected automatically¶
A runner reports which agent harnesses are installed on its host when it
registers and keeps the report current on its heartbeat. Nothing needs to be
configured: install and sign in to the harness as the user the runner runs as
(copilot login, cursor-agent login), then start or restart the runner.
preloop runner inventory # what this runner reports
preloop runner inventory --refresh # rebuild now; a running service picks it up within a minute
preloop runner inventory --json
The inventory is rebuilt when the runner starts, every 6 hours, after
preloop runner inventory --refresh, and within a minute of
preloop agents onboard or preloop agents offboard. The Runners page in
the console shows each harness with its login state, governance, models and
billing. A runner on an older CLI shows Inventory unknown there and keeps
working with hand-written profiles.
For GitHub Copilot CLI (copilot) and Cursor CLI (cursor-agent)
the runner also generates a host execution profile named copilot or
cursor, so a flow can run them without a profile file. Generated profiles
use conservative defaults: no repository checkout, no publication, no forced
writes and no tool grants beyond the harness default. Models listed for the
harness are offered as-is in the profile's model map.
For Claude Code (claude) the runner generates a profile named
claude when the executable is found and the login is verified, either
because ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN or
CLAUDE_CODE_OAUTH_TOKEN is set or because claude auth status --json
reports loggedIn: true (only that field is kept). Otherwise Claude Code is
reported as presence only and no flow is routed to it. A flow selects it with
agent type claude_code, agent_config.harness set to claude_code and an
optional agent_config.claude_code_model (opus, sonnet, haiku or a
model id seen in an earlier run). The runner starts
claude -p --output-format stream-json --verbose [--model <alias>] in a fresh
execution directory and writes the prompt to its standard input. Every
argument is runner-owned, so a hand-written Claude profile may set
model_map, workspace_root, timeout_seconds and pass_env, but not
argv or tool grants. Tool use is gated by the Preloop approval hook: run
preloop agents onboard "Claude Code" --approvals as the runner user first,
or the job fails with claude_approval_hook_missing before Claude starts.
The flow's Preloop tools are passed with --mcp-config from a per-job file
and allowed as mcp__preloop-flow. The runner never writes under
~/.claude, and the job and its login probe never open a Claude credential
file. (Agent discovery, which the inventory shares with preloop agents discover,
still checks whether a Claude credential file or keychain entry exists; that
check alone never makes Claude Code runnable.) The approval hook is found in ~/.claude/settings.json,
~/.claude/settings.local.json (or the same files under
$CLAUDE_CONFIG_DIR) or the managed settings file. Claude Code also runs
remote sessions.
What the runner checks, and what it never reads:
- Login. For Copilot CLI: whether
COPILOT_GITHUB_TOKEN,GH_TOKENorGITHUB_TOKENis set (the value is never read), otherwise whether~/.copilot/config.json(or$COPILOT_HOME/config.json) has a stored login record, of which only the host is read. On a host with no keychain, set one of those variables in the runner service environment (Headless hosts (no keychain)). A run that fails with "No authentication information found" marks Copilot signed out until the next successful run. For Cursor CLI: whetherCURSOR_API_KEYis set, otherwise the result ofcursor-agent status. The runner never opens a credential store and never reads, copies or forwards a harness token. - Account host.
COPILOT_GH_HOSTorGH_HOST, or the host of the stored login (for examplegithub.comor<name>.ghe.com). Host only. - Governance. The same checks
preloop agents statususes: Preloop hooks and the approval hook installed (governed), usage hooks or MCP only (partial), nothing installed (ungoverned). - Models. Copilot CLI has no command that lists models, so the runner
reports a short list shipped with the CLI (
static), themodelset in Copilot's config (configured) and models the seat's own runs reported (observed). A Copilot session turn or host execution that includes a model id in itsusageevent is remembered for that harness (the id only; paths, tokens and environment values are not) and published on the next inventory refresh. An id already in the static list is not repeated, and the most recently observed id is listed immediately afterauto. The static list is updated in the CLI when Copilot's models change (the 1.0.9x list includesclaude-sonnet-5.5and the older ids); update the runner's CLI to pick up a new list, then runpreloop runner inventory --refresh.
Executable paths, arguments, environment values and user names never leave the host.
Turning a harness off¶
To stop a runner offering a harness (it is still reported, marked disabled,
so compliance views see it), add it to ~/.preloop/runner.json:
Harness ids: copilot_cli, cursor_cli, claude_code, codex_cli,
opencode, gemini_cli, claude_desktop, vscode_copilot. Restart the
runner or run preloop runner inventory --refresh after editing.
Host execution profiles (advanced override, private only)¶
The generated copilot and cursor profiles above are the default path. A
hand-written profile file is the advanced override: use it to pin a working
directory, map team model aliases, grant Copilot tools, or opt in to
checkout and publication. A hand-written profile for the same harness, or
with the name copilot or cursor, replaces the generated one.
Docker remains the default, including a flow's custom image /
docker_image. A host execution profile is a separate, explicit
capability: the runner host runs a fixed local command instead of
docker run. This is not Agent Control, and it is not the
preloop cursor operator launcher.
Create ~/.preloop/runner-host-profiles.json (or point
PRELOOP_RUNNER_HOST_PROFILES at an absolute path):
{
"profiles": [
{
"name": "cursor-ask",
"executable": "cursor-agent",
"argv": ["--print", "--output-format", "stream-json", "--mode=ask"],
"workspace_root": "/home/example/src",
"timeout_seconds": 1800,
"force_writes": false,
"model_map": {"team-fast": "sonnet-4.6"}
}
]
}
The runner advertises profile names, capabilities and supported requested model
identifiers (at most 64 profiles and 64 models per profile). Executables, argv,
local aliases and credentials stay on the host. Restart preloop runner fg
after editing the file. On the flow, choose cursor, select a private runner
pool and set agent_config.host_exec_profile. Hosted compute never runs host
profiles. Windows and macOS runners support them; the
Windows quickstart and
macOS quickstart list the per-OS executable detection
paths, service install and Windows command-line limits.
An optional local model_map maps requested identifiers to Cursor aliases,
for example "model_map": {"team-fast": "sonnet-4.6"}. Every nonempty requested
model must match this map. The scheduler selects a runner advertising that
identifier, and the runner passes the mapped alias to Cursor. The legacy
pass_model field does not bypass this mapping.
The selected API model's credentials are never delivered to the host. Leave
the requested model empty to use Cursor Auto. Auto is Cursor's own
selector, not a named model such as Grok 4.7. Set the flow's Cursor model
to a Cursor id such as grok-4.7-high and map that same id in model_map
to pin it. An actual model is
recorded only when Cursor reports it, never inferred from the request.
The lease supplies the prompt as one argument after --, plus the profile,
requested model and deadline. It cannot inject an executable, extra argv,
environment, API key or session id. Only cursor-agent, agent and
copilot executables are accepted. Local argv cannot override runner-managed workspace, model,
resume or credential controls. The profile should retain stream-json output
so the runner can validate structured completion. force_writes defaults to false; enable it only for
a profile whose operator intends to permit writes.
Each job creates a fresh directory under
{workspace_root}/.preloop-host-exec/{execution_id}. workspace_root is
optional; when omitted, workspaces live under ~/.preloop/host-workspaces
(mode 0700). Existing directories and symlinks are rejected. This controls
working-directory placement, not OS filesystem access: Cursor runs as the
runner user with that user's local login and filesystem permissions. Use a
dedicated OS user or VM when stronger host isolation is needed. Halt,
cancellation and deadline expiry clean up the process group. The tighter
profile/flow timeout applies.
The job's environment is built from an allowlist, not inherited wholesale: a
per-OS system baseline (HOME, PATH, locale, proxy and TLS variables), the
harness's own variables (CURSOR_* for Cursor; COPILOT_*, GH_* and
GITHUB_TOKEN for Copilot, minus the BYOK overrides), and any names the
profile lists in "pass_env" (for example
"pass_env": ["SSH_AUTH_SOCK"]). The runner's own PRELOOP_TOKEN and
unrelated secrets in the operator's session never reach the job.
Cursor's local configuration, MCP servers and hooks apply. When the flow
allows MCP tools or servers, the runner also adds a preloop-flow MCP
server for that run only: a .cursor/mcp.json in the run directory (mode
0600, removed afterwards) plus --approve-mcps. Copilot profiles get the
same server through --additional-mcp-config. The server's token is scoped
to the execution, exposes only the flow's allowed tools and is revoked when
the run completes. The enforced controls are profile selection, explicit
model mapping, working-directory creation, deadline, cancellation, the
flow's MCP tool list and terminal result validation. This slice does not
add Agent Control or native session continuation. Runs use the operator's
Cursor plan or Copilot seat; no unlimited usage or inferred billing is
promised.
The runner exports PRELOOP_FLOW_EXECUTION_ID and PRELOOP_FLOW_ID to the
CLI. The Preloop usage hook forwards the execution id with each record, so
hook-observed sessions and events are linked to the flow execution (only
for host-profile executions of the same account). Records pushed without it
are linked at completion by the CLI session id.
Success requires exit zero and a successful structured result; exit zero
alone fails. Custom commands, clone setup_commands, workspace seeds,
native CLI session resume, pull request creation and isolated publication
are rejected before execution.
Repository checkout (allow_checkout)¶
A flow with git clone enabled gets a checkout plan with each lease: for
every repository the URL, branch, pinned commit (for a pull request, its
head commit and the refs that reach it), a path relative to the run
directory and a read credential from the flow's tracker. The plan is built
at delivery and never stored in pending_job. The runner clones only when
the profile opts in:
A Copilot profile that may also push one repository for a flow that opens a
pull request adds allow_publish (see
Copilot CLI publication):
Without allow_checkout the run fails with host_checkout_not_allowed.
With it, the runner:
- runs
git cloneinto the run directory (workspacefor a trigger project,workspace-1,workspace-2orworkspace/<path>for configured repositories; absolute and..paths are refused), then checks out the pinned commit, fetching the listed refs when the commit is not on the cloned branch; - passes the credential as an HTTP header scoped to that repository URL,
with redirects, terminal prompts, askpass and non-HTTP transports off,
and inherited
GIT_*overrides removed. The token never appears in the remote URL,.git/config, argv or the log. A plan that pairs a credential with a plainhttpURL is refused unless the host is loopback; - sets the flow's git user name and email in each clone and starts the prompt with a short note listing the checkout paths;
- stops the clone on halt or cancellation, and gives the checkout at most
15 minutes (or the profile timeout, when shorter) on top of the CLI
run's own deadline. Errors are reported as
host_checkout_failedorgit_not_installed.
A checked-out repository is untrusted input. The CLI runs as the runner
user in that directory, so repository instructions and tool configuration
reach it. Enable allow_checkout only on a profile whose runner user and
tool rules suit the repositories the flow clones, and use a dedicated OS
user or VM when stronger isolation is needed. Host runs do not push
branches or open pull requests unless a Copilot profile also sets
"allow_publish": true; see
Copilot CLI publication.
Otherwise use the Docker harness for flows that publish.
Running a host flow in an authorized directory¶
By default every host run gets a fresh execution directory. A flow can
instead work in a directory the runner owner listed with
preloop runner dirs add (the same list remote sessions use):
{
"harness": "claude_code",
"workspace": {"kind": "authorized_directory", "id": "dir_9f2c", "allow_dirty": false}
}
The control plane sends only the id. A run is leased only to a runner that
advertises that directory as write for the flow's harness, and the runner
re-checks the entry before the CLI starts: it refuses an unknown id, a
read_only entry, a harness the entry does not allow, the filesystem root,
the home directory, and a git work tree with uncommitted changes unless the
flow sets "allow_dirty": true. The dirty check runs after the runner
accepts the lease and before the CLI starts, so a slow git status never
delays heartbeats. Such a flow cannot also clone repositories or use
"harness_fallback": "fallback_server" (the directory does not exist on
hosted compute); the API refuses both when the flow is saved. Per-job files
(the MCP config) stay in the runner's own execution directory, so Cursor and
Devin flows with Preloop tools are refused in an authorized directory: both
read MCP configuration from their working directory. The CLI
runs with the directory's own project configuration, so authorize only
directories whose contents you trust. Two flows that name the same
directory can run at the same time; give them separate directories or limit
the runner's concurrency if that matters.
Copilot CLI profiles¶
Set "executable": "copilot" to run GitHub Copilot CLI under the runner
user's Copilot login. Choose agent type copilot on the flow and set the
optional copilot_model to a model_map alias. Copilot profiles take
allow_tools, deny_tools and allow_all_tools instead of
force_writes; allow_all_tools requires the Preloop approval hook. The
completion must carry exactly one Copilot result event with exit code 0.
The rest of this section applies unchanged. See
Copilot CLI
for the profile format and named errors, and
Copilot coverage for what this path does and does not
meter.
Trusted runner options¶
Private runners are machines you operate. Hosted executors ignore the
agent_config.runner block; only preloop runner honors it. Every flag
defaults off.
agent_config:
runner:
mount_docker_socket: true
persist_workspace: true
extra_mounts:
- /var/cache/builds:/cache:ro
network: preloop-trusted
mount_docker_socket: bind/var/run/docker.sockinto the agent so it can start sibling containers (for exampledocker compose up).persist_workspace: keep/workspaceon the host at~/.preloop/workspaces/<execution_id>(mode 0700). A later job whose payload includesresume_fromreuses that directory. Directories older than 24 hours that are not the current job are deleted on each lease; override the window withPRELOOP_RUNNER_WORKSPACE_TTL_HOURS.extra_mounts:host:container[:ro]bind mounts. Host paths must be absolute.network: Docker network to join (--network). Created if missing.
Every job also sets COMPOSE_PROJECT_NAME=preloop-<short execution id>
so docker compose up gets isolated containers, networks, and volumes
per execution. When more than one runner can run at the same time,
compose files should avoid fixed host ports and reach services by
container-network name instead.
Enable these options only on machines you own. Mounting the Docker socket gives the agent the same privileges as the runner user.
Troubleshooting¶
| Symptom | Fix |
|---|---|
docker is not available in execution log |
docker info must work as the runner user (add to docker group, or fix LXC nesting). |
no agent image in payload |
The flow's agent type has no default image and no image/docker_image was set. |
| Execution FAILED after ~15 min queued | No runner matching runner_pool was online; check preloop runner status and labels. |
| Service dies after SSH logout | sudo loginctl enable-linger $USER. |
| Runner shows offline after IP change | Restart: preloop runner restart: registration resumes from ~/.preloop/runner.json. |
Runner connection recovery¶
The runner reconnects when a WebSocket read or write times out. With a server that supports log acknowledgments, it retains unacknowledged log batches and replays their stable IDs after reconnecting, so the server stores each line once. Terminal reports are also retained for reconnect, and execution completion is committed together with releasing its runner lease. A late report cannot replace a timeout or cancellation that the server has already recorded.
The pending batches and terminal report are held in the CLI process's memory. They do not survive stopping or restarting the CLI process. Servers without log acknowledgment support retain the older best-effort log delivery behavior. The server records received PR and native-session handoff markers before final completion, which preserves that metadata if a later terminal report is lost. A created PR by itself does not mean the agent process has finished or that the execution succeeded.
Account runner requirements¶
See Account runner requirements for console policy settings, compliance and member warnings.
Codex, OpenCode and DeepSeek native profiles¶
Private runners can execute installed Codex (codex), OpenCode (opencode)
and DeepSeek CLI (dsh) directly on Windows, macOS and Linux without Docker.
This path uses the runner user's existing CLI configuration. It requires an
explicit local host profile and a private runner pool; it does not silently
switch to hosted compute. Existing Docker flows keep their existing behavior
when no host profile is selected.
Onboard Codex through Preloop as the runner user
(preloop agents onboard "Codex CLI" --approvals) and keep that
configuration: its model_provider = "preloop" sends every model call through
the Preloop gateway with the agent's own key. For each run the runner adds the
execution marker header to that provider with a command-line override (your
config.toml is not edited), so the gateway attributes the run's requests to
the flow execution as well as the agent, enforces the flow's run limits, and
the result records gateway_metered: true and gateway_request_count. Codex
exposes no per-request count, so a Codex run passes this check with at least
one gateway row. For Claude Code the runner reports the exact model call count
(cli_model_calls), and a run whose CLI calls exceed its gateway rows by more
than 2 calls and more than 20% fails with gateway_bypassed too. Do not
point the runner at a separate CODEX_HOME without the Preloop provider: the
run would call the model service directly and end FAILED with
gateway_bypassed. Claude Code profiles work the same way when Claude Code is
onboarded to the gateway (ANTHROPIC_BASE_URL on Preloop); the marker is sent
with ANTHROPIC_CUSTOM_HEADERS. A flow that must run a Codex or Claude Code
host CLI unmetered can set agent_config.allow_unmetered_host_model: true
(break-glass, recorded on the result as gateway_bypass_allowed); read-only
review never allows it.
For example, add these profiles to the local runner-host-profiles.json
file (use provider and model names configured in your local CLI):
{
"profiles": [
{"name": "local-codex", "executable": "codex", "model_map": {"team-fast": "local-model"}},
{"name": "local-opencode", "executable": "opencode", "model_map": {"team-fast": "provider/model"}},
{"name": "local-deepseek", "executable": "dsh", "model_map": {"team-fast": "provider/model"}}
]
}
These adapters manage their own command arguments; leave argv empty.
Use agent_type: codex, opencode or deepseek, respectively, and set
agent_config.host_exec_profile to the local profile name. The advertised
harness capabilities are codex_cli, opencode and deepseek. Configure the
fixed executable and model map locally; the runner owns arguments and the
control plane cannot supply an
arbitrary host command. Map requested model identifiers to local CLI aliases
in the profile's model_map, and select the alias with codex_model,
opencode_model or deepseek_model in agent_config. Unknown requested
models fail closed instead of silently selecting another model.
Onboard the CLI's Preloop governance hooks or plugin before enabling the profile. Native permissions and Preloop approval policy both apply; installing a profile does not grant blanket approval.
A command that matches a rule requiring approval does not run and does not
hold the hook open. The execution pauses (WAITING_FOR_HUMAN), the runner job
is stopped and its credential released, and a normal approval request naming
the flow, the execution, the command and the rule appears in Approvals and
on your notification channels. The window is the flow's
approval_window_seconds (or the workflow and account defaults). If it is
approved, the run resumes and that exact command may run once. If it is
refused, cancelled or the window closes, the run ends FAILED with
approval_denied or approval_timeout. While a run is paused, any other
command it tries is denied without a second request. The runner does not need
to stay online while the run is paused. If OpenCode or DeepSeek reports
host_governance_not_ready, reinstall or check the Preloop plugin: the runner
withholds the task until plugin activation is acknowledged and terminates the
process if readiness times out. Per-job MCP configuration carries
execution credentials without placing tokens on the command line. A host
workspace uses the runner user's filesystem permissions and is not a sandbox.
DeepSeek requires dsh 0.2.0-rc.2 (the version installed by
preloop agents install-runtime deepseek); the older 0.1.5 preview does not
implement this JSON/stdin protocol. The per-run profile enables the harness's
native credential and account services so dsh resolves its own saved login.
Preloop does not import or copy those credentials. Extra provider environment
variables require an explicit local profile pass_env entry; this does not
create or select a hosted provider account.
These three executors initially support fresh flow executions only. Native resume and pull-request publication are unavailable. Claude Code retains its existing native behavior. Protocol fixtures and fake executable tests validate launch and completion behavior without using credentials or paid providers; those checks are not evidence of live provider acceptance or native execution on every operating system. Validate your installed CLI version and governance configuration before using a production profile.
For OpenCode and DeepSeek, each local model alias must include the configured
provider as provider/model; DeepSeek rejects managed gateway model settings
on this path. For Codex, see the official
noninteractive protocol
and native Windows setup.
The runner selects workspace-write sandboxing and an approval policy of
never (escalation is denied). On Windows, complete the native sandbox setup
under your operator policy; Windows 11 is recommended and recent Windows 10
is best effort. This mode launches the Windows CLI directly, without WSL.
Native acceptance evidence distinguishes implementation checks from live runs:
| Agent | Linux | macOS | Windows | Live provider acceptance |
|---|---|---|---|---|
| Claude Code | Protocol / fake executable coverage | Protocol / fake executable coverage | CI / portable fake executable coverage | Not claimed |
| Codex | Protocol / fake executable coverage | Protocol / fake executable coverage | CI / portable fake executable coverage | Not claimed |
| OpenCode | Protocol / fake executable coverage | Protocol / fake executable coverage | CI / portable fake executable coverage | Not claimed |
DeepSeek CLI (dsh) |
Protocol / fake executable coverage | Protocol / fake executable coverage | CI / portable fake executable coverage | Not claimed |
These cells describe available test coverage, not successful native runs on every OS. A Windows cross-compile checks build compatibility only; a CI job counts as execution evidence only after its tests have run successfully.
Codex must report the Preloop PreToolUse hook as enabled and trusted for the
actual run workspace, with its hooks feature enabled. Open Codex /hooks as
the runner user to review and trust the current hook, or use administrator
managed policy. Updating a hook can require renewed trust. The runner checks
effective metadata through hooks/list before delivering a task and fails
closed when the API is unavailable; upgrade Codex if the check is unsupported.
It does not grant hook trust or bypass native hook policy.
If an older Codex hook used an unquoted Preloop path containing spaces, rerun
preloop agents onboard "Codex CLI" --approvals as the runner user, then
review and trust the updated hook in Codex /hooks. Native Windows hooks
quote the executable for cmd.exe; no PowerShell invocation prefix is needed.