Elyra
Elyra The coding agent eTerm The terminal that knows where each command ends Huskarl AI colleagues on your own Mac Starf An activity monitor for Apple silicon that never invents a number etrans An SSH and SFTP client for macOS Elyra VM Virtual machines for macOS, Linux and Windows on your Mac Litr A small, native web browser for macOS Notr A notebook for macOS 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 Refr Local-first PDF workspace for macOS Elyra Workspace A desktop workspace for coding agents 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
Daemon protocol

Daemon protocol

The contract between the Félagi server and a daemon running on someone's machine. Nine endpoints, all under /api/daemon, all POST.

The daemon makes no product decisions. It discovers CLIs, claims work the server offers, prepares a directory, starts a process and reports what happens. Everything else is the server's business.


Authentication

Every request carries a workspace-scoped daemon token as a bearer credential:

Authorization: Bearer fdt_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
X-Felagi-Client-Version: 0.2.0
X-Felagi-Client-Capabilities: streaming,resume,cancel,thread

Tokens are stored hashed with only a 12-character prefix in clear, so they can be identified in a list but never recovered. Issue one from Admin → Runtimes → Connect a machine, or:

php artisan felagi:daemon-token acme-industries kh@example.com --name=kh-macbook --days=90

Version negotiation

Server and daemon ship separately, so the version header is mandatory. A daemon older than felagi.protocol.min_daemon_version is refused with 426 Upgrade Required and a payload it can act on:

{ "message": "Daemon 0.1.0 is no longer supported. Minimum is 0.2.0.",
  "minimum_version": "0.2.0",
  "download_url": "https://github.com/kwhorne/Felagi/releases" }

Authentication is checked before version: an unknown caller gets 401, a known but stale daemon gets 426. Every response carries X-Felagi-Protocol-Version.

Clients other than felagi-daemon

A client that is not felagi-daemon names itself instead, in X-Felagi-Client: name/version, and is held to its own minimum (felagi.protocol.clients.<name>.min_version) rather than the daemon's: its version numbers have nothing to do with felagi-daemon's. An unknown name is refused with 426. The only one today is Huskarl's huskarld:

X-Felagi-Client: huskarld/0.1.0
X-Felagi-Client-Capabilities: await,actors

Protocol versions

Version What changed
2 Repositories travel as {name, url}, and the daemon checks them out
3 result.usage on completion
4 X-Felagi-Client, actors on register, agent.external_id, and runs that wait (await, resume)
5 thread: the issue's comments travel with the task
6 harness: what becomes of what the run produces
7 workspace.committer: who a commit made in a checkout is by

Every change from 3 on is additive: a client that sends none of it is served as before.


Endpoints

POST /register

Registers one runtime per detected provider. Idempotent on (workspace, daemon_id, provider), so a restart re-registers the same machine rather than creating a second one.

{ "daemon_id": "5f2c…", "name": "kh-macbook", "version": "0.2.0",
  "providers": ["claude_code", "codex"], "capabilities": { "os": "darwin", "arch": "arm64" } }

→ 201 with { "workspace": {...}, "runtimes": [{ "id": "01k…", "provider": "…", "status": "online" }] }

daemon_id is a UUID the daemon generates once and keeps in ~/.felagi/config.json. It is deliberately not the token: rotating a credential must not make the server think a new machine appeared.

POST /heartbeat

{ "daemon_id": "5f2c…" }

Refreshes every runtime on that machine. 410 Gone means the machine is not registered and should call /register again. A runtime with no heartbeat for 45 seconds is marked offline by the sweeper.

POST /tasks/claim

{ "daemon_id": "5f2c…" }

Returns 204 No Content when there is nothing to do — the common case, since daemons poll every three seconds.

Otherwise 200 with a task envelope: everything needed to run the work without asking a second question.

