Runner setup
Provisioning a machine that runs dispatched agents.
An agent machine is an ordinary Ubuntu VM with the two agent CLIs signed in, a base checkout of the repository, and the Kairoku daemon dialling out to your Kairoku workspace. Most of it is scripted by one command of the Kairoku CLI. The parts that stay manual are manual for a reason — they are browser sign-ins, sudo, or secrets, and none of those belong to an automated pass.
This page sets up the CLI's kairoku daemon on Linux. The Rust daemon, kairokud, can be a runner too — and on a mac it is the only service form; see kairokud as a runner.
Prerequisites
- An Ubuntu VM with a normal user account, and a shell on it —
sshin.sudois required for a few steps; passwordlesssudolets setup do more of them. - Git access to the application repository from the machine (an HTTPS URL with credentials, or an SSH key) — setup clones it with plain
git, so whatever host the repository lives on works, GitHub or GitLab. - Outbound HTTPS to your Kairoku deployment. Nothing needs to reach the machine. No inbound port, no port forward, no public address.
Register the machine, then set it up
The machine is not configured with a URL you type from memory. It is configured from a snippet the app prints once.
In the app: Settings → Developers → Machines → Register a machine. Name the machine. The app mints a daemon token and shows a two-line snippet, once:
KAIROKU_URL=https://app.kairoku.io
KAIROKU_DAEMON_TOKEN=kai_…That is the only time the token is shown. Copy both lines. The token can only heartbeat, claim work and report run status; it cannot read or write documents, plans or projects.
On the machine:
curl -fsSL https://gitlab.com/owds-inc/releases/kairoku/distribution/-/raw/main/install.sh | sh
kairoku setup --daemon # asks for the app URL and the token, then does the machine steps
kairoku setup --daemon # rerun after each manual step: only what is still missing runs
kairoku doctor # verify — nonzero exit on any failurePaste the token when it asks. The URL prompt defaults to the API origin, https://api.kairoku.io, which is where the daemon routes live; the snippet's app URL also works today because the web app forwards /api/daemon/* to the API, but that forward is a temporary bridge. --app-url and --app-token pass both non-interactively; --repo <url> names the application repository to clone as the worktree base, and --yes takes the defaults without asking. Everything is remembered in ~/.kairoku/config.json, so a rerun never asks again.
Setup proves the link before it installs anything. It writes ~/.kairoku/token.env at mode 600 and sends exactly one heartbeat. A 200 and it goes on to write the service; a 401 stops it dead with the reason — token not accepted by <appUrl> — and no service is installed. The rejected token is left on disk for you to replace; setup exits nonzero.
The machine appears in Settings → Developers → Machines as online within a minute, with its host, version, n of m running, the repositories it holds, the teams it can run and the models each provider offers. The app calls it online up to 90 seconds since its last beat, stale up to 300, and offline after that.
setup is idempotent, and safe on a machine with live daemons: it installs only what is missing, never upgrades a runtime out from under a running agent, and rewrites the service unit only when its content would actually change — so a rerun never bounces a daemon that is mid-run. Rerunning after each manual step it prints is the intended way to finish the parts it had to skip. doctor is strictly read-only — it verifies and changes nothing.
The link, in one direction
The daemon dials out. Nothing dials in. There is no POST /runs any more, no inbound bearer token and no constant-time compare on a request from outside — runs start in the app, and the machine picks them up.
Two timers do the whole of it:
| Timer | Cadence | What it does |
|---|---|---|
| Heartbeat | Every 10 s while any run is active, otherwise the interval the app returns (30 s) | Reports the machine's meta — protocol version, host, version, capacity, repositories, providers and models, teams — and each run's role, state and new event lines. The response carries anything the app wants cancelled |
| Claim | Every 5 s while running < max | Asks for one queued dispatch this machine is allowed to take |
It calls four paths and nothing else outbound, all under /api/daemon/ on its appUrl: heartbeat, claim, update, and drain to acknowledge a drain the app asked for. That is not a convention, it is a test — the daemon's own suite scans its sources and fails on any other outbound call.
On a 5xx or a network error it backs off from 30 seconds to a 5-minute cap and resets on the first success, and it never claims while a heartbeat is failing. On a 401 it stops both timers, logs once, and keeps the local listener up so doctor can tell you what happened.
The local listener
The daemon still listens, on 127.0.0.1:7801 — loopback, and a wildcard bind is refused outright. It has no credential, because reachability on 127.0.0.1 is the trust boundary now that nothing arrives from off the machine:
curl -s localhost:7801/capacity # { running, max }
curl -s localhost:7801/status # version, capacity, app link, live runsGET /status is what kairoku doctor reads. Both exist for the operator standing on the machine and for nobody else.
KAIROKU_DAEMON_TOKEN is now the credential the daemon presents to the app, not one it checks. It lives in ~/.kairoku/token.env at mode 600, beside the config, so the service unit carries no secret.
What the automated pass does
Runtimes. Installs nvm and Node 24, Bun, and the claude, codex and paseo CLIs — only the ones that are missing — then reports each step as done, skipped, or handed back to you.
The Kairoku plugin. Installs it through the claude CLI and records where it landed as pluginPath in the config: the Claude provider hands that directory to the Agent SDK for every run, so a machine that cannot find its plugin refuses Claude runs rather than running them without their role agents.
ast-grep. One binary, installed with npm install -g @ast-grep/cli. It is what checks a repository's own rules, and it is the one tool here whose absence is not survivable: a repository that declares rules on a machine without it fails every run closed, before a worktree is cut. A failed install is reported as a step you still owe, in those words.
typescript-language-server. Installed with bun add -g typescript-language-server typescript, and only when the configured checkout has a tsconfig.json. Claude Code's built-in LSP tool finds it on PATH, so an implementer can resolve a symbol instead of grepping for its name. A machine without it still runs every role, which is why doctor reports it as a WARN and a failed install does not stop setup.
A non-interactive PATH. See environment fact 4 below — this is the step that makes every later step work over ssh <host> <command>.
The base checkout. Clones the application repository into ~/work/kairoku with plain git clone — the URL you gave, no gh — and installs its dependencies. A failed clone is reported with git's own message rather than retried.
Codex sandbox prerequisites (the sysctl in fact 1 — needs sudo; without passwordless sudo the two commands are printed for you) and a read of ~/.codex/config.toml: setup reports what it finds there and never rewrites it, because that file is your own Codex's, and the run's credential now lives in the run's own config instead (fact 2).
Docker and the per-run port range. Installs Docker with the compose plugin where it can — apt-get install docker.io docker-compose-plugin on Linux with passwordless sudo, then adding this user to the docker group; on macOS, and without sudo, it names the installer (OrbStack or Docker Desktop) and hands the step back to you. Then it asks once for the port range that kairoku.json environments allocate from — 20000-29999 by default, remembered as ports in the config. An answer that is not a range is asked again rather than written, because a bad range does nothing visible until the first run with a compose profile cannot get a port.
The service. ~/.kairoku/config.json with the app URL, the listen host and port, a concurrency limit and the checkout path; the token file; then the kairoku-daemon systemd unit — a system unit where passwordless sudo allows it, otherwise a user unit with the loginctl enable-linger step named so it survives your logout — started, and only after the proving heartbeat has passed.
Six environment facts
These are not preferences. Each one was found by hitting it, and each is now handled by kairoku setup --daemon.
1. Ubuntu's userns restriction breaks the codex sandbox
Symptom. codex exec fails to start its sandbox with:
bwrap: loopback: Failed RTM_NEWADDRCause. Ubuntu 24.04 and later restrict unprivileged user namespaces, which is exactly what bubblewrap needs.
Fix. Open it, and persist it so a reboot does not undo it:
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
echo "kernel.apparmor_restrict_unprivileged_userns=0" | sudo tee /etc/sysctl.d/99-codex-userns.conf2. Codex MCP writes are auto-denied under approval_policy = "never"
Symptom. A dispatched run reads from Kairoku fine and then silently fails every write. Nothing prompts, because nothing can — the run is non-interactive.
Cause. An MCP write generates an approval request. With approval_policy set to never, an approval request is not skipped — it is auto-denied.
Fix. Pre-approve the server's tools on the Kairoku entry:
default_tools_approval_mode = "approve""auto" is not enough — writes still prompt under it, and a prompt in a headless run is a denial.
Where it goes has moved. The daemon writes that line, with the run's own credential, into the worktree's per-run .codex/config.toml — so nothing has to be set in ~/.codex/config.toml and the machine keeps none of it after the run. Setup no longer edits your global config; it reads it and tells you if what it finds would break your own Codex (see the manual steps below).
3. Briefs travel over stdin, and codex needs a git repo
Symptom. A brief passed as a command-line argument is truncated, mangled by the shell, or lost; or codex refuses to start at all.
Cause. An argv brief runs through shell quoting and argument length limits that a multi-paragraph brief will eventually hit, and codex exec expects its prompt on stdin; codex also refuses to run outside a git repository.
Fix. Pipe the brief to codex exec on stdin, not as an argv string. And run it with a working directory inside a git repository — which is what the per-run worktree provides, and also what makes the agent read the repository's AGENTS.md from that worktree.
4. Ubuntu's .bashrc interactive guard hides your PATH
Symptom. ssh <host> 'bun --version' reports "command not found" while the identical command works fine after ssh <host> and a prompt. systemd units fail the same way.
Cause. Ubuntu's default .bashrc returns early for non-interactive shells. nvm's own lines sit below that guard, so they never run for ssh <host> <command>.
Fix. Prepend the export above the guard — it must be the first line of .bashrc, not appended at the end:
export PATH="$HOME/.bun/bin:$HOME/.nvm/versions/node/<version>/bin:$PATH"5. systemd units need an explicit PATH
Symptom. A unit fails at start with:
env: node: No such file or directoryCause. systemd does not inherit your login shell's environment, and the CLIs use a #!/usr/bin/env node shebang.
Fix. Give the unit its own PATH, including ~/.bun/bin and the Node bin directory:
Environment=PATH=/home/<user>/.bun/bin:/home/<user>/.nvm/versions/node/<version>/bin:/usr/bin:/binWhile you are there: on paseo 0.6.x the subcommand is daemon start --foreground. daemon run no longer exists, and a unit written against it fails on every restart.
6. db:push cannot reach a local database
Symptom. bun run db:push on the VM cannot reach the local Postgres container, and hangs or errors on connection.
Cause. drizzle-kit bundles its own Neon driver, which is websocket-only. It cannot talk to a local proxy over a plain connection.
Fix. Use bun run db:migrate, which now applies the generated SQL through the app's own database module and works against a local container. psql remains the manual equivalent:
cat drizzle/*.sql | docker exec -i <postgres-container> psql -U postgres -d <database>Never bun run db:push on an agent machine.
The manual steps
setup ends by printing only what is still owed on this machine — a checklist that lists steps already done teaches the reader to skim it. The full set, in order:
sudo apt update && sudo apt install -y tmux build-essentialThe two browser sign-ins, which are human-only and cannot be scripted or delegated:
claude # then /login
codex loginThen give your own Codex on this machine its Kairoku access — one sign-in, and the CLI's stdio bridge registered as the kairoku MCP server:
kairoku login
kairoku mcp setup --agent codexkairoku login opens a browser (or, with --paste, prints a link and reads a code back for an SSH shell) and mints a token named cli:<hostname>; mcp setup points Codex at kairoku mcp-bridge, so Codex carries no bearer and needs no OAuth of its own. The older form — codex mcp add kairoku --url …/api/mcp then codex mcp login kairoku — still works, but kairoku doctor now reports it as codex MCP entry is the stale URL form.
Do not add --bearer-token-env-var KAIROKU_PAT to the machine-wide entry. That variable is set only inside a dispatched run, and Codex prefers the bearer path over its own OAuth login as soon as the variable is configured — so a global bearer entry gives your interactive Codex 401 No authorization provided while silently ignoring its own sign-in. kairoku doctor fails the codex MCP is a human login check when it finds one. If you have it already: codex mcp remove kairoku, then kairoku mcp setup --agent codex.
A dispatched run does not use that entry at all. The daemon writes its own [mcp_servers.kairoku] into the worktree's per-run .codex/config.toml — with bearer_token_env_var = "KAIROKU_PAT" and default_tools_approval_mode = "approve" — fresh per run and excluded from the diff. The agent's own Kairoku credential arrives as KAIROKU_PAT in the run's environment, minted by the app for that one run and deleted when the run ends. It is never stored on the machine by setup.
Paseo, the optional cockpit
Paseo is a manual cockpit and nothing else. It is Apache-2.0 — the earlier note in these docs saying AGPL was wrong — and it is used here as a behavioural reference only: nothing from its codebase is copied, ported or closely paraphrased. Automated dispatch never goes through it, and no Kairoku code path points at its port.
It is genuinely optional. kairoku doctor reports its absence as absent (cockpit is optional), not as a failure. Install it if you want to watch or steer a session by hand.
Setup installs the paseo CLI and nothing more; the cockpit is configured by hand because its unit carries a password. Bind it to the LAN address and write the unit yourself:
mkdir -p ~/.paseo && printf '{ "daemon": { "listen": "<vm-ip>:6767" } }\n' > ~/.paseo/config.json# /etc/systemd/system/paseo.service
[Unit]
Description=Paseo daemon
After=network-online.target
[Service]
User=<user>
Environment=PASEO_PASSWORD=<choose one>
Environment=PATH=/home/<user>/.bun/bin:/home/<user>/.nvm/versions/node/<version>/bin:/usr/bin:/bin
ExecStart=/home/<user>/.nvm/versions/node/<version>/bin/paseo daemon start --foreground
Restart=on-failure
[Install]
WantedBy=multi-user.targetsudo systemctl daemon-reload && sudo systemctl enable --now paseoRunning the daemon
kairoku daemon status # systemctl is-active kairoku-daemon, and whether it is enabled
ss -tlnp | grep 7801 # must show 127.0.0.1, never 0.0.0.0
journalctl -u kairoku-daemon -fkairoku daemon start and kairoku daemon stop drive the same unit; kairoku daemon prune removes stale run worktrees left by a dead daemon, after showing them and asking — it never deletes a branch.
Each run gets its own git worktree off the repository's default branch, its own branch, and its own environment. Teardown runs on every exit path — normal exit, the per-run time limit, a cancel from the app, and daemon shutdown — killing the process group so nothing is orphaned and removing the worktree. A failed run can keep its worktree for a post-mortem; that is the one opt-out.
Verify the machine
kairoku doctorRead-only, and exits nonzero on any FAIL, so it works in a script as well as by eye. PASS, WARN or FAIL per check, in this order. Where a Rust kairokud is on PATH, a short group about that installation comes first (rust daemon installation, rust daemon health, versions, provider prerequisites). The rows down to the kairokud group run on every machine. daemon configured appears only on a machine that has none, where it is a WARN and the last line printed; every row below it runs where a daemon is configured, and the rows marked (Linux) only there.
| Check | What a failure means for you |
|---|---|
| claude installed | claude is not on PATH. The two plugin checks below are skipped with it, and no Claude run can start on this machine |
| kairoku plugin installed | The plugin is not installed or is disabled — kairoku plugin install |
| kairoku marketplace source | The kairoku-marketplace registration is missing (FAIL) or points somewhere other than the GitLab distribution project (FAIL; the old GitHub source is a WARN). See CLI |
| kairoku plugin path | Installed but not findable: no directory containing .claude-plugin/plugin.json for the daemon to hand the Agent SDK, so Claude runs fail closed. Set pluginPath in config.json |
| desktop daemon (kairokud), desktop daemon slack, cloud link | WARN at worst, never FAIL — whether a desktop kairokud answers on its socket, its Slack state, and whether it is linked to the app as a runner. See CLI |
| daemon configured | WARN only, and only when neither ~/.kairoku nor the pre-rename ~/.hikyaku exists. This machine carries the plugin alone; doctor stops here and exits 0 |
| node ≥ 24 | Node is older than 24, or absent |
| bun installed | bun is not on PATH |
| codex installed | codex is not on PATH |
| paseo installed | WARN when absent — the cockpit is optional |
| PATH export is ~/.bashrc line 1 (Linux) | The export is missing or sits below the interactive guard, so ssh <host> <command> and systemd units find no bun or node (environment fact 4) |
| exported dirs exist (Linux) | A directory named in that export is gone — usually a Node version that was removed — so the PATH points at nothing |
| userns unrestricted (Linux) | kernel.apparmor_restrict_unprivileged_userns is not 0, so the codex sandbox will not start (environment fact 1). WARN when the kernel has no such key |
| codex MCP is a human login | Your own Codex cannot reach Kairoku. FAIL when the global kairoku entry carries bearer_token_env_var — that variable is only ever set inside a run, and Codex prefers the bearer path once it is configured, so your interactive Codex gets 401 and its OAuth login is ignored. FAIL too when the resolved MCP URL is still an unresolved ${…} placeholder from an out-of-date Codex plugin manifest. WARN when there is no kairoku entry at all (environment fact 2) |
| codex MCP entry is the stale URL form | WARN — Codex still reaches Kairoku through --url …/api/mcp; kairoku mcp setup --agent codex moves it to the stdio bridge |
| kairoku login (MCP access) | WARN when this machine has no kairoku login |
| kairoku mcp-bridge reachable | With a login recorded: the token is missing, revoked (401), or minted against a different resource than the app now publishes — kairoku login --replace |
| config.json | ~/.kairoku/config.json is absent |
| token.env | The token file is absent, or its mode is not 600 |
| daemon service | kairoku-daemon is not active under systemd — or, on a mac, io.kairoku.daemon is not loaded |
| daemon reachable | The local listener did not answer /status on the host and port in config.json. The service is not running, or the config has no listen |
| app link | No appUrl, no token, or the app refused this machine's heartbeat. A rejected token says so in as many words; the token itself is never printed |
| runs in flight | How many runs this machine holds right now, from its own /status. WARN when the daemon is not answering |
| link errors | FAIL when the daemon's link loop has stopped (a refused token), WARN with the last error otherwise |
| docker compose | Docker is installed but docker compose version does not answer — every run of a repository with a compose profile will fail here. WARN when docker is simply absent, since a repository that declares no services still runs |
| run port range | ports in config.json is not a range like 20000-29999, or nothing at the bottom of it is free. Widen it or move it |
| kairoku.json | The manifest on origin/<default-branch> of the configured checkout does not parse; the message names the JSON path. WARN when the repository has none — that is "no manifest → today's behaviour" |
| ast-grep | The binary is not on PATH. FAIL when the base branch declares rules — every run of that repository fails closed, and the line says how many rules. WARN when it declares none, since a repository with no .kairoku/rules never needs it |
| rules on base branch | Never fails — it is the count itself, so the ast-grep line above can be read against something. 0 means nothing enforces shapes on this repository |
| typescript-language-server | WARN when absent — symbols are grepped rather than resolved. Never a FAIL: every role still runs |
| codegraph | WARN when absent — a repository whose kairoku.json lists it under intelligence runs without the index rather than failing. The index is on probation; the rules are not |
| secret resolvers | WARN when neither op nor aws is installed: a run whose secrets arrive as a {ref} will fail |
| paseo.service (Linux) | The unit is installed but not active. WARN when it is staged or absent — the cockpit is optional |
| repo present | repoPath is not a git checkout, so every claim naming that repository is refused |
| repo clean | WARN — the base checkout has modified paths. Worktrees are cut from origin/<default-branch>, so this does not break a run; it means someone has been editing on the machine |
Beyond doctor:
claude -p "say ok"andcodex exec "say ok"both answer without a browser.- A worktree round-trip works: add one off the default branch, install into it, remove it.
- Nothing listens on
0.0.0.0. - A dispatch queued from a plan item in the app reaches this machine, produces a branch and honest test counts, and shows up on the Floor under its own run credential.
Operator
An owner-scoped conversation in Mission Control that reads and acts across your workspace with the same tools as MCP, pausing on anything that needs a person.
kairokud as a runner
Linking the Rust daemon, kairokud, to your account so it shows under Machines and claims Floor runs — what is published, how the link works, and what kairokud doctor checks.