Installing the Shuttle daemon¶
The felt CLI installs cleanly from a release binary. The Shuttle daemon ships
no release artifact, no package, and no container. You build it from a checkout
and you keep the checkout.
This path is currently fleet-oriented
Shuttle runs on one person's machines: a macOS hub and a few HPC login nodes. Several defaults still point at private things — see Honest scoping. Sharp edges below names each one you will actually trip over.
Platform: Linux and macOS both support single-host use — the daemon,
the board, and workers on one machine, with a real keep-alive on either.
Multi-host tunnel management (felt shuttle tunnels) is macOS-only, since
it manages launchd autossh jobs. macOS gets the most use. Windows is
unsupported.
Prerequisites¶
| Tool | Required | Purpose |
|---|---|---|
go 1.23+ |
yes | Builds the felt CLI. The daemon shells out to felt for every store walk. |
elixir 1.19+ / OTP 27 |
yes | mix.exs declares elixir: "~> 1.19". CI builds on OTP 27. |
escript |
yes | The daemon runs as an escript. It ships with Erlang/OTP. |
tmux |
yes | Every worker runs in a tmux session. On a Linux host without systemd, the daemon's own keep-alive is a tmux loop too. |
node 22+ / npm |
only for the board | Builds the kanban bundle into ui/dist. |
jq |
optional | session.sh uses it to pretty-print the SessionStart envelope. Without it the hook falls back to felt hook session. |
bootstrap.sh checks all of these and names what is missing.
Bootstrap¶
Clone the repo, then run the bootstrap. make install runs the same thing.
git clone https://github.com/cailmdaley/felt ~/dev/felt
cd ~/dev/felt
./bootstrap.sh --dry-run # check prerequisites, print the plan, change nothing
./bootstrap.sh # or: make install
Six steps run in order.
- Prerequisites. Named, with install hints. A missing required tool aborts the run before anything is built.
feltCLI.GOBIN=~/.local/bin go install .from this checkout — not the release binary. The daemon shells the CLI, so the two must never skew.- Daemon escript.
mix deps.get, thenmake daemon→bin/shuttle. The step records the checkout path in~/.shuttle/repo, so remote revival over SSH can find it without an environment. ui/dist. The served board bundle. Built by default when Node is on PATH, skipped otherwise — rsync it from a host that has Node instead.- Event stream. Runs
felt setup claudeandfelt setup codexagainst this checkout, so the plugin hooks match the binary. Then it pipes a probe payload throughfelt hook eventand checks the line it writes. See The event stream. - Keep-alive. macOS: a launchd LaunchAgent. Linux: a systemd user unit, falling back to a tmux respawn loop where there is no systemd user session.
Useful flags: --dry-run, --skip-ui, --build-ui, --skip-hook,
--skip-cli, --with-tunnels.
Keep-alive¶
make install-agent installs the supervisor, branching on uname -s. Both
arms render a template from share/ and bake in the same three environment
values, for the same reason: neither supervisor hands the daemon your login
environment.
macOS (launchd)¶
Step 6 calls make install-agent, which renders
share/io.shuttle.daemon.plist.template into
~/Library/LaunchAgents/io.shuttle.daemon.plist and loads it. The agent sets
RunAtLoad and KeepAlive, so the daemon starts at login and restarts on
crash.
make install-agent bakes three environment variables into the plist. Each one
exists because the obvious approach failed:
PATH— captured frombash -lc 'echo $PATH'at install time. launchd's own environment is nearly empty: the#!/usr/bin/env escriptshebang cannot findescript, and the daemon cannot findfelt. Sourcing the login profile at runtime does not fix it, because the profile is not self-sufficient from a bare environment. So the plist freezes the real loginPATH. APATHwithoutfelton it gives you a daemon that boots, serves the board, and returns 500 on/api/v1/fibers/composite.FELT_STORES— the stores the daemon polls, comma-separated. There is no default:make install-agent AGENT_FELT_STORES=~/myprojectis required, and the target refuses without it. felt re-discovers a store's symlinked substores, so one cross-project store is usually the only entry you need.SSH_AUTH_SOCK—~/.ssh/agent.sockon macOS, the persistent login agent. launchd hands the daemon a bare per-session Keychain agent that holds only the default key, which breaks every SSH the daemon makes to a remote host. Override withAGENT_SSH_AUTH_SOCKif your socket lives elsewhere.
Logs go to ~/Library/Logs/shuttle.log (make logs tails it). Remove the agent
with make uninstall-agent.
Linux (systemd user unit)¶
make install-agent renders share/io.shuttle.daemon.service.template into
~/.config/systemd/user/shuttle-daemon.service, then runs systemctl --user
enable --now. Restart=always with RestartSec=10 is the KeepAlive analog;
WantedBy=default.target starts the daemon at login. It bakes in the same
PATH, FELT_STORES, and SSH_AUTH_SOCK as the plist, for the same reasons —
a systemd user manager inherits almost nothing either. An empty
SSH_AUTH_SOCK is dropped from the rendered unit rather than baked in as a
dead path, since Linux has no canonical agent socket.
It is a user unit: the daemon runs as you and wants no root.
loginctl enable-linger $(id -un)
Run that once. A systemd user manager normally stops at your last logout, which
would take the daemon down with your ssh session; lingering keeps it alive
across logout and starts it at boot. make install-agent prints the command
but does not run it, because enabling linger needs privileges the install does
not assume.
Day-to-day:
systemctl --user status shuttle-daemon # is it up
systemctl --user restart shuttle-daemon # cycle onto a freshly built escript
journalctl --user -u shuttle-daemon # unit-level events
make logs # the daemon's own log
Logs go to ~/.shuttle/shuttle.log on Linux — beside the daemon's other state,
and the same file make start and the respawn loop write, so make logs finds
it whichever path is running. Remove the unit with make uninstall-agent.
make install-agent kills the tmux respawn loop first; both would bind :4000.
Linux without systemd (tmux respawn loop)¶
Plenty of Linux hosts have no systemd user session — an HPC login node
typically does not, and neither does a bare container. make install-agent
says so and refuses there. Bootstrap detects the same thing and falls back:
it copies bin/shuttle-launch to ~/.local/bin and starts a tmux session named
shuttle-daemon running a respawn loop. (It installs shuttle-launch on every
Linux host either way, because remote revival invokes it over SSH.)
The loop runs ./bin/shuttle start --force and backs off exponentially. A
daemon that exits within 60 seconds doubles the sleep, from 2s up to a 300s cap.
One that survives 60 seconds resets it. This exists because a wedged login node
once drove a fixed 2-second loop to roughly 35,000 restarts.
To cycle onto a freshly built escript, kill the listener and let the loop respawn it:
lsof -ti:4000 -sTCP:LISTEN | xargs kill
To pick up a new shuttle-launch itself:
SHUTTLE_DIR=~/dev/felt ~/.local/bin/shuttle-launch
Configuring stores¶
The daemon polls felt stores. It resolves them in this order:
FELT_STORES— a comma-separated list of store paths.~/.config/felt/stores.json— the persisted registry (override the path withFELT_STORES_FILE).
Shuttle assumes no default store. An unset variable and an absent registry
resolve to an empty list. The daemon then polls nothing: it boots, binds
:4000, serves an empty board, and dispatches nothing. make install-agent
requires AGENT_FELT_STORES precisely so a supervised daemon never boots into
that state by accident.
The registry file takes this canonical shape. A bare JSON array also works.
{
"version": 1,
"felt_stores": ["/home/you/dev/myproject"]
}
POST /api/v1/felt-stores rewrites the file, and the board's store picker uses
that endpoint. A store path must contain a .felt/ directory.
From an empty board to a first dispatch¶
With the daemon up and a store registered, here is the fastest path from nothing to a worker running.
Add the store, if FELT_STORES does not already cover it:
curl -s -X POST http://127.0.0.1:4000/api/v1/felt-stores \
-H 'Content-Type: application/json' \
-d '{"felt_stores": ["'"$PWD"'"]}'
Add a fiber and give it a constitution tag — tags gate nothing, but they
make the fiber findable as one:
felt add pipeline/first-pass "Rewrite the covariance loader" -t constitution
Open .felt/pipeline/first-pass/first-pass.md and write the spec: a
heading-less lede, then a ## Desired State section stating what "done" looks
like in checkable terms. See Writing a
constitution for the shape.
Install the shuttle: block. This is what turns the fiber into something the
daemon will pick up:
felt shuttle install pipeline/first-pass --project-dir "$PWD" --model claude-sonnet
Open http://127.0.0.1:4000/ — the fiber shows up as a card, armed. The
daemon polls every 30 seconds by default, so the card moves to in-flight on
its own; felt shuttle dispatch pipeline/first-pass skips the wait. felt
shuttle ps lists the live tmux session, and felt shuttle attach
pipeline/first-pass drops you into it.
When the worker hands off, the fiber's outcome and ## Status rewrite in
place and the card lands in Awaiting review.
Configuring agents¶
felt shuttle agents prints the effective registry. It layers your own file
over a built-in set of eight records. Set $FELT_AGENTS_FILE to choose the
path; otherwise felt reads ~/.config/felt/agents.json.
felt shuttle agents init # seed the user file from the built-ins
felt shuttle agents # the merged table, with a source footer
felt shuttle agents --source user
Each record names a CLI, a model, and its axis metadata (effort_levels,
default_effort, chrome_capable). share/agents.example.json in the repo
works every field across several harnesses — copy from it. A missing file is
silent. A malformed file fails loudly and names the path.
The file's builtins key controls the merge. "merge" (the default) folds your
records over the built-ins by id, last one wins. "replace" drops the built-in
layer entirely; human stays reserved either way.
Configuring remotes¶
One daemon can aggregate other daemons over SSH tunnels. The fleet file lists them, and both the Go CLI and the daemon read it at runtime.
felt shuttle remotes path # ~/.config/felt/remotes.json
felt shuttle remotes add hub-a --port 4001 # --ssh, --remote-port, --display, --checkout
felt shuttle remotes add hub-b --port 4004 --multiplex
felt shuttle remotes list # also the validator
felt shuttle remotes rm hub-a
list reports parse errors, duplicate names, and port collisions. --multiplex
rides an existing ControlMaster socket, which is what a 2FA host needs. A
launchd_label_prefix key in the file names the launchd labels
felt shuttle tunnels install writes. Single-machine use needs none of this: an
absent file means no remotes.
bin/shuttle-deploy reads the same file, so the fleet is described once. Give
a remote a checkout (its repo path) to make it a deploy target — a remote
without one is skipped. Two more optional keys serve deploy only: ssh_flags,
a list of extra ssh arguments, and auth, which the deploy script reads as
"pubkey" (the default) or "interactive". Mark a host "interactive" when
its ssh needs a live human credential — push-2FA or a short-lived certificate —
and bin/shuttle-deploy --handshake will bootstrap a ControlMaster for it
instead of failing.
The event stream¶
The daemon ranks in-flight workers by idle time and renders each card's
sent-files trail. Both read one host-local file, ~/.shuttle/events.jsonl.
felt hook event writes it. The plugin registers that command on seven events —
SessionStart, UserPromptSubmit, PreToolUse, Stop, SubagentStop, Notification,
and SessionEnd — for Claude Code and Codex alike. felt setup claude and felt
setup codex install the wiring; bootstrap step 5 runs both.
The hook writes only when the stream's parent directory already exists, so a
felt-only install grows no stream. SHUTTLE_EVENTS_FILE overrides the path and
creates its parent. SHUTTLE_EVENTS=off disables recording. Probe the writer
by hand:
echo '{"hook_event_name":"SessionStart"}' | SHUTTLE_EVENTS_FILE=/tmp/e.jsonl felt hook event
Verify¶
curl -s http://127.0.0.1:4000/api/v1/version # daemon answers
felt shuttle ps # running workers
make logs # tail the daemon log
make status # ps + a snapshot summary
Open http://127.0.0.1:4000/ in your browser for the kanban board.
The daemon binds 127.0.0.1:4000 and nothing else. It stays loopback-only by
construction. It carries no auth layer, because nothing off the machine can
reach it.
Sharp edges¶
Roughly in the order a new installer hits them.
make daemon refreshes the felt CLI when Go is available. The daemon
shells the felt CLI for its writes, so a stale installed CLI can break
daemon-shelled commands mid-dispatch — make daemon rebuilds it first whenever
Go is on PATH. On a host with no Go toolchain, make daemon builds only the
escript, against whatever felt is already installed there. bootstrap.sh
--skip-cli passes SKIP_CLI=1 through to make daemon, so it skips the CLI
rebuild too, even on a host that has Go.
The UI build needs no private checkout. npm run build runs tsc --noEmit
&& vite build. The src/paper entry imports @lightcone/renderer, a private
package — but ui/src/paper/lightcone.d.ts ships ambient type declarations
that satisfy the typecheck without it, and the Vite build drops the paper entry
when the real package isn't resolvable. A fresh clone builds ui/dist fine:
cd ui && npm ci && npm run build
macOS TCC: keep the checkout out of ~/Documents. launchd-spawned processes
cannot read ~/Documents, ~/Desktop, or ~/Downloads. Full Disk Access does
not inherit across the launchd process tree the way it does under a terminal. A
daemon rooted in a protected folder crash-loops or silently fails its store
walks. make install-agent warns and installs anyway. Use ~/dev/felt, or
anything else outside those folders. The same trap catches a store whose real
path sits under ~/Documents, even when the checkout is clean.
make restart silently no-ops under a supervisor. make stop matches the
daemon by a relative-path pattern; launchd and systemd both launch it by
absolute path. So after make install-agent, make restart rebuilds the
escript, stops nothing, and reports "already running." Bounce it properly:
launchctl kickstart -k gui/$(id -u)/io.shuttle.daemon # macOS
systemctl --user restart shuttle-daemon # Linux
make restart works only when you started the daemon with make start.
Every restart arms a boot quarantine. On every (re)start the daemon parks
each dispatchable candidate it has never observed running into pending_launch.
Nothing fresh launches until a human runs bin/shuttle release. (Work the
daemon did observe alive — adopted at boot, or dispatched since — keeps
redispatching, because that counts as continuation. See
Boot quarantine for why.) The quarantine guards
your token budget. It also explains why your first worker never starts while
nothing appears to be wrong.
bin/shuttle release
A worker needs project_dir, host, and active. You set these three
gates by hand on the fiber's shuttle: block. All three fail quietly, by simply
not dispatching. host is strict: absent or empty leaves the fiber unowned and
ineligible on every daemon. Shuttle offers no "local" default and no
wildcard. The host id comes from SHUTTLE_HOST, else the file
~/.shuttle/host (override the path with SHUTTLE_HOST_FILE), else the system
hostname. For the full ordered predicate list the daemon evaluates, see
Dispatch eligibility.
Every built-in agent assumes its CLI is installed. The eight built-in
records name claude, codex, and the human pseudo-agent, with
claude-sonnet as the default. A record whose CLI is absent or unauthenticated
fails at dispatch, not at install. Run felt shuttle agents init and cut the
list down to what you actually have — see Configuring
agents.
felt shuttle tunnels is macOS-only, and needs a fleet file first. It
renders launchd autossh plists from ~/.config/felt/remotes.json, so install
refuses outright on Linux — multi-host aggregation runs from a Mac hub, though
the remotes it aggregates can be any platform. With no remotes configured it has
nothing to write, and bootstrap.sh --with-tunnels does nothing useful.
bin/shuttle-deploy still targets the maintainer's host layout despite its
general name.
The event stream stays empty until ~/.shuttle exists. felt hook event
refuses to create its own directory, so a felt-only install records nothing.
Degradation is graceful — the board still serves — but the activity ranking and
the sent-files trail stay empty. Bootstrap step 3 creates the directory, so a
bootstrapped host is already enabled. See The event
stream.
License¶
The felt CLI and the board UI carry the MIT license. The daemon you just built
(lib/) contains code derived from OpenAI's Symphony under the Apache License
2.0, preserved in
NOTICE.