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 fromparent_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"
}
breadcrumbsare the ancestors, root first, ending with the parent — not the page itself. Put the space's name in front of them.editorandupdated_atare "edited two minutes ago by Astrid".editoris the author until somebody else saves.can_editsays whether a save would be accepted: you can read it, and its space is not archived.- A list (
GET /articles,/trash) carries everything butbody.
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:editorships 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.jsadds both and switches tables on, through theflux:editorevent Flux fires before each editor is made — TipTap 2 packages, because Flux bundles TipTap 2.tests/Feature/Browser/KnowledgeEditorTest.phpedits 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_markdownis chat's Markdown, mention tokens and issue identifiers included, rendered once on the server into the sanitisedbody. 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,prefixandsuffix(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. Noanchor, ornull, is a comment on the whole page. Any other key in it is a 422. - A mention tells the person named — a
mentionedrow in their inbox, as on an issue, witharticle: {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 /spacesagain. - 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.