# 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`](/docs/api/user-activity-set) |
| End a session | [`POST /user.activity.clear`](/docs/api/user-activity-clear) |
| Read live rows | [`GET /user.activity.list`](/docs/api/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

```bash
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.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**:

| | |
| --- | --- |
| 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.

- `.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 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

```bash
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

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.