# PingRoom for AI Agents

PingRoom lets AI agents act on a user's behalf: create rooms, send Pings, activate a private Agent Inbox, and hand one task to one human. MCP clients use OAuth 2.1 + PKCE; native agents can use **auth.md**. Both paths issue revocable credentials limited to named scopes.

A human-readable version of this guide lives at https://pingroom.io/documents/agent-access.

## Discovery

- Protocol spec: https://pingroom.io/auth.md
- OAuth authorization server metadata: https://pingroom.io/.well-known/oauth-authorization-server
- Protected resource metadata: https://pingroom.io/.well-known/oauth-protected-resource
- API base URL: https://api.pingroom.io
- MCP endpoint (Streamable HTTP, JSON-RPC 2.0): https://api.pingroom.io/api/agent/mcp
- Question Protocol (ask a human, one attributable answer): https://pingroom.io/questionprotocol.md
- Live Activities & status streams (agent- or webhook-driven Lock Screen state): https://pingroom.io/liveactivities.md

## Connecting

There are two ways in; pick the one your runtime supports.

**1. MCP clients (Cursor, Claude Desktop, Claude Code, …)**: add PingRoom as a remote MCP server using the MCP endpoint above. The server speaks the MCP authorization spec: standard **OAuth 2.1 + PKCE** with **RFC 7591 dynamic client registration**, so the client registers itself, the user approves scopes on a PingRoom consent page (confirmed by an email code), and the client gets a token with no manual key handling. The tools the credential is scoped for are then listed via `tools/list`.

Claude Code, one line:

```bash
claude mcp add --transport http pingroom https://api.pingroom.io/api/agent/mcp
```

The OAuth/consent flow runs on first use; after that `tools/list` returns the scoped tools.

- Authorization endpoint: https://api.pingroom.io/oauth/authorize
- Token endpoint: https://api.pingroom.io/oauth/token
- Dynamic client registration: https://api.pingroom.io/oauth/register
- PKCE: `S256` required. Public clients (no secret) are supported.

**2. Native agents (OpenAI, Anthropic, Google)**: use the **auth.md** flow. Present an ID-JAG signed by your trusted IdP, or run the user-claim email flow. See https://pingroom.io/auth.md.

Both paths mint the same scoped credential and reach the same API and MCP surface.

## Scopes

- `pingroom:rooms:read`: List rooms and read a room's details and quick actions.
- `pingroom:rooms:write`: Create rooms (free accounts: up to five rooms).
- `pingroom:rooms:publish`: Create a public, discoverable room with a @handle.
- `pingroom:broadcast:send`: Send a custom Ping (your own title and body).
- `pingroom:attachments:write`: Upload and manage bounded private files that can be attached to broadcasts or Questions.
- `pingroom:actions:trigger`: Press a quick action to send a Ping.
- `pingroom:rooms:join`: Join a room on the user's behalf using an invite code.
- `pingroom:notifications:read`: Read the Pings across the rooms the user belongs to.
- `pingroom:actions:write`: Create and edit the numbered quick-action buttons in rooms the user owns.
- `pingroom:webhooks:read`: List a room's incoming webhooks.
- `pingroom:webhooks:write`: Create and edit incoming webhooks so external systems can fire Pings into a room (Pro).
- `pingroom:webhooks:delete`: Delete a room's incoming webhooks.
- `pingroom:profile:write`: Set the agent's profile picture from the PingRoom bot avatar set and rotate its public handle.
- `pingroom:agents:ping`: Retired — grants nothing. Handle-addressed pings across accounts always answer `410 cross_account_ping_retired`. An agent reaches only the account that connected it; to reach anyone else, broadcast into a room you both belong to (`pingroom:broadcast:send`).
- `pingroom:approvals:request`: Use the legacy approve-or-deny request surface and wait for the human decision.
- `pingroom:questions:ask`: Ask a human a question with a few tappable answers and wait for the choice (generalizes approvals; the legacy `pingroom:approvals:request` scope is also accepted).
- `pingroom:handoffs:create`: Verify the connection in the private room chosen during consent and hand an acknowledgement or 2–4 option question to exactly one human.
- `pingroom:live:write`: Start, update, and end a live progress card (an iOS Live Activity / Android live update) in rooms the user owns. Not one of the default scopes — request it explicitly.

## Capabilities

