Elyra
Elyra The coding agent 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
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.


Endpoints

/api/v1, versioned from the first line — the daemon protocol taught that lesson twice.

Reading

GET /me Who this token is, its workspace, its abilities
GET /issues Filter by status, type, priority, project_id, assignee_type, assignee_id, updated_since, q, label (repeatable, AND), cycle (number or none); per_page up to 100
GET /issues/{id} One issue
GET /issues/{id}/comments The thread
GET /issues/{id}/artifacts What the runs delivered
GET /projects, GET /projects/{id}
GET /agents Only the ones you could assign to
GET /runs, GET /runs/{ulid} Filter by status, agent_id

Writing

POST /issues 201. title required
PATCH /issues/{id} Any subset of the fields
POST /issues/{id}/comments 201. Mentioning an agent hands it the thread
POST /issues/{id}/runs 202. agent_id required

POST /issues/{id}/runs returns 202, not 201. The run is queued; whether a machine picks it up depends on a daemon that may not be running. "Created" would promise something the response cannot know.

The other two set 201 explicitly. Laravel infers it from wasRecentlyCreated, which fresh() clears — so one endpoint answered 200 and another 201, and the difference was whether the controller happened to reload the model.



Responses

A collection

{
  "data": [ … ],
  "links": { "first": "…?page=1", "last": "…?page=14", "prev": null, "next": "…?page=2" },
  "meta": {
    "current_page": 1, "from": 1, "to": 25, "last_page": 14,
    "per_page": 25, "total": 342, "path": "…/api/v1/issues",
    "links": [ … ]
  }
}

meta.links is Laravel's page-number list. Ignore it and follow links.next until it is null; page numbers drift if something is inserted while you page, which is the trade for not having cursors.

A single record is the same envelope without links and meta: { "data": { … } }.

An error

{ "message": "The title field is required.", "errors": { "title": ["The title field is required."] } }

errors is present only on a 422. Everything else carries message alone.

Status When
401 No token, or a token that is wrong, revoked, expired, or whose owner has left
403 A read token attempting a write. The message says what to do about it
404 No such record or it belongs to another workspace — the same answer, deliberately
422 Validation, including an id that belongs to another workspace
429 Rate limited. Retry-After says how long

Every field

Issue

Field Type Notes
id string ACM-231. The identifier a person types, not the database key
number int The number within the workspace
title string
description string | null Sanitised HTML, as stored
acceptance_criteria string[]
type enum epic bug feature task cosmetics exception usability_problem performance_problem
status enum backlog todo in_progress in_review on_hold done canceled
priority enum urgent high medium low
project object | null { id, name }. Only on a single issue, not in a list
epic object | null { id, title }id is the identifier
assignee actor | null
creator actor | null
start_date, due_date date | null YYYY-MM-DD
estimate_minutes int | null
spent_minutes int Includes agent run time — machine time is time
labels string[] Names, not ids. A label is a word; an identifier for a string is one more thing to resolve
cycle object | null number, starts_on, ends_on. Null when the issue is in no window
carried_over int How many closed cycles this has survived. Most trackers do not record it
created_at, updated_at ISO 8601

Actor — a person or an agent

{ "type": "agent", "id": 4, "name": "Freya" }

type is user or agent. The fields are identical for both, so a client that does not care never has to ask. name reads Former member when the account was deleted — accounts are hard-deleted and the relation is polymorphic, so there is no foreign key protecting the name.

Cycle

{ "number": 12, "starts_on": "2026-08-03", "ends_on": "2026-08-16" }
Field Type Notes
number int What a person says out loud. Cycles are numbered, not named
starts_on, ends_on date Inclusive. Generated on a cadence, so they are never edited

Null when the issue is in no window, and absent entirely when the workspace has cycles switched off.

Run

Field Type Notes
id string A ULID
status enum queued dispatched running completed failed canceled
agent object { id, name }
issue string | null The issue identifier
attempts int
queued_at, started_at ISO 8601 | null
finished_at ISO 8601 | null One timestamp for the end; status says how it ended
error string | null
summary string | null What the agent reported
usage object | null See below
artifacts array Only when the run is loaded with them

usage is null, never zeroed, when the CLI reported nothing. A client that read 0 and summed it would produce a total that looks authoritative and is not.

"usage": {
  "input_tokens": 2, "output_tokens": 7,
  "cached_tokens": 0, "cache_write_tokens": 20787,
  "cost_usd": 0.13072
}

Cost is stored as micro-dollars and returned as dollars. Any of these may be null on its own — providers report different subsets.

The lease token and the session id are never returned. One is a credential; the other identifies a conversation on somebody else's machine.

Artifact

Field Type Notes
id int
type enum pull_request branch commit document url
state enum | null draft open merged closed — only as fresh as the last thing that said so
url string | null https only
reference string | null A branch name or revision
title string Falls back to the reference, then the type
created_at ISO 8601

