Self-hosted runner quickstart (plain Linux / Proxmox)¶
Editions: OSS, Cloud, Enterprise. Unless stated otherwise, everything on this page ships in OSS.
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).
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
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.
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.
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 default ghcr.io/openai/codex-universal:latest
entrypoint is preserved because it 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 (also use this for pinned
or mirrored codex-universal images). Images that cannot execute this bootstrap
fail explicitly; an idle shell cannot be reported as successful work.
Host execution profiles (opt-in, private only)¶
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:
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; use the Docker harness for flows that
publish.
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.