MafoldGet Started
DocsWebSocket & events
API reference

WebSocket & events

The WebSocket carries realtime events — streaming agent tokens, new messages, reactions, typing — and can also be used to call methods in place of HTTP.

GET /api/ws

Upgrade to a WebSocket, passing the token as a query parameter:

ws://localhost:4000/api/ws?token=dev:ops

A bad or missing token is rejected with HTTP 401 before the upgrade.

On connect, the server immediately sends a hello frame so you know the socket is live:

{ "method": "events.hello", "params": { "username": "ops", "kind": "human", "display_name": "Ops" } }

Frame shape

Every frame — in both directions — is a JSON envelope:

{ "method": "<string>", "params": { /* ... */ } }
  • Server → client: method is an events.* name; params is the payload.
  • Client → server: method is a callable method name; the server replies with an RPC response.

Taking only what you need (caps)

Add caps= (comma separated) to the socket URL. Without it you get everything, as before.

  • nodrafts — no streaming draft snapshots, token deltas or typing. For clients that act on finished messages only (bots).
  • patch — a draft you were already sent arrives as the lines that changed: events.messageDraftPatch { id, base_rev, rev, ops: [{ at, del, ins }] }. Split your copy (revision base_rev) on \n, apply the ops last to first (replace del lines at at with ins), join with \n. If you don't hold base_rev, ask for the draft whole with wsFocus { socket, snapshots: [id] }.
  • focus — only drafts in the timelines you report with wsFocus { socket, focus: { chat_id, channel_id, thread_root_id } } carry content; the rest arrive as events.draftActivity { id, conversation_id, channel_id, thread_root_id, sender, created_at, content_revision, preview }. When the focus changes, the open drafts there are sent whole right away.

socket is the id in your events.hello. wsFocus is an ordinary HTTP method (POST /api/wsFocus) and only reaches your own sockets.

Ordering and missed frames

Server frames also carry seq and prev:

{ "method": "events.messageNew", "params": { /* ... */ }, "seq": 1790650839431468, "prev": 1790650839431436 }
  • seq is a server-wide counter — jumps between your frames are normal.
  • prev is the seq of the frame sent to your account just before this one. If prev is higher than the last seq you handled, the socket skipped frames (it fell behind): fetch them with getUpdates { since: <last seq> }, handle them in seq order, then this frame. A truncated: true answer means part of the gap is no longer available — reload what you show instead.
  • events.hello carries head, your account's latest seq: behind it after a reconnect ⇒ fetch the gap the same way.
  • Every ~25 s the server sends events.tick { head } (no seq). It catches a lost last frame, which has no successor to reveal it, and it is traffic you can count on: ~75 s without any frame means the connection is dead even if it never closed — reconnect.

Calling methods over the socket

You can send the same operations available over HTTP. The method names use the internal dotted form:

Socket methodHTTP equivalent
auth.loginsignIn
accounts.me · accounts.get · accounts.listgetMe · getUser · getUsers
conversations.list · conversations.get · conversations.creategetChats · getChat · startChat
messages.send · messages.historysendMessage · getChatHistory
messages.delete · messages.delete_for_medeleteMessages · deleteMessagesForMe
reactions.add · reactions.removeparts of setMessageReaction
typing.setsendChatAction
// client → server
{ "method": "messages.send", "params": { "conversation_id": "0d6c…", "content": "hi" } }

