Félagi daemon
The Félagi agent daemon. Runs on the machine that has your coding agent CLIs installed, claims work from a Félagi server, and streams the result back.
It makes no product decisions: the server decides which agent does what, this process only handles operating system concerns — process spawning, isolated work directories, environment filtering, and output streaming.
Usage
felagi setup --server https://felagi.test --token fdt_...
felagi providers # which agent CLIs were found on PATH
felagi status # configuration and server reachability
felagi start # run the daemon in the foreground
Configuration and work directories live in ~/.felagi/.
Installing it properly — prerequisites per platform, the configuration file, updating,
and every symptom with its cause — is docs/installing.md. Keeping it
running under systemd or launchd, and the three things every service manager gets wrong
by default, is docs/running-as-a-service.md.
Protocol
The daemon and the server ship separately, so they negotiate on every request.
This version speaks protocol 3 — repositories as {name, url}, and usage
on completion.
A lease token is issued at claim and presented on every later call about that task. A workspace credential says which company we work for, never which machine we are — without the token the server cannot tell two of our daemons apart, and one could finish a run another was in the middle of.
Repositories arrive as {name, url} and are expected to be checked out. The
directory name is decided by the server so two daemons on different platforms
cannot disagree about where a checkout went, which would send a resumed task
looking for its files in the wrong place.
A daemon older than the server's FELAGI_MIN_DAEMON_VERSION is refused with a
426 and a download URL, rather than failing halfway through a run.
Rate limits are per token, and this daemon honours them: a 429 on the poll
loop is a wait, not an error. Retry-After above a day is ignored — that is a
misconfigured proxy, and obeying it would leave this daemon silently gone until
somebody restarted it.
The full contract is documented on the server side, in
docs/reference/daemon-protocol.md.
A quiet run keeps its lease
The server renews a task's lease on start and on every message batch, and on
nothing else — and the lease is five minutes. A CLI that is compiling, running a
test suite or waiting on a model can say nothing for longer than that, and then
the sweeper requeues the task, a second attempt starts on the same work, and the
first run's completion is refused as no longer ours.
So a run that has been silent for ninety seconds gets one system line — Still running — no output for 90s — which renews the lease and is also where a cancellation would arrive. A message rather than an empty batch, because the server refuses an empty one; and a useful message, because from the issue page a clone, a build and a hang otherwise look identical.
stderr is read, and kept
It used to be piped and never drained. A CLI that writes enough to it — a
progress bar, a warning per file, a stack trace — fills the pipe and blocks on
the next write, which from the daemon looks exactly like a run gone quiet. It is
now streamed as stderr messages, and the last twenty lines travel with a
failure.
A failed run is reported as one
A result the CLI itself flags as an error (is_error), or a non-zero exit, ends
in fail with retryable: false: the CLI ran and said the work failed, and
running the same prompt against the same code twice more bills the issue for two
more attempts at a failure the first one already reported. Before this it reached
complete with the error text as its summary — the server marked the task done,
posted the error as the agent's comment, and added the duration to the issue.
Only the daemon's own faults — a spawn that fails, a network call that does not land — are retryable, since nothing about the work has been shown to be wrong.
Stopping is not crashing
SIGTERM and SIGINT both stop it cleanly — systemctl stop, launchctl and
every container runtime send the former, and only Ctrl-C used to be caught. Each
running task kills its CLI and reports itself retryable, so the server requeues
it at once rather than five minutes later when the lease runs out; the process
waits up to thirty seconds for those reports and then exits.
A forgotten machine registers again
410 Gone on a heartbeat means the server no longer knows this machine. The
contract says to register again; the daemon now does, instead of logging it and
polling on for work the server would not hand to an offline runtime.
Repositories
A task checks out the repositories its workspace allows, into its work directory, before the agent CLI starts.
~/.felagi/workspaces/<workspace>/<task>/workdir/
acme-api/ ← git clone
acme-web/
One repository means the agent starts inside it. Several means it starts in the work directory, and the prompt says where each one is.
Credentials never leave this machine. The server holds no git credential and is never asked for one. Cloning uses whatever this machine already has: an SSH agent, a credential helper, a deploy key. This is the same rule as the server never calling a model — the secret stays where it already lives.
Three things follow from that:
- A URL with a password in it is refused by the server, before it is ever stored. If a clone needs authentication, configure it here, in git.
- Prompting is disabled.
GIT_TERMINAL_PROMPT=0and no askpass: a headless machine that stops for a password is a task that hangs until its lease expires with nothing to explain it. It fails quickly instead. - The allowlist is the whole list. A task cannot ask for a repository, and the daemon reads URLs from nowhere else.
An existing checkout is fetched, never reset
A resumed task finds its own work where it left it — including uncommitted changes. Throwing away an agent's half-finished edit because a run was resumed would be losing work to tidiness.
It follows that a checkout can drift from the remote's default branch if an agent leaves it somewhere else. That is the agent's business; the daemon does not supervise a working tree it did not make.
If fetching fails — an offline machine, a host that is down — the run continues with the checkout as it stands, and says so. Refusing to work from yesterday's code is worse than working from yesterday's code.
Providers
| Claude Code | claude — --print --output-format stream-json --verbose, plus --mcp-config when the agent has MCP servers configured |
| Elyra | elyra — --print --output-format jsonl |
The server also knows a Codex provider, and its documentation lists codex
as a CLI a runtime may have. This daemon has no adapter for it: a task for a
Codex agent is refused at claim with a message saying so. Every adapter here was
written against the real CLI's output, and codex was not installed on any
machine this was built on — an adapter written from memory would be the
half-working kind this project refuses to ship.
MCP servers reach the CLI
An agent's mcp_config from the server is written to .felagi/mcp.json in the
work directory — outside any checkout, beside the delivery file — and passed to
Claude Code as --mcp-config <file>. Only when there is one: an empty object
writes nothing, so the CLI is not handed an empty config to complain about.
felagi providers lists which are on this machine. A provider the daemon has no
adapter for is refused at claim with a message saying so, rather than failing
halfway through a run.
The Claude Code adapter was written against real output
Not against documentation. Every shape it parses came from running the CLI and reading what it printed:
{"type":"system","subtype":"init","session_id":"…","model":"claude-opus-4-8"}
{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Read","input":{…}}]}}
{"type":"user","message":{"content":[{"type":"tool_result","content":"…"}]}}
{"type":"result","result":"…","total_cost_usd":0.046941,"usage":{…}}
Three details that only turn up by running it:
--verbose is required. Without it, stream-json prints only the final
result — and claude accepts the combination quietly rather than refusing it. It
is the difference between a live timeline and one paragraph at the end.
Content is an array, so one line can carry text and a tool call together. Only the first block is reported: the protocol is one message per line, and splitting one into several needs sequence numbers this layer does not own.
A tool result is a user turn, and its content is either a string or an array
of blocks depending on the tool. Both are flattened.
Permissions are not widened here
A headless run cannot answer a permission prompt, so somebody has to decide what
an agent may do — and that somebody is whoever configured the agent, through
custom_args. Putting --dangerously-skip-permissions in the adapter would hand
every Claude Code agent in every workspace unrestricted tool access because of a
line in this file. The sandbox is the floor under that decision, not a substitute
for it.
The CLI must be authenticated as the user the daemon runs as
Its credentials live in $HOME. Same rule as git and gh: if a person can run
claude -p "hello" on that machine as that user, the daemon can. If they cannot,
every run fails with Not logged in — which the timeline will say, because the
adapter reports it rather than swallowing it.
Cost
Where a CLI reports what a run cost, the daemon passes it on:
"usage": { "input_tokens": 2, "output_tokens": 7, "cached_tokens": 0,
"cache_write_tokens": 20787, "cost_micros": 130720 }
Micro-dollars, not a float. A run at $0.129993 summed across four thousand runs as a float is a total nobody can reconcile against an invoice — and reconciling is the only reason to record cost.
Absent rather than zero when the CLI says nothing. Elyra reports no usage yet,
and nobody should read $0.00 and conclude a run was free.
The pricing is the CLI's. It knows the model and the tier; the daemon would be guessing at a table that changes without telling anybody.
Deliveries
A run can hand something over: a pull request, a branch, a document, a link.
Every task gets an environment variable pointing at a file:
FELAGI_DELIVERY=/…/workdir/.felagi/delivery.json
The agent writes JSON there, the daemon reads it after the run, and it travels with the completion.
{"artifacts": [
{"type": "pull_request", "url": "https://…/pull/482", "state": "open"},
{"type": "branch", "reference": "felagi/ACM-231-rate-limiter"}
]}
A file rather than the CLI's output, because every provider formats its output differently and a delivery has to survive all of them without an adapter change for each. A file is the only thing they can all write.
Three rules around it:
- Cleared before every run. A resumed task reuses the directory, and yesterday's link reported again today is a stale delivery presented as a fresh one.
- Outside any checkout. Inside the repository it would show up in
git statusand eventually in somebody's commit. custom_envcannot redirect it. A task that could set that path could report a link to anywhere as its issue's outcome. It is set after the credential scrub, so it is the oneFELAGI_variable the agent does see.
A malformed file is a warning, never a failed run. Both {"artifacts": [...]}
and a bare array are accepted — an agent following a written instruction gets the
wrapper wrong about half the time, and refusing the delivery over that loses the
one thing the run was for.
What to put in the file, and the skill that tells an agent to, is documented on the server side.
The sandbox
The argument for running on your own hardware is that you control it. Without confinement that also means an agent having a bad afternoon has the whole machine — the same sentence sells the product and frightens the buyer.
{ "sandbox": "confined" }
in ~/.felagi/config.json. Three settings: confined (the default), limits,
off.
off |
limits |
confined |
|
|---|---|---|---|
| Writes outside the task | allowed | allowed | refused |
| CPU time cap | — | 4 hours | 4 hours |
| Single-file size cap | — | 8 GB | 8 GB |
Own TMPDIR per run |
— | yes | yes |
The policy is reported to the server at registration and shown on the runtimes
page, so one unconfined build box is visible without logging into it. confined
falls back to limits on a machine that cannot enforce it, and says so in the log
rather than claiming otherwise.
What it uses
macOS: sandbox-exec, present on every install.
Linux: bubblewrap (bwrap), the
same tool Flatpak uses — no daemon, no root, no configuration. Without it, Linux
gets limits only.
Resource caps go through the shell's own ulimit, then exec, so the shell
replaces itself and the pid the daemon holds is the agent's. Arguments are passed
as "$@" and never through the shell's parser: a prompt containing a quote cannot
become a second command.
What it deliberately does not do
The network is untouched. An agent CLI exists to call a model over the internet. This does not pretend to contain a hostile agent — it contains a careless one, which is the failure that actually happens.
Reads are not confined. A CLI reads its interpreter, its libraries,
certificates, PATH and its own configuration. A read policy strict enough to
matter breaks every provider before it protects anybody.
Memory is not capped. ulimit -v refuses the address space Node and the JVM
reserve and never touch.
Process count is not capped. RLIMIT_NPROC counts every process the user
owns, not the ones this run started. On the machine this was written on the user
already held 807, so a cap of 512 meant fork: Resource temporarily unavailable
before the agent read its prompt — and a cap above current usage protects against
nothing. A real one needs cgroups, which is Linux-only.
/tmp stays writable. Enough tools hardcode it that denying it breaks them.
Each run gets its own TMPDIR inside the task instead, so the well-behaved
majority never goes near the shared one.
What stays writable
The task's work directory, the run's own TMPDIR, and each agent CLI's
configuration directory under HOME — .claude, .codex, .elyra, and the
caches every toolchain uses. Named individually rather than granted wholesale:
HOME itself stays read-only, so a stray rm -rf ~ is refused by the kernel
rather than by hope.
Safety
Two rules are enforced before any CLI starts, because this runs on somebody's own machine:
- Every task gets its own directory, reused only when resuming the same agent and issue.
custom_envcannot override the daemon's own variables —FELAGI_TOKEN,FELAGI_SERVER_URL,PATH,HOME— and the credential is removed from the child environment entirely.- A checkout directory name is checked before it is used. The server derives
it, but a daemon that trusts a path from the network to be one harmless segment
is a malformed envelope away from writing outside its own work directory. Names
containing a separator,
.., or a leading-(which git would read as a flag) are refused.
Skill file paths are validated here as well as on the server. These land on a real filesystem, and a daemon that takes the server's word for a path is not one to trust on a laptop.