Skip to main content

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.

ActionEndpoint
Start or heartbeat a sessionPOST /user.activity.set
End a sessionPOST /user.activity.clear
Read live rowsGET /user.activity.list

Authorizationโ€‹

CredentialWho you can targetScopes
Organization API key / org OAuthAny user in the workspaceuser:write.activity to set/clear, user:read.activity to list
Personal OAuthThe token owner onlySame scopes โ€” personal-mode OAuth is not exempt
Personal Access TokenThe token owner onlyScope 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.

FieldRequiredLimitRole
emojiyes16 Unicode code pointsBadge on the seat
titleyes140 Unicode code pointsHover tooltip
subtitleno140 Unicode code pointsSupporting line after the title
colornocurated nameSeat 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.color set โ€” the seat gets a colored glow, the same stacked treatment first-party agent glows use.
  • display.color omitted โ€” 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: true rows collapse into one ExternalActivity reason, which stacks with Zoom/Meet auto-DND and manual DND. Clearing one activity leaves the others. When the last dnd: true row 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:

Default10 minutes
Maximum60 minutes
Over-max ttlSeconds / expiresAtClamped, not rejected โ€” the response echoes the real expiresAt
ttlSeconds and expiresAtMutually exclusive (400 invalid_arguments)
ttlSeconds <= 0 or past expiresAt400 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.

  • .list returns every live row for that user, from every integration, newest startedAt first. You can only set / clear your 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 startedAt desc. 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 isBadge + tooltipGlowDND
Own assigned officeyesif color setif dnd: true
Meeting room or someone else's officeyesnono
Auditorium / theater seating (stage, backstage, floor mic, invisible observer)nonono

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โ€‹

CodeStatusWhen
missing_parameter400userId, externalId, display.emoji, or display.title missing
invalid_parameter400Over-length fields, unknown color, non-positive ttlSeconds, past expiresAt
invalid_arguments400Both ttlSeconds and expiresAt sent
missing_scope403OAuth / API key lacks user:write.activity or user:read.activity
access_denied403Personal token targeting someone other than the owner
user_not_found404Unknown user, or a user in another account

Example: phone-call integrationโ€‹

  1. On call.started, POST /user.activity.set with externalId = yourapp:call:{callId}, a phone emoji, a glow color, dnd: true, and ttlSeconds a bit longer than your longest expected call (or 3600).
  2. If the call can last more than your TTL, heartbeat the same externalId on a timer or on talk-time webhooks.
  3. On call.ended, POST /user.activity.clear with 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.