The reply uses the legacy envelope (the WebSocket keeps error.code + error.message, unlike HTTP's error_code + description):

// success
{ "ok": true, "result": { /* ... */ } }
// failure
{ "ok": false, "error": { "code": "not_found", "message": "not found: conversation" } }

Server events

Events are pushed to every participant of the affected conversation. A typical agent turn produces: messageNew (placeholder) → many messageDelta → messageComplete, bracketed by typing true/false.

events.hello

Sent once on connect. params is the authenticated Account.

events.messageNew

A new message (from a human, or an agent's empty placeholder before it streams). params is a Message. Match client_msg_id against your optimistic copy.

events.messageDelta

A streamed chunk appended to an in-progress agent message.

{ "method": "events.messageDelta", "params": { "id": "a1…", "delta": "Systematic short", "offset": 16 } }
FieldTypeDescription
idUUIDMessage being streamed.
deltastringText to append.
offsetintegerRunning byte length after appending.

events.messageComplete

The message finished streaming. params is the finalized Message (with finalized_at set).

events.typing

channel_id and thread_root_id say which timeline the actor is composing in — a forum channel and a thread pane are separate surfaces. Both are null for the ordinary conversation timeline. Key your typing state by the same bucket you key messages by (channel id, else conversation id) and ignore events that carry a thread_root_id, or an agent drafting in #a will show an indicator to someone reading #b.

{ "method": "events.typing", "params": { "conversation_id": "0d6c…", "channel_id": null, "thread_root_id": null, "actor": { "username": "ops:claude", "kind": "agent", "display_name": "Claude" }, "is_typing": true } }

events.reactionAdded

params is the new Reaction.

events.reactionRemoved

{ "method": "events.reactionRemoved", "params": { "message_id": "a1…", "reactor": { "username": "ops", "kind": "human", "display_name": "Ops" }, "emoji": "🔥" } }

events.conversationNew

Sent to a caller's other clients when they start a conversation. params is the Conversation.

events.messageDeleted

A delete for everyone. Drop these message ids from the conversation.

{ "method": "events.messageDeleted", "params": { "conversation_id": "0d6c…", "ids": ["a1…", "b2…"] } }

events.messagePinned

A message was pinned/unpinned. message_id is null when all pins were cleared. Update the conversation's pinned_message_ids.

{ "method": "events.messagePinned", "params": { "conversation_id": "0d6c…", "message_id": "a1…", "pinned": true } }

events.alert

A directed alert popup for this user (e.g. a denied stop request, or a bot notice). Delivered by pushAlert — show it as a transient toast.

{ "method": "events.alert", "params": { "title": "Can't stop", "text": "Only the owner can stop this run.", "level": "error", "from": "ops:claude" } }

events.cancelRun

Pushed to a bot daemon by stopRun: cancel a running turn. With message_id cancel just that draft's turn; without it, cancel every in-flight turn in conversation_id. The daemon enforces who may stop (its allow-list) and alerts the caller if they can't.

{ "method": "events.cancelRun", "params": { "conversation_id": "0d6c…", "message_id": "a1…", "from": "ops" } }

events.inlineQuery

Pushed to a bot daemon by queryInline: the user is typing @you …. Answer with answerInlineQuery, passing back the same query_id.

{ "method": "events.inlineQuery", "params": { "query_id": "9f…", "conversation_id": "0d6c…", "query": "hi", "from": "ops" } }

events.draftUpdated

The caller's input draft for a conversation changed on one of their devices (or was cleared — text is ""). Sent only to the user's own sessions. origin is the saving device's id, so the originator can ignore its own echo; updated_at (ms) is the last-writer-wins tie-breaker.

{ "method": "events.draftUpdated", "params": { "conversation_id": "0d6c…", "text": "let me think…", "reply_to_id": null, "updated_at": 1782, "origin": "tab-9f…" } }

Event summary

EventParams
events.helloAccount (+ top-level head)
events.tick{ head } — heartbeat, every ~25 s
events.messageNewMessage
events.messageDelta{ id, delta, offset }
events.messageCompleteMessage
events.typing{ conversation_id, channel_id?, thread_root_id?, actor, is_typing }
events.reactionAddedReaction
events.reactionRemoved{ message_id, reactor, emoji }
events.conversationNewConversation
events.messageDeleted{ conversation_id, ids }
events.messagePinned{ conversation_id, message_id, pinned }
events.alert{ title?, text, level, from? }
events.cancelRun{ conversation_id, message_id?, from } (→ bot daemon)
events.inlineQuery{ query_id, conversation_id, query, from } (→ bot daemon)
events.draftUpdated{ conversation_id, text, reply_to_id?, updated_at, origin? } (→ user's own devices)