# Chat

When an app can **read**, **post**, **react**, and **receive webhooks** for
chats, threads, DMs, and reactions.

This is the **current** membership model (`Roam-Version` `2026-08-20`, the
default for new integrations). Organization vs Personal **identity** (who the
token is, how messages are attributed) lives in
[Access Models](/docs/guides/access-models). Dated pins live in
[API Versioning](/docs/guides/api-versioning).

## Organization vs Personal

Think of the two access models the way Slack developers think of **bot tokens**
and **user tokens**:

| | Organization token (Slack **bot token**) | Personal token (Slack **user token**) |
| --- | --- | --- |
| **Who it is** | The app's own bot | The owner's **personal bot** (attributed to that user, e.g. "Alex's Notetaker") |
| **Membership** | The **bot** is in the chat | The **owner** is in the chat |
| **Posts as** | The app | The personal bot — never the owner's human address |
| **Can be a group member** | Yes — add it, or [`group.join`](/docs/api/group-join) | **No.** [`group.join`](/docs/api/group-join) adds the **owner person**, never the PAT bot |
| **Typical use** | Workspace bots, alerts, channel listeners | AI assistants and automations that ride one user's inbox |

A personal bot has no standalone group membership. It is a member of exactly
one chat — the `{bot, owner}` self-DM — and everywhere else it **rides the
owner's access**.

The two models are not a superset/subset. An org bot that is not in a public
group cannot read history there; a PAT whose owner is not in that group
**can**. An org bot can **post** to that group without joining; a PAT
**cannot**.

## Access matrix

`✓` means the call succeeds or the webhook is delivered. `403` is
`not_in_chat` (or the equivalent drop for webhooks). **Read** here is
[`chat.history`](/docs/api/chat-history). **Post** is
[`chat.post`](/docs/api/chat-post) /
[`chat.startStream`](/docs/api/chat-start-stream). **React** is
[`reaction.add`](/docs/api/reaction-add). **Webhook** is
[`chat.message`](/docs/webhooks/chat-message) and
[`chat.reaction`](/docs/webhooks/chat-reaction).

### Groups

| Destination | Organization | Personal |
| --- | --- | --- |
| Public group, bot / owner **is** a member | read ✓ · post ✓ · react ✓ · webhook ✓ | same |
| Public group, bot / owner is **not** a member | read **403** · post **✓** · react **403** · **no webhook** | read **✓** · post **403** · react **403** · **no webhook** |
| Private group, bot / owner is a member | all ✓ | all ✓ |
| Private group, bot / owner is **not** a member | 403 / not delivered | 403 / not delivered |
| Group in another roam, bot / owner is a member | ✓ | ✓ |
| Group in another roam, not a member | 403 | 403 |

Cross-roam access is membership: people (and org bots) are routinely in
channels hosted in a partner roam. Membership is checked **before** the roam
gate. Public-group fallbacks apply only inside the caller's own roam.

