The API
A REST API over the same data the interface shows, authenticated with a personal token.
curl https://felagi.example.com/api/v1/me \
-H "Authorization: Bearer fat_…"
Create a token under Settings → API tokens. It is shown once. Félagi stores a hash, so a lost token is replaced rather than recovered.
What a token is
It acts as you. What it may reach is decided by your membership and your role, exactly as in the browser. There is no second permission model to disagree with the first — and a disagreement between two authorisation systems is always found by a customer rather than by us.
It names one workspace. Anybody can belong to several, and a credential that followed whatever the browser last selected would have a reach nobody could state.
It stops working when you leave. A departure is precisely the moment nobody is thinking about tokens, so membership is checked on every request.
It can never do more than you can. Abilities only narrow.
| Ability | What it allows |
|---|---|
read |
List and read issues, projects, agents, runs and deliveries |
write |
Everything above, plus opening issues, commenting, and queueing runs |
Two, not twelve. A permission system with a dozen scopes gets one of them wrong, and every integration ends up asking for all of them anyway.
Endpoints
/api/v1, versioned from the first line — the daemon protocol taught that lesson
twice.
Reading
GET /me |
Who this token is, its workspace, its abilities |
GET /issues |
Filter by status, type, priority, project_id, assignee_type, assignee_id, updated_since, q, label (repeatable, AND), cycle (number or none); per_page up to 100 |
GET /issues/{id} |
One issue |
GET /issues/{id}/comments |
The thread |
GET /issues/{id}/artifacts |
What the runs delivered |
GET /projects, GET /projects/{id} |
|
GET /agents |
Only the ones you could assign to |
GET /runs, GET /runs/{ulid} |
Filter by status, agent_id |
Writing
POST /issues |
201. title required |
PATCH /issues/{id} |
Any subset of the fields |
POST /issues/{id}/comments |
201. Mentioning an agent hands it the thread |
POST /issues/{id}/runs |
202. agent_id required |
POST /issues/{id}/runs returns 202, not 201. The run is queued; whether a
machine picks it up depends on a daemon that may not be running. "Created" would
promise something the response cannot know.
The other two set 201 explicitly. Laravel infers it from wasRecentlyCreated,
which fresh() clears — so one endpoint answered 200 and another 201, and the
difference was whether the controller happened to reload the model.
Responses
A collection
{
"data": [ … ],
"links": { "first": "…?page=1", "last": "…?page=14", "prev": null, "next": "…?page=2" },
"meta": {
"current_page": 1, "from": 1, "to": 25, "last_page": 14,
"per_page": 25, "total": 342, "path": "…/api/v1/issues",
"links": [ … ]
}
}
meta.links is Laravel's page-number list. Ignore it and follow links.next
until it is null; page numbers drift if something is inserted while you page,
which is the trade for not having cursors.
A single record is the same envelope without links and meta:
{ "data": { … } }.
An error
{ "message": "The title field is required.", "errors": { "title": ["The title field is required."] } }
errors is present only on a 422. Everything else carries message alone.
| Status | When |
|---|---|
401 |
No token, or a token that is wrong, revoked, expired, or whose owner has left |
403 |
A read token attempting a write. The message says what to do about it |
404 |
No such record or it belongs to another workspace — the same answer, deliberately |
422 |
Validation, including an id that belongs to another workspace |
429 |
Rate limited. Retry-After says how long |
Every field
Issue
| Field | Type | Notes |
|---|---|---|
id |
string | ACM-231. The identifier a person types, not the database key |
number |
int | The number within the workspace |
title |
string | |
description |
string | null | Sanitised HTML, as stored |
acceptance_criteria |
string[] | |
type |
enum | epic bug feature task cosmetics exception usability_problem performance_problem |
status |
enum | backlog todo in_progress in_review on_hold done canceled |
priority |
enum | urgent high medium low |
project |
object | null | { id, name }. Only on a single issue, not in a list |
epic |
object | null | { id, title } — id is the identifier |
assignee |
actor | null | |
creator |
actor | null | |
start_date, due_date |
date | null | YYYY-MM-DD |
estimate_minutes |
int | null | |
spent_minutes |
int | Includes agent run time — machine time is time |
labels |
string[] | Names, not ids. A label is a word; an identifier for a string is one more thing to resolve |
cycle |
object | null | number, starts_on, ends_on. Null when the issue is in no window |
carried_over |
int | How many closed cycles this has survived. Most trackers do not record it |
created_at, updated_at |
ISO 8601 |
Actor — a person or an agent
{ "type": "agent", "id": 4, "name": "Freya" }
type is user or agent. The fields are identical for both, so a client that
does not care never has to ask. name reads Former member when the account was
deleted — accounts are hard-deleted and the relation is polymorphic, so there is no
foreign key protecting the name.
Cycle
{ "number": 12, "starts_on": "2026-08-03", "ends_on": "2026-08-16" }
| Field | Type | Notes |
|---|---|---|
number |
int | What a person says out loud. Cycles are numbered, not named |
starts_on, ends_on |
date | Inclusive. Generated on a cadence, so they are never edited |
Null when the issue is in no window, and absent entirely when the workspace has cycles switched off.
Run
| Field | Type | Notes |
|---|---|---|
id |
string | A ULID |
status |
enum | queued dispatched running completed failed canceled |
agent |
object | { id, name } |
issue |
string | null | The issue identifier |
attempts |
int | |
queued_at, started_at |
ISO 8601 | null | |
finished_at |
ISO 8601 | null | One timestamp for the end; status says how it ended |
error |
string | null | |
summary |
string | null | What the agent reported |
usage |
object | null | See below |
artifacts |
array | Only when the run is loaded with them |
usage is null, never zeroed, when the CLI reported nothing. A client that
read 0 and summed it would produce a total that looks authoritative and is not.
"usage": {
"input_tokens": 2, "output_tokens": 7,
"cached_tokens": 0, "cache_write_tokens": 20787,
"cost_usd": 0.13072
}
Cost is stored as micro-dollars and returned as dollars. Any of these may be
null on its own — providers report different subsets.
The lease token and the session id are never returned. One is a credential; the other identifies a conversation on somebody else's machine.
Artifact
| Field | Type | Notes |
|---|---|---|
id |
int | |
type |
enum | pull_request branch commit document url |
state |
enum | null | draft open merged closed — only as fresh as the last thing that said so |
url |
string | null | https only |
reference |
string | null | A branch name or revision |
title |
string | Falls back to the reference, then the type |
created_at |
ISO 8601 |
Comment
| Field | Type | Notes |
|---|---|---|
id |
int | |
body |
string | Sanitised HTML |
author |
actor | |
in_reply_to |
int | null | |
from_run |
string | null | The run's ULID when an agent's run produced it — a report rather than a remark |
attachments |
array | See below |
created_at |
ISO 8601 |
Attachment
| Field | Type | Notes |
|---|---|---|
id |
int | |
name |
string | The name it was uploaded under, cleaned of anything that is a path |
mime |
string | Detected from the contents, not from the extension |
size |
int | Bytes |
checksum |
string | null | sha256 of the contents |
uploader |
actor | null | |
url |
string | Through the application. The route checks membership of the attachment's workspace, so the link is useless to anybody else — and there is no signed direct link to leak |
created_at |
ISO 8601 |
Uploading is not in the API. Reading an attachment is; adding one is not, and that is a gap rather than a decision — a multipart endpoint needs its own quota and rate rules, and half of one is worse than none.
Project
| Field | Type | Notes |
|---|---|---|
id |
int | |
name |
string | |
description |
string | null | |
status |
enum | planned active paused completed canceled |
priority |
enum | As on an issue |
lead |
actor | null | |
target_date |
date | null | |
issue_types |
string[] | Which types this project offers |
created_at |
ISO 8601 |
Agent
| Field | Type | Notes |
|---|---|---|
id |
int | |
name |
string | |
provider |
enum | claude_code codex elyra |
model |
string | null | |
status |
enum | idle busy offline disabled |
visibility |
enum | private workspace |
max_concurrent_tasks |
int | |
weekly_hours |
int | Capacity, used by the Gantt's critical chain |
runtime |
object | null | The machine it runs on — see below |
Agent runtime
| Field | Type | Notes |
|---|---|---|
id |
int | |
name |
string | |
online |
bool | Heard from within the grace period, not what a status column says |
sandbox |
enum | confined limits off unknown — what that machine can enforce, as it reported at registration |
Never returned: instructions, custom_env, custom_args, mcp_config. A
team's prompt is their working knowledge, and custom_env is where somebody
eventually puts a secret however often they are told not to.
Me
{
"user": { "id": 1, "name": "…", "email": "…" },
"workspace": { "id": 1, "name": "…", "slug": "…", "issue_prefix": "ACM" },
"role": "owner",
"token": { "name": "Deploy pipeline", "abilities": ["write"], "expires_at": null }
}
Two ways to put an agent to work
Both are one call, and both are what the browser does.
Assign it. Setting assignee_type: "agent" queues a run, the same as choosing
an agent in the picker:
curl -X PATCH …/api/v1/issues/ACM-231 \
-H "Authorization: Bearer fat_…" -H "Content-Type: application/json" \
-d '{"assignee_type": "agent", "assignee_id": 4}'
Mention it. A comment naming an agent hands it the thread:
curl -X POST …/api/v1/issues/ACM-231/comments \
-d '{"body": "@Freya the build is failing on main — have a look"}'
The second is often what a CI server should do: it leaves a sentence explaining why, which the agent then reads.
Shapes worth knowing
An issue is named the way a person names it. "id": "ACM-231", not 47. The
identifier is what somebody types into a chat.
A person and an agent have the same shape. Identical keys, with type naming
which:
"assignee": { "type": "agent", "id": 4, "name": "Freya" }
A client that special-cases agents has learned something this product is trying to
unlearn. null means unassigned; "Former member" means the account was deleted
— accounts are hard-deleted and the relation is polymorphic, so there is no
foreign key to protect the name.
Rich text is sanitised HTML, sent as stored. A client that wants plain text can strip it; one that guessed which flavour of markdown we meant would be wrong.
What it will not tell you
Not omissions — decisions:
- An agent's instructions. A team's prompt is their working knowledge.
custom_env. It is where somebody eventually puts a secret, however often they are told not to.- A run's lease token or session id. One is a credential; the other identifies a conversation held on somebody else's machine.
- Anything in another workspace. A record that is not yours answers 404, never 403 — telling somebody a record exists but is not theirs confirms an id and a customer in one response.
Every id you send is checked against your workspace before it is used. A
project_id from elsewhere is a 422, not a silent cross-tenant link.
Rate limits
300 requests a minute per token (FELAGI_API_THROTTLE). Every response carries
X-RateLimit-Limit and X-RateLimit-Remaining so a client can slow down before
it is refused rather than by being refused. A 429 carries Retry-After.
Keyed on the token, not the address — several machines behind one office share an address, and one integration's runaway loop must not throttle another's.
The daemon endpoints are not part of this
/api/daemon/* is a different contract with a different credential, documented in
the daemon protocol. An API token cannot claim, complete or
fail a run: those need a lease proving which machine is doing the work, and a
token proves who somebody is. Different question.
What may change inside v1
A version is worth having only if it means something, so here is what it means.
These can happen without a new version, and a client should tolerate them:
- A new field on any response
- A new endpoint
- A new value in an enum —
status,type,priority,provider, artifacttypeandstatewill all grow. Match the ones you care about and pass the rest through; a client that throws on an unknown status will break on a Tuesday - A new optional query parameter or body field
- A stricter rate limit, or a looser one
These need v2:
- Removing or renaming a field
- Changing a field's type, or what it means
- Making an optional parameter required
- Changing a status code for the same outcome
One thing has already changed and is worth naming rather than burying: a run
returned completed_at and failed_at in 0.16.0. failed_at read a column that
does not exist and was permanently null; both are now one finished_at, with
status saying how it ended. That is a rename, and by the rule above it should have
waited for v2 — it is done inside v1 because the API was one day old and a field
that can only ever be null is worse than a break nobody has depended on yet.
Filtering by label and cycle
?label=bug&label=regression asks for issues carrying both. Every other
filter on this endpoint is a single-valued column where repeating a parameter
could only mean "either"; a label is not, and widening the result on the second
one is the opposite of what a filter is for.
?cycle=12 takes the cycle number, which is what a person says out loud, not
a database id. ?cycle=none returns everything nobody has planned into a window —
the most useful question this filter answers.
Not there yet
- No webhooks out from the API. Félagi does call you — see
Webhooks — but they are configured in the interface,
not through this API, and there is no endpoint for managing them.
updated_sinceonGET /issuesexists so that polling stays cheap for anybody who would rather not run a listener. - No write access to projects, agents or skills. Read only, deliberately for now: creating an agent means naming a runtime and a provider, and getting that wrong over an API is harder to see than getting it wrong in a form.
- No time entries, in or out.
- No OpenAPI document. This page is the specification.
- No pagination cursors. Page numbers, which drift if things are inserted while you page.