Installing on macOS
From a Mac with a coding-agent CLI on it to a runtime showing online in Félagi, and then to a machine that is still online next month. Written for the case it is usually deployed in: a Mac mini that nobody sits at, serving agents around the clock.
This page is the whole macOS path, so it can be followed start to finish. The platform-neutral detail lives in Installing and Running as a service; this repeats what a Mac needs and adds the five things that are only true here.
The short version
xcode-select --install # the linker, once per machine
npm install -g @elyracode/coding-agent # gives you `elyra`
npm install -g @anthropic-ai/claude-code # or this, gives you `claude`
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
git clone <daemon-repository-url> felagi-daemon && cd felagi-daemon
cargo build --release
sudo install -m 755 target/release/felagi /usr/local/bin/felagi
felagi providers # must list your CLI
felagi setup --server https://felagi.example.com --token fdt_… --name mac-mini
felagi start # leave it, watch Admin → Runtimes
Then read Keeping it up, because everything above survives until the next reboot and no longer.
No, you do not need to notarize anything
The question comes up because macOS refuses unsigned software people download, so it is worth answering plainly rather than leaving as a doubt.
You build the binary on the machine that runs it. The linker signs it ad-hoc as it links,
which is what Apple Silicon requires in order to execute at all, and nothing ever sets the
com.apple.quarantine attribute because nothing downloaded it. Gatekeeper therefore never
assesses the binary. There is no dialog, no spctl exception, no Developer ID, and no
notarization.
$ codesign -dv /usr/local/bin/felagi
Format=Mach-O thin (arm64)
CodeDirectory … flags=0x20002(adhoc,linker-signed)
Signature=adhoc
That is the expected output. Signature=adhoc is not a warning.
Notarization becomes relevant only if this project starts publishing prebuilt binaries for
people to download, and even then it is narrower than it sounds: a browser sets the quarantine
attribute and curl -O does not, so a curl install line works unsigned while the same file
fetched from a release page is refused. If that day comes it needs Developer ID signing,
notarizing and stapling, and a paid Apple Developer account. Until then, there is nothing to do.
What the machine needs
| Notes | |
|---|---|
| Xcode Command Line Tools | xcode-select --install. Rust links through cc, so without them cargo build fails on the link step with linker 'cc' not found — after compiling everything, which makes it look like a code problem |
| Rust 1.85 or newer | rustup puts cargo in ~/.cargo/bin. rustc --version to check. Needed to build, never to run |
An agent CLI on PATH |
claude or elyra, both installed with npm — see below. Where the binary lands is whatever npm config get prefix says plus /bin, which differs per machine. Do not guess it: felagi providers prints the real path and you will need it verbatim later |
| That CLI signed in as this user | Its credentials live in this user's $HOME. Not logged in on every run is this and nothing else |
| Git, with working credentials | An SSH agent, a credential helper or a deploy key. The daemon holds none of its own |
| A daemon token | Admin → Runtimes → Connect a machine in Félagi. Shown once |
Nothing else. In particular no Full Disk Access and no other permission grant — see what macOS does not ask you for.
Installing the agent CLIs
Both are npm packages, and the package name is not the command name:
npm install -g @elyracode/coding-agent # the command is `elyra`
npm install -g @anthropic-ai/claude-code # the command is `claude`
One is enough; the daemon registers a runtime for each it finds. Install them as the user the
daemon will run as, not with sudo: a CLI installed as root signs in as root, and the daemon
then runs as you and finds no credentials. If npm -g needs sudo on this machine, set a user
prefix instead:
npm config set prefix ~/.npm-global
# then put ~/.npm-global/bin on your PATH
Then sign each one in, as that user. That is a browser flow, so do it while you are actually sitting at the machine or connected over Screen Sharing, before you make the daemon a service.
The test for whether this machine is ready is not a checklist. Sign in as the user the daemon will run as and do these two things:
claude -p "hello"
git clone git@github.com:acme/api.git
If a person cannot, the daemon cannot, and no configuration will fix it.
Build and install
git clone <daemon-repository-url> felagi-daemon && cd felagi-daemon
cargo build --release
A release build takes a minute or two the first time. Then:
sudo install -m 755 target/release/felagi /usr/local/bin/felagi
felagi --version
install rather than cp, so the mode is set in the same step and a later build replaces the
running binary atomically.
/usr/local/bin is deliberate. It is not on launchd's default PATH, which is why the
service definition later names the binary by its absolute path. Putting it in
~/.cargo/bin instead works from your terminal and then fails as a service for a reason that
takes an afternoon to find.
Check the machine before configuring it
felagi providers
elyra /Users/kh/.npm-global/bin/elyra
claude_code /Users/kh/.npm-global/bin/claude
A CLI that is installed and not listed is not on the PATH of this shell. Write down the
directory it printed — that is npm's prefix on this machine, and it is not the same on the next
one. You will need them verbatim in the service definition, because a
service starts with a far shorter PATH than your terminal has, and a daemon that found two
providers by hand finds none as a service.
Configure
felagi setup --server https://felagi.example.com --token fdt_… --name mac-mini
That writes ~/.felagi/config.json with mode 0600, because it holds the token. Name the
machine after itself: mac-mini, studio-in-the-rack. That name is what you read later when
deciding where a run happened.
Re-running setup keeps the machine's identity and replaces the rest, which is how a token is
rotated without the server thinking a new machine appeared.
Run it in the foreground first
felagi start
INFO registered workspace=acme runtimes=2 providers=claude_code, elyra sandbox=confined
That line is the whole test, and Admin → Runtimes should show the machine online within a
few seconds. If it does not, felagi status prints the configuration, the providers it found
and whether the server answered.
Hand an agent an issue and watch a real run finish before going further. A service that has never been seen to work by hand is a service that cannot be debugged.
Keeping it up
Stop the foreground process and install it as a LaunchAgent, so it runs as you and finds
your $HOME. launchd does not expand ~, so every path here is absolute.
<!-- ~/Library/LaunchAgents/no.felagi.daemon.plist -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>no.felagi.daemon</string>
<key>ProgramArguments</key>
<array>
<string>/usr/local/bin/felagi</string>
<string>start</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<!-- The daemon hands its tasks back on SIGTERM and needs up to 30 s to do it. -->
<key>ExitTimeOut</key>
<integer>40</integer>
<key>EnvironmentVariables</key>
<dict>
<key>FELAGI_LOG</key>
<string>info</string>
<!-- The directories `felagi providers` printed, then the system ones. -->
<key>PATH</key>
<string>/Users/kh/.npm-global/bin:/usr/local/bin:/usr/bin:/bin</string>
</dict>
<key>StandardOutPath</key>
<string>/Users/kh/Library/Logs/felagi.log</string>
<key>StandardErrorPath</key>
<string>/Users/kh/Library/Logs/felagi.log</string>
</dict>
</plist>
Replace /Users/kh throughout with this machine's home.
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/no.felagi.daemon.plist
launchctl print gui/$(id -u)/no.felagi.daemon | head -20
tail -f ~/Library/Logs/felagi.log
To stop it, or before loading an edited plist:
launchctl bootout gui/$(id -u)/no.felagi.daemon
Three things about launchd that cost people time:
The plist must be owned by you and not group-writable, or bootstrap refuses with an
input/output error that says nothing about permissions. chmod 644 it.
An edited plist is not reloaded. bootout then bootstrap again. There is no reload.
bootstrap, not load. launchctl load is the legacy interface and still half-works,
which is worse than not working.
The log will grow for ever
macOS rotates nothing you write yourself. One line per registration and per task start is not much, but a year is a year:
# /etc/newsyslog.d/felagi.conf
# logfilename [owner:group] mode count size when flags
/Users/kh/Library/Logs/felagi.log kh:staff 644 7 5120 * J
Seven files of five megabytes, compressed. sudo newsyslog -nvv shows what it would do without
doing it.
A Mac that is meant to stay up
This is the part that has nothing to do with the daemon and decides whether it is running when you need it. All of it applies to a Mac mini in a cupboard.
It has to be logged in, and FileVault fights that
A LaunchAgent runs only while its user is logged in. Not "the Mac is on" — logged in. A locked screen is fine; the login window is not.
So you turn on automatic login: System Settings → Users & Groups → Automatically log in as.
And FileVault makes that option unavailable. With FileVault on, the disk has to be unlocked by a password at the login window after every boot, before any user session exists — so after a power cut, a software update or any reboot, the machine sits at that screen and the daemon is not running. It will show offline in Félagi and stay offline until somebody types the password.
There is no way around it, and it is worth being clear about why: running as a system daemon
in /Library/LaunchDaemons/ does not help, because the disk is still encrypted, and neither
does anything the daemon could do differently. The choice is yours to make:
| FileVault off | The machine boots to a session unattended and the daemon comes back on its own. Defensible for a machine in a locked room that holds a checkout and a CLI's token; a stolen disk is readable |
| FileVault on | Every reboot needs a person. Correct for a machine that could walk, and a real operational cost |
Decide on purpose. The failure mode of not deciding is a Mac that is offline every time the power flickers and nobody knows why.
It must not sleep
A run stops when the machine sleeps: the CLI is stopped, the heartbeat stops, the server marks the runtime offline within 45 seconds, and the task is requeued when its lease expires. Nothing is lost and nothing hangs, but a machine that sleeps is not one to rely on.
sudo pmset -a sleep 0 disksleep 0 displaysleep 0 powernap 0
sudo pmset -a autorestart 1 # come back by itself after a power failure
sudo pmset -g custom # check it
autorestart exists on desktops and not on laptops, so it is the one line that will be refused
on a MacBook. powernap 0 because a Power Nap wake is not a session that runs agents, and it
makes the runtime flicker.
It does not need a display
A headless mini is fine. Screen Sharing or ssh is enough to administer it, and the LaunchAgent
does not care whether anything is plugged into HDMI. Log in once with a display or over Screen
Sharing, confirm automatic login works by rebooting, and then take the display away.
Software updates reboot it
An automatic macOS update is a reboot, which is the FileVault question again and a gap in coverage either way. On a machine serving agents, install updates deliberately rather than automatically: System Settings → General → Software Update → Automatic updates, and turn off installing macOS updates automatically.
What macOS does not ask you for
Worth stating, because it is the classic macOS trap and you will not hit it: no Full Disk Access, and no permission prompts.
A process launched by launchd cannot show a permission dialog. When one is needed, the operation
is simply denied, silently, and the only sign is a failure with no reason. That happens to
background tools that touch ~/Documents, ~/Desktop, ~/Downloads or iCloud Drive.
The daemon keeps everything — its configuration, its per-task checkouts, each run's own
temporary directory — under ~/.felagi/, which is outside every protected location. Nothing
it does needs a grant. Keep it that way: point a repository at a path under ~/Documents and
you have invented the problem.
The sandbox works here without installing anything
confined is the default policy and macOS enforces it with sandbox-exec, which ships with
every install. Writes outside the task directory are refused; the network is untouched, because
an agent CLI exists to call a model.
Linux needs bubblewrap installed for the same guarantee. macOS needs nothing.
One thing to know for the long term: Apple has deprecated sandbox-exec. It works, and on
current macOS it runs without even printing a deprecation notice, so nothing pollutes a run's
output — but it is the one part of this page that could need revisiting on some future release.
The policy in force is reported to the server and shown on the runtimes page, so if it ever
falls back to limits, that is visible in Félagi without logging in to the machine.
When it does not work
| Symptom | Cause |
|---|---|
linker 'cc' not found after a long build |
Xcode Command Line Tools. xcode-select --install |
felagi providers lists nothing |
The CLI is not on this shell's PATH |
| Works by hand, finds no providers as a service | The PATH in the plist. Put the directories felagi providers printed in it |
| Not logged in on every run | The CLI is signed in as a different user than the service runs as |
| A clone fails as a service, works by hand | Same cause: HOME, and the credentials in it |
bootstrap fails with an I/O error |
Plist ownership or mode. chmod 644, owned by you |
| Runtime offline after every reboot | Automatic login is off, or FileVault is on. See above |
| Runtime flickers offline | The machine is sleeping, or Power Nap is on |
| Tasks stuck as running for five minutes after a restart | The service manager killed the daemon before it could hand them back. ExitTimeOut 40 |
Sandbox reported as limits, not confined |
Not possible from a missing tool on macOS — sandbox-exec is always there. Check sandbox in ~/.felagi/config.json |
Updating
There is no self-update. When the server answers 426 Upgrade Required the daemon says so and
stops.
cd felagi-daemon && git pull --ff-only && cargo build --release
launchctl bootout gui/$(id -u)/no.felagi.daemon # hands its tasks back, up to 30 s
sudo install -m 755 target/release/felagi /usr/local/bin/felagi
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/no.felagi.daemon.plist
In that order, and not with kickstart -k. bootout takes the normal termination path, so
the daemon stops claiming, kills each running CLI and reports its tasks back as retryable before
exiting — which is what ExitTimeOut in the plist is for. launchctl kickstart -k is one
command instead of three and the manual page promises only to "kill the running instance", which
is not a promise that the tasks get handed back. Stopping first also means the binary is not
replaced underneath a process that is using it.
A daemon stopped properly requeues its work at once. Killed, the tasks it held sit as running in Félagi until the sweeper notices, five minutes later.