| Action                                    | Endpoint                                                     | Scope                         |
| ----------------------------------------- | ------------------------------------------------------------ | ----------------------------- |
| Create a room                             | `POST /api/agent/rooms`                                      | `pingroom:rooms:write`        |
| Join a room                               | `POST /api/agent/rooms/join`                                 | `pingroom:rooms:join`         |
| List a room's quick actions               | `GET /api/agent/rooms/{inviteCode}/actions`                  | `pingroom:rooms:read`         |
| Set up a quick action                     | `PUT /api/agent/rooms/{inviteCode}/actions/{n}`              | `pingroom:actions:write`      |
| Send a Ping (press action 1 to 4)         | `POST /api/agent/rooms/{inviteCode}/actions/{n}/trigger`     | `pingroom:actions:trigger`    |
| Send a custom Ping (broadcast)            | `POST /api/agent/rooms/{inviteCode}/notifications`           | `pingroom:broadcast:send`     |
| Start / update / end a live progress card | `POST /api/agent/rooms/{inviteCode}/live`                    | `pingroom:live:write`         |
| Read a live stream back                   | `GET /api/agent/rooms/{inviteCode}/live/{correlationId}`     | `pingroom:live:write`         |
| Read Pings                                | `GET /api/agent/notifications`                               | `pingroom:notifications:read` |
| Verify the agent connection               | `POST /api/agent/inbox/ensure`                               | `pingroom:handoffs:create`    |
| Create a private one-human Handoff        | `POST /api/agent/handoffs`                                   | `pingroom:handoffs:create`    |
| Fetch / list Handoffs                     | `GET /api/agent/handoffs/{id}` · `GET /api/agent/handoffs`   | `pingroom:handoffs:create`    |
| Wait for a Handoff                        | `GET /api/agent/handoffs/{id}/wait`                          | `pingroom:handoffs:create`    |
| Ask a question                            | `POST /api/agent/rooms/{inviteCode}/questions`               | `pingroom:questions:ask`      |
| Wait for an answer                        | `GET /api/agent/questions/{id}/wait`                         | `pingroom:questions:ask`      |
| Fetch / list questions                    | `GET /api/agent/questions/{id}` · `GET /api/agent/questions` | `pingroom:questions:ask`      |
| Cancel a question                         | `POST /api/agent/questions/{id}/cancel`                      | `pingroom:questions:ask`      |
| Set a bot avatar                          | `POST /api/agent/profile/avatar`                             | `pingroom:profile:write`      |

Avatars must come from PingRoom's bot set. Fetch the catalog at `GET /api/avatars` and use an id from the `bots` category (any other category returns `422 invalid_avatar`).

### Link pings

A broadcast becomes a tappable link button when its structured `data` object carries the reserved keys `url` (absolute http(s) URL, ≤ 2048 chars) and optional `button_label` (≤ 26 chars):

```json
{
  "message": "Build 512 ready",
  "data": {
    "commit": "abc123",
    "url": "https://ci.example.com/b/512",
    "button_label": "Open build"
  }
}
```

Recipients get the button on the ping in-app and in channel mirrors (Telegram, Slack, Discord, Teams). The same convention works on incoming webhooks. Everything else in `data` (≤ 25 keys / 8KB total) plus `correlation_id` / `reply_to` is echoed back unchanged on every read surface — with the reserved keys noted below as the one exception.

### Reserved keys inside `data`

`data` is mostly yours, but a few keys are PingRoom's own structured vocabulary and are validated. Get one wrong and the request is a `422` — it is not stored as an opaque blob.

| Reserved key | Contract |
|---|---|
| `url` | Link ping target. Absolute `http://` or `https://` URL, ≤ 2048 chars. |
| `button_label` | Link-ping button text, ≤ 26 chars. |
| `label` | Link-ping title, ≤ 26 chars. |
| `location` | **A closed schema, not a free-form object.** Exactly `latitude`, `longitude`, `label`, `address` — nothing else. `latitude` (−90..90) and `longitude` (−180..180) are both required whenever `location` is present; `label` ≤ 100 chars; `address` ≤ 255 chars. |

```json
{
  "message": "Driver arriving",
  "data": {
    "order_id": "A-4471",
    "location": {
      "latitude": 25.2048,
      "longitude": 55.2708,
      "label": "Pickup",
      "address": "Downtown Dubai"
    }
  }
}
```

