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

Chat

Channels, direct conversations, threads and reactions, over the API and live over the websocket. Written for anybody building a client against it — today, the desktop app.

Desktop only, for now. There is no chat page in the browser yet. Agents act on a mention only in a thread about an issue — see Issues in chat. Everything below describes behaviour that exists and is covered by tests.


The model

Conversation A channel — named, public or private — or a direct conversation between two to eight people and agents, named by who is in it
Member A person or an agent in a conversation, with a role (owner, member), a read position, and how loudly the conversation may interrupt them
Message Markdown as typed, and the HTML everybody is shown, rendered from it on the server
Thread Replies under a message. One level deep: a reply has no replies of its own
Reaction One person's emoji on one message

Chat messages are their own records, not a kind of issue comment. A comment is part of an issue's record and follows an issue's rules; a message belongs to a conversation and has threads, reactions and a read position per person that a comment has no use for.

The tables are in the data model.

Direct conversations are reused

The same people always get the same direct conversation, whoever opens it and in whatever order they are named. The member set — the opener included — is hashed into direct_key, which is unique per workspace, so two people starting the same conversation at the same moment end up in one. POST /conversations answers 200 with the existing one and 201 with a new one; a client never has to search first.

Its members are fixed, because they are what it is. Nobody is added to or removed from one, and nobody leaves one: open a new conversation with everybody in it instead.


Who may do what

Membership is the permission. Only members read and post.

Who
See a public channel, browse it, join it Anybody whose role works on issues — the API's rule everywhere
See a private channel or a direct conversation Its members. Everybody else gets 404, administrators included
Open a channel Anybody who can use the API. The creator becomes its owner
Add somebody to a channel Any member, public or private
Rename, change the topic, archive, remove somebody The channel's owner, or a workspace owner or admin who can see it
Edit a message Its author only
Delete a message Its author, or a workspace owner or admin

Everything done to a message also takes being in its conversation: an author who has left a channel, or an administrator who never joined it, is answered as a non-member.

The order of the answers is deliberate:

  1. Another workspace's conversation or message is a 404, as every record in the API is.
  2. So is a private channel or a direct conversation you are not in. A 403 would confirm it exists. Administering the workspace does not open one: an administrator can rename a private channel they are in, and cannot read one they are not.
  3. A public channel you have not joined is a 403 for its messages. It is listed in browse for anybody to see, so there is nothing to hide, and "join it first" is the useful answer. GET /conversations/{conversation} answers 200 for one, with membership: null.

An archived conversation is read-only. Posting, editing and reacting answer 422; deleting still works, because an archived channel is exactly where a leaked password is found a month later.


Writing a message

