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/apiThe 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/apiconnect command on the host to detect, install, and log in the CLIs.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 pairedEach 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.
| Tier | Cadence | Active while |
|---|---|---|
| Hot | 1s | A 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. |
| Warm | 5s | The 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. |
| Idle | pollIntervalMs | Neither 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.
| Route | Budget | Sized for |
|---|---|---|
GET /poll | 70/min | 60 polls a minute at the 1s floor, plus headroom |
POST /events | 240/min | The daemon's 300ms frame-flush loop |
POST /tool-call | 240/min | Any real CLI tool-call rate - a run above it is looping |
POST /runs/:runId/ack | 300/min | 5 runs drained per poll at the floor |
| The instruction-result POSTs | 30/min | One per delivered connect / disconnect instruction |
POST /connect, POST /terminal-spec | 10/min each | Boot, 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
logcommand. 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 withpolicy 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
SHA256SUMSmanifest and provenance attestations a user can verify before installing.
Companion Daemon
Let your users run their own AI subscriptions for your app, 24/7, from hardware they control - point at an existing shared companion like OpenCompanion, or publish your own branded daemon.
Terminal Sessions
Any user can open their own coding CLI already wired to your product - your capabilities on its MCP surface, your prompt in its context - from their own terminal or the desktop app.