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. Dated pins live in
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 | No. 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. Post is
chat.post /
chat.startStream. React is
reaction.add. Webhook is
chat.message and
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.
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 / chat.startStream to an unjoined public group | Allowed | 403 not_in_chat | Bot chat:write.public |
chat.history of an unjoined public group | 403 not_in_chat | Allowed (same roam) | User-token channels:history |
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 | Membership required — no public-group carveout | Owner must be a member | chat.postEphemeral |
reaction.add / .remove | Membership required | Owner must be a member | reactions:write |
chat.link.shared | Member or public group | Owner in the chat | Events API + links:read |
chat.unfurl | Organization only (API key / OAuth). PATs cannot register domains or unfurl | — | chat.unfurl |
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. 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 | 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.
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 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. See
Unfurling links.
Filters
Pass a filter on 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 | Adds the bot (Slack conversations.join) | Adds the owner person, never the PAT bot |
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.
threadTimestampis the parent message'stimestamp(unix microseconds). All replies in that thread share it.- DMs have no threads.
replyTimestampis 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
{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:
| 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). 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, 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 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 adds the bot | group.join adds the owner |
chat:write.public | Org chat.post / chat.startStream 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 of same-roam public groups |
search:read.public | Org chat.search includes unjoined public groups | PAT search includes anything the owner can read |
message.channels / message.groups / message.im | chat.message for chats the bot is in | 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 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 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.
{
"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
first.
Interactive org bot
group.join(or Add Members) for each group it should hear.- Subscribe to unfiltered
chat.message. - Reply in the same thread:
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.
{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.historyof 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 can hit unjoined public groups in the
bot's roam. 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 and
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.
See also
- Access Models — which endpoints each token can call, and org vs personal identity
- Identity & Principals —
userId/userType, mentions, Slack identity mapping - Scopes —
chat:history,chat:send_message,chat:read,group:write - Webhooks overview — envelope, signing, retries