{ "data": {
  "id": "01kyt…", "status": "dispatched", "lease_expires_at": "2026-07-31T06:12:00+00:00",
  "lease_token": "9f3c…",
  "session_id": "sess_8f2a", "work_dir": "/Users/kh/.felagi/workspaces/acme/01kyt…/workdir",
  "agent": { "name": "Freya", "provider": "claude_code", "command": "claude",
             "model": null, "instructions": "…", "custom_env": {}, "custom_args": [], "mcp_config": {} },
  "workspace": { "slug": "acme", "context": "…", "repositories": ["git@github.com:acme/app.git"] },
  "issue": { "identifier": "ACME-42", "title": "…", "description": "…", "acceptance_criteria": [],
             "comments": [ { "author": "Freya", "is_agent": true,
                             "posted_at": "2026-07-30T14:02:00+00:00", "body": "## What I found…" } ],
             "comments_omitted": 0 },
  "harness": [ "Your final message is posted as a comment on this issue…",
               "To hand back a pull request or another artifact, write it to…" ],
  "skills": [ { "name": "deploy-checklist", "content": "## Deploying…",
                "files": [ { "path": "examples/rollback.md", "content": "…" } ] } ],
  "context": {}
} }

Claiming is transactional and uses SELECT … FOR UPDATE SKIP LOCKED, so two daemons polling at the same instant neither claim the same task nor wait on each other. Candidates whose agent is at its concurrency limit are passed over rather than blocking the queue behind them.

Skills travel as documents, not paths. Where each lands is the daemon's decision, because only it knows which CLI it is about to start.

harness says what becomes of what the run produces. The final message is posted as a comment on the issue, and a delivery written to the file named by FELAGI_DELIVERY is recorded as an artifact. Both have always worked and nothing told the agent, so agents went looking for a gh or a Linear client and reported its absence as a fault. Each line is a sentence to put in the prompt as it stands; worked out per task, since a run with no checkout has nowhere to push a branch and is told that instead. Sent to every client -- it is two sentences, and one that does not render them ignores the field.

The thread travels with the task, for a client that declares the thread capability. An agent that cannot see what has already been said on an issue arrives at a conversation it has no record of -- including its own last run -- and will report work somebody has already reported.

issue.comments is oldest first, each one {author, is_agent, posted_at, body}, with the body as Markdown rather than the editor's HTML. Withdrawn comments are not there at all, the same as for a person reading the issue. A long thread is cut from the oldest end, never the newest, and bounded by both a comment count and a total length, since a hundred one-line comments and two long reports are both too much and neither limit catches the other. The newest comment is always sent whatever its size. issue.comments_omitted counts what did not fit: a run that is shown the last eight of forty comments can say so, and one that is shown eight and told nothing will answer as though it has read all forty.

POST /tasks/{ulid}/start

The work directory is ready and the CLI is running.

→ { "status": "running", "lease_expires_at": "…", "cancel_requested": false }

POST /tasks/{ulid}/messages

A batch of output lines. Idempotent on (task, seq): a retry after a network failure re-sends the same sequence numbers and duplicates are ignored, so no output is doubled or lost.

{ "messages": [
  { "seq": 1, "type": "assistant", "content": "Reading the failing test." },
  { "seq": 2, "type": "tool_use", "tool": "Bash", "payload": { "command": "php artisan test" } }
] }

→ 202 with { "accepted": 2, "next_seq": 3, "lease_expires_at": "…", "cancel_requested": false }

Every batch renews the lease. Cancellation arrives in this response — a request the daemon is already making — rather than over a separate channel.

POST /tasks/{ulid}/session

Reports the CLI's session id and working directory as soon as they are known, not at the end, so a crashed run can still be resumed.

POST /tasks/{ulid}/complete

{
  "result": {
    "summary": "Fixed the redirect and added a test.",
    "artifacts": [
      { "type": "pull_request", "url": "https://github.com/acme/api/pull/482",
        "title": "Fix the redirect", "state": "open" },
      { "type": "branch", "reference": "felagi/ACM-231-redirect" }
    ]
  },
  "session_id": "sess_8f2a",
  "work_dir": "/…/workdir"
}