**Unknown keys inside `data.location` are rejected**, so `"location": { "latitude": 1, "longitude": 2, "accuracy": 5 }` is a `422`. Put your own fields at the top level of `data` (`order_id` above), where they are still passed through untouched. The same `location` rules apply on broadcasts, incoming webhooks, and Questions.

For ordinary custom Pings, `title` is optional and capped at 40 characters.
`message` is capped at 120 characters in a private room and 160 in a public
room. Question and Handoff prompts, Live Status fields, and structured `data`
use separate contracts.

### Urgency and acknowledgement are two different things

`is_urgent` and `requires_ack` are independent booleans. They used to be one
flag, which meant asking for a ping that cuts through Focus also demanded that
somebody acknowledge it — so pick the one you actually mean:

| Field | What it does | What it does NOT do |
|---|---|---|
| `is_urgent` | Delivers the ping **time-sensitive**, so it breaks through Focus / Do Not Disturb. | Ask anything of the recipient. No acknowledgement, no lock-screen card. |
| `requires_ack` | Holds the ping **open until one recipient acknowledges it**, and shows them a lock-screen card with an Acknowledge button they can tap without opening the app. Optional `ack_timeout_seconds` (60–86400) sets a deadline. | Raise the interruption level. On its own it delivers at normal priority. |

Set both when something is genuinely urgent *and* needs a human to confirm they
have it. Both work on a broadcast, on an incoming webhook, and — for `is_urgent`
as a one-shot modifier for a single press — on `trigger_quick_action`.

```json
{ "message": "Prod is down", "is_urgent": true }
{ "message": "Deploy approved?", "requires_ack": true, "ack_timeout_seconds": 300 }
{ "message": "Pager: DB failover", "is_urgent": true, "requires_ack": true }
```

### Live progress cards

Work that takes minutes deserves a card that stays on the lock screen instead of a ping per step. `POST /api/agent/rooms/{inviteCode}/live` (scope `pingroom:live:write`, room owner only) drives an iOS Live Activity / Android live update. The body is the same live-status contract the incoming webhook uses:

```json
{
  "correlation_id": "deploy-42",
  "live_status": {
    "state": "running",
    "template": "progress",
    "message": "Building…",
    "progress": 0.4
  },
  "action": 1
}
```

`correlation_id` is the stream key: reuse it on every ping of one stream. The first ping creates the card and sends one alert, further `running` pings move it silently with no alert, and the first `done`/`failed` sends one completion alert and ends it. `template` picks the layout (`status`, `steps`, `progress`, `metrics`, `countdown`, `question`, `matchup`) and is fixed at creation, as are `category` and `steps`. The legacy `category` (`status`, `steps`, `alert`) is worth one exception: `alert` has no `template` equivalent and is the only way to start a stream **time-sensitive** — breaking through Focus. (A stream takes `category: "alert"`, not `is_urgent`, which the live-status route does not accept.) `title`, `action`, `data`, `requires_ack`, and `ack_timeout_seconds` work as they do on a broadcast; the selected quick `action` supplies the card's icon and sound. The full field table, per-template fields, and update-throttle rules are in the producer contract at https://pingroom.io/liveactivities.md.

The response is `201` on stream creation and `200` on update, terminal, and idempotent replay:

```json
{
  "notification_id": "…",
  "correlation_id": "deploy-42",
  "state": "running",
  "idempotent": false,
  "action_state": null
}
```

Rejections use the standard agent error envelope, `{ "code", "message", "retry_after"? }`.

`GET /api/agent/rooms/{inviteCode}/live/{correlationId}` reads a stream back, so a producer that restarted can reconcile instead of starting a duplicate. It returns `notification_id` and `correlation_id`, then **every** stored `live_status` field — `state`, `progress`, `message`, `category`, `template`, `agent_id`, `accent_override`, `eta_at`, `deadline_at`, `metrics`, `prompt`, `options`, `left`, `right`, `center`, `steps`, `current_step` — then `action_state` and `updated_at`. Fields you never set come back as `null` rather than being omitted, so a `matchup`, `metrics`, `countdown` or `question` stream resumes without re-sending content you no longer hold. Returns `404 stream_not_found` if there is no such stream in the last 24 hours. Throttled `30/min`.

Streams belong to the credential that created them. Your agent can only advance or read a stream it started: another registration's stream, or one started by an incoming webhook, is invisible to you even under the same `correlation_id`.

