# Identity & Principals

Every v1 acting `userId` emitted by chat history/search or the `chat.message` and `chat.reaction`
webhooks resolves through [`user.info`](/docs/api/user-info) under the same credentials, and any
accompanying `userType` or `messageAuthorType` equals `user.info.type`.

Roam uses one principal ID namespace for people and automated actors. A principal always has one of
two public types:

| Principal                                           | `type` | Additional signal                                             |
| --------------------------------------------------- | ------ | ------------------------------------------------------------- |
| Workspace member                                    | `user` | Member fields such as `isAdmin` are present                   |
| Guest (a person without membership in your account) | `user` | `isGuest: true`                                               |
| Classic bot, agent, assistant, or coworker          | `bot`  | `botCode` and `integrationId` appear when that actor has them |

Automated implementation details intentionally collapse to `bot`. Do not branch on display names or
assume that every bot has `botCode` or `integrationId`.

## Directory vs. hydration

An unfiltered [`user.list`](/docs/api/user-list) is the workspace member directory. It lists active
members only; it never enumerates guests or bots.

Use `user.list?ids=<id1,id2,...>` to hydrate IDs you already hold. This mode accepts up to 100
comma-separated bare or tagged IDs, deduplicates them in first-seen order, and returns resolved
principals in that order. It has no cursor and cannot be combined with `q`, `limit`, or `cursor`.
Unknown IDs, groups, and principals outside your authorization boundary are silently omitted, so an
empty result does not reveal whether an ID exists.

Explicit ID lookup is historical as well as current: `user.info?id=...` and `user.list?ids=...` can
resolve archived or deactivated principals. This lets stored messages remain attributable after
offboarding. Email lookup is different: it is member-only and requires `user:read.email`.

For user targets, a known UUID plus `user:read` is sufficient; prior shared-chat provenance is not
required. Guests expose public display identity and `isGuest: true`, with email only when the
credential also has `user:read.email`. Bots and agents are limited to the caller's Roam, assistants
to the caller's account, and coworkers to the account of their owning principal. Singular
unauthorized lookups return `user_not_found`; batch hydration silently omits them.

## Avoid bot loops

History/search messages and `chat.message` webhooks include required `userType`. Reaction webhooks
include both actor `userType` and `messageAuthorType`. Use these fields before doing any extra
lookup:

```javascript
if (event.data.userType === "bot") return;
```

When a surface supplies only an ID—such as `group.members`, `reaction.list`, poll voters, chat
previews, or meeting participants—hydrate it with `user.info` or batch IDs through `user.list?ids`.

## Address sidecars

Principals are only one kind of chat address. Mentions may also refer to groups or the literal
`all` — the mention token's sigil tells you which: `<@ID>` is a principal (resolve with
[`user.info`](/docs/api/user-info)), `<!subteam^ID>` is a group or channel (resolve with
[`group.info`](/docs/api/group-info)).

For chat payloads, request `expand=addresses` on [`chat.list`](/docs/api/chat-list),
[`chat.history`](/docs/api/chat-history), or [`chat.search`](/docs/api/chat-search) to receive an
address map keyed by UUID. `chat.link.resolve` does not support this expansion.

## Slack capability mapping

| Slack concept                   | Roam v1 equivalent                                      | Important difference                                                           |
| ------------------------------- | ------------------------------------------------------- | ------------------------------------------------------------------------------ |
| `users.list`                    | Unfiltered `user.list`                                  | Roam keeps this strictly as the active workspace member directory              |
| `users.info`                    | `user.info`                                             | Roam also hydrates guests and bot-like actors from the same `userId` namespace |
| `users.info` called repeatedly  | `user.list?ids=`                                        | Roam provides ordered bulk hydration for up to 100 IDs                         |
| `is_restricted` / guest users   | `type: "user", isGuest: true`                           | Roam's guest signal means no membership in the caller's account                |
| `is_bot`, `bot_id`, or `app_id` | `type: "bot"` plus optional `botCode` / `integrationId` | Agents, assistants, and coworkers intentionally share the bot vocabulary       |
| Message bot subtype checks      | `userType` / `messageAuthorType`                        | Roam puts principal type directly beside actor IDs                             |
| `<@U…>` user mentions           | `<@uuid>`                                               | Same syntax; a `\|label` suffix is accepted and ignored on write, reads are always bare |
| `<!subteam^S…>` group mentions  | `<!subteam^uuid>`                                       | Same syntax and semantics (notifies members); Roam also uses it for channel mentions, which Slack lacks |
| `<!channel>` broadcast          | `<!channel>`                                            | Same syntax and semantics; the legacy `<@all>` is accepted on write            |
| `<#C…>` channel links, `<!here>`, `<!everyone>` | Reserved — not tokens                   | Roam has no channel links or presence/workspace-scoped notify yet; these stay literal text |

The model is Slack-capable without copying Slack's split identity namespaces: one Roam `userId` is
sufficient for attribution, loop prevention, and later hydration.