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
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.
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": [] },
"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.
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.
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.
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.
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.