External activity
Roam already shows what someone is doing inside Roam โ in a meeting, screen sharing, an agent working in their office. External activity lets an integration paint the same kind of indicator for work that happens outside Roam: a JustCall or Dialpad customer call, a browser-only Zoom meeting, a custom CRM session.
The integration owns the lifecycle (set when the session starts, clear
when it ends). Roam owns presentation, and a mandatory TTL guarantees the
indicator cannot stay stuck if your "ended" webhook never arrives.
| Action | Endpoint |
|---|---|
| Start or heartbeat a session | POST /user.activity.set |
| End a session | POST /user.activity.clear |
| Read live rows | GET /user.activity.list |
Authorizationโ
| Credential | Who you can target | Scopes |
|---|---|---|
| Organization API key / org OAuth | Any user in the workspace | user:write.activity to set/clear, user:read.activity to list |
| Personal OAuth | The token owner only | Same scopes โ personal-mode OAuth is not exempt |
| Personal Access Token | The token owner only | Scope check is skipped. Targeting anyone else returns 403 access_denied |
Unknown or cross-account users return 404 user_not_found.
Set an activityโ
curl -X POST https://api.ro.am/v1/user.activity.set \
-H "Authorization: Bearer $ROAM_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"userId": "0cc74785-e31e-4403-aa5e-0cc7c1897e66",
"externalId": "justcall:call:CA123",
"display": {
"emoji": "๐",
"title": "On a customer call",
"subtitle": "JustCall ยท Acme Corp",
"color": "green"
},
"ttlSeconds": 1800,
"dnd": true
}'
externalId is your session key, unique per integration and user. Prefix
it with your product (justcall:call:โฆ) so retries and heartbeats land on
the same row. Two apps can use the same string independently.
The 200 body is the live item, including the server-stamped startedAt and
expiresAt. The same shape comes back from .list.
Displayโ
display is what the client renders. Hover shows ๐ Title โ Subtitle.
| Field | Required | Limit | Role |
|---|---|---|---|
emoji | yes | 16 Unicode code points | Badge on the seat |
title | yes | 140 Unicode code points | Hover tooltip |
subtitle | no | 140 Unicode code points | Supporting line after the title |
color | no | curated name | Seat glow |
Limits count Unicode code points, not bytes, so CJK and emoji-bearing titles are not clipped early, and ZWJ sequences (family emoji, flags) stay valid.
Quiet vs glowingโ
These two knobs are independent of DND:
display.colorset โ the seat gets a colored glow, the same stacked treatment first-party agent glows use.display.coloromitted โ badge and tooltip only. This is the Zoom/Meet/Teams "on a call" shape: visible, not loud.
Palette names: blue, gold, gray, green, indigo, lime, orange,
pink, purple, red, teal, yellow. Unknown names are rejected at
set time. The API never takes a hex value โ clients resolve the name to
light/dark colors, so the palette can be retuned without a breaking change.
First-party agent glows (Claude, Codex, Pi, room agents) are not in this list. Integrations cannot impersonate them.
v1 is emoji-only. There is no custom-icon or workspace-emoji path yet.
Do Not Disturbโ
Pass dnd: true to also lock the user's assigned office while the
activity is live. Default is false: a sloppy caller can paint a badge
without locking an office.
- DND only ever applies to the person's own office. A visitor in a meeting room or someone else's office never changes that room's access mode.
- Multiple integrations'
dnd: truerows collapse into oneExternalActivityreason, which stacks with Zoom/Meet auto-DND and manual DND. Clearing one activity leaves the others. When the lastdnd: truerow is gone, that reason drops; the previous access mode is restored only if nothing else is still holding DND.
JustCall-style call integrations typically set both a glow color and
dnd: true on one call lifecycle.
TTL and heartbeatsโ
Expiry is mandatory and server-stamped:
| Default | 10 minutes |
| Maximum | 60 minutes |
Over-max ttlSeconds / expiresAt | Clamped, not rejected โ the response echoes the real expiresAt |
ttlSeconds and expiresAt | Mutually exclusive (400 invalid_arguments) |
ttlSeconds <= 0 or past expiresAt | 400 invalid_parameter |
Long-running sessions heartbeat by re-posting the same externalId. That
upserts the row in place: expiresAt moves forward, startedAt stays put
(unless you send startedAt explicitly). Webhook retries are safe for the
same reason.
A future startedAt is clamped to the server's now. Projection ranks
newest-startedAt first, so an unbounded future value could pin one
integration's slot; clamping also absorbs caller clock skew.
There is no tighter cap on glowing or DND-flagged rows. Webhook-driven integrations have no timer to heartbeat with, and a short cap would kill the indicator mid-call.
Multiple activitiesโ
Rows are keyed (integration, user, externalId). One integration can hold
many concurrent rows (a mid-transfer JustCall call), and several
integrations can hold rows on the same person at once.
.listreturns every live row for that user, from every integration, neweststartedAtfirst. You can onlyset/clearyour own.- Glowing rows render as additive glow passes, one palette color each. Quiet rows contribute only the badge and tooltip.
- The map projection keeps the top 3 by
startedAtdesc. The rest stay in the database and in.list. The cap always keeps at least one DND-flagged row, so a locked office cannot vanish behind three newer badges.
Where it appearsโ
The indicator follows the person. It survives a section restart, follows
them across floors, and appears when someone who was unseated at set
time sits down.
| Where the user is | Badge + tooltip | Glow | DND |
|---|---|---|---|
| Own assigned office | yes | if color set | if dnd: true |
| Meeting room or someone else's office | yes | no | no |
| Auditorium / theater seating (stage, backstage, floor mic, invisible observer) | no | no | no |
Stage seating is a deliberate blank: indicators are not wanted on a stage,
and an invisible observer's badge would leak their presence. The row stays
in the database and in .list.
Several people in the same room keep independent badges. Updating one user does not replace another seated user's rows.
Clearโ
curl -X POST https://api.ro.am/v1/user.activity.clear \
-H "Authorization: Bearer $ROAM_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"userId": "0cc74785-e31e-4403-aa5e-0cc7c1897e66",
"externalId": "justcall:call:CA123"
}'
Response is 204 No Content, including when the row is already gone. Call this from your "call ended" / "meeting left" webhook; combine it with TTL so a missed delivery still goes dark within an hour.
Errorsโ
| Code | Status | When |
|---|---|---|
missing_parameter | 400 | userId, externalId, display.emoji, or display.title missing |
invalid_parameter | 400 | Over-length fields, unknown color, non-positive ttlSeconds, past expiresAt |
invalid_arguments | 400 | Both ttlSeconds and expiresAt sent |
missing_scope | 403 | OAuth / API key lacks user:write.activity or user:read.activity |
access_denied | 403 | Personal token targeting someone other than the owner |
user_not_found | 404 | Unknown user, or a user in another account |
Example: phone-call integrationโ
- On
call.started,POST /user.activity.setwithexternalId=yourapp:call:{callId}, a phone emoji, a glow color,dnd: true, andttlSecondsa bit longer than your longest expected call (or 3600). - If the call can last more than your TTL, heartbeat the same
externalIdon a timer or on talk-time webhooks. - On
call.ended,POST /user.activity.clearwith that same pair. Treat 204 as success even if you already cleared it, or if TTL beat you to it.
A second integration (desktop Zoom auto-DND, another telephony app) can run at the same time. Clearing yours leaves theirs intact.