Elyra
Elyra The coding agent eTerm The terminal that knows where each command ends Huskarl AI colleagues on your own Mac Starf An activity monitor for Apple silicon that never invents a number etrans An SSH and SFTP client for macOS Elyra VM Virtual machines for macOS, Linux and Windows on your Mac Litr A small, native web browser for macOS Notr A notebook for macOS 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 Refr Local-first PDF workspace for macOS Elyra Workspace A desktop workspace for coding agents 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

Docs

Teamspaces, a tree of pages in each, favorites, a trash, covers and pictures, and saving without overwriting somebody — over the API and live over the websocket. Written for anybody building a client against it: today, the desktop app.

The pages are the knowledge base's articles. Nothing here is a second store: a page written in the desktop app is an article in the browser, and the other way round.

"Live" means a change arrives when somebody saves. Two people typing into one page at once is not supported; the second save is refused instead of overwriting the first. See Saving over somebody. What a client can show is who else has the page open, and whether they are editing — see Who else has it open.


Teamspaces

A space is a named tree of pages with its own readers — the sidebar's Teamspaces. Every page belongs to exactly one, and a child always lives in its parent's.

Open Read and written by everybody who may use the knowledge base — every role that works on issues. Joining is a preference: it is how a space stays in somebody's sidebar
Closed Its members, and nobody else. Everybody else gets 404 — for the space, its tree, its pages, their pictures, search hits and favorites — administrators included

Management cannot read the knowledge base at all, in the browser or over the API, and that is unchanged.

Every workspace has one default space, made with the workspace and called General. Everything written before spaces existed is in it. It cannot be closed, archived or left: it is where a page goes when nobody said where, and the place everything falls back to has to be somewhere everybody can reach. It can be renamed.

Who
Make a space Anybody. The maker is its owner
Rename, change the icon, open or close, reorder, archive The space's owner, or a workspace owner or admin who can see it
Add somebody Anybody in the space, or anybody who manages it
Remove somebody The owner, or an administrator
Write pages in it Anybody who may read it, unless it is archived

An administrator can manage a closed space they are in, and cannot read one they are not — the same rule as a private chat channel.

Names are at most 80 characters and unique among the workspace's spaces that are not archived, so an archived Engineering does not stop a new one. Bringing a space back from the archive under a name somebody has taken since is a 422.

An icon is an emoji: up to 16 characters, at least one of them not plain ASCII, with no spaces or markup characters. The same rule for a page's icon.

An archived space is read-only: its pages stay readable by search and by link, and nothing new is written in it. It is left out of GET /spaces unless asked for.


The tree

GET /spaces/{space}/tree is the sidebar for one space: every page in it you may see, without the prose — titles, icons and where each one sits.

{ "data": [
  { "id": "01jd…", "parent_id": null, "space_id": "01jc…", "title": "Engineering handbook",
    "icon": "📘", "state": "published", "position": 1000, "has_children": true,
    "updated_at": "2026-11-05T09:12:00+00:00" },
  { "id": "01je…", "parent_id": "01jd…", "space_id": "01jc…", "title": "Deploying",
    "icon": "🚀", "state": "published", "position": 1000, "has_children": false,
    "updated_at": "2026-11-05T09:14:00+00:00" }
] }
  • In reading order: each parent, then its children, depth first; siblings by position. Draw it top to bottom, or build the nesting from parent_id.
  • Published and in-review pages, and your own drafts where you put them. Archived pages are found by search and links, not in the tree — the browser's rule.
  • A page whose parent you cannot see comes up to the top with parent_id: null. A page filed under somebody else's draft loses the grouping, not the visibility.
  • Not paged: it is a sidebar, read whole. Six levels at most.

A page

GET /articles/{article} — the Article shape, with the body:

