Elyra
Elyra The coding agent eTerm The terminal that knows where each command ends Starf An activity monitor for Apple silicon that never invents a number e The native code editor Elyra Grove Native local development environment Askr The real server for Laravel & PHP Elyra Framework Rust + Svelte 5 framework for desktop apps Elyra Conductor Local project conductor Elyra SQL Server MySQL-compatible SQL server in Rust Elyra Félagi Agents as teammates on one board Elyra SQL Client Native desktop SQL workbench Elyra SQL Anywhere Replication-ready SQL engine Elyra Sjá SEO & GEO workspace for macOS Elyra DataGrid Server-driven data grid for Laravel
Start here
Concepts
Release notes
What's new
Elyra

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, canceledderived, 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, artifact type and state will 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_since on GET /issues exists 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-views reads 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.