GenerateSaaS

Install & Pairing

What your users do - the one-line install from your configured companion repo, device approval, connecting their coding CLIs, and the daemon's local trust guarantees.

Your app's Companions page (under AI in the dashboard) renders the exact install command from your config.companion.daemon block, with your API URL already substituted - whichever companion source you chose. The page also shows the device-approval link and the user's paired devices with a live online / offline status and an Unpair button. This page is what happens from there.

What your users do

Until you publish, the page shows a publish-first state - no install command and no dead-end connect buttons - a daemon that has no release cannot be installed (vendored projects before their first publish).

Once you have published, a user runs one command on the machine that will host the daemon:

curl -fsSL https://github.com/your-org/your-companion/releases/latest/download/install.sh | sh -s -- --url https://your-app.com/api

The installer verifies the download against the release SHA256SUMS, links the launcher onto the PATH, and runs setup - which pairs, connects the user's coding CLIs, and installs the always-on OS service in a single step. A user who already runs your daemon for another install just pairs again:

<binary> pair --url https://your-app.com/api
Approve the device. Setup prints a URL and a short code; the user opens it in a browser where they are already signed in to your app and approves the pairing.
Connect a coding CLI. In the Companions connect wizard, each CLI has a Connect on this device button that asks the paired daemon to connect it - no terminal needed when the CLI is already signed in. Or run the daemon's connect command on the host to detect, install, and log in the CLIs.
Done. The daemon runs as an always-on service (launchd, a systemd user service, or a Windows Scheduled Task) and picks up dispatched work 24/7. Its uninstall command removes the service, drops pairings, and deletes all of its data.

App-scoped installs. Pass --app-scoped to the installer (or set <PREFIX>_APP_SCOPED=1) to install the daemon binary only and skip setup - no always-on service is registered, and a product app drives pairing and runs the daemon itself. You rarely pass it by hand.

The daemon drives the user's own subscription CLIs - claude-code, codex, opencode, hermes - and never stores an API key.

Several accounts on one machine

One install serves as many of your app's accounts as the person at the keyboard has - a personal login and a work login, or one seat per client. Running pair again and approving as a different account adds a pairing instead of replacing the one already there:

<binary> pair --url https://your-app.com/api   # approve as the first account
<binary> pair --url https://your-app.com/api   # approve as the second: both stay paired

Each pairing keeps its own bearer, connected CLIs, policy ceiling, local MCP servers, folder grants, and its own work folder - so a run dispatched for one account cannot read another account's files. The always-on service serves every paired account at once, with nothing extra to install or start.

Commands that act on one pairing take --user <id> beside --url to say which account, when one backend carries two: setup, connect, disconnect, unpair, policy, mcp, and terminal. Your users rarely type it - backends lists every pairing with its user id, a command given no --url offers a picker in a terminal, and a command that cannot tell which account is meant refuses and prints the ids rather than guessing.

Separate computer logins. Two people with their own OS user account on one computer each get their own daemon data and their own always-on service. On Windows the startup task is registered under \<daemon-name>\<os-username>, so installing the service never repoints another user's.

Upgrading an existing install. Pairings made by an older daemon re-key themselves at the first boot after the upgrade - nothing to run, and the work folder does not move. A pairing whose stored sign-in has since expired is left exactly as it is and named on the way past; running pair again for it finishes the job. Nothing is ever dropped.

Works on any deployment

The companion transport is stateless HTTP: the daemon pulls dispatched work and pushes results over ordinary requests, so your app never holds a connection open for it. That means it runs on any deploy shape - fullstack on Node, fullstack on serverless (Vercel), or a separate backend - with nothing extra to provision. The daemon targets your app's API URL, so it reaches the transport wherever the API is mounted - under /api on a fullstack app, or at the backend's own origin. Because a companion is idle almost all the time, it polls on a relaxed cadence and speeds up only while there is work to carry, so an idle companion costs almost nothing - see poll cadence and request budgets.

Poll cadence and request budgets

config.companion.pollIntervalMs is the idle floor - the cadence a companion polls at with nothing to do. Default 10s, clamped to [1s, 60s] (the poll doubles as the heartbeat, so it must stay under the 90s presence TTL).