result.usage is optional and additive — a daemon that omits it behaves exactly as before:

"usage": { "input_tokens": 2, "output_tokens": 7, "cached_tokens": 0,
           "cache_write_tokens": 20787, "cost_micros": 130720 }

Micro-dollars, integer. A run at $0.129993 summed as a float across four thousand runs 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 reports nothing: a provider's silence is not a free run.

On completion the server does three things beyond marking the task done: the agent posts the summary as a comment on the issue, the run's duration is added to the issue's spent time, and anything in artifacts is recorded as a delivery.

result is free-form. result.artifacts is not.

The rest of result has always been stored as it arrives, because nothing renders it. An artifact becomes a link in somebody's browser, so it is untrusted input from a runtime and validated like it:

Field Rules
type One of pull_request, branch, commit, document, url
url https only. No credentials, no control characters, 2048 characters. Required for pull_request, document, url
reference A branch name or revision. Required for branch and commit
title Optional, truncated at 250
state Optional: draft, open, merged, closed

At most twenty per completion.

A malformed artifact is dropped, not refused. A run that did the work and mis-reported one link has still done the work, and failing the completion would lose the summary, the time and the session with it. The refusal is logged.

The same delivery reported twice is one row. A re-run that mentions the same pull request updates it rather than stacking up a second chip, matched on a normalised URL — so a trailing slash or a different capitalisation does not hide it.

A pull_request moves the issue to In review, and only from a status before review. This is the one place an agent changes an issue's status. See Delivering work.

POST /tasks/{ulid}/fail

{ "error": "Test suite failed.", "retryable": true }

Retryable failures go back to queued until attempts reaches 3. Non-retryable ones are final.

POST /tasks/{ulid}/cancel-ack

Confirms the process was stopped. Refused with 409 if nobody requested a cancellation, so a buggy daemon cannot cancel work on its own.

POST /tasks/{ulid}/await

The run stops to wait for a person: an approval of something the agent wants to do, or an answer. The person decides in the client (in Huskarl, where the mail or the record is), not here.

{ "waiting_for": "approval", "summary": "Send the reply to Kari Nordmann?" }

waiting_for is approval or input. The task becomes waiting and the lease is given up: a waiting run holds no agent slot, the sweeper leaves it alone, and the time it waits is not billed. The issue shows what it waits for. Returns { status, waiting_since }.

POST /tasks/{ulid}/resume

Carries a waiting run on, once the person has decided. A waiting task has no lease, so this call is not lease-held: the proof is the machine, and only the daemon whose runtime the task ran on may resume it (409 otherwise).

{ "daemon_id": "kh-mac" }

Returns { status: "running", lease_token, lease_expires_at, cancel_requested }. The new lease token is the one for every later call. A waiting run that is canceled in Félagi is canceled at once, since nobody holds it; resuming it then gets 409.

actors on POST /register

