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.
For your own editor or coding agent
Working from your editor is the guide: setup, a shell function, and a worked example of every task. This page is the specification.
The common case, and the reason tokens are not gated to admins: the tool already open on somebody's desk should be able to read an issue and log an afternoon without a second window.
Creating a token in Settings hands back a block to paste straight into a
.env:
FELAGI_URL=https://felagi.example.com
FELAGI_TOKEN=fat_…
FELAGI_WORKSPACE=Acme Industries
curl -H "Authorization: Bearer $FELAGI_TOKEN" \
"$FELAGI_URL/api/v1/issues?assignee_type=user&q=login"
curl -X POST -H "Authorization: Bearer $FELAGI_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"duration":"2h 30m","note":"Traced the redirect loop"}' \
"$FELAGI_URL/api/v1/issues/231/time"
A token is the password, and a better one. It is scoped to one workspace, can be read-only, can be given an expiry, and can be revoked without changing anything else you own. An account password unlocks everything including the ability to change the password — which is exactly what should not be sitting in a file on a laptop.
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, assignee (actor key or none), project_id, updated_since, q (title, description and comments), 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, and whether each one matched its summary |
GET /projects, GET /projects/{id} |
|
GET /agents |
Only the ones you could assign to |
GET /saved-views |
The views you may open: your own, plus what the workspace has shared. Filter by surface |
GET /runs, GET /runs/{ulid} |
Filter by status, agent_id |
GET /meetings |
Meetings. Filter by project, when (upcoming, past, all); per_page up to 100 |
GET /meetings/{meeting} |
One meeting, with its agenda, minutes and action items |
GET /articles |
The knowledge base. Filter by q, parent; per_page up to 100 |
GET /articles/{article} |
One article, with its body |
GET /whiteboards |
Boards you can see |
GET /whiteboards/{whiteboard} |
One board, with everything on it |
GET /time-entries |
Filter by from, to, issue_id, meeting, mine, source, absence (work, absence, or a kind); per_page up to 100 |
GET /absences |
Leave on its own. Filter by from, to, kind, mine; per_page up to 100 |
GET /timer |
What is running, or null. Not a 404 — "nothing is running" is an answer |
Writing
POST /issues |
201. title required. estimate_minutes is minutes, as the column stores them; capacity_share for a generic issue |
POST /meetings |
201. title, project and scheduled_for required. Sends nothing |
PATCH /meetings/{meeting} |
Any subset. Organiser or administrator only |
POST /articles |
201. title required. body is HTML, sanitised on the way to disk |
PATCH /articles/{article} |
Any subset. Omitting body leaves the document alone |
POST /whiteboards/{whiteboard}/notes |
201. Put a sticky note on a board |
POST /projects |
201. name required. lead takes an actor key, checked against your workspace |
PATCH /projects/{id} |
Any subset. Leaves out what it does not mention |
PATCH /issues/{id} |
Any subset of the fields, estimate_minutes among them. null clears it |
DELETE /issues/{id} |
204. Take an issue off the board. Author or administrator, and only when nothing is attached |
POST /issues/{id}/comments |
201. Mentioning an agent hands it the thread |
GET /attachments/{id} |
200. The file itself. ?download=1 for the original bytes as a download |
POST /issues/{id}/attachments |
201. Multipart, one part called file. The interface's own limits |
POST /issues/{id}/comments/{comment}/attachments |
201. The same, onto a comment |
DELETE /attachments/{id} |
204. A file you uploaded, or any if you administer the workspace |
PATCH /issues/{id}/comments/{comment} |
200. body is the only field. Author only, and never a comment a run wrote |
DELETE /issues/{id}/comments/{comment} |
204. Author or administrator. Leaves a tombstone, not a hole |
POST /issues/{id}/runs |
202. agent_id required |
POST /issues/{id}/time |
201. duration or minutes; optional spent_on, note |
POST /meetings/{meeting}/time |
201. The same, against a meeting |
POST /absences |
201. kind and duration or minutes; date for one day or from and to for a range; optional note |
PATCH /time-entries/{timeEntry} |
200. duration or minutes, spent_on, note — any subset. Only hours you logged yourself |
DELETE /time-entries/{timeEntry} |
204. Only hours you logged yourself |
DELETE /absences/{timeEntry} |
204. The same rule. 404 on an id that is not an absence |
POST /issues/{id}/timer |
201. Start the clock. Optional note. Returns what it stopped, if anything |
POST /timer/stop |
201. Stop it and write the hours down |
DELETE /timer |
204. Throw it away, recording nothing |
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
Every collection, including GET /projects — which answered a bare data with no
links and no meta until 0.69.1, and is the reason this sentence names it.
links.next is built from the configured application URL rather than from the request,
so it is absolute and the same on every call. It was neither: identical consecutive
requests returned a relative path and an absolute URL in turn, because production runs
long-lived workers and whichever one answered decided whether there was a root. Follow
the link; do not assemble it.
{
"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, or a token belonging to a Management account — the API is about issues, and that role has none. The message says which |
404 |
No such record or it belongs to another workspace — the same answer, deliberately |
422 |
Validation, including an unknown query parameter, an id that belongs to another workspace, and a parent_id the epic rules refuse |
429 |
Rate limited. Retry-After says how long |
An unknown query parameter is refused
A misspelled filter answers 422 and names what the endpoint does accept:
{
"message": "Unknown parameter…",
"errors": { "assignnee_id": ["Unknown parameter. This endpoint accepts: assignee, …, per_page."] }
}
It used to be ignored, which meant ?assignee_id=1 narrowed to one person and
?assignnee_id=1 returned the whole workspace — two responses that look identical to
anything reading them. A papercut on a one-off request, and a trap in a saved view: a
view whose filter key stopped being recognised would quietly show every issue and still
look like a working view.
page is accepted everywhere, because this API's own collection links contain it.
Request bodies are not treated this way. An unknown field on a POST or PATCH is
still ignored, deliberately: clients commonly send a whole resource back, including
fields they never meant to set, and refusing those would break integrations to catch a
kind of typo that has not been a problem here.
Every field
Issue
Naming one in a URL. Anywhere an endpoint takes {issue}, two forms work:
| You send | It means |
|---|---|
ACM-231 |
Issue number 231 in this token's workspace. Any prefix the workspace answers to — its own or a project's — and case does not matter |
231 |
The database key, which is not the same number |
The two are deliberately different namespaces. number restarts nowhere and is unique per
workspace; the database key is global, so on an installation with more than one workspace
/issues/5 and /issues/ACM-5 are different issues. The numeric form kept its old meaning
because clients already use it.
A prefix nothing in the workspace answers to is a 404, never a fallback to the number:
otherwise XYZ-231 would return issue 231 and you would believe you found what you asked
for. A run-together form like ACM231 is refused too — a prefix may contain digits, so it
has more than one reading.
| Field | Type | Notes |
|---|---|---|
id |
string | ACM-231. The identifier a person types, not the database key — and one you can send straight back |
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 | |
capacity_share |
string | null | What a generic issue claims of somebody's week: 10%, 1d, 4h. Null on every other type |
claims_capacity |
bool | null | Whether it is claiming that share right now — assigned, and in progress. Null on every other type |
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 |
Asking who has it
| Written | Means |
|---|---|
?assignee=user:5, ?assignee=agent:3 |
That actor's issues |
?assignee=none |
Nobody has picked it up |
?assignee_type=user&assignee_id=5 |
The same as the first form. Still accepted, still means what it meant |
One parameter, taking an actor key — the vocabulary a mention and a project's lead already use, and the same string the board's own address bar carries, so a link somebody sends and a query a script runs read alike.
none was the gap. assignee_type=none was the obvious guess and answered 422, and
unassigned work could not be asked for at any layer — which is the answer a team most
needs, the same way cycle=none is the most useful thing to ask about planning.
Removing one
DELETE /issues/{id} answers 204. The row survives — an identifier gets quoted in a
commit message and in a standup, and a dozen places in this application already fall back
to Deleted work for an issue that is not there. What it stops being is a thing on a
board, a row in GET /issues, and a number in the progress fraction an epic and a project
are measured by.
That last part is why this is a deletion and not another status. Progress is closed
children over total children, so an issue created by mistake inflates both halves for
ever, and the error is invisible. canceled means something else: work that was real and
then abandoned.
Who. The author, plus anybody who administers the workspace — the rule comment withdrawal follows. An agent-created issue has no human author, so for that one an administrator is the only person who can.
It refuses when anything is attached, with a 422 naming what: children, logged hours, runs, whiteboard notes, a running timer, or a meeting's minutes. Deleting an issue must never quietly destroy somebody's hours, and there is no override — an issue created in error has none of these by definition, so an override would only ever be used on an issue that was real.
Its own comments and files go with it. They belong to the issue rather than to anything outside it.
An epic that still contains issues cannot be removed, which is the containment rule an epic already follows for demotion rather than a second rule invented here.
Standing work
Support, datacenter maintenance, a rota: work that never finishes and is carried as a
share of somebody's week rather than as a thing to complete. Its type is generic, and
what it has instead of an estimate is capacity_share:
curl -X POST "$FELAGI_URL/api/v1/issues" \
-H "Authorization: Bearer $FELAGI_TOKEN" -H 'Content-Type: application/json' \
-d '{"title":"Support","type":"generic","project_id":4,"capacity_share":"10%"}'
10%, 1d and 4h are all accepted, and the two forms mean different things: a
percentage is resolved against that person's own weekly hours, so it follows a colleague
down to three days a week, while 1d is a day whoever else's week it is. At most a whole
week — a share is a rate, not a total.
It claims the share while it is in progress, and not a moment longer. The status is the switch, so nothing has to be remembered or expired: move it back to the backlog and the capacity returns.
A share and an estimate are two plans for one issue, so setting capacity_share
clears estimate_minutes. And a generic issue needs a project_id — standing work is
always somebody's share of a project, or the hours have nowhere to be reported.
What it stays out of. Everything derived from completion: an epic's progress, a project's, a cycle's carry-over. A thing that is not supposed to end cannot be behind, and counting it would stop a bar short of a hundred for ever. What it does count towards is capacity — a person with 10% support has 36 plannable hours in a 40-hour week, which is what the Gantt's projections and the cycle's capacity use.
Hours are logged against it like any issue. POST /issues/{id}/time needs no special
case, and the share is the plan while the entries are the actual — so a standing task that
consistently takes 25% of a week says so.
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.
Meeting
{ "id": "01k9…", "title": "Incident review", "project": "Platform",
"location": "Room 2", "scheduled_for": "2026-08-06T10:00:00+00:00",
"ends_at": "2026-08-06T11:00:00+00:00", "duration_minutes": 60,
"status": "held", "recurring": true,
"organiser": { "type": "user", "id": 3, "name": "Knut" },
"attendees": [ { "name": "Astrid", "required": true } ],
"action_items": [ { "identifier": "ACM-14", "title": "Rewrite the deploy runbook",
"status": "todo" } ],
"minutes_sent_at": null }
| Field | Type | Notes |
|---|---|---|
status |
string | scheduled, held, minuted, canceled — derived, never stored. A status somebody has to remember to set is a status that lies within a week |
recurring |
bool | Whether a series scheduled it |
ends_at |
string | Sent as well as the duration, so a client does not repeat the arithmetic |
attendees |
array | Names, not ids, and no reply: being on the list is the answer. Somebody not coming is taken off it |
agenda, minutes |
string | HTML. Only on a single meeting — a list of meetings is a calendar, and sending every set of minutes with it is the mistake an article body would be |
notes |
string | Only for the organiser or an administrator, matching the page. Notes are fragments typed in a room, and an API that ignored that restraint would make the interface's decorative |
action_items |
array | The issues the meeting produced. Each carries identifier (e.g. ACM-14), title and status, so a client can fetch the issue without a lookup |
This is the endpoint that completes the picture for an agent. The action items are already issues it can see; without this it could see the work and not the sentence that produced it. "Rewrite the deploy runbook" is a different instruction once you know the room decided the current one caused an outage.
POST /meetings sends nothing. Invitations are a separate, deliberate step in the
interface, which is what makes writing here safe:
{ "title": "Architecture review", "project": 4,
"scheduled_for": "2026-08-09T10:00:00Z", "duration_minutes": 90 }
PATCH /meetings/{meeting} takes any subset. The useful field is notes — something
that watched a meeting happen can put down what it heard, and the organiser turns it
into minutes:
{ "notes": "- Astrid: runbook by Friday\n- dropped the nightly job" }
Once minutes have been circulated they are the record and a PATCH that includes
minutes answers 409. Minutes that quietly change after people have read them are
worse than late ones.
Article
{ "id": "01k9…", "title": "Restoring the database", "parent": "01k8…", "depth": 1,
"state": "published", "published": true, "excerpt": "Stop the queue workers first…",
"author": { "type": "user", "id": 3, "name": "Knut" },
"updated_at": "2026-08-03T09:12:00+00:00" }
| Field | Type | Notes |
|---|---|---|
id, parent |
string | ULIDs. The tree is arbitrary depth, up to six |
depth |
int | How far down it sits, so a client can indent without walking |
state |
string | draft · in_review · published · archived. See the four states for who may read each |
published |
bool | True only for published. Kept for clients written before 0.54.1, and derived from the state rather than stored beside it |
excerpt |
string | First 200 characters of the plain text, so a client can decide whether to fetch the body |
body |
string | HTML. Only on GET /articles/{article} — a list of four hundred articles is a table of contents, and sending every document with it would be megabytes for a client that wanted titles |
Writing accepts either. state says all four things; published says two of them and
still means what it meant — true publishes, false returns it to a draft. When a
request carries both, the state decides, because it is the one that can express the
answer. A state this API does not know is a 422, not a silent default.
This is the one endpoint worth having for an agent. Skills are what an agent is given; the knowledge base is what it can look up. An agent that reads the team's runbook before it starts is an agent that follows the team's conventions.
Drafts belong to whoever is writing them and never appear for anybody else, over the API as in the interface. Half a page found through an API is worse than not finding it, because something will act on it without a person reading it first.
Whiteboard
{ "id": "01k9…", "name": "Retro, 3 August", "visibility": "workspace",
"author": { "type": "user", "id": 3, "name": "Knut" },
"elements": [ { "ulid": "01k9…", "type": "note", "text": "Deploys are scary",
"x": 320.5, "y": -140.25, "color": "yellow", "votes": 2 } ] }
elements appears only on a single board, for the same reason an article's
body does. A private board is invisible here as it is everywhere else: hearing
about a surface is reading it.
POST /whiteboards/{whiteboard}/notes takes text, and optionally type (note or
box), color, x and y:
{ "text": "Rate limiting came up again", "color": "pink" }
Notes and boxes only. An arrow needs two points a caller cannot see and an image needs an upload this API does not do; accepting either and ignoring it would be a field that lies. Anybody with the board open sees it appear, exactly as if somebody had put it there by hand.
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.
Time entry
{ "id": 91, "issue": "ACM-231", "minutes": 150, "spent_on": "2026-08-01",
"source": "manual", "editable": true, "note": "Traced the redirect loop",
"actor": { "type": "user", "id": 3, "name": "Knut" } }
| Field | Type | Notes |
|---|---|---|
minutes |
int | Always minutes on the way out, however it was written on the way in |
spent_on |
date | The day the work happened, not the day it was typed |
source |
string | manual, timer or agent_run |
editable |
bool | Whether you may correct it. False for a run — its duration is a record of something that happened — and false for hours somebody else logged |
note |
string | null | Plain text. The note is written in an editor; a terminal is not a browser |
actor |
actor | Whose hours these are. People and agents through one shape |
Logging time
A delivery says whether it matched its summary
{ "type": "pull_request", "url": "…", "claim_check": [
{ "claim": "tests", "says": "The summary says it added tests.", "found": "No test file was changed." }
], "claim_checked_at": "2026-08-21T21:14:00+00:00" }
Three states, and conflating two of them is the mistake to avoid:
claim_checked_at |
claim_check |
Means |
|---|---|---|
null |
null |
Never checked — a person's pull request, no summary, or the files could not be fetched |
| set | [] |
Checked, nothing to report |
| set | non-empty | The summary and the diff disagree |
A client that treats the first two the same will call an unexamined delivery a clean one. That is the whole reason the timestamp is a separate field rather than an empty array standing in for both.
It is not a review of the code. It answers one question — was this claim kept — and only for claims a list of file paths can settle. See Delivering work.
Correcting one
curl -X PATCH "$FELAGI_URL/api/v1/time-entries/91" \
-H "Authorization: Bearer $FELAGI_TOKEN" -H 'Content-Type: application/json' \
-d '{"duration":"3h 15m","spent_on":"2026-08-25"}'
Any subset of the duration, the day and the note. The day matters as much as the number: an hour worked on Monday and typed in on Friday should say Monday, and the wrong day is as easy a mistake as the wrong figure.
Correcting is not deleting and logging again, which is why this exists rather than the
workaround being documented. Re-creating an entry turns a timer into a manual one —
silently downgrading a measured number to a remembered one, in exactly the way the note
under source warns about. It is also not atomic, so a failed second half leaves the hours
gone; it resets created_at, so a correction becomes indistinguishable from an entry made
three weeks late; and the id changes under anything holding it.
source is therefore not among the fields a correction can change. A stopwatch entry stays
measured however often its figure is fixed — the clock was running, and that is a fact
about how the number was arrived at rather than about the number.
Your own hours only, and never a run's. Which is what editable reports, so a client can
tell before it tries.
An hour belongs to one of three things
Every time entry names its subject, because there are three kinds:
{ "subject": "meeting", "meeting": "01J…", "title": "Quarterly planning", "minutes": 90 }
{ "subject": "issue", "issue": "ACM-231", "title": "The login redirect loops", "minutes": 45 }
{ "subject": "absence", "absence": "vacation", "title": "Vacation", "minutes": 450 }
subject is always there and is issue, meeting or absence. absence carries the
kind on an absence and is null on work. title is always there too, so a client can
list hours without joining anything.
A client that sums per issue must handle the other two, or drop them on purpose. This
field has now been wrong twice for the same reason. When meetings gained hours it kept
returning only the issue, so a meeting's hours arrived with no subject at all; when
absence arrived it reported meeting with a null meeting, so a fortnight of somebody's
leave was dropped by any client grouping by meeting. Silently, both times, which is the
worst way to be wrong about somebody's time.
Leave has its own route, and appears on the other one too
Absence belongs to no issue and no meeting, so POST /absences is a third store method
rather than a flag on the first two — the same call POST /meetings/{meeting}/time made.
{ "kind": "vacation", "duration": "1d", "from": "2026-06-01", "to": "2026-06-14" }
A range, and that is the point of it. A week off was five requests, and a script that
had to work out which of the days were weekends was a script reimplementing the calendar
— badly, and differently from the interface. date is accepted instead of from for a
single day, because that is the shape a script logging today writes.
The rules are the interface's, because they are the same object's: weekends are skipped, a
public holiday inside a range is recorded as public_holiday rather than as the leave
that was asked for, and a day already carrying absence is left alone. The response says
which, and how many:
{
"data": [ … 10 entries … ],
"summary": "Vacation: 3 days · 2 weekend days skipped · Maundy Thursday, Good Friday as public holiday.",
"skipped": {
"weekends": 4,
"holidays": { "2026-04-02": "Maundy Thursday", "2026-04-03": "Good Friday" },
"already_recorded": []
}
}
A client that sent fourteen days and got ten entries has to be able to tell why.
Without skipped, the sensible thing for it to do is retry — and the retry is a double
entry. The holidays are named rather than counted, because "1 public holiday" is a number
somebody then has to go and look up.
Ranges are capped at 366 days. That is a guard on a mistyped year, not a policy: the alternative is several thousand rows across a decade of somebody's timesheet.
Absence appears in GET /time-entries as well, and that is deliberate — leaving it
out of the whole record of somebody's hours is how a fortnight disappears. It is
separable there with ?absence=work, ?absence=absence, or a particular kind. GET /absences exists so a client that wants leave does not have to know that filter, and
does not silently get issues if it forgets it.
DELETE /absences/{id} follows DELETE /time-entries/{id}'s rule exactly — your own
hours only — and answers 404 for an id that is not an absence. A mistyped id should
be told it named the wrong thing, not quietly remove somebody's work.
POST /issues/{id}/time takes a duration the way a person says it:
{ "duration": "2h 30m", "spent_on": "2026-08-01", "note": "Traced the redirect loop" }
1w, 1d, 90m and 2h 30m all work — a week is five days and a day is eight hours —
and minutes is accepted instead for a tool
that has already done the arithmetic. Requiring minutes would be a tool somebody
stops using on the third day.
Three provenances, not two. timer is measured — a stopwatch was running while
the work happened — and manual is remembered, typed from memory afterwards. They
are different kinds of number, and a client adding them together without saying so
is reporting a guess. agent_run is measured too, by the daemon.
Timed hours are still correctable by whoever recorded them — PATCH /time-entries/{id},
which changes the figure and not the provenance — and only a run is not.
The hours are always the token's own. There is no actor field, because a credential that can log time against a colleague is a credential that can rewrite their timesheet — and hours are somebody's statement about their own week.
DELETE /time-entries/{timeEntry} follows the same two rules as the interface: an agent
run's duration cannot be removed, and neither can anybody else's hours.
The timer
One running clock per person, enforced by a unique index rather than by a check — nobody works on two things at once, and a race between two tabs should be settled by the database.
{ "data": { "issue": "ACM-231", "issue_id": 231,
"started_at": "2026-08-03T09:12:00+00:00", "note": null, "max_minutes": 480 } }
The start time, never the elapsed time. A count of seconds is stale the instant it is serialised; a timestamp stays right however long the response sat in a pipe, and the caller counts on its own.
Starting a second timer stops the first and logs it, and the response says so:
{ "data": { "issue": "ACM-240", "…": "…" },
"stopped": { "id": 91, "issue": "ACM-231", "minutes": 45, "…": "…" } }
Switching tasks is the normal case, so it is not an error — but banking time against something else silently would be, which is why the entry comes back.
Stopping rounds to the nearest minute with a floor of one. A forty-second timer is still work somebody did, and rounding it to zero would throw it away quietly, which is worse than a minute that is slightly generous.
A timer left running is capped at max_minutes — eight hours — and the note on
the resulting entry says it was capped. A clock that ran for fourteen hours did not
measure fourteen hours of work; somebody went home. Recording that number would put
something indefensible into a report, and discarding it would lose an afternoon
that really happened. A scheduled command closes them hourly, so nothing sits open
overnight.
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 |
mentions |
array of actors | Who the body was resolved as naming. Empty when nothing matched, which is how you tell a mention that landed from one that did not |
attachments |
array | See below |
withdrawn |
bool | The comment was taken back. body is empty, the attachments are gone, and the entry is still in the thread because the replies under it point at it |
created_at |
ISO 8601 | |
updated_at |
ISO 8601 | Moves on any write. The one to compare a cached copy against |
edited_at |
ISO 8601 | null | Null until the wording is changed. Answers "are these the original words" |
Editing a comment. PATCH takes body and nothing else. Moving a comment to another
issue or under another parent is not a correction — it rewrites a conversation people have
already read — so those fields are ignored rather than honoured. Like every other PATCH
here it takes any subset, and there is one field to leave out: a request that does not
mention body changes nothing and is not recorded as an edit. Only the author can edit,
and a comment produced by an agent's run cannot be edited by anybody: it is the record of
what the agent reported, and an agent that can be made to have said something else is not
evidence of anything.
The wording's history is not kept, only the fact that it changed. edited_at is what a
reader sees; who changed it and when is on the issue's own history, as
issue.comment_edited.
A run triggered by a comment holds a reference to it, not a copy, so an agent that has not started yet reads the corrected wording — which is usually the point of correcting it. An agent that has already read the comment will not read it again.
An edit hands out no work. Adding an agent's name to a comment by editing it queues
nothing, because a mention that dispatched on every save would be one nobody could
correct — fixing a typo in the name would send the run twice. The response still reports
the mentions it now resolves, so a client can tell a name that matches from one that
does not; it simply is not an instruction. Post a comment to hand out the work.
Withdrawing a comment. DELETE clears the words and the attachments and keeps the
row. comments.parent_id cascades, so removing the row outright would remove every reply
to it, which is not what somebody taking back their own typo has in mind. What is left
reads withdrawn: true with an empty body, still in GET /issues/{id}/comments and
still the in_reply_to of everything that answered it. There is no way back from it.
The author can withdraw their own, and so can anybody who administers the workspace — wider than editing on purpose: editing puts new words in somebody's mouth, while removing takes words out of the room, which is what a workspace needs when a comment carries a customer's name or a key that should never have been pasted. An agent's comment has no human author, so for that one an administrator is the only person who can.
Withdrawing takes back what the comment set in motion. A run the comment queued and
that has not started yet is canceled, because a run holds a reference to its trigger
comment rather than a copy — leaving it would hand an agent an empty sentence to act on.
A run the daemon has already claimed is left alone: the agent has read the words, and
stopping one mid-run is a separate decision the interface offers on its own. The count is
on the issue's history, in issue.comment_withdrawn.
No webhook fires for editing or withdrawing. comment.created still means a new comment
and nothing else.
A comment must have words in it. <p></p> is what an untouched editor sends, and it is
a present, non-empty string — so both POST and PATCH check for actual text and answer
422 rather than accepting markup that carries none.
Mentions, and how to write one. Three forms are accepted, in the body's text:
| Written | Resolves to |
|---|---|
@agent:1, @user:3 |
That actor, unambiguously. The shape the rest of this API uses — a project's lead is set the same way |
@Freya, @Alex Berg |
By display name, longest first |
@Alex |
A person by first name. Not offered for agents: an agent's name is short and chosen, and splitting it invents a handle nobody wrote down |
Two colleagues can share a display name and a display name can change, so a script should
send an actor key. It renders as the name it refers to, so nobody has to read @user:3.
Mentioning an agent hands it the thread — a run is queued against the issue with the comment travelling in its context, so the agent reads the sentence rather than only the issue. Mentioning a person delivers to their inbox instead: an inbox row and a queued run are not the same response, which is why the two kinds are separate.
Nobody is told about their own mention. It still renders as one — a comment saying @Knut
contains a mention of Knut whoever wrote it — it simply does not arrive in his own inbox.
An unresolvable name is not an error. @Nobody returns 201 with mentions: []
rather than a 422: an @ in a comment is usually not a mention at all — an address, a time,
a colleague who does not use Félagi — and refusing those would make the endpoint unusable
for ordinary prose. The empty array is the answer to "did it work".
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. Takes three flags, below |
created_at |
ISO 8601 |
Attaching one
curl -X POST "$FELAGI_URL/api/v1/issues/ACM-231/attachments" \
-H "Authorization: Bearer $FELAGI_TOKEN" \
-F "file=@attributes.csv"
201 with the object above. One file per request, in a multipart part called file.
A body could carry ten, but then a request half-succeeds or reports one status for
several outcomes, and "201 with this object" is a contract a client can act on.
POST /issues/{id}/comments/{comment}/attachments puts one on a comment instead — posted
after the comment rather than with it, so a comment cannot fail because its second file
was too large.
The limits are the interface's own, not a second set: 25 MB per file — 50 MB for
video — and an allowlist of images, video, PDF, plain text, Markdown, CSV, JSON, XML,
HTML, ZIP and Office documents. The type is read from the contents rather than from the name, so a .png
that is really text is stored and served as text — the name never decides what
something is.
A refusal is 422 and names the type it read:
{
"message": "That file was read as audio/wav, which cannot be attached. Accepted: images, video, PDF, text, Markdown, CSV, JSON, XML, HTML, ZIP, Word and Excel. The type is read from the contents, not from the name.",
"errors": { "file": ["That file was read as audio/wav, which cannot be attached. …"] }
}
One exception to reading the contents, and it is narrow. A text file whose prose
contains tag-like text is sniffed as text/html — a Markdown report that quotes
<head> is classified as HTML, while a shorter excerpt of the same document is not. So
a sniff of text/html against a text extension (.md, .txt, .log, .csv, .json,
.xml) is read as that extension. Nothing else defers to the name, and the exception can
only move a file towards text: an extension cannot talk its way into being a PDF.
HTML is accepted, and always downloaded. Content-Disposition: attachment for
everything except PNG, JPEG, GIF and WebP, which are the only types served inline. That
is what makes accepting it safe rather than the type being harmless: HTML served inline
from this origin would be stored cross-site scripting. SVG is on the same footing, and has
been all along.
The URL's three flags
?download=1 |
The original bytes, as a download, whatever the type. This is the one to use to get a file back out. It wins over the other two |
?size=thumb or ?size=large |
An image resized to 512 or 1400 pixels wide, as WebP, inline. Falls back to the original whenever a smaller copy cannot be made, so the URL always answers with a picture |
?preview=1 |
PDFs and text shown inline rather than downloaded — under Content-Security-Policy: sandbox, which is the whole reason they may be. Text is served as text/plain whatever it claimed to be |
Without any flag, an image is served inline at its original size and everything else
downloads. download=1 refuses the other two on purpose: a download of size=large would
hand back WebP bytes under a name ending in .png, and a download is never sandboxed
because nothing runs.
Reading one back
curl -H "Authorization: Bearer $FELAGI_TOKEN" \
"$FELAGI_URL/api/v1/attachments/78" -o shot.png
200 with the bytes. This is what the url field points at, and for a while it did
not: it named the web route, which takes a session and answers a bearer token with the
login page — a 200, an HTML body, and nothing to say what went wrong. Complete metadata
beside an unreachable URL reads as working, which is the expensive kind of broken.
It is the same response the interface serves, so an image comes back inline, a video is
seekable through Range, and the three flags above all apply. ?download=1 is the one
worth knowing: it hands over the original bytes rather than a resized copy.
404, never 403, for a file in another workspace — the same reasoning as deleting one. A refusal you can tell apart is a way to find out that a file exists.
A read token is enough. write includes read, so there is no token that can upload a
file and not fetch one.
DELETE /attachments/{id} answers 204. Whoever uploaded it, or an administrator —
and 404 for a file in another workspace, so this cannot be used to find out that one
exists. A file an agent's run produced has no human uploader, so for that one an
administrator is the only person who can.
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 | |
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.
Saved view
| Field | Type | Notes |
|---|---|---|
id |
int | |
name |
string | |
surface |
string | Which page the view belongs to — issues for the board |
filters |
object | The filters as saved, with the keys GET /issues accepts. What makes a view something to carry out rather than only to name |
owner |
actor | Who kept it |
shared |
bool | Visible to the workspace, rather than only to its author |
created_at, updated_at |
ISO 8601 |
Read-only, and that is the whole endpoint. A view is composed in the interface by clicking the filters; a write path would be a second way to build one that has to agree with the first.
Worth reading, though: an agent works the same board as the people who share it, and a view a colleague kept is the clearest statement of what they consider their work. Until now that statement was legible only in a browser.
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.
Belonging to your workspace is not on its own enough to be usable. A parent_id also has
to satisfy the epic rules — only an epic may
be a parent, and an epic cannot be filed under another — which this endpoint checked for
neither until 0.54.0. It accepted both and the interface refused both, so the same
hierarchy had two sets of rules depending on how you reached it.
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 time entries for anybody but yourself. You can read the whole workspace's hours and write only your own.
- No write access to agents or skills. Read only, deliberately: creating an agent means naming a runtime and a provider, and getting that wrong over an API is much harder to see than getting it wrong in a form. That argument never applied to projects, which are a name and a description, so those are writable.
- No writing saved views.
GET /saved-viewsreads them; composing one is done by clicking the filters in the interface. - No OpenAPI document. This page is the specification.
- No pagination cursors. Page numbers, which drift if things are inserted while you page.