The backend re-resolves the cadence on every poll and only ever overrides your value downward, so a project that configured faster polling than a tier keeps its own value. Nothing is tuned on the device.

TierCadenceActive while
Hot1sA run is being delivered or is streaming frames. Every dispatch, ack, and frame batch holds the window open another 60s; a run's terminal frame shrinks it to a 15s cooldown, so the last frames still arrive fast and a finished run leaves no tail.
Warm5sThe pairing user has your app open. Their activity re-stamps a 5 minute window continuously, so only the tail after they close the tab decays.
IdlepollIntervalMsNeither of the above.

Per-companion request budgets

The transport enforces a per-minute budget on each route, keyed on the verified wire token rather than on IP - so one companion can never spend another's, and the 1s floor is enforced by your backend instead of trusted to the client. All of them live in cacheConfig.companionRateLimit (packages/config/src/cache.ts), so you retune them without touching route code.

RouteBudgetSized for
GET /poll70/min60 polls a minute at the 1s floor, plus headroom
POST /events240/minThe daemon's 300ms frame-flush loop
POST /tool-call240/minAny real CLI tool-call rate - a run above it is looping
POST /runs/:runId/ack300/min5 runs drained per poll at the floor
The instruction-result POSTs30/minOne per delivered connect / disconnect instruction
POST /connect, POST /terminal-spec10/min eachBoot, 401 recovery, and wire-token refresh

Over budget returns 429 with a Retry-After header, and the daemon waits it out before its next request. A 429 that carries no Retry-After came from infrastructure in front of your backend (a CDN or a load balancer); the daemon still backs off, for 90s.

Its own IP budget, not your app's. The daemon transport swaps off your app's global IP-keyed rate limit onto a fleet-sized one (cacheConfig.companionRateLimit.ip, 2000/min per address), because a daemon fleet's meaningful identity is the device and not the address: one healthy hot companion's own traffic exceeds the global budget, so companions behind a single home or office IP used to throttle each other. The per-companion budgets above are the real cap; the address budget stays underneath them because they can only bound a caller that has already authenticated - a request refused with 401 spends none of a wire-keyed bucket, and the two pre-auth routes key on a deviceId the client supplies.

The coding CLIs it drives

The daemon can drive four CLIs. It detects, installs, and logs in Claude Code, Codex, and opencode for the user during connect. Hermes Agent is system-install-only, so the user installs it themselves and the daemon connects to whatever is on their PATH. Claude Code, Codex, and Hermes accept your capability tools at full parity (injected over loopback MCP); opencode exposes no per-invocation MCP, so its tools degrade.

Enforcement caveat (Hermes). Hermes runs its own tools under --accept-hooks, so the daemon's read-only ceiling and network-off posture are not enforced for a Hermes run. Where enforcement matters - unattended read-only or air-gapped work - prefer Claude Code or Codex (Codex is the one CLI that OS-enforces network-off).

Trust surface

This is what you can tell a security-conscious user about what running the daemon for your app does and does not allow. Every guarantee is enforced locally, by the daemon, never trusted to your backend:

  • 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 the daemon's log command. Because the log is authored on their machine, it is a record your backend cannot forge or edit.
  • Local policy, clamp-only. The user sets each backend's ceiling with the daemon's policy set --url <app> --permission-mode <read-only|auto-edit|full> --network <on|off> and inspects it with policy show. Your app can request a run at or below the ceiling; ask for more and the daemon clamps it down. Your app can never raise it.
  • Confinement. Dispatched runs are confined to your app's work folder on the user's machine and stay there, and that folder is per account - two of the user's logins on one machine never share one. A terminal session the user starts themselves starts in that folder, or in one they granted at that machine - your app can never add, name, or widen a grant. A terminal is the user's own CLI under their own hands, though, so a granted folder is where the session starts, not a sandbox around it: what holds it there is the CLI's own approval prompts, and the ceiling above decides whether those stay on.
  • Local MCP only. Any MCP server your backend tries to push into a run is dropped. The only ones a session gets are the user's own, added with the daemon's mcp add.
  • Public, verifiable builds. The daemon is published from your own public repo; releases are built in the open and shipped with a SHA256SUMS manifest and provenance attestations a user can verify before installing.

On this page