Abandoned `running` streams are ended automatically about 15 minutes after their last update, so a crashed producer never leaves a card open forever. Still, send the terminal `done`/`failed` yourself — it is never rate-limited or quota-blocked, precisely so a stream can always close.

Over MCP the same two routes are the tools `live_status(invite_code, correlation_id, live_status, title?, action?, data?, requires_ack?, ack_timeout_seconds?)` and `get_live_status(invite_code, correlation_id)`, both scoped `pingroom:live:write`.

## Agent Inbox and Handoffs

After the human connects your credential with `pingroom:handoffs:create`, call `POST /api/agent/inbox/ensure` with that **agent credential**. A user app JWT cannot call this route. The call reuses the standard private room the human chose during consent and sends one onboarding Question: “PingRoom connected. Can you answer this?” Calling it again returns the same viable attempt without spamming duplicates. If an attempt expires, is cancelled, or is answered before its phone receipt arrives, the next call creates one numbered retry instead of leaving the connection permanently stuck.

```bash
curl -sX POST https://api.pingroom.io/api/agent/inbox/ensure \
  -H "Authorization: Bearer $PINGROOM_TOKEN" \
  -H 'Content-Type: application/json' -d '{}'
```

Read the returned `question.id` and poll its Handoff wait route until the Question leaves `pending`. If it is answered, the originating agent receives the result; expiry or cancellation is not activation.

```bash
curl -s "https://api.pingroom.io/api/agent/handoffs/$QUESTION_ID/wait?timeout=20" \
  -H "Authorization: Bearer $PINGROOM_TOKEN"
```

On a server and mobile build that support verified activation, poll while the Question is `pending`, within a bounded local deadline. Success is an `answered` response with `activation_completed: true`. That stamp requires the native phone to return the opaque proof carried in this push before the human answer, followed by this agent observation. An answered result without the stamp is terminal and incomplete; do not keep polling as if a later callback could rewrite the required order. The callback proves native processing on a phone; it does not prove banner display or human read. APNs/FCM HTTP acceptance remains delivery diagnostics only.

Over MCP, use `activate_agent_inbox` for the viable-attempt replay/create call and `wait_for_handoff` for the same bounded observation loop when those tools appear in `tools/list`.

A **Handoff** is always direct to exactly one human and private from every other room member. Use `kind: "ack"` for “confirm you saw this,” or `kind: "question"` with **2 to 4** ordered options when the agent needs a choice. The default direct target is the credential's bound human (`user_id: "me"`), so there is no room code to copy.

```bash
curl -sX POST https://api.pingroom.io/api/agent/handoffs \
  -H "Authorization: Bearer $PINGROOM_TOKEN" \
  -H "Idempotency-Key: deploy-1.4.0" \
  -H 'Content-Type: application/json' \
  -d '{
    "kind":"question",
    "prompt":"Ship 1.4.0?",
    "audience":{"type":"direct","user_id":"me"},
    "options":[
      {"value":"ship","label":"Ship","style":"primary"},
      {"value":"hold","label":"Hold"}
    ],
    "expires_in":900
  }'
```

Reuse the same `Idempotency-Key` and identical body on network retries. Fetch with `GET /api/agent/handoffs/{id}`, list with `GET /api/agent/handoffs?state=open`, or long-poll `GET /api/agent/handoffs/{id}/wait?timeout=20` until terminal. Ack states are `open → acked | expired`; Question states are `pending → answered | expired | cancelled`. A negative choice such as `hold` is a successful `answered` result, not a transport error. `acked_by` is an object (`{ "id", "display_name" }`) or null/redacted — never assume it is a scalar id.

Readiness is fail-closed: `409 recipient_not_ready` means the human needs a Handoff-capable PingRoom 1.4 device; `503 capability_check_unavailable` means PingRoom could not safely verify readiness and you should retry. `409 idempotency_conflict` means the key was reused with a different request body.

## Questions

A **Question** is a Ping that asks a person to choose one of a few predefined answers, or (when you opt in with `text_input`) to type a short reply. It lands on the lock screen with tappable answer buttons (and a reply field when you invite typed text) and resolves in a single tap, which makes it about the lowest-friction way for your agent to get a human decision. Approvals are just the two-option case of this (the old approval endpoints still work; an approval is a Question with `approve`/`deny`).

**Ask**: `POST /api/agent/rooms/{inviteCode}/questions` (scope `pingroom:questions:ask`, counts against the daily Ping quota). Body:

