GenerateSaaS

Install & Pairing

What your users do - the containerized or binary AgentRunner install, one-time enrollment, signing their coding CLIs in from the app - plus the deployment requirements and the daemon's local trust guarantees.

Your app's Runners settings tab opens an Add a runner dialog that writes the exact install command from your config.runner.daemon block - container or binary, with your API URL and a freshly minted one-time enrollment code already in it - beside each paired device's live online / offline status and Unpair button. This page is what happens from there.

What your users do

Run the command from Add a runner. The dialog writes the whole line - Docker by default, Binary as the other tab - and mints the --enroll code inside it, so nobody hand-writes a command or a URL. The device pairs itself on first boot: no approval click, no terminal.
Sign a coding CLI in, from the app. Each CLI has Connect on this device, and a Sign in button that runs that CLI's own login on the device and relays its link, code and output into the browser - no terminal either way. The daemon's connect command is the fallback, offered to a daemon too old to relay a login.
Done. The daemon stays up on its own - the container's restart: unless-stopped, or an always-on OS service (launchd, a systemd user service, a Windows Scheduled Task) for a binary install - and picks up work 24/7.

Enrollment codes are one-shot and expire in an hour, and minting a new one revokes the last. A device that boots without a live code falls back to the dialog's device-approval link.

Docker (default)

docker run -d --name agentrunner --restart unless-stopped \
  --cap-drop ALL --cap-add SETUID --cap-add SETGID --cap-add CHOWN \
  --security-opt no-new-privileges:true \
  -v agentrunner-data:/data \
  ghcr.io/alexchelan/agentrunner:latest \
  --url https://your-app.com/api --enroll <one-time-code>
  • No Docker on the machine? The dialog links Get Docker - Docker Engine, OrbStack, and Docker Desktop all work.
  • Compose is the same container as a file to keep. The dialog's Docker Compose panel emits it, with AGENTRUNNER_BACKEND_URL and AGENTRUNNER_ENROLL in place of the flags.
  • The /data volume holds everything - pairings, secrets, managed CLIs, work folders - so the container is disposable.
  • The CLIs sign in inside the container, so the host's own Claude Code or Codex credentials are never touched.

No host disk, not no network. One named volume, no host path and no Docker socket, so a run cannot reach the user's files or credentials. The network is ordinary: outbound works, and the host is reachable at host.docker.internal / the bridge gateway. Put the container on a restricted Docker network if that matters.

Binary

curl -fsSL https://github.com/AlexChelan/AgentRunner/releases/latest/download/install.sh | sh -s -- --url https://your-app.com/api --enroll <one-time-code>

The installer verifies the download against the release SHA256SUMS, links the launcher onto the PATH, and runs setup - which pairs, connects their coding CLIs, and installs the always-on OS service in one step. Windows gets a PowerShell twin, and an existing install pairs again with agentrunner pair --url https://your-app.com/api --enroll <one-time-code>.

Update and uninstall

ContainerBinary
Updatedocker compose pull && docker compose up -d, or docker pull the image and recreate - the image is the update unit, so a container never self-updatesSelf-updating while idle; agentrunner update forces it, --auto off pins it
Uninstalldocker rm -f agentrunner && docker volume rm agentrunner-dataagentrunner uninstall - removes the service, drops pairings, deletes its data

Several accounts on one machine

One install serves as many of your app's accounts as the person at the keyboard has. Running pair again and approving as a different account adds a pairing rather than replacing it:

agentrunner pair --url https://your-app.com/api   # approve as the first account
agentrunner pair --url https://your-app.com/api   # approve as the second: both stay paired
  • Nothing is shared. Each pairing keeps its own bearer, connected CLIs, local MCP servers, and work folder, and one daemon serves them all at once.
  • --user <id> picks the account on setup, connect, disconnect, unpair, origin, mcp, and terminal. Users rarely type it: backends lists every pairing with its id, and an ambiguous command prints the ids rather than guessing.
  • Separate computer logins stay separate. Each OS user gets their own daemon data and service.

Deployment requirements