`chat.link.shared` is the webhook exception for organization apps — it also
fires in unjoined **public** groups. See [Carveouts](#carveouts-read-vs-write-vs-search).

### Direct messages

| Destination | Organization | Personal |
| --- | --- | --- |
| Bot is a DM participant | read ✓ · post ✓ · react ✓ · webhook ✓ | n/a — a PAT bot cannot be added as a member |
| `userIds: [X]`, X ≠ owner | Org DMs are bot ↔ people; the bot is the participant | Routes to the owner's `{owner, X}` DM (created if needed). Post ✓ · react ✓ · webhook ✓ |
| `userIds: [owner]` | n/a | `{bot, owner}` self-DM — the one chat where the PAT bot is a counterpart, not a rider. Webhook ✓ |
| Add the PAT bot as a group or DM member | n/a | **400** rejected |

Several PATs owned by the same person share one `{owner, X}` DM per
recipient. The message is still authored by that PAT's bot.

### Carveouts (read vs write vs search)

Membership is the default. These are the Slack-shaped exceptions:

| Surface | Organization | Personal | Slack analogue |
| --- | --- | --- | --- |
| [`chat.post`](/docs/api/chat-post) / [`chat.startStream`](/docs/api/chat-start-stream) to an **unjoined public** group | Allowed | `403` `not_in_chat` | Bot `chat:write.public` |
| [`chat.history`](/docs/api/chat-history) of an unjoined public group | `403` `not_in_chat` | Allowed (same roam) | User-token `channels:history` |
| [`chat.search`](/docs/api/chat-search) | Member chats **plus** unjoined public groups in the bot's roam | Chats the owner can read, including unjoined public groups | Bot `search:read.public` |
| [`chat.postEphemeral`](/docs/api/chat-post-ephemeral) | Membership required — **no** public-group carveout | Owner must be a member | `chat.postEphemeral` |
| [`reaction.add`](/docs/api/reaction-add) / [`.remove`](/docs/api/reaction-remove) | Membership required | Owner must be a member | reactions:write |
| [`chat.link.shared`](/docs/webhooks/chat-link-shared) | Member **or** public group | Owner in the chat | Events API + `links:read` |
| [`chat.unfurl`](/docs/api/chat-unfurl) | Organization only (API key / OAuth). PATs cannot register domains or unfurl | — | `chat.unfurl` |

[`chat.list`](/docs/api/chat-list) is the inbox you already have: organization
tokens list chats the bot is in (no unread/preview fields — bots do not keep
an inbox); personal tokens list the **owner's** inbox, including thread rows
keyed by `threadTimestamp`.

## Webhooks

Subscribe with [`webhook.subscribe`](/docs/webhooks/webhook-subscribe). Chat
events require `chat:history`. Roam does **not** deliver every message in the
workspace.

### `chat.message`

| Situation | Organization | Personal |
| --- | --- | --- |
| Member of the group / participant in the DM | Every message in that chat (top-level **and** thread replies) | Every message in chats the **owner** is in, plus the `{bot, owner}` self-DM |
| Not a member of a **public** group | **Not delivered** — including `@mention`. The client may prompt **Invite the app to this group?**; dismissing still sends the message and does **not** add the app | **Not delivered** (owner is not in the chat). REST history of that public group still works |
| Not a member of a **private** group | Not delivered, even on `@mention` | Not delivered |
| `{mention: true}` on subscribe | **Filter only.** Restricts the member-chat stream to explicit @ of the app. Does **not** grant extra chats | **Filter only.** @ of the personal bot in chats the owner is in. Un-@'d DMs do not match |
| Your own posts | Delivered if you would otherwise receive the chat. Ignore loops with `data.userType === "bot"` or [`token.info`](/docs/api/token-info) | Same |

@-mention does **not** join a group and does **not** start a thread-follow.
There is no leftover "mentioned once, hear the rest of the thread" behavior.
Thread replies use the **same membership rule** as top-level messages: if you
would not receive the parent, you do not receive the replies.

Edits and deletes reuse `chat.message`. Dispatch on `data.version` and
`data.contentType: "deleted"`. A delete carries no mentions, so a
`{mention: true}` filter never matches a delete — key on the original
`(chatId, timestamp)` you already have. Details:
[`chat.message`](/docs/webhooks/chat-message).

### `chat.reaction`

Same per-recipient gate as `chat.message` (Slack `reaction_added` /
`reaction_removed`): delivered for chats the bot can **see**, not only
reactions on the bot's own messages. Filter to your own messages with
`data.messageAuthorId`.

- **Organization:** chats the bot is a member of. DMs only when the bot is a
  participant.
- **Personal:** any chat the owner is in (groups and DMs). No `{mention: true}`
  filter on this event.

One event per confirmed change (`action: added | removed`, a single `name`).
Call [`reaction.list`](/docs/api/reaction-list) for the current set.

### `chat.link.shared`

Fires for **new** text/markdown messages that contain a registered unfurl
domain. Organization apps see member chats **or** public groups; personal
installations need the owner in the chat. PATs cannot register domains or call
[`chat.unfurl`](/docs/api/chat-unfurl). See
[Unfurling links](/docs/guides/unfurling-links).

### Filters

Pass a filter on [`webhook.subscribe`](/docs/webhooks/webhook-subscribe):

| Filter | Event | Matches |
| --- | --- | --- |
| `chatType` | `chat.message` | `"dm"` or `"group"` |
| `mention` | `chat.message` | Explicit @ of **your app** only (Slack `app_mention`) |
| `names` | `chat.reaction` | Only those reaction names |

An un-@'d DM does not match `{mention: true}`. Agents that want a
conversation omit the filter.

## Joining a group

To **hear** a group (webhooks, history, reactions, ephemeral), the org bot or
the PAT's **owner** must be a member.

| How | Organization | Personal |
| --- | --- | --- |
| [`group.join`](/docs/api/group-join) | Adds the **bot** (Slack `conversations.join`) | Adds the **owner person**, never the PAT bot |
| [`group.add`](/docs/api/group-add) | Add the bot (or people) if you are a member, or the group is public in your roam | Cannot add the PAT bot (`400`) |
| Roam UI | Group Settings → Add Members → search for the app | Add the **owner** as a person |
| @-mention | Does **not** join. In a public group the client may prompt **Invite the app to this group?** | Does **not** join the PAT bot |

Private groups cannot be self-joined (`403`). Idempotent if already a member.
Non-members of a group in another roam get an opaque `403` (`group_not_found`)
— archived / type / privacy are not distinguished.

Required scope for `group.join`: `group:write`.

## Threads

Group threads are keyed by the parent message. **Membership of the parent chat
is membership of the thread.**

- `threadTimestamp` is the parent message's `timestamp` (unix microseconds).
  All replies in that thread share it.
- DMs have **no** threads.
- `replyTimestamp` is a **quote**, allowed in DMs or inside an existing
  channel thread. It is not a thread id and does not start a thread.

**Post a reply** with [`chat.post`](/docs/api/chat-post)
`{chatId, threadTimestamp}`.

**`threadKey`** is an external stable id (max 64 chars) on `chat.post`. First
use creates a top-level message and stores the timestamp; later posts with the
same key reply under it. Mutually exclusive with `threadTimestamp`. Forces a
synchronous post. Useful for incident / deploy bots (`dedup_key`,
`fingerprint`).

**History split** — [`chat.history`](/docs/api/chat-history):

| Call | What you get | Slack analogue |
| --- | --- | --- |
| No `threadTimestamp` | Main view (top-level messages) | `conversations.history` |
| With `threadTimestamp` | That thread's replies | `conversations.replies` |

Parent messages in the main view include `replyCount` when the thread has
replies.

Groups can set `enforceThreadedMode` (create with `enforceThreads` on
[`group.create`](/docs/api/group-create)). The client steers conversation into
threads; the API still uses the same `threadTimestamp` / `threadKey` fields.

Meeting chats use the same membership gate as ordinary groups.

## Addressing

[`chat.post`](/docs/api/chat-post), [`chat.history`](/docs/api/chat-history),
and several siblings take **exactly one** destination:

| You have | Use | Typical call |
| --- | --- | --- |
| An existing conversation | `chatId` | Continue a chat you already know |
| A group | `groupId` | Post to or read that group's channel |
| People | `userIds` | Open or continue a DM / multi-DM |

Specifying more than one destination returns `400`.

Mentions in `text` use Slack token syntax. See
[Identity & Principals](/docs/guides/identity-and-principals) for
`<@uuid>` / `<!subteam^uuid>` / `<!channel>` and how to hydrate IDs.

## Comparison with Slack

Roam's current pin is built to match Slack's **bot token vs user token**
split, not Slack's Events API event-name zoo. One `chat.message` event covers
channel messages, group messages, DMs, and (with a filter) `app_mention`.

| Slack | Roam organization (bot token) | Roam personal (user token) |
| --- | --- | --- |
| Bot added to a channel | Org bot is a group member | n/a — PAT bots cannot be members |
| User in a channel | n/a | Owner is a group member |
| `conversations.join` | [`group.join`](/docs/api/group-join) adds the **bot** | [`group.join`](/docs/api/group-join) adds the **owner** |
| `chat:write.public` | Org [`chat.post`](/docs/api/chat-post) / [`chat.startStream`](/docs/api/chat-start-stream) to an unjoined public group | **No** — `403` `not_in_chat` |
| User-token `channels:history` on a public channel the user hasn't joined | **No** — org history is membership-only | **Yes** — PAT [`chat.history`](/docs/api/chat-history) of same-roam public groups |
| `search:read.public` | Org [`chat.search`](/docs/api/chat-search) includes unjoined public groups | PAT search includes anything the owner can read |
| `message.channels` / `message.groups` / `message.im` | [`chat.message`](/docs/webhooks/chat-message) for chats the **bot** is in | [`chat.message`](/docs/webhooks/chat-message) for chats the **owner** is in |
| `app_mention` | `{mention: true}` on `chat.message` | Same filter, scoped to the owner's chats |
| Invite bot after `@app` in a public channel | Roam may prompt **Invite**; @ alone does **not** join and does **not** deliver | n/a |
| Thread follow without channel membership | **None.** Threads inherit parent-chat membership | Same |
| `reaction_added` / `reaction_removed` | [`chat.reaction`](/docs/webhooks/chat-reaction) for member chats | Owner-in-chat; no mention filter |
| `chat.postEphemeral` | Membership required (no public write carveout) | Owner must be a member |
| `conversations.history` / `conversations.replies` | [`chat.history`](/docs/api/chat-history) without / with `threadTimestamp` | Same |

Slack requires a bot in the channel before **any** Events API traffic for
that channel. Roam matches that for **webhooks and history** on the current
pin. The remaining bot-token affordance is **posting** (and streaming) to a
public group without joining — the same hole Slack leaves with
`chat:write.public`.

## Recipes

### Notification bot (org, no webhooks)

Post alerts into a public group with `groupId` + optional `threadKey`. You do
**not** need to join. Scope: `chat:send_message`. See
[Quickstart: Notification Bot](/docs/guides/quickstart-notification-bot).

```json
{
  "groupId": "88bebce7-6cbb-4666-96f9-5c02d73e6661",
  "threadKey": "INCIDENT-12345",
  "text": "PagerDuty: SEV-2 on checkout"
}
```

You will **not** receive `chat.message` for that group until the bot is a
member. If you need to hear replies, [`group.join`](/docs/api/group-join)
first.

### Interactive org bot

1. [`group.join`](/docs/api/group-join) (or Add Members) for each group it
   should hear.
2. Subscribe to unfiltered `chat.message`.
3. Reply in the same thread:

```js
threadTimestamp: data.threadTimestamp ?? data.timestamp,
```

If the inbound message is already a reply, `data.threadTimestamp` is the
parent. If it is top-level, use `data.timestamp` so your reply starts that
thread. Keep the `userType === "bot"` loop guard. See
[Quickstart: Interactive Bot](/docs/guides/quickstart-interactive-bot).

`{mention: true}` is optional once you are a member — it limits you to
explicit @, and **drops** un-@'d DMs and thread follow-ups that do not
mention you.

### Personal AI assistant

Subscribe to `chat.message` **without** joining groups (you cannot). You
receive every message in chats the owner is in, plus the self-DM.

- Use `{mention: true}` if the assistant should only wake on @.
- [`chat.history`](/docs/api/chat-history) of an unjoined public group in the
  owner's roam is allowed; posting or reacting there is not until the owner
  joins.
- Outbound DMs to someone else land in the owner's `{owner, X}` DM. Replies
  there **are** pushed on the current pin.

### Search without joining (org)

[`chat.search`](/docs/api/chat-search) can hit unjoined public groups in the
bot's roam. [`chat.history`](/docs/api/chat-history) of those same groups
returns `403` until you join. Join when you need the transcript, reactions, or
webhooks.

## Plugin policy vs Roam

Roam delivers `chat.message` according to the matrix above. Agent runtimes may
still drop messages — for example a personal-bot plugin that only responds to
its owner, or a sender allowlist. Platform delivery is not the same as the
plugin acting on the event. See [Hermes](/docs/integrations/hermes) and
[OpenClaw](/docs/integrations/openclaw).

## Older pins

Integrations created **before** `2026-08-20` (and frozen v0 webhook
subscriptions) keep leftover public-group `@` notify for **organization**
bots: an @ of a non-member org app in a public group could still deliver that
one message. Personal tokens on older pins stay mention-only + self-DM.

New API keys, OAuth apps, and PATs pin to `2026-08-20`. This page describes
that model. To move an existing integration, see
[API Versioning](/docs/guides/api-versioning).

## See also

- [Access Models](/docs/guides/access-models) — which endpoints each token can
  call, and org vs personal identity
- [Identity & Principals](/docs/guides/identity-and-principals) — `userId` /
  `userType`, mentions, Slack identity mapping
- [Scopes](/docs/guides/scopes) — `chat:history`, `chat:send_message`,
  `chat:read`, `group:write`
- [Webhooks overview](/docs/webhooks/webhooks) — envelope, signing, retries