The body is Markdown — GitHub-flavoured CommonMark, the converter the rest of Félagi uses. It is rendered on the server, once, and both are stored: body_markdown so the author can edit what they typed, and body, the sanitised HTML everybody is shown. A client never renders Markdown itself, so two clients can never show the same message two ways.

  • Raw HTML is escaped, not passed through. <script> typed into a chat box is a sentence about a script, and arrives as text.
  • Unsafe links are refused — javascript: and the like — and the result goes through the same sanitiser as every other piece of stored HTML.
  • Code blocks keep their language: ```php becomes <pre><code class="language-php">, so a client can highlight it.
  • At most 40,000 characters.

Mentions are tokens

Written Means Rendered
<@user:12> A person, by id <span class="mention" data-type="user" data-id="12">@Knut Horne</span>
<@agent:4> An agent, by id <span class="mention" data-type="agent" data-id="4">@Freya</span>
<#01jd…> A channel, by ULID <a class="channel" data-ulid="01jd…">#deploys</a>
<!here>, <!channel> Everybody in the conversation <span class="mention" data-type="here">@here</span>

Ids, not names, so a renamed colleague is still the person the message meant. The name is looked up when the message is written and put into the HTML; data-id is there for a client that wants to show the current one.

A token this workspace cannot resolve stays the text it was typed as. A person from another workspace, an id that does not exist, an agent the author could not see in the assignee picker — all render as <@user:99>, literally, and are not recorded as mentions. A message can never be used to learn the name behind somebody else's id.

A private channel is named only inside itself. Linked from anywhere else it reads #private channel — not a valid channel name, so it cannot be mistaken for one — with its data-ulid intact, so a client whose person is in it can show the real name. A private channel the author is not in is not linked at all.

Tokens inside code, and inside a link's text, are left alone.

Issues are linked by their identifier

ACM-42 in a message becomes a link to the issue — no token needed, because that is how people already write an issue's name:

<a class="issue" data-id="ACM-42" href="/app/issues/42">ACM-42</a>
  • Any prefix the workspace answers to, its own or a project's, with the rules an {id} in a URL follows. WEB-42 written about an issue that is ACM-42 links it, keeps the text as written, and carries the identifier it has now in data-id.
  • Upper case, whole words. ACM-42 and PRE2-7 are identifiers; acm-42, XACM-42 and ACM-42b are not, so ordinary prose — covid-19 — never turns into links.
  • What names nothing here stays text: a prefix nobody uses, a number the workspace has not reached, another workspace's issue. A message cannot be used to learn which issues exist.
  • Not inside code or a link, like a mention.
  • href is the web's page for the issue, by number — the one thing about an issue that never changes — and relative, so the same HTML works against any server. The sanitiser keeps no other address on an a.issue, and keeps data-id on nothing else.

What the body named is recorded, like its mentions, and listed on the message as issues — see Issues in chat.

<!here> and <!channel> mean the same thing here: everybody in the conversation. There is presence now, but it lives in the websocket server and the clients on it; the server writing the mention does not know who is online, so it cannot tell the two apart.

Sending it once

client_id is the client's own id for a message it is trying to send, up to 64 characters. Posting the same client_id again returns the first message with 200 rather than a second one with 201, so an outbox can retry until it hears back. It is unique per author; reusing one in a different conversation is a 422, not a retry.

The same id comes back on the message and on its MessagePosted event, which is how the device that sent it recognises its own echo.

Files

Upload first, then post. Each file is its own request, sent the moment it is dropped into the composer; the message names the files by id when it goes:

POST /api/v1/conversations/{conversation}/attachments
Authorization: Bearer fat_…
Content-Type: multipart/form-data; boundary=…

(one part called `file`)

201 with the file as a message will show it — see Shapes:

{ "data": { "id": 812, "name": "chart.png", "mime": "image/png", "size": 48213, "is_image": true,
            "url": "https://felagi.example/api/attachments/812/signed?expires=1762430400&signature=…",
            "thumbnail_url": "https://felagi.example/api/attachments/812/signed?expires=…&size=thumb&signature=…",
            "download_url": "https://felagi.example/api/attachments/812/signed?download=1&expires=…&signature=…" } }

Then POST /conversations/{conversation}/messages with "attachments": [812, 813].

  • Who may upload is who may post: a member, with a write token. A private channel or direct conversation you are not in is a 404, a public channel you have not joined a 403, an archived conversation a 422.
  • The limits are every upload's: 25 MB a file, 50 MB for video, the same allowlist of images, video, PDF, text, CSV, JSON, XML, HTML, ZIP and Office files, the type read from the contents — see Attachment. Chat refuses SVG as well, like a page does: it is an image a client would draw inline, and it can carry script.
  • Up to ten files on one message, each uploaded by you to this conversation and not yet on a message. Anything else — somebody else's upload, another conversation's, one already posted, an id that names nothing — is a 422 on attachments.{index}, and nothing is posted. More than ten is a 422 on attachments.
  • With a file, the body may be empty. A screenshot needs no caption. Without one, the body is required as ever, and still at most 40,000 characters. The same goes for an edit: the words of a message with files may be edited away.
  • A retry is still a retry. Posting the same client_id again returns the first message, files and all, with 200 — it is recognised before the files are checked, because the first post already took them.
  • Until it is posted, an upload is pending: it hangs from the conversation, only its uploader can open it, and DELETE /attachments/{id} takes it back. One nobody posts is removed after a day by the nightly prune — long enough for an outbox that was offline overnight. See Operations.
  • Editing does not change the files. PATCH /messages/{message} takes body only, and a file on a message cannot be deleted on its own (422).
  • Deleting a message deletes its files — the rows and the bytes on disk, at once. Every address handed out for them stops working.

Every address is signed, because the desktop app draws a conversation in a webview, and an <img> or a download there cannot send a bearer token:

  • url is the file as it is: inline for PNG, JPEG, GIF, WebP and video, a download for anything else. thumbnail_url is the same picture 512 pixels wide — made on first request and kept, the original whenever a smaller copy cannot be made — and null for anything that is not a picture. download_url is the original bytes with Content-Disposition: attachment; filename=…, whatever the type.
  • The flags are inside the signature. Adding one to an address — &preview=1 — breaks it, so the three are three addresses rather than one with options.
  • They last at least 24 hours, rounded up to the next whole hour, like a page's pictures: the same message read twice in an hour is byte for byte the same, and a webview's cache keeps the picture. Read the messages again for fresh ones.
  • No token on the request. GET /api/attachments/{id}/signed?… answers while the signature is valid and the file is still waiting in its conversation or on a message that has not been deleted. A changed, forged or expired signature is 403; a deleted message's file is 404.
  • Being removed from a conversation does not withdraw them. What you were shown in the last day loads until its address expires — the trade a day-long address makes, and the reason it is a day and not a week, as with pictures in docs.
  • In a broadcast, the host is app.url. A MessagePosted is built once for everybody, so its addresses name the configured application URL rather than the host the poster's request arrived at. An API response names the host it was asked on. Both are signed relative to the root, so either works on whichever host reaches the server.

With a token, the same file is also GET /api/v1/attachments/{id}, for the members of the conversation — and for its uploader alone while it is pending.

What posting does

In one transaction: the message, its files, its mentions, the thread's reply_count and last_reply_at, the conversation's last_message_at, and the author's own read position — what you wrote, and everything before it, is read. The broadcast is built after the transaction commits, so it never describes half of that.

Editing and deleting

PATCH /messages/{message} takes body and nothing else. The Markdown is rendered again and the mentions recorded again, and edited_at is set. A PATCH without body changes nothing and is not an edit.

DELETE /messages/{message} keeps the row and clears both bodies, its mentions, its reactions and its files — those from the disk as well. What is left is a tombstone with deleted_at set and body: null, still in the channel and still the root of its thread — replies keep their place. Deleting one already deleted answers 204 again.


Reading

A window of the channel

GET /conversations/{conversation}/messages — what the channel shows: messages, and replies sent to the channel as well (also_in_channel). Oldest first in the response.

Parameter
before A message's ULID. The limit messages before it — scrolling up
after The limit messages after it — catching up after a reconnect
around The message and the ones either side of it — opening on a link
limit 1–100, default 50

One cursor at a time; two are a 422. A ULID from another conversation is a 422 too. With none, the newest limit messages.

{ "data": [ … ], "has_more_before": true, "has_more_after": false }

Cursors, not pages. Messages arrive while somebody scrolls, so page two means a different set of messages a second later. Deleted messages are in the window, as tombstones, so a list a client holds keeps its shape.

A thread

GET /messages/{message}/replies?after=&limit= — the root and its replies, oldest first, forwards from after:

{ "root": { … }, "data": [ … ], "has_more_after": false }

Asking for the replies of a reply is a 422.

Unread, and mentions

unread_count on a conversation counts what the channel shows — messages and replies also sent to it — after your read position, not deleted, and not written by you. Nobody has an unread message from themselves. A reply that stays in its thread is read there, and is not unread in the channel.

mention_count is the same messages, narrowed to those naming you or the whole room (<!here>, <!channel>).

Neither changes with notify_level or muted_until. Those are stored for the client to decide what deserves a notification; a count that moved with a preference would make two devices disagree about how much there is to read.

Joining starts your position at the newest message, so a channel with five years of history does not arrive as five years of unread.

Moving the read position

POST /conversations/{conversation}/read with { "message": "<ulid>" } reads up to and including that message. Forwards only: a slower device reporting an older message cannot bring back what was read on another. { "message": "<ulid>", "unread": true } is the one way back — it marks that message and everything after it unread. Both answer with the conversation, counts included.

Threads I follow

Following is worked out, not stored: you follow a thread when you wrote the message it is under, replied in it, or were named in it or in a reply. GET /threads lists them, most recently answered first, only in conversations you are still in, each with the replies from other people after your position in it. POST /messages/{message}/read with { "reply": "<ulid>" } moves that position, forwards only. Replying reads the thread up to your reply.


Endpoints

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

GET /conversations Mine, newest activity first, with counts. Not paged — this is a sidebar. ?archived=1 adds archived ones
GET /conversations/browse Public channels I am not in. q, per_page; the usual page envelope
POST /conversations {"kind": "channel", "name", "topic"?, "visibility"?, "project_id"?} or {"kind": "direct", "members": [{"type", "id"}]}. 201, or 200 for a direct conversation that already existed
GET /conversations/{conversation} One, as I see it. A public channel I am not in has membership: null
PATCH /conversations/{conversation} name, topic, archived (bool), project_id (or null). Owner or administrator; 422 for a direct conversation
POST /conversations/{conversation}/join A public channel. Already in it is not an error
POST /conversations/{conversation}/leave 204. Not for a direct conversation
POST /conversations/{conversation}/members {"type", "id"}. Any member of a channel. 201, or 200 when they were already in it
DELETE /conversations/{conversation}/members/{type}/{id} 204. Owner or administrator
PATCH /conversations/{conversation}/membership notify_level (all, mentions, none), muted_until (a time, or null)
GET /conversations/{conversation}/messages A window, above
POST /conversations/{conversation}/attachments Multipart, one part called file. 201 with a pending file — see Files
POST /conversations/{conversation}/messages body (Markdown; optional with files), attachments? (up to ten ids), thread_root_id?, also_in_channel?, client_id?. 201, or 200 for a retry
POST /conversations/{conversation}/read message, unread?
PATCH /messages/{message} body. Author only
DELETE /messages/{message} 204. Author or administrator
GET /messages/{message}/replies A thread, above
POST /messages/{message}/reactions {"emoji"}, up to 64 characters — a character or a shortcode, stored as sent. 201, or 200 when you had it already. Returns the message
DELETE /messages/{message}/reactions/{emoji} 204, whether or not you had it. The emoji URL-encoded
POST /messages/{message}/read reply
POST /messages/{message}/issue Make an issue from it. title?, type?, priority?, project_id?, assignee_type?, assignee_id?. 201 — see below
GET /threads Threads I follow. limit, 1–100
GET /search/messages Messages by their words, in conversations I am in — see Searching
GET /runs/{ulid}/output What a run has said, from a cursor — see Following a run
GET /actors People who work here and agents I could assign to, in one list of actors, for completing an @. q, limit (default 20)
POST /conversations/{conversation}/scheduled-messages body (Markdown), send_at, thread_root_id?, also_in_channel?. 201 — see Scheduled messages
GET /scheduled-messages Mine, pending and failed, soonest first. status, conversation
PATCH /scheduled-messages/{scheduledMessage} body, send_at. While pending; 409 after
DELETE /scheduled-messages/{scheduledMessage} 204
POST /reminders remind_at, and message, issue or only note. 201 — see Reminders
GET /reminders Mine, not done, soonest first. state
PATCH /reminders/{reminder} remind_at (arms it again), note, done
DELETE /reminders/{reminder} 204
PATCH /chat-links/{link} {"mirror": bool}. Any member of the thread's conversation — see Mirroring

Channel names are lowercase letters, digits, hyphens and underscores, at most 80 — what can follow a # without quoting — and unique in the workspace, archived channels included, so an old #deploys and a new one never mean two things in the history.

Direct conversations take one to seven others, each checked against this workspace: people whose role works on issues, and agents you could assign work to. Anybody else is a 422 naming the field, the same as a project_id from elsewhere. Naming yourself is harmless.

Shapes

A conversation:

{
  "id": "01jd…", "kind": "channel", "name": "deploys", "topic": "Ship it",
  "visibility": "public", "archived_at": null,
  "last_message_at": "2026-11-05T09:12:00+00:00", "created_at": "2026-11-01T08:00:00+00:00",
  "members_count": 12,
  "project": { "id": 7, "name": "Checkout" },
  "membership": { "role": "owner", "notify_level": "all", "muted_until": null,
                  "last_read_message_id": "01jd…" },
  "unread_count": 3, "mention_count": 1
}

A direct conversation has name: null, visibility: "private", and members — the actors in it other than you, which is what names it. project is null unless a channel was linked to one.

A message:

{
  "id": "01jd…", "conversation_id": "01jd…", "client_id": "c3f1…",
  "author": { "type": "user", "id": 3, "name": "Knut Horne" },
  "body": "<p>Deploying <span class=\"mention\" data-type=\"agent\" data-id=\"4\">@Freya</span></p>",
  "body_markdown": "Deploying <@agent:4>",
  "thread_root_id": null, "also_in_channel": false,
  "reply_count": 2, "last_reply_at": "2026-11-05T09:20:00+00:00",
  "reply_authors": [ { "type": "user", "id": 5, "name": "Astrid Berg" } ],
  "reactions": [ { "emoji": "🎉", "count": 2, "mine": true, "users": ["Astrid Berg", "Knut Horne"] } ],
  "mentions": [ { "type": "agent", "id": 4 }, { "type": "here" } ],
  "issues": [],
  "source": null,
  "chat_links": [ { "id": 12, "issue": "ACM-42", "mirror": false } ],
  "attachments": [
    { "id": 812, "name": "chart.png", "mime": "image/png", "size": 48213, "is_image": true,
      "url": "https://…/api/attachments/812/signed?expires=…&signature=…",
      "thumbnail_url": "https://…/api/attachments/812/signed?expires=…&size=thumb&signature=…",
      "download_url": "https://…/api/attachments/812/signed?download=1&expires=…&signature=…" }
  ],
  "edited_at": null, "deleted_at": null, "created_at": "2026-11-05T09:12:00+00:00"
}
  • reply_authors is up to three, most recent first.
  • reactions is one entry per emoji, in the order they were first used; users is up to ten names, count the whole truth.
  • mentions is who the body was resolved as naming — {type, id} for an actor, {type} for here or channel.
  • issues is the issues the body links, by identifier, in the order it first names them, each once. The identifier each has now; an issue taken off the board drops out. Empty once the message is deleted.
  • source is null for anything somebody typed, and { "type": "comment", "issue": "ACM-42", "comment_id": 7 } for a comment posted into the thread — an agent's, see Agents in chat, or a person's on a mirrored thread.
  • chat_links is, on a thread's root, the issues the thread is linked to — { id, issue, mirror } each, issue the identifier. Empty on a reply: the root carries its thread's links.
  • attachments is the files on it, in the order they were uploaded, each with three signed addresses — see Files. is_image says to draw it inline; anything else is a chip with its name and size. Empty once the message is deleted.
  • body and body_markdown are null once deleted_at is set.

A thread, in GET /threads:

{ "root": { …a message… }, "conversation_id": "01jd…", "unread_count": 2, "last_read_reply_id": null }

Searching

GET /search/messages?q=deploy — messages by what they said, newest first, in the usual page envelope (data, links, meta):

{ "data": [
  { "message": { "id": "01jd…", "conversation_id": "01jd…", "body_markdown": "Another DEPLOY tomorrow", … },
    "conversation": { "id": "01jd…", "kind": "channel", "name": "deploys", "members": null },
    "snippet": "Another <mark>DEPLOY</mark> tomorrow" }
], "links": { … }, "meta": { … } }

Only conversations you are in. Not every public channel: one you have not joined is one you read by joining, and a search that reached into all of them would answer every common word with other people's conversations. A private channel or a direct conversation you are not in is searched by nobody, administrators included. Deleted messages are not found.

Parameter
q Required, 2–200 characters. Matched anywhere in the words, case as the database compares it
conversation One conversation's ULID — one you are in. Anything else is a 422, whether it exists or not
from_type, from_id The author: user or agent and an id. Both or neither
has attachments or issues — messages with files, or that name an issue
before, after Moments, ISO 8601 or a date (a date is its midnight UTC). Exclusive
per_page 1–50, default 20. And page

Anything else is a 422, as everywhere in the API.

  • message is the whole message, as a channel draws it, so a client can show it and open its thread without asking again. conversation is enough to say where it was said: id, kind, name, and for a direct conversation members, the people other than you who name it (null for a channel).
  • snippet is HTML: the words as they are read, cut to about ninety characters either side of the first match with … where it was cut, escaped, and the match in a <mark>. Escaped first and marked after, so <script> in a message is text in the snippet and the only markup in it is ours. It keeps the message's own casing.
  • The words as they are read, not the Markdown: search_text is the rendered message with the tags taken out, so Knut finds a message that mentions <@user:12> — shown to everybody as "@Knut Horne" — and user:12 finds nothing. Written whenever the body is, emptied when the message is deleted.
  • LIKE, as every search in Félagi — see Search. No ranking beyond newest first.

Project channels

A channel can be about a project: project_id when it is opened, or on PATCH /conversations/{conversation} by its owner or an administrator, and null to unlink it. One of this workspace's projects — anything else is a 422 on project_id, the same answer whether the id is another workspace's or nobody's. A direct conversation is about its people and takes none (422). Deleting the project leaves the channel, unlinked.

Every conversation carries project: {id, name} or null, and linking, relinking or unlinking sends a ConversationChanged with updated. What the channel's header shows comes from the project itself, GET /projects/{id}:

{ "id": 7, "name": "Checkout", "lead": { "type": "user", "id": 3, "name": "Knut Horne" },
  "open_issues_count": 14,
  "active_cycle": { "number": 12, "starts_on": "2026-11-02", "ends_on": "2026-11-15" }, … }

open_issues_count counts as the projects page does — standing work left out. active_cycle is the workspace's current cycle — cycles are the workspace's cadence, so every project is in the same one — or null when cycles are off or none runs today.


Presence

Who is in a conversation, who in the workspace is online, and who is typing — from the websocket server, not from the application. Three presence channels, authorised like every other channel at /api/broadcasting/auth with your token (or /broadcasting/auth with the browser's session):

Channel Who may join Each member is
presence-conversation.{ulid} The conversation's members — not somebody looking at a public channel they have not joined { "id": 12, "name": "Knut Horne", "avatar_url": null }
presence-online.{workspace id} Everybody in the workspace whose role works on issues — the API's rule { "id": 12, "name": "Knut Horne" }
presence-article.{ulid} The page's readers, by its space's rules; a draft's author alone { "id": 12, "name": "Knut Horne" } — see Docs

Anybody else is a 403, as for a private channel: a conversation you are not in, a workspace you are not in, a page you cannot read, a ULID that names nothing. avatar_url is null until people have pictures; draw initials.

The authorisation answers { "auth": "<key>:<signature>", "channel_data": "<JSON string>" }; send both with pusher:subscribe. channel_data is {"user_id": "12", "user_info": {…}} — the id a string, as the Pusher library writes it. Then the server tells you who is there (pusher_internal:subscription_succeeded, with every member's user_info), and who arrives and leaves (pusher_internal:member_added, pusher_internal:member_removed).

The green dots are the members of presence-online.{id}: join it once per workspace. Somebody with two devices is one member, and leaves when their last device does.

Typing is a whisper. A client sends a client event on the conversation's channel — {"event": "client-typing", "channel": "presence-conversation.{ulid}", "data": {…}} — and the websocket server relays it to everybody else on the channel, with the sender's user_id added by the server. The application never sees it, stores nothing, and sends nothing on these channels itself. Say what you like in data; something like {"typing": true}, sent again every few seconds while the person types and once with false when they stop, is what a client on the other side can draw from. Only a member can whisper — Reverb's accept_client_events_from is members — see Broadcasting.

Presence is not delivery. Messages still arrive on private-user.{id} whether or not a client is on a conversation's presence channel; join it for the conversation on screen, and leave it when the person looks away.


Following a run

An agent handed an issue in a thread works in a run, and its card shows latest_run. To show the run's output as it happens — the lines the browser's run pane prints — read it from a cursor while it is live:

GET /api/v1/runs/01jd…/output?after=118&limit=200
{ "data": [
    { "seq": 119, "kind": "assistant", "tool": null, "text": "Reading the failing test…", "created_at": "2026-11-05T09:14:02+00:00" },
    { "seq": 120, "kind": "tool_use", "tool": "Bash", "text": null, "created_at": "2026-11-05T09:14:03+00:00" }
  ],
  "next": 120, "has_more": false,
  "run": { "id": "01jd…", "status": "running", "finished": false, "summary": null, "error": null, … } }
  • seq is the cursor. Send after the next you were given — the last line returned, or the after you sent when nothing is new. Start at 0, or leave it out.
  • next is null once there is nothing more to ask for: the run has finished and every line it wrote has been handed over. Stop polling then. has_more says more is already waiting than one page held — ask again at once.
  • kind is what the daemon called the line — assistant, tool_use, result, error, stderr, system and others; draw the ones you know and print the rest plainly. tool names the tool of a tool_use line, whose text is often null. Nothing else the daemon keeps for itself leaves the server — not a line's raw payload, not the lease, not the session.
  • run is the Run: status, finished, summary and error say how it is going or how it ended.
  • limit is 1–200, default 200. The status is read before the lines, so a run is never reported finished with its last lines missing.

Who may read it is who may read the run: anybody in the workspace. Another workspace's run is a 404. A few seconds between polls is plenty; the browser listens on task.{ulid} instead, which a desktop app drawing runs inside threads cannot subscribe to one by one.


Live

One channel per person

Every chat event goes to private-user.{id} — one channel per person, the one their inbox badge already uses — for every conversation they are in, in every workspace. Each payload names its workspace_id and conversation_id.

Why per person rather than per conversation. A desktop app shows dozens of conversations across several workspaces. A subscription per conversation would be dozens of authorisation requests on every reconnect, and one more to make or drop every time somebody joins or leaves anything — with a gap, each time, in which messages are missed. One subscription per person has none of that, and joining a channel needs no new one.

Nothing is sent toOthers(). The person who posted has other devices, and they must hear it too. The device that sent a message recognises the echo by client_id, or by the ULID it got back from the POST, and drops it.

One event, many channels. A message in a channel of three hundred is one queued job, which Laravel sends to the websocket server in batches of a hundred channels.

Agents are never sent anything. They are members, and nothing listens on their behalf.

Issue cards are live on a second channel, the workspace's — see Cards.

Events ride the broadcasts queue, like every other live update, and are announced after the write commits. A websocket server that is down does not fail the write — see Broadcasting.

Signing in to the websocket with a token

The browser authorises a private channel with its session, at /broadcasting/auth. A desktop app has a token instead, so the same authorisation — the same channel rules, answered by the same broadcaster — is reachable with it:

POST /api/broadcasting/auth
Authorization: Bearer fat_…
Content-Type: application/x-www-form-urlencoded

socket_id=123456.7890123&channel_name=private-user.12

JSON works as well. 200:

{ "auth": "<app key>:<signature>" }

The signature is HMAC-SHA256 of socket_id:channel_name with the application's secret, which never leaves the server. 403 for any other person's channel; 401 for a bad token, as everywhere in the API. A read token is enough — listening is reading.

From the top, for a Pusher-protocol client:

  1. Read your own user id from GET /api/v1/me (user.id) — it is the same in every workspace.
  2. Connect to wss://{host}:{port}/app/{app key}?protocol=7&client=…&version=…. The server answers pusher:connection_established, whose data carries the socket_id.
  3. Post socket_id and channel_name=private-user.{id} here, with any one of your tokens. One subscription covers every workspace.
  4. Send {"event": "pusher:subscribe", "data": {"channel": "private-user.{id}", "auth": "<auth>"}}.
  5. Events arrive as {"event": "MessagePosted", "channel": "private-user.{id}", "data": "<JSON string>"}. data is a string in the Pusher protocol, and is parsed a second time.

The host, port and app key are the server's VITE_REVERB_* values — see Broadcasting. No endpoint hands them out yet, so a client is configured with them.

A token for one workspace hears every workspace. The channel is the person's, and so is what is on it: a token for workspace A that subscribes receives B's messages too. That is the point of one channel per person, and it is a wider reach than the rest of the API gives a token; a client should file each event under its workspace_id.

Events

Event names are short — MessagePosted, not App\Events\MessagePosted. With Laravel Echo, listen for .MessagePosted. Every payload has workspace_id (an integer) and conversation_id (a ULID).

Event Payload, besides those two When
MessagePosted message — a whole message, as above, files included. For a reply, also root: {id, reply_count, last_reply_at, reply_authors} A message or a reply arrives
MessageUpdated message Its words changed
MessageDeleted message_id, thread_root_id It was deleted. Ids only: there are no words left
ReactionChanged message_id, emoji, user (an actor), added (bool) One person added or removed one emoji
ConversationChanged action: created, updated, archived, member_added, member_removed Read the conversation again
ReadChanged last_read_message_id Your read position moved — sent to you only, for your other devices

The message in MessagePosted is one payload for everybody: reactions is empty and every mine is false. Each client knows who it is.

A reply carries its root's new count, so "3 replies" under a message becomes "4" without a request. There is no separate event for a thread growing, and the root's count moving does not send a MessageUpdated.

Two more events arrive on the same channel, about you rather than a conversation — they carry workspace_id and no conversation_id:

Event Payload, besides workspace_id When
ScheduledMessageChanged action (created, updated, deleted, sent, failed), scheduled_message — the whole row One of your scheduled messages changed. Your other devices keep the same list
ReminderDue reminder — the whole reminder, inbox_item_id One of your reminders fired. Raise a notification. The badge moves on the InboxChanged sent beside it

Both are in Broadcasting.

ReactionChanged is the change, not the new total, so two people reacting at once cannot overwrite each other's count on a third screen.

ConversationChanged is thin — the action and the conversation, like IssueChanged. What a sidebar needs after one differs for every person it goes to, so the client reads GET /conversations/{conversation} again. A new conversation sends one created, not one member_added per person in it. member_removed also goes to whoever was removed, who would otherwise go on showing a conversation they cannot open. A change to your own notify_level or muted_until is an updated sent to you alone.

Posting does not send a ReadChanged for the author's own position. Their other devices hear the message, and a message you wrote is read: apply that rule locally.

After a reconnect

The Pusher protocol has no replay; anything sent while a client was away is gone. Catch up with GET /conversations for the counts, then GET /conversations/{conversation}/messages?after=<newest ulid you have> for each conversation you were showing.


Issues in chat

A message that names an issue shows it; a message can become one; and naming an agent in a thread about an issue hands the agent that issue, with its answer coming back to the thread. All three go through the issue tracker's own machinery rather than beside it — a comment, an assignment, a run — so nothing here is a second set of rules about who may hand an agent work.

Cards

Each identifier in issues is a card. Read them together:

GET /api/v1/issues?ids[]=ACM-42&ids[]=ACM-7

Up to 50 at once; one that names nothing here is left out rather than refused. Each is the API's issue — title, status, priority, assignee (a person or an agent) — plus the two things a card needs that the board does not:

"latest_run": { "id": "01jd…", "status": "running", "finished": false,
                "agent": { "type": "agent", "id": 4, "name": "Freya" },
                "started_at": "2026-11-05T09:13:00+00:00", "finished_at": null },
"following": true

The card's actions are endpoints that already exist: PATCH /issues/{id} with status or assignee_type + assignee_id (assigning an agent queues a run, as it always has), PUT / DELETE /issues/{id}/follow. Status names and order are the workspace's, from GET /workflow-states.

Keeping them live. Subscribe to private-workspace.{id} as well as your own channel — authorised at /api/broadcasting/auth with the same token, and refused for a workspace you are not in. IssueChanged carries identifier, title, status, priority and assignee, so a card updates without a request; RunChanged carries the issue's identifier, so its run line does. Both are in Broadcasting. Unlike private-user.{id}, this channel is per workspace: one subscription each.

A message, made into an issue

POST /messages/{message}/issue — the right-click "Create issue". Any member of the conversation, with a write token; everybody else is answered as for the message itself (404, or 403 for a public channel you are not in), and an archived conversation is a 422.

Field
title Optional. Otherwise the message's first line, as text, at most 120 characters
type, priority, project_id Optional, as on POST /issues. project_id from another workspace is a 422
assignee_type, assignee_id Optional. An agent is handed the issue and a run is queued, exactly as assigning one anywhere else does. One that cannot be assigned is a 404, and nothing is created

The description is the message as it was shown, then — when it is a thread's root with replies — the replies as a quoted transcript, Author: text, then a line saying where it came from: From #deploys in Félagi chat, 5 Nov 2026 at 09:12 UTC. In words rather than a link, because there is no page for a message in the browser and a link would answer 404 for everybody without the desktop app. A private channel is not named — the issue can be read by the whole workspace — and reads a private channel.

Then, in one transaction: the thread is linked to the issue (below), and a message is posted into the thread — under the message, or under its root when it is a reply — by you, whose body is the identifier and nothing else: ACM-43. It draws as the issue's card for everybody who was there, and reads the same in every language.

{ "issue": { "id": "ACM-43", "title": "Checkout times out on Safari", … },
  "message": { "id": "01jd…", "thread_root_id": "01jd…", "body_markdown": "ACM-43", "issues": ["ACM-43"], … } }

201, with the two side by side rather than under data: they are two records, and neither is the other's detail. The message also arrives as a MessagePosted like any other.

Agents in chat

Naming an agent in a message about an issue hands it the issue. A message is about an issue when — the first that names anything decides:

  1. the message itself names exactly one issue;
  2. it is a reply, and its thread is linked to exactly one issue;
  3. it is a reply, and its thread's root names exactly one issue.

Then the message becomes a comment on that issue, by whoever wrote the message, with each person and agent it named written as their actor key (@agent:4) — the form a comment's mentions are read from. That comment hands the agent the work exactly as one typed on the issue page does: a run is queued with the comment as its context, the people named are told in their inbox, the author follows the issue. The thread is linked to the issue if it was not already.

  • Two issues, or none, and nothing happens on the server. Guessing which issue somebody meant would hand an agent the wrong work; offering to make one is the client's question to ask — POST /messages/{message}/issue with assignee_type: "agent".
  • Once per message. Editing a message to add an agent's name hands out nothing, the rule an edited comment keeps: a mention that dispatched on every save would be one nobody could correct.
  • Only a person's message. An agent naming another agent puts nobody to work.

What the agent writes on the issue comes back. Every comment an agent leaves on a linked issue — the report at the end of its run, typically — is posted into each linked thread as a reply by the agent, with the comment's HTML as its body and source saying where it came from:

"author": { "type": "agent", "id": 4, "name": "Freya" },
"source": { "type": "comment", "issue": "ACM-42", "comment_id": 118 }

A person's comment is not posted back: the conversation that produced it is already in the thread, and the message that handed the work over is itself one of those comments — unless the link mirrors, which somebody in the thread turned on to hear the issue. Nothing is posted into an archived conversation. How the run is going is on the card, as latest_run, rather than as messages in the thread; what it is saying, line by line, is read from a cursor.

Chat links

An issue and a thread that belong together: the thread an issue was made from, or one an agent was handed it in. Always the thread's root — a thread is one level deep, so the root is the thread. An issue can be linked to several threads, and a thread to several issues, though a thread linked to two hands nothing to an agent automatically. They are stored in issue_chat_links.

They are read in two places, each with mirror:

  • On a thread's root, as chat_links: [{ "id": 12, "issue": "ACM-42", "mirror": false }].
  • On a single issue (GET /issues/{id}), as chat_links: [{ "id": 12, "issue": "ACM-42", "conversation_id": "01jd…", "message_id": "01jd…", "mirror": false }] — only the threads in conversations you are in. The issue is the whole workspace's to read; a private channel's threads are not.

Links are made by the server — making an issue from a message, handing an agent an issue in a thread. There is no endpoint to make or remove one.

Mirroring a thread and its issue

Turned on per link, by anybody in the thread's conversation:

PATCH /api/v1/chat-links/12
Content-Type: application/json

{ "mirror": true }

200 with the link — { "data": { "id": 12, "issue": "ACM-42", "conversation_id": "01jd…", "message_id": "01jd…", "mirror": true } } — and everybody in the conversation hears the thread's root again as a MessageUpdated, with the new chat_links. Who may is who may reply there: another workspace's link, or one in a private conversation you are not in, is a 404; a public channel you have not joined a 403; an archived conversation a 422.

With it on:

Here Becomes there
A person's reply in the thread A comment on the issue, by the same person. comments.source_message_id names the reply. Its mentions are written as actor keys (@user:3), so the people it names are told in their inbox, as by any comment
A person's comment on the issue A reply in the thread, by the same person, with source: { "type": "comment", … }. Its actor keys become chat's mention spans, and count as mentions for the people they name
An edit of either An edit of the copy, while the link still mirrors
A delete of the reply, or a withdrawal of the comment The copy deleted or withdrawn, while the link still mirrors

Everything goes through the paths that already exist, so both sides do what they always do: MessagePosted, unread counts, the issue's followers told, the comment on the issue page.

What is not copied:

  • A copy. A comment with source_message_id, or a message with source_comment_id, came from the other side and stops here. That is what keeps the two from echoing each other, and it is in the data rather than in a flag.
  • An agent's anything. An agent's comment already comes back to every linked thread, mirrored or not — see Agents in chat — and is not posted twice. An agent's message in a thread is not a comment anybody wrote.
  • A reply that names an agent. Naming an agent in a thread about one issue already hands it the issue by turning the reply into a comment; in a thread linked to two, guessing is the mistake that rule avoids. A copy never hands out work the original did not.
  • The thread's root, top-level messages, files, and an empty body.
  • Into an archived conversation.
  • Edits made to a copy. Editing the copy changes the copy; edits travel from the original only.

A comment is copied into the thread whoever wrote it, member of the conversation or not — the people in the thread turned mirroring on to hear the issue. And a reply in a private channel becomes a comment the whole workspace can read: turning mirroring on is that decision.


Later

Two things that happen when their time comes, run every minute by the scheduler — see Operations. Both are yours alone: anybody else's scheduled message or reminder is a 404, whatever their role.

Scheduled messages

POST /api/v1/conversations/01jd…/scheduled-messages
Content-Type: application/json

{ "body": "Standup in five <!here>", "send_at": "2026-11-06T08:55:00+01:00",
  "thread_root_id": null, "also_in_channel": false }

201:

{ "data": {
  "id": "01k9…", "conversation_id": "01jd…", "thread_root_id": null, "also_in_channel": false,
  "body_markdown": "Standup in five <!here>", "send_at": "2026-11-06T07:55:00+00:00",
  "status": "pending", "failure_reason": null, "message_id": null, "sent_at": null,
  "created_at": "2026-11-05T16:02:00+00:00", "updated_at": "2026-11-05T16:02:00+00:00"
} }
  • Writing one takes what posting takes: a member (404 for a private conversation you are not in, 403 for a public channel you have not joined), a conversation that is not archived (422), a thread_root_id that is a message there and not a reply (422). body is required, up to 40,000 characters. No files — an upload not posted within a day is pruned. send_at in the future, any offset; answered in UTC.
  • When it goes, it is posted by Conversation::post() — the call POST /conversations/{conversation}/messages makes — so MessagePosted, mentions, unread counts, an agent handed an issue and a mirrored thread all behave as if the author had pressed send then. Its client_id is scheduled: and the row's ULID.
  • The rules are asked again when it goes. An author who has left the conversation or the workspace, a conversation archived or deleted, a thread whose root is gone: the row becomes failed, failure_reason says which in words to show, and nothing is posted.
  • Once. The claim is a conditional update — pending to sent — in the same transaction as the post. Two schedulers reaching the same row settle it the way the engine settles any conflict: MySQL makes the second wait and find nothing; ElyraSQL refuses the second commit with 40001, and its retry finds nothing.
  • GET /scheduled-messages lists pending and failed, soonest first, not paged; status narrows to one (sent is the hundred most recent), conversation to one conversation. PATCH changes body and send_at while pending, 409 after. DELETE is 204 whatever the status, and a sent one's message stays.
  • Every change is a ScheduledMessageChanged on your private-user.{id}.

Reminders

POST /api/v1/reminders
Content-Type: application/json

{ "remind_at": "2026-11-06T15:00:00+01:00", "message": "01jd…", "note": "Answer Astrid" }

201:

{ "data": {
  "id": "01k9…", "note": "Answer Astrid", "state": "upcoming",
  "remind_at": "2026-11-06T14:00:00+00:00", "fired_at": null, "done_at": null,
  "message": { "id": "01jd…", "conversation_id": "01jd…", "thread_root_id": null,
               "author": { "type": "user", "id": 5, "name": "Astrid Berg" },
               "excerpt": "Can you look at the checkout numbers before Friday?" },
  "issue": null,
  "created_at": "2026-11-05T16:02:00+00:00"
} }
  • About a message (message, a ULID in a conversation you are in), an issue (issue, an identifier or number) or only a note (note required then, up to 500 characters). Not both. A message you cannot open — private, unjoined, deleted, elsewhere — is the same 422 on message whether it exists or not.

  • When it fires: an inbox row of type reminder — summary is "Reminder: " and your note, or what it is about; message, issue and reminder: { id, note } on the row — and ReminderDue on your private-user.{id}:

    { "workspace_id": 1, "reminder": { …as above, "state": "fired"… }, "inbox_item_id": 913 }
    

    followed by InboxChanged with your unread count. A reminder fires once, however many schedulers run (fired_at is claimed in the transaction that writes the inbox row). One whose owner has left the workspace is marked fired and sends nothing.

  • message.author and message.excerpt (200 characters of text) are null once the message is deleted or you are no longer in its conversation.

  • PATCH /reminders/{reminder}: a new remind_at arms it again — that is snoozing — and takes it out of done; {"done": true} finishes it, {"done": false} puts it back. GET /reminders lists those not done, soonest first; state is upcoming, fired or done (the hundred most recent). DELETE is 204.

  • Not on a page yet: a reminder is about a message, an issue or nothing.


Not there yet

  • No chat in the browser. The desktop app is the only client.
  • An agent does nothing when named outside a thread about an issue. It can be a member and be named; handing it a whole conversation is a later phase.
  • Scheduled messages carry no files, and a mirrored thread copies none either.
  • Reminders on a page are not there yet; on a message, an issue or a note only.
  • No pinned messages, and search is LIKE: no ranking, no search across conversations you are not in.
  • A message's files are fixed once it is posted. No adding one later, and no taking one off without deleting the message.
  • No list of a conversation's files apart from its messages.
  • The server does not know who is online. Presence is the websocket server's, so <!here> and <!channel> still mean the same thing, and nothing is stored about who was there.