{
  "id": "01je…", "title": "Deploying", "parent": "01jd…", "parent_id": "01jd…",
  "space_id": "01jc…", "icon": "🚀",
  "cover": "attachment:812",
  "cover_url": "https://felagi.example/api/attachments/812/signed?expires=…&signature=…",
  "depth": 1,
  "breadcrumbs": [ { "id": "01jd…", "title": "Engineering handbook", "icon": "📘" } ],
  "state": "published", "published": true,
  "body": "<h2>Before you start</h2><ul data-type=\"taskList\">…</ul>",
  "excerpt": "Before you start…", "has_children": false, "favorite": true, "can_edit": true,
  "author": { "type": "user", "id": 3, "name": "Knut Horne" },
  "editor": { "type": "user", "id": 5, "name": "Astrid Berg" },
  "updated_at": "2026-11-05T09:14:00+00:00"
}
  • breadcrumbs are the ancestors, root first, ending with the parent — not the page itself. Put the space's name in front of them.
  • editor and updated_at are "edited two minutes ago by Astrid". editor is the author until somebody else saves.
  • can_edit says whether a save would be accepted: you can read it, and its space is not archived.
  • A list (GET /articles, /trash) carries everything but body.

Writing one

POST /articles takes space_id, parent_id, title, icon, cover, body and state — all optional. No space and no parent is the default space. A parent decides the space; naming a different one beside it is a 422. No title takes the body's opening heading, or Untitled. A new page is a draft unless it says otherwise.

PATCH /articles/{article} takes any of title, icon, cover, body and state, and moves the page with:

parent_id Under that page, or null for the top of its space. parent is the older name and still works
space_id Alone: the top of that space. With parent_id: must be the parent's space
after After that sibling, or null for first. Without it, a page that changed parent goes last and one that did not stays put

A page moves with everything under it, into its new parent's space. A loop — a page under its own descendant — and a move that would put anything deeper than six levels are 422s; the depth counts what hangs under the page, not only the page. The server works out position: halfway between the neighbours, and when a gap is too small to halve the siblings are spread out again.

A cover is gradient:<name>, one of dusk, aurora, ember, ocean, forest, slate, drawn by the client; or attachment:<id>, an image attached to this page, which comes with a signed cover_url. null removes it. Anything else is a 422.

POST /articles/{article}/duplicate makes a copy beside the original, as your draft: title plus " (copy)", body, icon and cover, not its children. Its pictures are copied as rows that share the files, so the copy keeps them after the original is deleted. 201.


The HTML

The body is HTML, and the desktop editor and the browser's flux:editor are both TipTap: each reads what the other saved. What survives the sanitiser is therefore a contract, and this is all of it beyond paragraphs, headings, lists, bold, italic, links and the rest of ordinary prose:

What Stored exactly as
Checklist <ul data-type="taskList"><li data-checked="true" data-type="taskItem"><p>Buy milk</p></li></ul>. data-checked is true or false. TipTap's <label><input type="checkbox"><span></span></label><div>…</div> around the text is removed, the text kept; TipTap draws the box again from data-checked
Table table, thead, tbody, tfoot, tr, th, td. colspan, rowspan and TipTap's colwidth ("150,80") on cells, scope on th. Styles, colgroup and col are removed
Quote, rule <blockquote>, <hr />
Code <pre><code class="language-php">…</code></pre>
Image <img src="/attachments/812" alt="…" /> — only that form. See Pictures
Link to a page <a class="page" data-ulid="01je…" href="/app/knowledge?a=01je…">Deploying</a>. href is optional; with it, the browser can follow the link
Mention <span class="mention" data-type="user" data-id="3">@Knut Horne</span>, as in chat

Attribute values are held to exactly these. Anything else — a script, an event handler, a style, a javascript: or data: URL, a picture from another host — is removed, and an image whose address was refused is removed whole rather than kept empty. Void elements come back as <hr /> and <img … />; attribute order is not significant.

The browser's editor keeps all of it. flux:editor ships without TipTap's TaskList and Image extensions and with tables switched off, so on its own an edit in the browser turned a checklist into bullets and dropped the pictures. resources/js/editor-extensions.js adds both and switches tables on, through the flux:editor event Flux fires before each editor is made — TipTap 2 packages, because Flux bundles TipTap 2. tests/Feature/Browser/KnowledgeEditorTest.php edits such a page in a real browser and checks nothing was lost. The browser's toolbar still has no checklist button: [ ] at the start of a line makes one.


Pictures