```json
{
  "prompt": "Deploy v1.4.0 to production?",
  "context": "Deploy",
  "options": [
    { "value": "approve", "label": "Approve", "style": "primary" },
    { "value": "deny", "label": "Deny", "style": "danger" }
  ],
  "text_input": null,
  "responder_scope": "direct",
  "target_user_id": null,
  "ttl": 3600,
  "correlation_id": "deploy-42",
  "reply_to": null,
  "data": { "commit": "abc123" }
}
```

- **prompt** (required, ≤500): the text the person reads. **context** (optional, ≤40): a secondary line (e.g. a build number).
- **options** (2 to 4): each option is a stable `value` (a machine token, ≤40, unique) plus a human `label` (≤40), with an optional `style` that colors the button. Use `primary` for the recommended/affirmative choice (the brand strike color, at most one), `danger` for a genuinely destructive one (danger red), or `default` for a neutral choice (the default when you leave `style` out). The legacy flags `"primary": true` and `"destructive": true` are still accepted as shorthand for `style: "primary"` and `style: "danger"`. The array is ordered, and the buttons render in exactly that order (two side by side, three or four stacked in a sheet), so reorder the array to reorder the buttons. Omit `options` entirely for the binary `approve`/`deny` default. **Two options is the lock-screen fast path**: they render as two side-by-side actions with no expansion, while three or four need the notification expanded, so prefer binary questions.
- **text_input** (optional): opt in to a typed answer with `{ "placeholder": "Type a reason", "max_length": 60 }`. Supply it on its own for a text-only question, or alongside `options` to offer both a tap and a typed reply. `max_length` is hard-capped at 60 (a short answer, not a chat). Omit it for an options-only question. On the lock screen this adds a reply field (iOS `UNTextInputNotificationAction`, Android `RemoteInput`) next to any option buttons; in-app it's a text field with Send, or an "or type a reply" field below the options. The person answers with either a tapped option or a typed reply, and the typed string comes back as `answer.text` (see the shape below).
- **responder_scope**: `direct` (one named person via `target_user_id`, defaulting to your bound user; the UI reads "asks you") or `room` (any eligible member; "asks the room").
- **resolution_policy**: `first` (the first valid answer wins; later taps are clean no-ops). Only `first` exists today.
- **ttl** (seconds, clamped 30…86400, default 3600): every Question has a deadline. No answer by then and it lands in terminal `expired`.
- **data** / **correlation_id** / **reply_to**: the standard structured-Ping fields, echoed back unchanged on every read surface.

Returns the created Question (see the shape below) with its `id` and computed `expires_at`. Questions can only be asked in standard private rooms.

**Receive the outcome**, three ways, one payload:

- **Wait (long-poll)**: `GET /api/agent/questions/{id}/wait?timeout=20` returns immediately on resolution, otherwise after the bounded timeout (≤30s) so you re-poll. Throttled `30/min`.
- **Fetch / list**: `GET /api/agent/questions/{id}` and `GET /api/agent/questions?state=pending|answered|expired|cancelled` for polling and audit/recovery.
- **Outgoing webhook**: if the room has an outgoing webhook, the resolution fires as a `question.answered` / `question.expired` / `question.cancelled` event. The body is a compact envelope (not the full Question shape): `{ "event": "question.answered", "question_id": "…", "room": { "name": "…", "code": "…" }, "state": "answered", "answer": { "value", "label", "text", "responder": { "id", "display_name" }, "answered_at" } | null, "correlation_id", "reply_to", "data", "timestamp" }`. These events are matched **by event name** against the room's `events` filter (`null`/`[]` = forward all), so under a non-empty filter you must list `question.answered`/`question.expired`/`question.cancelled` explicitly to keep receiving them. Match a resolution to its original ask by `correlation_id` (or `question_id`).

**Cancel**: `POST /api/agent/questions/{id}/cancel` withdraws a still-pending Question, moving it to terminal `cancelled`.

**States**: `pending` → `answered` (an option was chosen) · `expired` (no answer by the deadline) · `cancelled` (the asker withdrew). Terminal states are immutable. The three outcomes are distinct: "the person said no" is an **answered** Question whose chosen value is the negative option, not `expired` (never answered) or `cancelled` (withdrawn).

**The Question shape** (identical across create, fetch, and wait; the outgoing-webhook event uses the compact envelope described above, not this full shape):