A client that runs agents of its own (Huskarl's bots) registers them with the runtime, so nobody hires them by hand. Only for a runtime of the huskarl provider:

{ "daemon_id": "kh-mac", "name": "kh-mac", "providers": ["huskarl"],
  "actors": [{ "external_id": "01a14485", "name": "Inbox Manager", "description": "Sorts mail" }] }

New actors become agents of the runtime, known ones keep their agent (and get its name updated), and agents of the runtime whose actor is no longer listed are archived; listing one again brings it back. A name already taken in the workspace gets (Huskarl) after it. The response adds agents: [{ id, external_id, name }], and a claimed task's agent.external_id says which actor runs it. Huskarl agents get no repositories: workspace.repositories is [].


The lease token

The claim response carries a lease_token, generated by the server and handed out once. Every later call about that task must present it:

X-Felagi-Lease: 9f3c…

A daemon credential is scoped to a workspace, not to a machine, so it can never answer "did you claim this". Without the token, any daemon in the workspace could complete a run another machine was in the middle of — and a task the sweeper had requeued could be finished by the very daemon it was taken away from, because a cleared lease is not an expired one.

The token is cleared whenever the lease is, so a requeued task belongs to nobody until it is claimed again. Renewing a lease does not rotate it: renewal is the daemon saying it is still there, and rotating would invalidate the token it is holding mid-run.

Rules the server enforces on every task endpoint

Condition Response
Task belongs to another workspace 404
Task has already finished 409
Task is not dispatched or running 409
The lease has expired 409
The lease token is missing or belongs to another daemon 409

The sweeper

php artisan felagi:reap runs every 30 seconds and repairs the two states the system cannot recover from on its own:

  • Runtimes with no heartbeat for 45 seconds are marked offline, and their agents with them
  • Tasks whose lease expired are requeued, or failed for good once attempts run out

Without it, the queue stops silently the first time a laptop lid closes mid-run.


Writing a daemon

The reference implementation is a single Rust binary. Its provider abstraction is five methods:

trait Provider {
    fn id(&self) -> &'static str;
    fn command(&self) -> &'static str;
    fn invocation(&self, task: &Task, prompt: &str) -> Invocation;
    fn skill_path(&self, work_dir: &PathBuf, skill: &str) -> PathBuf;
    fn parse_line(&self, line: &str) -> Option<ParsedLine>;
}

Two safety rules are enforced before any CLI starts, and a daemon that skips them is not trustworthy on someone's laptop:

  1. Every task gets its own directory. Reused only when resuming the same (agent, issue) pair.
  2. custom_env cannot override reserved variables — FELAGI_TOKEN, FELAGI_SERVER_URL, PATH, HOME — and the daemon's own credential is removed from the child environment.

workspace.committer says who a commit is by, when the workspace has given its agents a GitHub App and GitHub has said which bot that app is:

"committer": { "name": "felagi-agents[bot]", "email": "198…+felagi-agents[bot]@users.noreply.github.com" }

A daemon should set these as user.name and user.email on each checkout. This is a different question from the token, and a run proved them apart: the push went through on the installation token and the commit read Bragi <bragi@Bragis-Mac-mini.local>, because git invents an author from the machine's user and hostname when none is configured — an address belonging to no GitHub account. The token says the push is allowed; this says whose work it was.

The email form matters. GitHub links a commit to the bot only for <id>+<login>@users.noreply.github.com; the login on its own gives an unlinked name, which is what there already was.

The field is absent when the workspace has no app, when the run has no checkout, or when GitHub would not say which bot the app is. A daemon that gets nothing sets nothing, which is how every daemon behaved before this.

custom_env may carry a credential the workspace lent this run. When a workspace has given its agents a GitHub App, the envelope's custom_env arrives with GH_TOKEN and GITHUB_TOKEN set to an installation token — scoped to the repositories in workspace.repositories, and good for an hour.

It is in the envelope rather than in the agent's stored configuration on purpose. A token written into an agent's own custom_env would be unencrypted in the database, in every dump, and permanent; this one exists in one envelope, over TLS, and expires. A daemon must not write it to disk, log it, or keep it past the run — and a value the agent set itself always wins, because somebody chose that one deliberately.

The field is absent when the workspace has no app, when nothing on its list is hosted on GitHub, or when GitHub would not mint one. The last of those is deliberately not an error: a run with no token can still do everything that does not touch GitHub, and a claim that failed does nothing at all.

Skill file paths are validated again on the daemon side. The server validates them too; a daemon writing to a real filesystem does not take the server's word for it.

What a well-behaved daemon does that the endpoints do not say

Five conventions the reference daemon follows. None is enforced by the server, and each one was learned by getting it wrong.

A silent run still renews its lease. The lease is five minutes and is renewed by start and by message batches only; messages refuses an empty batch (min:1). A CLI compiling, running tests or waiting on a model can be silent for longer than five minutes — and then the sweeper requeues the task, a second attempt starts on the same work, and the first run's complete answers 409. So a run silent for ninety seconds sends one system line — Still running — no output for 90s — which renews the lease and is also where cancel_requested arrives. It is a useful line as well as a keepalive: from the issue page a clone, a build and a hang look identical otherwise.

stderr is drained, always. Piped and unread, it fills and the CLI blocks on its next write, which from the daemon looks exactly like a run gone quiet. The reference daemon streams it as messages of type stderr and carries the last twenty lines in a failure's error.

A run the CLI calls a failure ends in fail, not complete. Claude Code's result line carries is_error; a non-zero exit says the same. Sent to complete, the server marks the task done, posts the error text as the agent's comment and adds the duration to the issue's spent time. Sent to fail with retryable: false, none of that happens — and the run is not repeated twice more against the same code for the same answer. Only the daemon's own faults — a spawn, a network call — are retryable.

A stop hands the work back. On SIGTERM or SIGINT each running task kills its CLI and reports itself retryable: true, so the server requeues it at once rather than when the lease expires five minutes later with the issue still saying an agent is on it.

410 Gone on a heartbeat means register again, as stated above — and means doing it. A daemon that logs it and carries on is marked offline within a minute and stays that way, polling for work the server will not hand to an offline runtime.

And one field worth knowing is read: agent.mcp_config, a JSON object of MCP servers, which the reference daemon writes to .felagi/mcp.json in the work directory and hands to Claude Code as --mcp-config. An empty object writes nothing.


Repositories

The envelope carries the repositories a task may check out:

"workspace": {
  "slug": "acme-industries",
  "context": "…",
  "repositories": [
    { "name": "acme-api", "url": "https://github.com/acme/api.git" }
  ]
}

The name is derived on the server, not by the daemon. Two daemons on different platforms deriving it independently could disagree about where a checkout went, and a resumed task would look for its files in the wrong place. It is owner-repo, so acme/api and other/api do not collide in one work directory.

The list is an allowlist. A task cannot request a repository, and the daemon reads URLs from nowhere else.

No credential travels in it. A URL with a username or password is refused at the point somebody saves it — App\Support\RepositoryUrl — so it is never stored, never sent, and never logged. The runtime authenticates with what the machine already has.

What the daemon is expected to do with them is in its README: clone into the work directory, fetch rather than reset an existing checkout, and continue past a failure rather than abandoning the run.


Rate limits

Every route is throttled per token, not per address. Several daemons in one office share an address, and one customer's crash loop must not throttle another's work. A token is also the thing that can be revoked.

Bucket Routes Per minute What a healthy daemon does
poll tasks/claim 120 20 — one every 3 seconds
beat heartbeat 30 4 — one every 15 seconds
work every task route 600 ~80 per running task, several at once
register register 20 1, at startup

All four are configurable (FELAGI_DAEMON_THROTTLE_*).

Three decisions worth knowing:

The limits are far above normal traffic. They exist to stop a loop that has gone wrong, not to pace work. A limit tight enough to shape traffic is a limit that eventually refuses a real run.

Heartbeats have their own bucket. A silent runtime is reaped and its work cancelled, so sharing with polling would turn a client-side polling bug into lost work on the server.

The throttle is not Laravel's. The framework's throttle middleware sits in the middleware priority list, which pulls it ahead of authentication however a route declares the order — so it builds its key before any token has been resolved. That was in place, silently keyed on the address, until a test changed the address and got through.

Being refused

HTTP/1.1 429 Too Many Requests
Retry-After: 43
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 0

{"message": "Too many requests. Retry after the interval in Retry-After.", "retry_after": 43}

Every successful response also carries X-RateLimit-Limit and X-RateLimit-Remaining, so a daemon can slow itself down before it is refused rather than finding out by being refused.

The Rust daemon honours Retry-After on the poll loop by sleeping and resetting its interval — otherwise the first tick after a long sleep fires immediately and spends the new window on a burst. Values above a day are ignored: a Retry-After of a year is a misconfigured proxy, and obeying it means the daemon is silently gone until somebody restarts it.