The backend holds one SSE connection per paired device, so the runner needs an always-on backend: fullstack on Node, or a separate backend. Serverless (Vercel) cannot hold a connection open, so --runner is incompatible with --deploy vercel. The daemon targets your app's API URL, so it finds the transport wherever the API is mounted.

How work reaches a device

Nothing is tuned on the device, and there is no cadence to configure.

Dispatch writes the run to a durable 24h queue and rings a doorbell - one Redis set naming the devices with work waiting.
Every backend instance sweeps that set every 500ms and wakes only the connections it holds - one Redis read per instance per sweep, whatever your fleet size.
The device receives the run over its held stream and acks it.
At its concurrent-run limit it refuses and reports its free slots instead; the backend redelivers until one frees.
  • A missed doorbell costs latency, never a run. The queue is the delivery guarantee, and a reconnecting daemon drains it on connect.
  • The device's limit is per machine, not per backend, covering every paired account plus local work.
  • Presence is the connection. A device is online while its socket is held and offline the instant it closes, not up to 90 seconds later as a heartbeat TTL would report - exact enough to route unattended work to your fallback model.

Raise the file-descriptor limit. One connected device is one open file descriptor, so the common 1,024 default caps a deployment at roughly a thousand devices and fails as unexplained connection refusals. Your backend's own Dockerfile (not the daemon image) raises it; deploying another way, set ulimit -n well above your device count.

Per-runner request budgets

The transport enforces a per-minute budget on each route, keyed on the verified wire token rather than on IP, so one runner can never spend another's. All of them live in cacheConfig.runnerRateLimit (packages/config/src/cache.ts).

RouteBudgetSized for
GET /stream30/minBoot and reconnects
POST /report30/minCLI connect/disconnect, a newer build
POST /events240/minThe 300ms frame-flush loop
POST /tool-call240/minReal CLI tool-call rate - above it a run is looping
POST /runs/:runId/ack300/minRuns a device can be handed and start
POST /capacity300/minOne per refused run, one per run that finishes
POST /login-event240/minOne frame per relayed login line (its own bucket)
The instruction-result POSTs30/minOne per delivered instruction
POST /connect, POST /terminal-spec10/min eachBoot, 401 recovery, wire-token refresh

Over budget returns 429 with a Retry-After header, which the daemon waits out; a 429 without one came from infrastructure in front of your backend, and the daemon backs off 90s.

Its own IP budget, not your app's. The transport swaps your app's global IP limit for a fleet-sized one (cacheConfig.runnerRateLimit.ip, 2000/min per address), so runners behind one home or office IP no longer throttle each other.

The coding CLIs it drives

The daemon detects, installs, and logs in both Claude Code and Codex during connect, and both accept your capability tools at full parity (injected over loopback MCP). Every CLI it can be dispatched to must be confined:

  • Claude Code is contained everywhere, on either install path.
  • Codex needs an OS-enforced sandbox on a binary install, so on Windows and on Linux without bubblewrap (bwrap) a dispatched Codex run is refused rather than run unconfined. In the container the container is the boundary, so Codex is contained on every host.
  • Terminal sessions are unaffected by either rule.

Trust surface

This is what you can tell a security-conscious user. Every guarantee is enforced locally by the daemon, never trusted to your backend.

  • A dispatched run touches nothing. It may run inference on the user's subscription and call the tools your backend advertises - not read, write or search a file, run a shell, or reach local MCP servers. agentrunner status prints this verbatim.
  • Local audit log, fail-closed. Every dispatched run is written to a local append-only log before it executes; if the log cannot be written, the run does not run. The user reads it with log.
  • Confinement. A dispatched run's cwd is your app's work folder, per account. A terminal session is the opposite case: the user's own CLI in whatever folder they point --cwd at, deliberately unconfined and never nameable by your app.
  • Local MCP only. Any MCP server your backend pushes into a run is dropped; a session gets only the user's own, added with mcp add.
  • Public, verifiable builds, shipped with a SHA256SUMS manifest and provenance attestations.

On this page