```json
{
  "id": "…", "kind": "question",
  "prompt": "Deploy v1.4.0 to production?", "context": "Deploy",
  "options": [{ "value": "approve", "label": "Approve", "primary": true, "destructive": false }, …],
  "text_input": null,
  "responder_scope": "direct", "target_user_id": "…", "resolution_policy": "first",
  "state": "answered",
  "answer": { "value": "approve", "label": "Approve", "text": null, "responder": { "id": "…", "display_name": "Federica" }, "answered_at": "…" },
  "expires_at": "…", "resolved_at": "…",
  "correlation_id": "deploy-42", "reply_to": null, "data": { "commit": "abc123" },
  "created_at": "…", "room": { "code": "…", "name": "…" }, "asker": { "type": "agent", "id": "…", "handle": "…", "display_name": "…" }
}
```

`answer` is `null` until resolved; on `expired`/`cancelled` it stays `null` and `state` tells you why. When answered, the person did exactly one of two things: tapped an option (`answer.value` and `answer.label` set, `answer.text` null) or typed a reply (`answer.text` set, `answer.value` and `answer.label` null). You read `answer.text` back exactly like `answer.value`: on fetch/list, the wait result, and the outgoing webhook. The `responder` identity is the authenticated person who answered, never caller-supplied.

**Worked examples**

- _Deploy approval_: a two-option Question (`approve`/`deny`), `responder_scope: direct`. Wait on it, then act on the chosen value.
- _Environment picker_: a four-option Question (`dev`/`staging`/`prod`/`cancel`); it renders as a "choose an answer" sheet in-app and as expandable actions on the notification.
- _CI gate_: ask, then block your pipeline on the wait endpoint; the exit reflects answered vs. expired, so a human decision becomes a shell gate.

This contract is additive-only: field names and states are stable, and new fields (like the per-option `style`) may be added but never renamed or removed.

### Asking over MCP

If you connect through MCP (see "Connecting" above), the whole Question Protocol shows up as five tools, all scoped `pingroom:questions:ask`, each a 1:1 wrapper over the routes above. Arguments mirror the endpoint fields exactly (snake_case).

- `ask_question(invite_code, prompt, options?, text_input?, context?, responder_scope?, target_user_id?, correlation_id?, reply_to?, data?, ttl?)`: ask a person a question with 2 to 4 tappable options, delivered as a push (answerable from the lock screen or in-app), first valid answer wins. Omit `options` for a default Approve/Deny. Pass `text_input` (`{ placeholder, max_length }`, capped at 60) to invite a typed answer instead of, or alongside, the options.
- `wait_for_answer(question_id, timeout?)`: long-poll until answered or expired; returns the state and, once answered, the chosen option value and label (or the typed `answer.text`) plus the responder.
- `get_question(question_id)`: current state, no blocking.
- `list_questions(state?)`: your asked questions, newest first; `state` filters by `pending`, `answered`, `expired`, `cancelled`, or `all`.
- `cancel_question(question_id)`: withdraw a pending question.

Each `options` entry is `{ value, label, style? }`, where `style` is `primary` (affirmative, brand color), `danger` (destructive, red), or `default` (quiet, the default when omitted). `text_input` is `{ placeholder, max_length }` (max 60) for a typed answer, on its own or alongside `options`. `responder_scope` is `direct` (one person, the default) or `room` (anyone eligible). These match the field shapes documented above.

The typical agent loop is `ask_question` then `wait_for_answer` on the returned `id`, which turns a human decision into a blocking call. Approvals over MCP still work too via the legacy `request_approval` / `wait_for_approval` / `get_approval` tools (the two-option case).

## Limits

- `POST /api/agent/auth`: 10 / min
- `POST /api/agent/auth/claim/start`: 3 / min
- `POST /api/agent/auth/claim/complete`: 6 / min
- `POST /api/agent/auth/refresh`: 10 / min
- `POST /api/agent/auth/revoke`: 10 / min
- Pings on free accounts: 20 / day, then `402`. Pro lifts room and Ping limits.
- New live streams on free accounts: 5 / day, then `402 free_limit_reached`. This is a separate budget from the Ping quota, and only stream creation is charged — updates and the terminal `done`/`failed` ping are always free.
- `GET /api/agent/rooms/{inviteCode}/live/{correlationId}`: 30 / min
- Live stream updates: ~6 / min per `correlation_id`, and 10 new streams / min. Terminal states bypass the update throttle.

Start with the protocol spec at https://pingroom.io/auth.md.
