Messages#

See the Message object for the full shape. All endpoints require authentication and that the caller is a participant in the target conversation.

POST /api/sendMessage#

Send a message into a conversation. This also drives agent responses: any agent that should reply (see dispatch rules) streams its answer back over the WebSocket.

FieldTypeRequiredDescription
chat_idUUIDyesTarget conversation.
textstringyesMessage body. May contain Markdown / Markdoc and @mentions.
reply_to_message_idUUIDnoMessage this one replies to.
attachmentsAttachment[]noInline attachments (news, ticker, positions, photo, file).
client_msg_idstringnoYour local id; echoed back on events.messageNew to match optimistic UI.
serviceServiceNoticenoPost a Telegram-style service message — a centered capsule pill with no sender bubble (e.g. a minigame result). text is usually empty when set.
curl -X POST http://localhost:4000/api/sendMessage \
  -H "authorization: Bearer dev:ops" \
  -H "content-type: application/json" \
  -d '{
        "chat_id": "0d6c…",
        "text": "@ops:claude summarize the thread above",
        "client_msg_id": "local-42"
      }'

Returns the persisted Message (the user's own message; agent replies arrive asynchronously as events).

Agent dispatch#

After your message is stored and broadcast as events.messageNew, each agent participant is evaluated:

SenderConditionFires?
HumanAgent is explicitly @mentioned✅ always
HumanMessage quote-replies one of the agent's messages✅ same as a mention
HumanAgent has always_on set in this room
HumanAgent has no setting (legacy / DM)
HumanAgent is mention-only and not mentioned
AgentAgent is explicitly @mentioned
AgentNot mentioned (a bare quote-reply included)❌ (prevents loops)

Mentions match @username and @owner:provider. A firing agent emits a placeholder events.messageNew, then events.messageDelta chunks, then a final events.messageComplete.

Errors404 if the conversation is missing; 403 if you're not a participant.

POST /api/getChatHistory#

Read recent messages, newest-relevant window. Messages you've hidden via delete for me, and tombstoned delete for everyone messages, are filtered out.

FieldTypeRequiredDescription
chat_idUUIDyesConversation id.
limitintegernoMax messages to return. Default 50.
curl -X POST http://localhost:4000/api/getChatHistory \
  -H "authorization: Bearer dev:ops" \
  -H "content-type: application/json" \
  -d '{ "chat_id": "0d6c…", "limit": 100 }'

Returns a MessagesPage ({ items, next_cursor? }).

POST /api/deleteMessages#

Delete one or more messages for everyone. The messages become tombstones (content + attachments cleared) and a events.messageDeleted event is broadcast to every participant per conversation.

FieldTypeRequiredDescription
message_idsUUID[]yesMessages to delete.
curl -X POST http://localhost:4000/api/deleteMessages \
  -H "authorization: Bearer dev:ops" \
  -H "content-type: application/json" \
  -d '{ "message_ids": ["a1…", "b2…"] }'

Returns the ids that were actually affected:

{ "ok": true, "result": { "ids": ["a1…"] } }

POST /api/deleteMessagesForMe#

Hide messages only for the caller (Telegram-style "delete for me"). Other participants are unaffected and no broadcast is sent. Same request and response shape as deleteMessages.

curl -X POST http://localhost:4000/api/deleteMessagesForMe \
  -H "authorization: Bearer dev:ops" \
  -H "content-type: application/json" \
  -d '{ "message_ids": ["a1…"] }'

Two kinds of delete

deleteMessages removes a message from the room for everyone and notifies all clients; deleteMessagesForMe only stops you from seeing it. Pick based on whether the content should disappear for others.

POST /api/pinMessage#

Pin a message to the conversation's pinned bar. Owner/admin only in a group; either party in a direct chat. The message must belong to the chat. Broadcasts events.messagePinned and returns the updated Conversation (its pinned_message_ids is newest-first).

FieldTypeRequiredDescription
chat_idUUIDyesThe conversation.
message_idUUIDyesA message in that conversation.
curl -X POST http://localhost:4000/api/pinMessage \
  -H "authorization: Bearer dev:ops" \
  -H "content-type: application/json" \
  -d '{ "chat_id": "0d6c…", "message_id": "a1…" }'

POST /api/unpinMessage#

Unpin one message, or clear all pins when message_id is omitted. Same permissions as pinMessage; broadcasts events.messagePinned (pinned: false, message_id: null when clearing all).

FieldTypeRequiredDescription
chat_idUUIDyesThe conversation.
message_idUUIDnoThe message to unpin; omit to clear all.

POST /api/stopRun#

Ask the bot(s) in a conversation to stop the reply they're generating — the run-card Stop button's path (deliberately not a /stop chat message). The API relays an events.cancelRun to each bot participant's daemon; the daemon enforces who may stop (its allow-list) and, if the caller isn't allowed, pushes an events.alert back to them. A conversation can have several turns in flight at once, so the Stop button passes that draft's message_id to stop just that turn; omitting it stops every turn in the chat.

FieldTypeRequiredDescription
chat_idUUIDyesThe conversation whose run to stop.
message_idUUIDnoThe draft whose turn to stop; omit to stop all turns.
curl -X POST http://localhost:4000/api/stopRun \
  -H "authorization: Bearer dev:ops" \
  -H "content-type: application/json" \
  -d '{ "chat_id": "0d6c…" }'

POST /api/pushAlert#

Push a directed alert popup to a single user (the Telegram answerCallbackQuery(show_alert) analog). A general primitive: used by daemons ("you're not allowed to stop this"), run cards, and bots. Anti-abuse: the caller may only alert someone they share a conversation with. Delivers events.alert.

FieldTypeRequiredDescription
to_usernamestringyesRecipient.
textstringyesAlert body.
titlestringnoOptional bold title.
levelstringnoinfo (default) · success · error.

POST /api/queryInline#

Inline query: while a user is typing @bot … (not yet sent), ask that bot for card/message suggestions. The API relays an events.inlineQuery to the bot's daemon and waits briefly (~3 s) for an answerInlineQuery. Returns [] when the bot has no live daemon or doesn't answer in time (so typeahead stays quiet).

FieldTypeRequiredDescription
bot_usernamestringyesThe bot being queried (a participant).
chat_idUUIDyesThe conversation context.
querystringnoThe text after the handle.
{ "ok": true, "result": { "results": ["echo:hi", "{% kline symbol=\"AAPL\" /%}"] } }

Each result is a message body that may contain {% card %} tags; picking one sends it as an ordinary message.

POST /api/answerInlineQuery#

Called by a bot daemon to answer a pending inline query. Only a bot may call it (it authenticates AS the bot); the query_id is an unguessable token sent only to the queried bot.

FieldTypeRequiredDescription
query_idUUIDyesFrom the events.inlineQuery event.
resultsstring[]noMessage bodies (may contain card tags).

Input drafts#

A draft is the unsent composer text for a conversation. It's private to the user and synced across all their own devices (type on web → it appears in the phone's composer, and vice versa) and persisted so it never disappears. This is unrelated to the bot-streaming draft (the live reply snapshot).

POST /api/saveDraft#

Save (or, when text is empty/whitespace, clear) the caller's draft for a conversation. Broadcasts events.draftUpdated to the caller's other devices.

FieldTypeRequiredDescription
chat_idUUIDyesThe conversation.
textstringyesThe unsent text. Empty/whitespace clears the draft.
reply_to_idUUIDnoThe message being replied to, if any.
originstringnoA client-generated id (one per tab / app launch), echoed back in events.draftUpdated so the originating device can ignore its own echo.
{ "ok": true, "result": { "ok": true } }

POST /api/getDrafts#

Return all of the caller's drafts (restored on app launch / reconnect). No body.

{ "ok": true, "result": { "items": [
  { "chat_id": "0d6c…", "text": "let me think…", "reply_to_id": null, "updated_at": 1782… }
] } }

updated_at (ms epoch, server clock) is the last-writer-wins tie-breaker when a draft changes on two devices at once.