POST /articles/{article}/attachments — multipart, one part called file: a PNG, JPEG, GIF or WebP image (no SVG, which can carry script), within the usual upload limit. 201:

{ "data": { "id": 812, "url": "https://felagi.example/api/attachments/812/signed?expires=1762430400&signature=…",
            "name": "flow.png", "mime": "image/png", "size": 48213 } }

A webview cannot send a bearer token with an <img>, so the API hands out pictures as temporary signed addresses, which carry their own permission:

  • In every body the API returns, each /attachments/{id} you may open — an image on a page you can read — is rewritten to a signed address. One you may not open is left as it is stored and will not load: a picture copied out of a closed space does not become readable by being pasted somewhere else.
  • Put the address you were given into the body as it is. On save every address of ours — signed, absolute, or the API's own attachment URL — is turned back into /attachments/{id} before it is stored. A signature on disk would be a credential in a file that outlives it.
  • They last at least 24 hours: the expiry is rounded up to the next whole hour, so the same page read twice in an hour is byte for byte the same, and a webview's cache keeps the picture. Fetch the page again for fresh ones.
  • The address needs no token. GET /api/attachments/{id}/signed?expires=…&signature=… answers with the image while the signature is valid and the image still hangs off a page that is not in the trash. A changed, forged or expired signature is 403; a page in the trash is 404 — deleting a page withdraws its pictures at once, outstanding addresses included. Being removed from a closed space does not: what you were shown in the last day loads until it expires, which is why it is a day and not a week.

In the browser the same /attachments/{id} is opened with the session, and checks the page's readers too: a closed space's pictures are a 404 to people outside it.


Favorites

Mine, in my order, saying nothing to anybody else.

GET /favorites The tree's node shape, space_id included, in my order. One I can no longer read — a closed space I left, a page in the trash — is kept and not shown
PUT /articles/{article}/favorite 204. Star it; again is not an error and keeps its place. {"after": "<ulid>"} puts it after another favorite, {"after": null} first
DELETE /articles/{article}/favorite 204, whether or not it was starred

The trash

DELETE /articles/{article} puts the page and everything under it in the trash — the author or a workspace admin, as deleting always was. 204. Nothing is removed: the file, the history and the pictures stay.

GET /trash lists what is in the trash that you could see if it were not, newest first, with deleted_at and deleted_by. One entry per thing somebody deleted: a section of forty pages is one row, and restoring it brings back the forty. A page deleted on its own before its parent was is its own entry. Not paged.

POST /articles/{article}/restore — the author, whoever deleted it, or an admin. Brings back what was deleted with it, and not what was already in the trash on its own. A page whose parent is still in the trash comes back at the top of its space. 200 with the page.

After thirty days it is gone: the nightly model:prune removes the page, its subtree, its file, its history and its pictures. See Running it.

A page in the trash is a 404 everywhere else, its pictures included.


Saving over somebody

Send the updated_at you loaded in If-Unmodified-Since when you save:

PATCH /api/v1/articles/01je…
If-Unmodified-Since: 2026-11-05T09:14:00+00:00
Content-Type: application/json

{ "body": "<p>…</p>" }

If the page has been saved since, and the request changes title or body, it is refused with 412 and the page as it now stands — body included — so the client can show both:

{ "message": "Somebody else saved this page after you opened it. Nothing was changed; here it is as it stands.",
  "article": { "id": "01je…", "body": "<p>…</p>", "editor": { "type": "user", "id": 5, "name": "Astrid Berg" },
               "updated_at": "2026-11-05T09:20:00+00:00" } }
  • To the second, which is what the column holds. ISO 8601 and HTTP dates both work, in any timezone. A header that is not a date is a 422, not ignored: quietly overwriting is the one wrong answer.
  • Without the header, the last write wins, as it always has.
  • A change to anything else — the icon, the cover, a move — is not checked. It does move updated_at, so a save after somebody moved the page is refused too: the check is about the page, not the paragraph.
  • The check and the write happen under a row lock, so two saves arriving together cannot both pass.

Comments

On the whole page, or on a passage of it — the margin of a page, not a second page.

POST /api/v1/articles/{article}/comments
Content-Type: application/json

