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.
| Field | Type | Required | Description |
|---|---|---|---|
chat_id | UUID | yes | Target conversation. |
text | string | yes | Message body. May contain Markdown / Markdoc and @mentions. |
reply_to_message_id | UUID | no | Message this one replies to. |
attachments | Attachment[] | no | Inline attachments (news, ticker, positions, photo, file). |
client_msg_id | string | no | Your local id; echoed back on events.messageNew to match optimistic UI. |
service | ServiceNotice | no | Post 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:
| Sender | Condition | Fires? |
|---|---|---|
| Human | Agent is explicitly @mentioned | ✅ always |
| Human | Message quote-replies one of the agent's messages | ✅ same as a mention |
| Human | Agent has always_on set in this room | ✅ |
| Human | Agent has no setting (legacy / DM) | ✅ |
| Human | Agent is mention-only and not mentioned | ❌ |
| Agent | Agent is explicitly @mentioned | ✅ |
| Agent | Not 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.
Errors — 404 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.
| Field | Type | Required | Description |
|---|---|---|---|
chat_id | UUID | yes | Conversation id. |
limit | integer | no | Max 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.
| Field | Type | Required | Description |
|---|---|---|---|
message_ids | UUID[] | yes | Messages 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).
| Field | Type | Required | Description |
|---|---|---|---|
chat_id | UUID | yes | The conversation. |
message_id | UUID | yes | A 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).
| Field | Type | Required | Description |
|---|---|---|---|
chat_id | UUID | yes | The conversation. |
message_id | UUID | no | The 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.
| Field | Type | Required | Description |
|---|---|---|---|
chat_id | UUID | yes | The conversation whose run to stop. |
message_id | UUID | no | The 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.
| Field | Type | Required | Description |
|---|---|---|---|
to_username | string | yes | Recipient. |
text | string | yes | Alert body. |
title | string | no | Optional bold title. |
level | string | no | info (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).
| Field | Type | Required | Description |
|---|---|---|---|
bot_username | string | yes | The bot being queried (a participant). |
chat_id | UUID | yes | The conversation context. |
query | string | no | The 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.
| Field | Type | Required | Description |
|---|---|---|---|
query_id | UUID | yes | From the events.inlineQuery event. |
results | string[] | no | Message 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.
| Field | Type | Required | Description |
|---|---|---|---|
chat_id | UUID | yes | The conversation. |
text | string | yes | The unsent text. Empty/whitespace clears the draft. |
reply_to_id | UUID | no | The message being replied to, if any. |
origin | string | no | A 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.