Comment

Field Type Notes
id int
body string Sanitised HTML
author actor
in_reply_to int | null
from_run string | null The run's ULID when an agent's run produced it — a report rather than a remark
attachments array See below
created_at ISO 8601

Attachment

Field Type Notes
id int
name string The name it was uploaded under, cleaned of anything that is a path
mime string Detected from the contents, not from the extension
size int Bytes
checksum string | null sha256 of the contents
uploader actor | null
url string Through the application. The route checks membership of the attachment's workspace, so the link is useless to anybody else — and there is no signed direct link to leak
created_at ISO 8601

Uploading is not in the API. Reading an attachment is; adding one is not, and that is a gap rather than a decision — a multipart endpoint needs its own quota and rate rules, and half of one is worse than none.

Project

Field Type Notes
id int
name string
description string | null
status enum planned active paused completed canceled
priority enum As on an issue
lead actor | null
target_date date | null
issue_types string[] Which types this project offers
created_at ISO 8601

Agent

Field Type Notes
id int
name string
provider enum claude_code codex elyra
model string | null
status enum idle busy offline disabled
visibility enum private workspace
max_concurrent_tasks int
weekly_hours int Capacity, used by the Gantt's critical chain
runtime object | null The machine it runs on — see below

Agent runtime

Field Type Notes
id int
name string
online bool Heard from within the grace period, not what a status column says
sandbox enum confined limits off unknown — what that machine can enforce, as it reported at registration

Never returned: instructions, custom_env, custom_args, mcp_config. A team's prompt is their working knowledge, and custom_env is where somebody eventually puts a secret however often they are told not to.

Me

{
  "user": { "id": 1, "name": "…", "email": "…" },
  "workspace": { "id": 1, "name": "…", "slug": "…", "issue_prefix": "ACM" },
  "role": "owner",
  "token": { "name": "Deploy pipeline", "abilities": ["write"], "expires_at": null }
}

Two ways to put an agent to work

Both are one call, and both are what the browser does.

Assign it. Setting assignee_type: "agent" queues a run, the same as choosing an agent in the picker:

curl -X PATCH …/api/v1/issues/ACM-231 \
  -H "Authorization: Bearer fat_…" -H "Content-Type: application/json" \
  -d '{"assignee_type": "agent", "assignee_id": 4}'

Mention it. A comment naming an agent hands it the thread:

curl -X POST …/api/v1/issues/ACM-231/comments \
  -d '{"body": "@Freya the build is failing on main — have a look"}'

The second is often what a CI server should do: it leaves a sentence explaining why, which the agent then reads.


Shapes worth knowing

An issue is named the way a person names it. "id": "ACM-231", not 47. The identifier is what somebody types into a chat.

A person and an agent have the same shape. Identical keys, with type naming which:

"assignee": { "type": "agent", "id": 4, "name": "Freya" }

A client that special-cases agents has learned something this product is trying to unlearn. null means unassigned; "Former member" means the account was deleted — accounts are hard-deleted and the relation is polymorphic, so there is no foreign key to protect the name.

Rich text is sanitised HTML, sent as stored. A client that wants plain text can strip it; one that guessed which flavour of markdown we meant would be wrong.


What it will not tell you

Not omissions — decisions:

  • An agent's instructions. A team's prompt is their working knowledge.
  • custom_env. It is where somebody eventually puts a secret, however often they are told not to.
  • A run's lease token or session id. One is a credential; the other identifies a conversation held on somebody else's machine.
  • Anything in another workspace. A record that is not yours answers 404, never 403 — telling somebody a record exists but is not theirs confirms an id and a customer in one response.

Every id you send is checked against your workspace before it is used. A project_id from elsewhere is a 422, not a silent cross-tenant link.


Rate limits

300 requests a minute per token (FELAGI_API_THROTTLE). Every response carries X-RateLimit-Limit and X-RateLimit-Remaining so a client can slow down before it is refused rather than by being refused. A 429 carries Retry-After.

Keyed on the token, not the address — several machines behind one office share an address, and one integration's runaway loop must not throttle another's.


The daemon endpoints are not part of this

/api/daemon/* is a different contract with a different credential, documented in the daemon protocol. An API token cannot claim, complete or fail a run: those need a lease proving which machine is doing the work, and a token proves who somebody is. Different question.


What may change inside v1

A version is worth having only if it means something, so here is what it means.

These can happen without a new version, and a client should tolerate them:

  • A new field on any response
  • A new endpoint
  • A new value in an enum — status, type, priority, provider, 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 write access to projects, agents or skills. Read only, deliberately for now: creating an agent means naming a runtime and a provider, and getting that wrong over an API is harder to see than getting it wrong in a form.
  • No time entries, in or out.
  • No OpenAPI document. This page is the specification.
  • No pagination cursors. Page numbers, which drift if things are inserted while you page.