{ "body_markdown": "Is this still true, <@user:3>?",
  "anchor": { "quote": "Stop the queue workers first", "prefix": null, "suffix": ", then deploy." } }

201 with the comment:

{ "id": "01jf…",
  "body": "<p>Is this still true, <span class=\"mention\" data-type=\"user\" data-id=\"3\">@Knut Horne</span>?</p>",
  "body_markdown": "Is this still true, <@user:3>?",
  "author": { "type": "user", "id": 5, "name": "Astrid Berg" },
  "anchor": { "quote": "Stop the queue workers first", "prefix": null, "suffix": ", then deploy." },
  "mentions": [ { "type": "user", "id": 3 } ],
  "resolved_at": null, "resolved_by": null,
  "edited_at": null, "created_at": "2026-11-05T09:20:00+00:00" }
  • Written as a chat message is: body_markdown is chat's Markdown, mention tokens and issue identifiers included, rendered once on the server into the sanitised body. Raw HTML is escaped. At most 10,000 characters.
  • An anchor is the words, not a place: quote (1–500 characters) is the text that was selected, prefix and suffix (up to 64 each, optional) a little of what is either side. The client finds it again by searching the page for the quote and using the context to choose between repeats. An offset would point at the wrong words after the first edit above it. When the quote is no longer in the page the comment still is — show it as detached rather than hiding it. No anchor, or null, is a comment on the whole page. Any other key in it is a 422.
  • A mention tells the person named — a mentioned row in their inbox, as on an issue, with article: {id, title} in place of an issue — if they may read the page. Nobody is told about a page they cannot open, nobody about their own mention, and an edit tells nobody anything new. No email. <!here> and <!channel> are drawn but tell nobody: a page has no room for them to mean. Naming an agent puts it to no work.

GET /articles/{article}/comments lists them, oldest first, resolved ones included — not paged; a margin is read whole.

Who
Read and write comments Whoever may read the page. A draft's comments are its author's
Change the words — PATCH /article-comments/{comment} with body_markdown The comment's author only. Sets edited_at
Resolve or reopen — PATCH /article-comments/{comment} with resolved (bool) Anybody who may change the page. Sets resolved_at and resolved_by
Delete — DELETE /article-comments/{comment} The author, or anybody who may change the page. 204. Gone, not a tombstone

An archived space is read-only, its margins included: writing, editing and resolving are 422; deleting still works. A page you may not read — another workspace's, a closed space you are not in, somebody else's draft, one in the trash — is a 404 for the page and for every comment on it.

Every write sends an ArticleChanged with commented, to the page's readers. The page itself is untouched: a comment does not move updated_at, so it never makes the next save of the page look like a conflict.

Stored in article_comments — their own table rather than an issue's comments made to hold both. A comment on an issue is part of the issue's record: a thread, runs, tombstones, attachments, a copy posted to chat, an agent put to work by being named. None of that belongs in a page's margin.


History

Every save that changed the words keeps what was there before, the most recent fifty — the browser's history, over the wire.

GET /articles/{article}/revisions — newest first, without the words:

{ "data": [
  { "id": 418, "title": "Deploying", "editor": { "type": "user", "id": 5, "name": "Astrid Berg" },
    "bytes": 2814, "created_at": "2026-11-05T09:14:00+00:00" }
] }

A revision is an edit seen from before it: created_at is when somebody saved, editor who, and the version is what the page said — under title — until they did. bytes is its size, enough to tell a typo fix from the edit that removed half the page without opening either.

GET /articles/{article}/revisions/{revision} adds body, the HTML, its pictures as signed addresses as a page's are.

POST /articles/{article}/revisions/{revision}/restore puts it back and answers with the page, body included. Whoever may change the page — 403 otherwise, an archived space included. Restoring is itself an edit: what the page said is kept in the history first, so a restore done by mistake is undone the same way, and it sends an ArticleChanged updated like any save.

Revisions are named by their number, and only through their page: one from another page or another workspace is a 404, and so is the history of a page you may not read.


Who else has it open

