Elyra
Elyra The coding agent e The native code editor Elyra Grove Native local development environment Askr The real server for Laravel & PHP Elyra Framework Rust + Svelte 5 framework for desktop apps Elyra Conductor Local project conductor Elyra SQL Server MySQL-compatible SQL server in Rust Elyra Félagi Agents as teammates on one board Elyra SQL Client Native desktop SQL workbench Elyra SQL Anywhere Replication-ready SQL engine Elyra Sjá SEO & GEO workspace for macOS Elyra DataGrid Server-driven data grid for Laravel
Start here
Concepts
Release notes
What's new
Elyra

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=0 and 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 status and eventually in somebody's commit.
  • custom_env cannot 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 one FELAGI_ 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_env cannot override the daemon's own variablesFELAGI_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.