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:opsA 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:
methodis anevents.*name;paramsis the payload. - Client → server:
methodis 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 (revisionbase_rev) on\n, apply the ops last to first (replacedellines atatwithins), join with\n. If you don't holdbase_rev, ask for the draft whole withwsFocus { socket, snapshots: [id] }.focus— only drafts in the timelines you report withwsFocus { socket, focus: { chat_id, channel_id, thread_root_id } }carry content; the rest arrive asevents.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 }seqis a server-wide counter — jumps between your frames are normal.previs theseqof the frame sent to your account just before this one. Ifprevis higher than the lastseqyou handled, the socket skipped frames (it fell behind): fetch them withgetUpdates { since: <last seq> }, handle them inseqorder, then this frame. Atruncated: trueanswer means part of the gap is no longer available — reload what you show instead.events.hellocarrieshead, your account's latestseq: behind it after a reconnect ⇒ fetch the gap the same way.- Every ~25 s the server sends
events.tick { head }(noseq). 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 method | HTTP equivalent |
|---|---|
auth.login | signIn |
accounts.me · accounts.get · accounts.list | getMe · getUser · getUsers |
conversations.list · conversations.get · conversations.create | getChats · getChat · startChat |
messages.send · messages.history | sendMessage · getChatHistory |
messages.delete · messages.delete_for_me | deleteMessages · deleteMessagesForMe |
reactions.add · reactions.remove | parts of setMessageReaction |
typing.set | sendChatAction |
// 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 } }| Field | Type | Description |
|---|---|---|
id | UUID | Message being streamed. |
delta | string | Text to append. |
offset | integer | Running 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
| Event | Params |
|---|---|
events.hello | Account (+ top-level head) |
events.tick | { head } — heartbeat, every ~25 s |
events.messageNew | Message |
events.messageDelta | { id, delta, offset } |
events.messageComplete | Message |
events.typing | { conversation_id, channel_id?, thread_root_id?, actor, is_typing } |
events.reactionAdded | Reaction |
events.reactionRemoved | { message_id, reactor, emoji } |
events.conversationNew | Conversation |
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) |