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:
- Another workspace's conversation or message is a 404, as every record in the API is.
- 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.
- 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, withmembership: 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:
```phpbecomes<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-42written about an issue that isACM-42links it, keeps the text as written, and carries the identifier it has now indata-id. - Upper case, whole words.
ACM-42andPRE2-7are identifiers;acm-42,XACM-42andACM-42bare 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.
hrefis 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 ana.issue, and keepsdata-idon 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 onattachments. - 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_idagain 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}takesbodyonly, 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:
urlis the file as it is: inline for PNG, JPEG, GIF, WebP and video, a download for anything else.thumbnail_urlis 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_urlis the original bytes withContent-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. AMessagePostedis 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_authorsis up to three, most recent first.reactionsis one entry per emoji, in the order they were first used;usersis up to ten names,countthe whole truth.mentionsis who the body was resolved as naming —{type, id}for an actor,{type}forhereorchannel.issuesis 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.sourceis 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_linksis, on a thread's root, the issues the thread is linked to —{ id, issue, mirror }each,issuethe identifier. Empty on a reply: the root carries its thread's links.attachmentsis the files on it, in the order they were uploaded, each with three signed addresses — see Files.is_imagesays to draw it inline; anything else is a chip with its name and size. Empty once the message is deleted.bodyandbody_markdownare null oncedeleted_atis 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.
messageis the whole message, as a channel draws it, so a client can show it and open its thread without asking again.conversationis enough to say where it was said:id,kind,name, and for a direct conversationmembers, the people other than you who name it (null for a channel).snippetis 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_textis the rendered message with the tags taken out, soKnutfinds a message that mentions<@user:12>— shown to everybody as "@Knut Horne" — anduser:12finds 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, … } }
seqis the cursor. Sendafterthenextyou were given — the last line returned, or theafteryou sent when nothing is new. Start at0, or leave it out.nextis 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_moresays more is already waiting than one page held — ask again at once.kindis what the daemon called the line —assistant,tool_use,result,error,stderr,systemand others; draw the ones you know and print the rest plainly.toolnames the tool of atool_useline, whosetextis often null. Nothing else the daemon keeps for itself leaves the server — not a line's raw payload, not the lease, not the session.runis the Run:status,finished,summaryanderrorsay how it is going or how it ended.limitis 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:
- Read your own user id from
GET /api/v1/me(user.id) — it is the same in every workspace. - Connect to
wss://{host}:{port}/app/{app key}?protocol=7&client=…&version=…. The server answerspusher:connection_established, whosedatacarries thesocket_id. - Post
socket_idandchannel_name=private-user.{id}here, with any one of your tokens. One subscription covers every workspace. - Send
{"event": "pusher:subscribe", "data": {"channel": "private-user.{id}", "auth": "<auth>"}}. - Events arrive as
{"event": "MessagePosted", "channel": "private-user.{id}", "data": "<JSON string>"}.datais 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:
- the message itself names exactly one issue;
- it is a reply, and its thread is linked to exactly one issue;
- 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}/issuewithassignee_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}), aschat_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 withsource_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_idthat is a message there and not a reply (422).bodyis required, up to 40,000 characters. No files — an upload not posted within a day is pruned.send_atin the future, any offset; answered in UTC. - When it goes, it is posted by
Conversation::post()— the callPOST /conversations/{conversation}/messagesmakes — soMessagePosted, mentions, unread counts, an agent handed an issue and a mirrored thread all behave as if the author had pressed send then. Itsclient_idisscheduled: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_reasonsays which in words to show, and nothing is posted. - Once. The claim is a conditional update —
pendingtosent— 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 with40001, and its retry finds nothing. GET /scheduled-messageslistspendingandfailed, soonest first, not paged;statusnarrows to one (sentis the hundred most recent),conversationto one conversation.PATCHchangesbodyandsend_atwhile pending, 409 after.DELETEis 204 whatever the status, and a sent one's message stays.- Every change is a
ScheduledMessageChangedon yourprivate-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 (noterequired then, up to 500 characters). Not both. A message you cannot open — private, unjoined, deleted, elsewhere — is the same 422 onmessagewhether it exists or not. -
When it fires: an inbox row of type
reminder—summaryis "Reminder: " and your note, or what it is about;message,issueandreminder: { id, note }on the row — andReminderDueon yourprivate-user.{id}:{ "workspace_id": 1, "reminder": { …as above, "state": "fired"… }, "inbox_item_id": 913 }followed by
InboxChangedwith your unread count. A reminder fires once, however many schedulers run (fired_atis claimed in the transaction that writes the inbox row). One whose owner has left the workspace is marked fired and sends nothing. -
message.authorandmessage.excerpt(200 characters of text) are null once the message is deleted or you are no longer in its conversation. -
PATCH /reminders/{reminder}: a newremind_atarms it again — that is snoozing — and takes it out of done;{"done": true}finishes it,{"done": false}puts it back.GET /reminderslists those not done, soonest first;stateisupcoming,firedordone(the hundred most recent).DELETEis 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.