Skip to main content

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 isThe app's own botThe owner's personal bot (attributed to that user, e.g. "Alex's Notetaker")
MembershipThe bot is in the chatThe owner is in the chat
Posts asThe appThe personal bot — never the owner's human address
Can be a group memberYes — add it, or group.joinNo. group.join adds the owner person, never the PAT bot
Typical useWorkspace bots, alerts, channel listenersAI 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

DestinationOrganizationPersonal
Public group, bot / owner is a memberread ✓ · post ✓ · react ✓ · webhook ✓same
Public group, bot / owner is not a memberread 403 · post · react 403 · no webhookread · post 403 · react 403 · no webhook
Private group, bot / owner is a memberall ✓all ✓
Private group, bot / owner is not a member403 / not delivered403 / not delivered
Group in another roam, bot / owner is a member
Group in another roam, not a member403403

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

DestinationOrganizationPersonal
Bot is a DM participantread ✓ · post ✓ · react ✓ · webhook ✓n/a — a PAT bot cannot be added as a member
userIds: [X], X ≠ ownerOrg DMs are bot ↔ people; the bot is the participantRoutes 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 membern/a400 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.

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

SurfaceOrganizationPersonalSlack analogue
chat.post / chat.startStream to an unjoined public groupAllowed403 not_in_chatBot chat:write.public
chat.history of an unjoined public group403 not_in_chatAllowed (same roam)User-token channels:history
chat.searchMember chats plus unjoined public groups in the bot's roamChats the owner can read, including unjoined public groupsBot search:read.public
chat.postEphemeralMembership required — no public-group carveoutOwner must be a memberchat.postEphemeral
reaction.add / .removeMembership requiredOwner must be a memberreactions:write
chat.link.sharedMember or public groupOwner in the chatEvents API + links:read
chat.unfurlOrganization only (API key / OAuth). PATs cannot register domains or unfurlchat.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

SituationOrganizationPersonal
Member of the group / participant in the DMEvery 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 groupNot delivered — including @mention. The client may prompt Invite the app to this group?; dismissing still sends the message and does not add the appNot delivered (owner is not in the chat). REST history of that public group still works
Not a member of a private groupNot delivered, even on @mentionNot delivered
{mention: true} on subscribeFilter only. Restricts the member-chat stream to explicit @ of the app. Does not grant extra chatsFilter only. @ of the personal bot in chats the owner is in. Un-@'d DMs do not match
Your own postsDelivered if you would otherwise receive the chat. Ignore loops with data.userType === "bot" or token.infoSame

@-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:

FilterEventMatches
chatTypechat.message"dm" or "group"
mentionchat.messageExplicit @ of your app only (Slack app_mention)
nameschat.reactionOnly 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.

HowOrganizationPersonal
group.joinAdds the bot (Slack conversations.join)Adds the owner person, never the PAT bot
group.addAdd the bot (or people) if you are a member, or the group is public in your roamCannot add the PAT bot (400)
Roam UIGroup Settings → Add Members → search for the appAdd the owner as a person
@-mentionDoes 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 {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 splitchat.history:

CallWhat you getSlack analogue
No threadTimestampMain view (top-level messages)conversations.history
With threadTimestampThat thread's repliesconversations.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 haveUseTypical call
An existing conversationchatIdContinue a chat you already know
A groupgroupIdPost to or read that group's channel
PeopleuserIdsOpen 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.

SlackRoam organization (bot token)Roam personal (user token)
Bot added to a channelOrg bot is a group membern/a — PAT bots cannot be members
User in a channeln/aOwner is a group member
conversations.joingroup.join adds the botgroup.join adds the owner
chat:write.publicOrg chat.post / chat.startStream to an unjoined public groupNo403 not_in_chat
User-token channels:history on a public channel the user hasn't joinedNo — org history is membership-onlyYes — PAT chat.history of same-roam public groups
search:read.publicOrg chat.search includes unjoined public groupsPAT search includes anything the owner can read
message.channels / message.groups / message.imchat.message for chats the bot is inchat.message for chats the owner is in
app_mention{mention: true} on chat.messageSame filter, scoped to the owner's chats
Invite bot after @app in a public channelRoam may prompt Invite; @ alone does not join and does not delivern/a
Thread follow without channel membershipNone. Threads inherit parent-chat membershipSame
reaction_added / reaction_removedchat.reaction for member chatsOwner-in-chat; no mention filter
chat.postEphemeralMembership required (no public write carveout)Owner must be a member
conversations.history / conversations.replieschat.history without / with threadTimestampSame

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

  1. group.join (or Add Members) for each group it should hear.
  2. Subscribe to unfiltered chat.message.
  3. 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.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 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