presence-article.{ulid} — a presence channel per page, authorised at /api/broadcasting/auth with your token like every other. Who may join is who may read the page: its space's readers, and for a draft its author alone; anybody else is a 403. Each member is { "id": 12, "name": "Knut Horne" }, and the websocket server tells everybody on the channel who arrives and who leaves. See Presence in chat for the handshake.

Editing is a whisper. A client in edit mode sends a client event on the channel — client-editing, with data such as {"editing": true}, repeated every few seconds and once with false when it stops — and the websocket server relays it to the page's other readers with the sender's user_id. The application never sees it. It is a courtesy, not a lock: two people can still both press Edit, and the second save is still refused rather than merged.


Live

ArticleChanged, on the same private-user.{id} channel chat uses — see signing in to the websocket.

{ "workspace_id": 1, "article_id": "01je…", "space_id": "01jc…",
  "action": "updated", "editor": { "type": "user", "id": 5, "name": "Astrid Berg" } }
action When
created A page appeared — written, or duplicated
updated Its title, icon, cover, state or words changed
moved Its parent, space or position changed
trashed It went into the trash, with its subtree. editor is who did it
restored It came back
commented A comment on it was written, edited, resolved or deleted. editor is who did it. The page itself did not change
  • Thin, like ConversationChanged: read the tree or the page again.
  • Who reads it hears it: everybody who may read the space — for an open space that is everybody in the workspace who works on issues, which can be several hundred channels in one queued job; acceptable for now — and the members of a closed one. A draft is heard by its author alone. A page moved out of a space, or sent back to a draft, is also told to whoever could see it before, so their sidebar can drop it.
  • One event per save, however many writes the save took.
  • Nothing about favorites, and nothing about spaces themselves yet.

After a reconnect, read GET /spaces and the tree of each space you are showing.


Endpoints

All under /api/v1, with the API's token, errors and rate limit. Reading takes a read token; everything that changes something takes write.

GET /spaces The spaces I may read: the default first, then by position. ?archived=1 adds archived ones. Not paged
POST /spaces name, icon?, visibility? (open, closed). 201
PATCH /spaces/{space} name, icon, visibility, position, archived. Owner or administrator
POST /spaces/{space}/join An open space. Already in it is not an error
POST /spaces/{space}/leave 204. Not the default space
POST /spaces/{space}/members {"user_id"} — somebody who works here. 201, or 200 when they were in it already
DELETE /spaces/{space}/members/{user} 204. Owner or administrator
GET /spaces/{space}/tree The sidebar's tree
GET /articles Search: q (titles and text), space, parent, per_page
GET /articles/{article} One page, with its body
GET /articles/{article}/comments, POST /articles/{article}/comments Its comments. 201 for a new one
PATCH /article-comments/{comment}, DELETE /article-comments/{comment} body_markdown (author), resolved (page editors). 204 for a delete
GET /articles/{article}/revisions, GET /articles/{article}/revisions/{revision} Its history, and one version with its words
POST /articles/{article}/revisions/{revision}/restore Put a version back. Page editors
POST /articles 201
PATCH /articles/{article} 200, or 412
DELETE /articles/{article} 204. Into the trash
POST /articles/{article}/restore Out of it
POST /articles/{article}/duplicate 201
POST /articles/{article}/attachments 201. An image
GET /trash What is in the trash
GET /favorites, PUT /articles/{article}/favorite, DELETE /articles/{article}/favorite Favorites
GET /api/attachments/{id}/signed Outside /v1, without a token: a picture by signed address. Chat's files are served here too — see Chat

A space:

{ "id": "01jc…", "name": "Engineering", "icon": "🛠️", "visibility": "open",
  "is_default": false, "position": 2000, "archived_at": null,
  "membership": { "role": "owner" }, "can_manage": true }

membership is null for an open space you read without having joined it. can_manage says whether PATCH and removing somebody would be accepted.


Not there yet

  • Nobody types into a page at the same time as somebody else. The second save is refused with the first one's text; merging is the client's.
  • The browser shows one space at a time, and cannot move a page between spaces, open the trash or star a page.
  • No events for spaces — made, renamed, closed, joined. Read GET /spaces again.
  • No typing together. Presence says who else has a page open and whether they are editing; it does not merge what two people type.
  • No replies to a comment, and no comments in the browser yet.