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:
- Every task gets its own directory. Reused only when resuming the same
(agent, issue)pair. custom_envcannot 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.