Source: https://docs.any.org/types/chat.html
# Chat
The `chat` module turns an object into a conversation: one `chat_messages` storage collection, one record per message, edits and reactions that merge on every peer, and read state the SDK maintains for you. The general chat's root declares its own type with a part `{"module": "chat", "shared": true}` ([modules](https://docs.any.org/types/index.md.txt)) and hosts its own messages; chat is shared-only, one conversation per object. You write through a handful of chat endpoints and read everything — including live updates and unread flags — through the ordinary query primitive.
> **Note.** The `chat` module is reserved to the server: a space has exactly one chat, the general chat that the catalog's `general-chat` usecase installs (`system:general-chat/v1`, a derived root). A client cannot declare its own chat — a part, dataset or bundle body naming `chat` is `400 dataset.module_reserved` — and the chat's type is carried only by its own root: creating or attaching another object with it is `400 type.reserved_carrier`.
## The model in four sentences
Messages are records on the chat object's `chat_messages` dataset, ordered by `_ver.id` (set at creation, never changed by edits). Writes go through the chat endpoints; all reads and liveness go through `/query` and `/query/subscribe`. Read state is tracked by the SDK, **private to the account** (synced across your devices, never visible to other members — no read receipts, by design) and **forward-only**: a message once read never becomes unread again. Everything a client renders is materialized into ordinary queryable fields — you never compute read state yourself.
## Finding the chat object
A space has one chat, installed by the server's [catalog](https://docs.any.org/collaboration/bundles.md.txt#the-general-chat):
```bash
curl -X POST http://127.0.0.1:7001/v1/catalog/general-chat/setup \
-H 'Content-Type: application/json' \
-d '{"spaceId": "'$SP'"}'
# → 200 { "usecase": "general-chat", "bundles": [ { "id": "system:general-chat/v1",
# "bundle": { "rootId": "", "derived": true, … },
# "installed": true|false, "typeId": "" } ] }
```
The install is a derived, hidden root that is its own type with a chat part, so it accepts messages from the first write, and is filed under the `miniapp` collection, so the chat is a sidebar entry like any other app. Use its `rootId` as `` in every endpoint below. The call is adopt-or-install, and a derived root's id is a pure function of the space and the bundle id — every client, on any device, for any member, online or not, computes the same `rootId`, two sides of a 1-1 included. `POST /objects` never makes a chat, and there is no second chat to find. Chat content cannot be merged across objects (`creator` and `createdAt` are stamped from the change envelope, so copying messages re-attributes and re-times them), which is why the one chat is derived and permanent.
## Endpoints
| Method | Path | Purpose |
| --- | --- | --- |
| POST | `/v1/spaces/:spaceId/objects/:objectId/chat/messages` | send a message |
| PATCH | `…/chat/messages/:msgId` | edit own message text |
| DELETE | `…/chat/messages/:msgId` | delete own message |
| POST | `…/chat/messages/:msgId/reactions/:emoji` | toggle own reaction |
| POST | `…/chat/read-all` | mark everything read |
| POST | `…/chat/messages/:msgId/read` | mark the message and everything above it read |
| POST | `…/chat/messages/:msgId/reactions-read` | mark the message's reactions read |
Every write returns the shared write result `{versionId, changeId, recordIds}` — never the message body.
## Message wire shape
This is what `/query` and `/query/subscribe` return for a `chat_messages` record:
```json
{
"id": "",
"creator": "",
"createdAt": {"$date": "2026-05-01T21:00:00.000Z"},
"modifiedAt": {"$date": "2026-05-01T21:00:00.000Z"},
"replyToMessageId": "",
"text": "**hi** _there_",
"mentions": [""],
"attachments": { "a1": { "type": "image", "link": "any://f//" } },
"reactions": { "👍": { "": {"$date": "2026-05-01T21:00:05.000Z"} } },
"agent": { "name": "bao", "debugLink": "any:///#turn_3", "done": true },
"context": { "spaceId": "", "objectId": "", "view": "object" },
"unread": true
}
```
| Field | Who writes it | Notes |
| --- | --- | --- |
| `creator`, `createdAt`, `modifiedAt` | server-derived | Instants (`{"$date": …}`) from the change's second-resolution clock — display only. Mark a message edited when `_ver.text` differs from `_ver.id`, not by comparing the stamps. Client attempts to set them are rejected. |
| `text` | client | Markdown, ≤ 32 KiB. Rendering is the client's job. |
| `replyToMessageId` | client, create-only | Soft reference (≤ 256 bytes); the target is not validated. |
| `mentions` | server-derived | Identities mentioned in `text` plus the replied-to author. Never accepted from a client. Absent when nobody is mentioned. |
| `attachments` | client, create-only | Map of short ids (`[A-Za-z0-9_-]{1,64}`) → `{type, link}`. `type` is open (`link`, `image` known — render unknown types as a plain link); `link` ≤ 2 KiB; ≤ 32 entries. |
| `reactions` | server-derived leaves | `emoji → {accountId → instant}`; same shape on read and write. |
| `agent` | client, create-only | Marks an agent-authored message (UI hint, not signature-verified). `name` required (≤ 256 B), `debugLink` optional (≤ 2 KiB), `done` required boolean — `false` while the run is still going, so clients show a typing indicator until a `done: true` message lands; `outcome` optional (≤ 64 B, e.g. `interrupted`, `error`) says how a finished run ended when it did not end normally. |
| `context` | client, create-only | The sender's view at send time: `spaceId` required, `objectId` and `view` (an open string such as `object`) optional. How an agent reading the chat resolves "this page". |
| `control` | client, create-only | A signal to the agent serving the chat: `kind` required (open string ≤ 64 B; `break` asks the run in flight to stop), `hard` optional boolean. Rendered as a marker, never as a bubble. |
| `unread`, `unreadMention`, `unreadReactions` | SDK, local scope | Present (`true`) only while set; never synced to other members. |
`text` is required unless `attachments` is non-empty or `control` is set — an attachment-only send is valid. A message with none of them is rejected with `400 chat.text_required`. Other send rejections: `chat.text_too_long`, `chat.reply_id_invalid`, `chat.agent_invalid`, `chat.attachments_invalid`, `chat.context_invalid`, `chat.control_invalid` (all `400`).
## Sending, editing, deleting, reacting
```bash
# send
curl -X POST http://127.0.0.1:7001/v1/spaces/$SP/objects/$CHAT/chat/messages \
-H 'Content-Type: application/json' \
-d '{"text": "hello", "replyToMessageId": "abc"}'
# → 201 { "versionId": "…", "changeId": "…", "recordIds": [""] }
# edit (own only)
curl -X PATCH http://127.0.0.1:7001/v1/spaces/$SP/objects/$CHAT/chat/messages/$MSG \
-H 'Content-Type: application/json' -d '{"text": "hello, world"}'
# delete (own only)
curl -X DELETE http://127.0.0.1:7001/v1/spaces/$SP/objects/$CHAT/chat/messages/$MSG
# toggle a reaction (no body)
curl -X POST http://127.0.0.1:7001/v1/spaces/$SP/objects/$CHAT/chat/messages/$MSG/reactions/👍
```
The same from the CLI: `any chat send $SP $CHAT --text "hello" [--reply-to MSG] [--agent-name NAME --agent-debug-link L --agent-done=false]`, `any chat edit …`, `any chat delete …`, `any chat react $SP $CHAT $MSG 👍`. `--file -` reads message text from stdin.
Edit replaces `text` and bumps `modifiedAt`; delete tombstones the record. Both answer `403 chat.not_author` for anyone but the author and `404 chat.not_found` for unknown ids — and the handler enforces the same rules on changes arriving from peers, so a forged edit from another device is rejected at apply time, not just at the HTTP door.
A reaction toggle is a `$set` (add) or `$unset` (remove) on the single leaf `reactions..`, and only the change's signer can write into their own leaf. Because the leaf is unique per (emoji, identity), two members reacting simultaneously can never corrupt each other's reaction. The timestamp is server-derived; sort by it client-side if you want arrival order.
## Reading and live updates
Open a chat view by subscribing — the stream's `snapshot` frame *is* your initial load; a separate up-front query only opens a gap/duplicate race against the first `changes` frame:
```bash
curl -N -X POST http://127.0.0.1:7001/v1/spaces/$SP/query/subscribe \
-H 'Content-Type: application/json' \
-d '{"objectId": "'$CHAT'", "dataset": "chat_messages", "sort": ["-_ver.id"], "limit": 50}'
```
Subscribe **descending** with a `limit`. The window holds the top of the sort, so with `-_ver.id` new arrivals enter it and the oldest drops out as `removed`; ascending would pin the oldest 50 forever. New messages arrive in `added` (with the full body in `doc`); edits and reaction toggles in `updated`; deletes (`deleted`) and messages leaving the window (`displaced`) in `removed`. Always set a limit — an unbounded subscribe risks overflowing the mailbox.
`_ver.id` is the record's position in the sync DAG — logical order, not wall-clock time — stamped once at creation and never bumped by edits. Page into history with a one-shot query on the oldest id you hold:
```json
{ "objectId": "", "dataset": "chat_messages",
"sort": ["-_ver.id"], "filter": {"_ver.id": {"$lt": ""}}, "limit": 50 }
```
Repeat until a short page; reverse each page client-side for oldest-at-top. The same body without the filter, against `/query`, is the point-in-time read when you don't want live updates.
## Timestamps
`createdAt`, `modifiedAt` and every reaction leaf are instants: `{"$date": "2026-05-01T21:00:00.000Z"}`. Unwrap the one key and compare instants to instants. Tolerate a bare number there too — a peer on an older build materializes the same message as unix seconds, and the wire format deliberately does not gate on it, because refusing the change would stop messages syncing to protect a field's shape:
```js
const ms = v && typeof v === 'object' ? Date.parse(v.$date) : v * 1000;
```
## Read tracking
> **Why it matters.** Unread state in a hosted messenger is a server-side table the operator can read. Here it is computed by the SDK on your own device from the CRDT, synced only across your account's devices, and never encoded into the protocol at all — there is nothing for another member to observe.
The SDK maintains, per message, the local flags `unread`, `unreadMention` (the message's `mentions` contains you) and `unreadReactions` (only ever set on messages **you authored** — a reaction is a signal to the author). Per chat, it maintains counter properties on the chat object's row, under the `chat` module namespace (`chat.`, not top-level):
| Row path | Scope | Meaning |
| --- | --- | --- |
| `chat.unreadCount` | local | unread messages |
| `chat.unreadMentions` | local | unread mentions of you |
| `chat.unreadReactionsCount` | local | unread reactions on your messages |
| `chat.notifyMode` | account | per-chat push preference: `all`, `mentions` or `none`; absent means inherit the space's `settings.notifyMode` (see [push](https://docs.any.org/notifications/push.md.txt)) |
A counter is absent until first materialized — treat absent as 0, and read it at `chat.unreadCount`, never top-level (a top-level path reads 0 forever). Rules that follow:
- **Never count unread client-side.** Badge from the row; filter messages with `{"unread": true}`.
- **Your own messages are born read** on every one of your devices. Never call a read endpoint after sending.
- Edits never re-flag `unread`. The one exception is mentions: an edit whose new text mentions you sets `unreadMention`, and repeated re-edits collapse into one live mention entry.
- Reactions badge the message's author, nobody else. A reaction added and removed before you looked leaves no trace.
**Marking read — the viewport rule.** `…/:msgId/read` means "I have seen this message and everything above it" (in `_ver.id` order). Track the newest fully visible message; when it changes and stays visible for about a second, POST that one call. On opening a chat scrolled to the bottom, that clears the chat; reserve `read-all` for an explicit "mark as read" affordance — don't fire it merely because the chat opened. `…/:msgId/reactions-read` clears unread reactions on one message, which `/read` can never cover because a reaction is a change ordered after its target. Mark the visible messages read first: under the hood it also marks read the reaction's causal ancestry, i.e. unread messages the reactor had already seen. All three return `204`, are idempotent and forward-only, work offline, and sync across your devices — flags clear and counters drop as ordinary `updated` frames on your subscription, whichever device did the marking.
**The unread divider.** The first unread message is one index-backed query:
```json
{ "objectId": "", "dataset": "chat_messages",
"filter": {"unread": true}, "sort": ["_ver.id"], "limit": 1 }
```
Unread can appear mid-history: a message written offline by another member can slot between messages you already read once it syncs, so there can be more than one unread region and `unreadCount` can be nonzero while the bottom of the chat is read. The divider query finds the first region wherever it is.
## Mentions
A mention is a markdown link whose destination is a mention URI (see [links](https://docs.any.org/types/links.md.txt)):
```
Hey [Zarko](any://m//), take a look
```
The server derives `mentions: ["", …]` on every message at write time from two sources: every `any://m/…` link in the text (deduped, first-occurrence order) and — because a reply is a ping — the replied-to message's creator. Clients never write the array; text is the source of truth, so a spoofed array can neither silent-ping nor suppress a real ping. It is capped at 64 identities, and the reply fold-in always survives the cap. "All my mentions" and "my unread mentions" are the same shapes as the unread queries: `{"mentions": ""}` sorted `-_ver.id`, and `{"unreadMention": true}` sorted `_ver.id` with `limit: 1`. Self-mentions list you (an objective fact of the message) but never badge you.
Every `any://` reference in a message — text links and mentions, each attachment's `link`, the agent's `debugLink` — also lands in the server's link index, so "which messages mention this member" or "which messages link this page" is a backlinks read (see [objects](https://docs.any.org/database/objects.md.txt#links-and-backlinks)).
## Chat list and notifications
Each chat object's row already carries its counters, so a chat list is a plain objects query — `{"sort": ["-chat.unreadCount"]}` sorts unread-first, and a thousand chats cost one query. Do **not** subscribe to every chat to detect new messages. One space-wide objects subscription covers them all:
```json
{ "filter": {"$or": [{"any.type": {"$in": [""]}},
{"id": {"$in": [""]}}]},
"limit": 0 }
```
The ids are the `owners` of `chat_messages` in `GET /v1/spaces/:spaceId/datasets` — every type whose part declares the chat module; resolve them once per space. Both arms are needed: an ordinary chat object names its type in `any.type`, while a declaring root — the general chat is one — hosts its own messages and matches by `id`.
Counter went up → new unread in that chat; fetch `{"unread": true}` sorted `-_ver.id` with a small limit and toast only messages above a last-notified `_ver.id` you keep locally. Counter went down → the user read it somewhere (this window, another window, another device) — dismiss that chat's notifications. Total live surface for a desktop client: one objects subscription per space, one `/query/subscribe` for the open chat.
`chat_messages` keeps no [version history](https://docs.any.org/database/version-history.md.txt): the `/history` endpoints never list chat changes, and there is no per-message edit timeline — clients render live records only (an edit replaces `text`, a delete tombstones).
## Things not to do
- Don't call read endpoints on message receipt, on notification display, or unconditionally on chat open — only on actual visibility.
- Don't maintain your own read cursor or counters; the SDK's state is the durable, multi-device one.
- Don't infer other members' read state: there is none.
- Don't write `unread` or the counter properties — they are SDK-owned local fields and writes are rejected.