# Live Activities & Status Streams

**Version 1.2 · additive-only · status: stable**

A small, open protocol for showing a person the *live state* of something your
system is doing — a deploy, a build, a delivery, a long job — as a
self-updating card on their phone. On iOS it renders as a **Live Activity** on
the Lock Screen and in the **Dynamic Island**; on Android as an ongoing **live
update** notification; inside the PingRoom app as the same card, rendered from
the same template. You drive all three with plain JSON POSTs.

> **In one sentence.** Send a `live_status` ping — from a room's incoming
> webhook, an agent credential, or a person in the app; the first ping starts a
> live card on every member's phone, each update moves its progress or step
> tracker in place (no notification spam), and a final `done` or `failed`
> delivers one completion alert and ends the card.

The reference implementation is **[PingRoom](https://pingroom.io)**. This
document is written so any SDK, CLI, or agent framework can speak the protocol
from this text alone.

---

## 1. Why this exists

The alternative to a live status is a checking loop: the person polls a
dashboard, a CI page, or a chat thread until reality changes. A Live Activity
inverts that — the state lives on the Lock Screen and updates itself, and the
person glances instead of polling.

The catch is that ActivityKit and Android live updates are non-trivial to
drive: push-to-start tokens, per-activity update tokens, payload budgets,
stale-activity cleanup. This protocol hides all of it behind the webhook you
already have. You report state transitions; PingRoom owns the delivery
mechanics on both platforms.

### What it deliberately leaves out

- **No custom layouts.** You choose a rendering **category** (plain status,
  a step tracker, or an alert) and supply content; the card's design is
  PingRoom's. That's what makes it a one-POST integration.
- **No high-frequency telemetry.** Streams are for human-scale state changes
  (roughly one update every few seconds at most), not metrics firehoses.
- **No fan-in.** A stream has one producer. The credential that starts a stream
  is the only one that can move or end it (§3.5). Producers push; the
  human-facing surfaces consume. A producer can read its own stream state back
  (§3.4), and the backing ping through the agent API's notification endpoints.

---

## 2. Concepts and vocabulary

- **Stream**: one live thing being tracked, keyed by your `correlation_id`.
  One stream = one Live Activity = one feed row that updates in place.
- **State**: `running` while live; `done` or `failed` to finish. Terminal
  states are one-way and end the activity.
- **Template**: the layout the OS renders. Set with `live_status.template`,
  fixed at stream creation. Each reads its own content fields (below):
  - `status` (default) — title, message, optional progress bar.
  - `steps` — a segmented step tracker ("Step 2 of 4 · Deploy"), 2–8 labels.
  - `progress` — a determinate progress bar with an optional live ETA.
  - `metrics` — up to 3 labelled counters (e.g. `CPU 45%`).
  - `countdown` — a large live timer counting down to a deadline.
  - `question` — a prompt with up to 4 options shown on the lock screen.
  - `matchup` — two sides (left · center · right) for a score / A-vs-B.
- **Category** (legacy): `status` \| `steps` \| `alert`. Still accepted;
  `alert` selects urgency styling (start delivered time-sensitive) and has no
  `template` equivalent. Prefer `template`; the client resolves
  `template → category → status`.
- **Start / update / end**: the first ping for a correlation **starts** the
  stream (normal push + Live Activity start). Further `running` pings
  **update** it silently — the card changes, no new notification. The first
  `done`/`failed` **ends** it with a single completion alert.
- **Completion alert**: the one alert your stream earns at the end. Delivered
  time-sensitive so the outcome lands even through Focus modes.
- **Acknowledgement**: opt-in (`requires_ack`). The completion/start carries
  an **Acknowledge** button on the notification, on the Live Activity, and in
  the app; the first eligible member to tap resolves it for everyone.

---

## 3. The endpoints

Three producers speak this protocol — a room's incoming webhook, an agent
credential, and a signed-in person using the app composer. The body, the
lifecycle, the templates and the limits are identical; only the credential, the
gating and the error envelope differ.

### 3.1 A room's incoming webhook

A Pro feature. The webhook fires as the room owner.

```
POST https://api.pingroom.io/api/webhooks/{inviteCode}/{secret}
Content-Type: application/json
```

The `{inviteCode}` and `{secret}` come from the webhook you create in the app
(Room → Connections → Incoming webhook) or via the agent API. The path secret
authenticates the caller; there are no additional headers.

Any payload containing a `live_status` object is treated as a stream ping and
follows this protocol. Payloads without `live_status` are ordinary webhook
pings (one alert per POST) — see the room's webhook screen for that simpler
contract.

Responses use the webhook envelope: `{"success": true, …}`, or
`{"success": false, "error": "…"}` on rejection.

### 3.2 An agent credential

No Pro requirement and no webhook to configure. Needs the
`pingroom:live:write` consent scope, and the bound account must own the room.

```
POST https://api.pingroom.io/api/agent/rooms/{inviteCode}/live
Authorization: Bearer <agent-token>
Content-Type: application/json
```

The body is exactly the payload described below — `live_status` is a top-level
key here, not something detected inside a generic webhook post. Responses use
the agent envelope: `201` when the ping created the stream, `200` for an update,
a completion, or an idempotent replay, and `{"code": "…", "message": "…"}` on
rejection.

```json
{ "notification_id": "…", "correlation_id": "…", "state": "started", "idempotent": false, "action_state": null }
```

`state` is `started` on creation, then the state you sent — except when the very
first ping is already `done`/`failed`, which creates the row and reports that
terminal state.

Free accounts may start **5 new streams per day** (`402 free_limit_reached`
past that). Only stream *creation* is charged: silent updates and the terminal
`done`/`failed` ping are never metered, so a card can't be quota-blocked into
hanging open. Pro accounts are unmetered.

Over MCP the same route is the `live_status` tool; `get_live_status` is §3.4.
Via the CLI it is `pingroom live start|update|end|get`, and via the SDK
`pr.live.push()`.

### 3.3 A person in the app

No webhook, no agent credential, no Pro subscription. A member starts a live
activity from the room composer, picks any of the 7 templates, and drives it
with the same route:

```
POST https://api.pingroom.io/api/rooms/{inviteCode}/live
Authorization: Bearer <user-token>
Content-Type: application/json
```

The body is the agent body minus `live_status.agent_id`. Responses use the same
envelope as the agent path: `201` on creation, `200` on update / completion /
idempotent replay, `{"code": "…", "message": "…"}` on rejection.

Gating differs from the machine producers:

- **Free.** There is no Pro requirement and no agent stream quota. The only
  ceiling is an abuse limit of **50 new streams per person per day**
  (`429 live_stream_limit_reached`). Updates never cost a slot, and neither does
  a `done`/`failed` that ends a stream **you already own** — a cap that could
  block `done` would strand the card. A `done`/`failed` on a `correlation_id`
  you do not already own is not a completion: it creates a new stream row and
  sends its alert, so it is charged like any other create.
- **Standard private rooms only.** Public rooms and personal rooms return
  `422 unsupported_room`.
- **Ordinary send permission.** The caller must be a member who may send a ping
  in that room — the room owner or an admin always, anyone else only when the
  room has "everyone can trigger" on. Otherwise `403 forbidden`.
- **Attribution is the sender, not the room owner.** A non-owner member's stream
  carries their own name and avatar in the feed. (The webhook and agent
  producers still fire as the room owner.)

Because a person cannot POST an update from outside the app, the in-room card
renders **owner controls** for whoever produced the stream — advance step, set
progress, update score, mark done, mark failed. Each control re-POSTs to this
route with the same `correlation_id`, so the human loop runs the identical
create / update / terminal legs. Ordinary pings still cannot carry
`live_status`; this route is the only human writer.

### 3.4 Reading a stream back

```
GET https://api.pingroom.io/api/agent/rooms/{inviteCode}/live/{correlationId}
Authorization: Bearer <agent-token>

GET https://api.pingroom.io/api/rooms/{inviteCode}/live/{correlationId}
Authorization: Bearer <user-token>
```

Use it when a producer restarts and needs to reconcile rather than open a
duplicate. Rate-limited to 30/min. `404 stream_not_found` outside the 24-hour
window.

Both routes return the **whole** stored block, not a subset, so a `matchup`,
`metrics`, `countdown` or `question` stream can be resumed without re-sending
content the producer no longer has:

```json
{
  "notification_id": "…", "correlation_id": "…",
  "state": "running", "progress": 0.4, "message": "…", "category": null,
  "template": "progress", "agent_id": null, "accent_override": null,
  "eta_at": null, "deadline_at": null, "metrics": null,
  "prompt": null, "options": null,
  "left": null, "right": null, "center": null,
  "steps": null, "current_step": null,
  "action_state": null, "updated_at": "2026-07-31T09:00:00+00:00"
}
```

Fields you never set come back as `null` rather than being omitted. Keys are
snake_case exactly as you sent them; the one camelCase sub-key on the wire
(`left.iconSvg` / `right.iconSvg`) is returned untouched.

### 3.5 Streams belong to the credential that created them

A stream is keyed by `correlation_id` **and** its producer. A webhook can never
advance or complete a stream an agent started, an agent can never advance a
webhook's, one member can never advance another member's, and two agent
credentials using the same correlation id in the same room get two independent
streams — each side drives its own card.

The read-back endpoints follow the same rule: they only return streams the
presented credential created.

**One exception, on the human route.** Server-side ownership is per-credential,
but on the device a Live Activity's identity is the `correlation_id` *alone*, so
a second producer's `start` — or `done` — would end the first producer's card on
every recipient's lock screen. Since any room member can read another member's
`correlation_id` out of their own notification list, a human **create** is
refused with `409 correlation_in_use` while any other producer still has a
*running* stream on that `(room, correlation_id)`. Only creation is checked, so
your own updates and terminal pings are never affected, and a terminal stream
releases its correlation id immediately (its activity is already gone). The
webhook and agent routes do not apply this check: their credentials are
owner-managed, so a collision there is the owner colliding with themselves.

### 3.6 A room of one delivers to its sender

The webhook and agent producers fire **as the room owner**, and a room normally
delivers to every member *except* the sender. So an owner alone in their own
private room would receive nothing — which is exactly the situation of someone
wiring up their first stream and trying to see it on their own phone.

When the room is private, non-personal, and has a single member, the stream is
addressed to the sender instead, so the card lands on their device. With
`requires_ack`, that sender may also resolve their own ping — the one case where
a sender can acknowledge themselves. This applies to all three producers,
which is why the human route does **not** carry the solo-member `422` that an
ordinary ping or Question does: a lone member's own lock screen is the point.

Add a second member and normal behaviour resumes: the sender is excluded and
the stream goes to everyone else. Nothing about multi-member delivery changed.

---

## 4. Request fields

| Field | Required | Contract |
|---|---:|---|
| `live_status.state` | Yes | `running` \| `done` \| `failed` |
| `live_status.progress` | No | Number `0..1`; renders the progress bar / Dynamic Island gauge |
| `live_status.message` | No | String ≤ 256; the card's live message line |
| `live_status.template` | No | `status`\|`steps`\|`progress`\|`metrics`\|`countdown`\|`question`\|`matchup`; fixed at creation |
| `live_status.category` | No | Legacy `status` \| `steps` \| `alert`; fixed at creation |
| `live_status.steps` | Required on `steps` creation | 2–8 labels, each ≤ 40 chars; immutable after creation |
| `live_status.current_step` | No | Integer `0..steps.length`; the only mutable steps field |
| `live_status.metrics` | Expected on `metrics` | Up to 3 `{label ≤ 24, value ≤ 24}` counters |
| `live_status.deadline_at` | Expected on `countdown` | Epoch seconds the timer counts down to |
| `live_status.eta_at` | No | Epoch seconds; live ETA on `status`/`progress` |
| `live_status.prompt` | Expected on `question` | String ≤ 256; the ask (falls back to `title`) |
| `live_status.options` | Expected on `question` | Up to 4 `{value ≤ 64, label ≤ 40}` |
| `live_status.left` / `right` | Expected on `matchup` | `{label ≤ 24, value ≤ 24, iconSvg?}` |
| `live_status.center` | No | String ≤ 40; the `matchup` score/clock |
| `live_status.accent_override` | No | Hex `#rrggbb`; semantic accent for one frame (e.g. deadline red) |
| `live_status.agent_id` | No | String ≤ 255; owning agent id. Machine provenance — silently stripped on the human route |
| `correlation_id` | **Yes** | String ≤ 255 — the stream key. Reuse it on every ping of the same stream |
| `title` | No | String ≤ 40; falls back to the webhook's configured title |
| `action` | No | Quick-action number 1–4 to attribute the ping to |
| `data` | No | JSON **object** (not a list), ≤ 25 keys, ≤ 8 KB — your structured payload, echoed on read surfaces |
| `requires_ack` | No | Boolean; adds the Acknowledge affordance |
| `ack_timeout_seconds` | No | Integer 1..86400; deadline after which an unacknowledged ping expires |

"Expected on X" means the template is built around that field and renders
degraded or empty without it — a `countdown` with no `deadline_at` shows a dead
timer. Only `steps` is enforced at creation (`422 steps_required`, whether the
stream selects it via `template` or the legacy `category`); the rest are
accepted as omitted, so send them.

Branding (icon, color, sound) comes from the webhook's configuration and the
mapped quick action — a stream can't restyle the room.

Every accepted ping returns `{"success": true, "notification_id": "…"}`; the
`notification_id` is stable across the whole stream because updates rewrite
the same row.

### Rules the server enforces

- **Template and step labels are immutable after creation.** A later ping
  can't re-template a running activity: stored values win, and only
  `current_step`, `progress`, `message`, and `state` move.
- **Sticky content is inherited across sparse updates — and across the terminal
  ping.** Fields set at creation (`steps`, `metrics`, `prompt`/`options`,
  `left`/`right`, `progress`, `accent_override`, …) persist on later pings that
  omit them, so an update can carry just a new `progress` or `message` and keep
  rendering the template. Re-sending a field overrides it. A bare
  `{"state": "done"}` keeps the whole set too, so the final lock-screen frame
  still shows the finished scoreboard / metrics grid rather than an empty card.
- A `steps`-category stream must include `live_status.steps` on its **first**
  ping → otherwise `422 steps_required`.
- Sending step labels without a category implies `category: "steps"`.
- `current_step` beyond the stored label count → `422 invalid_current_step`.
- A `done` terminal snaps the step tracker to "all steps complete".
- A first ping that is already terminal (`done`/`failed`) is a plain one-shot
  alert — there's no activity to start.
- Streams are **webhook-scoped**: the webhook that created a correlation is
  the only one that can update or complete it. Streams older than 24 h can't
  be updated; start a new correlation.

---

## 5. Lifecycle

```
POST {state: running}            → feed row created, alert push, Live Activity starts
POST {state: running, ...}       → card updates in place (no new alert), any number of times
POST {state: done | failed}      → ONE completion alert, Live Activity ends
```

- **Stale cleanup.** A stream left `running` with no updates for ~15 minutes
  is ended by the server, and every activity carries a stale date (≤ 4 h), so
  an abandoned producer can never strand a card on someone's Lock Screen.
- **Terminal is idempotent.** Only the first `done`/`failed` delivers the
  completion alert; repeats are clean no-ops.
- **Dismissal.** People can end the card themselves — from the app (the ✕ on
  the live strip) or by dismissing the OS surface. Your stream keeps working;
  the feed row still updates.

---

## 6. Delivery limits

| Limit | Value | On excess |
|---|---|---|
| Updates per stream | ~6 / minute per correlation (~12 for a person on the human route) | `429` + `retry_after: 60` (terminal `done`/`failed` always goes through) |
| New streams | 10 / minute per producer | `429` + `retry_after: 60` |
| Public-room alerts | 1 / minute per room | `429` (start and completion alerts only; silent updates unaffected) |
| New streams, free accounts | 5 / day (agent credentials) | `402 free_limit_reached` (creation only; updates and any `done`/`failed` ping are never charged) |
| New streams, people | 50 / day per user | `429 live_stream_limit_reached` (creation only; the terminal ping of a stream **you already own** is never charged, but a `done`/`failed` on an unowned correlation id is a create and is charged) |
| Requests on the human route | 60 / min (POST), 30 / min (GET) | bare `429` route throttle |

The per-minute limits are scoped per producer — one webhook, one agent
credential, or one user — so producers never consume each other's budget. A
person's daily allowance is deliberately a separate namespace from the agent
budget, so a human stream can never spend an agent's slots or vice versa. The
public-room alert gate is the exception: it is per *room*, and is shared across
every alert-emitting path (it cannot fire on the human route, which refuses
public rooms outright).

The terminal bypass matters, and applies to the update throttle and to both
daily budgets: a chatty stream that exhausts its allowance can still end. A Live
Activity is never rate-limited into hanging open. The bypass covers *ending a
stream*, not opening one — on the human route it is scoped to streams the caller
already owns, so `done` cannot be used as a free create. (The agent budget
bypasses on the payload state alone.)

---

## 7. Interruption behavior (Focus / Do Not Disturb)

Delivered **time-sensitive** (pierces Focus when the user allows the app
time-sensitive delivery): `alert`-category starts, `done`/`failed` completion
alerts, and any ping with `requires_ack`. Ordinary starts and all silent
updates are normal-priority.

---

## 8. Acknowledgement semantics

With `requires_ack: true`:

- The ping shows an **Acknowledge** button — on the push notification, on the
  Live Activity, and on the feed row in the app.
- **First-ack-wins**: one member resolves it for the room; later taps are
  no-ops that show the winner.
- `ack_timeout_seconds` sets a deadline; an unacknowledged ping transitions to
  `expired` and its Live Activity ends.
- In public rooms the acker's identity is redacted from feeds and webhooks.
- Agents can wait on the resolution via the agent API
  (`GET /api/agent/notifications/{id}/ack/wait` — see
  [agent.md](https://pingroom.io/agent.md)).

An acknowledgement means *"seen / taken"*. If you need an actual decision with
options, use the [Question Protocol](https://pingroom.io/questionprotocol.md)
instead.

---

## 9. Errors

The machine-readable value below is `error` on the webhook envelope and `code`
on the agent and human envelopes; the strings themselves are identical.

| HTTP | `error` / `code` | Meaning |
|---|---|---|
| 422 | `steps_required` | First ping of a `steps` stream had no `steps` labels |
| 422 | `invalid_current_step` | `current_step` exceeds the stored label count |
| 422 | `invalid_data` | `data` was a JSON list, not an object |
| 422 | `payload_too_large` | `data` exceeded 8 KB serialized |
| 422/503 | `feature_temporarily_unavailable` | Acknowledgement authoring/dispatch is gated off |
| 429 | `rate_limited` | A limit from §6; honor `retry_after` |
| 429 | `public_rate_limited` | The room's shared 1/min public-alert gate; honor `retry_after` |
| 402 | `free_limit_reached` | Agent route only: the free daily new-stream budget is spent. Updates and terminal pings are unaffected |
| 429 | `live_stream_limit_reached` | Human route only: this person's 50 new streams for today are spent. Carries `limit` and `retry_after`; updates and the terminal ping of a stream they already own are unaffected |
| 409 | `correlation_in_use` | Human route only: another producer already has a *running* stream on this `(room, correlation_id)`. Use a different correlation id |
| 422 | `unsupported_room` | Human route only: the room is public or personal |
| 403 | `insufficient_scope` | Agent route only: the credential lacks `pingroom:live:write` |
| 403 | `forbidden` | Agent route: the bound account does not own the room. Human route: not a member, or not allowed to send a ping in this room |
| 403 | `webhook_disabled` / `owner_not_pro` | Webhook route only: the webhook is off, or the owner's Pro lapsed (config is kept; delivery resumes on resubscribe) |
| 404 | `stream_not_found` | Read-back only: no stream for that correlation id from this credential in the last 24h |
| 410 | `room_abandoned` | The room owner no longer exists — stop retrying |

---

## 10. Examples

A deploy, start to finish:

```bash
BASE="https://api.pingroom.io/api/webhooks/ROOMCODE1234/whsec_…"

# start — the Live Activity appears
curl -s "$BASE" -H 'Content-Type: application/json' -d '{
  "title": "Deploy v2.1.0",
  "correlation_id": "deploy-2026-07-22-42",
  "live_status": { "state": "running", "progress": 0.1, "message": "Building image" }
}'

# silent updates — the card moves, nobody gets a new notification
curl -s "$BASE" -H 'Content-Type: application/json' -d '{
  "correlation_id": "deploy-2026-07-22-42",
  "live_status": { "state": "running", "progress": 0.7, "message": "Rolling out" }
}'

# end — one completion alert, the activity closes
curl -s "$BASE" -H 'Content-Type: application/json' -d '{
  "correlation_id": "deploy-2026-07-22-42",
  "live_status": { "state": "done", "message": "Live on production" },
  "requires_ack": true, "ack_timeout_seconds": 900
}'
```

The same deploy from an agent credential — no webhook, no Pro requirement.
Note the top-level `live_status` and the bearer token:

```bash
ROOM=ROOMCODE1234
API="https://api.pingroom.io/api/agent/rooms/$ROOM/live"

curl -s "$API" \
  -H "Authorization: Bearer $PINGROOM_TOKEN" \
  -H 'Content-Type: application/json' -d '{
  "title": "Deploy v2.1.0",
  "correlation_id": "deploy-2026-07-22-42",
  "live_status": { "state": "running", "progress": 0.1, "message": "Building image" }
}'
# → 201 {"notification_id":"…","correlation_id":"…","state":"started","idempotent":false,"action_state":null}
```

Or, from CI, with the CLI:

```bash
pingroom live start  -c "deploy-$GITHUB_RUN_ID" --room "$ROOM" \
  --template steps --steps "Build,Test,Stage,Ship" -t "Deploy v2.1.0"
pingroom live update -c "deploy-$GITHUB_RUN_ID" --room "$ROOM" --step 2 -m "Smoke tests green"
pingroom live end    -c "deploy-$GITHUB_RUN_ID" --room "$ROOM" -m "Live on production"
```

A step tracker (labels fixed on the first ping, only `current_step` moves):

```bash
curl -s "$BASE" -H 'Content-Type: application/json' -d '{
  "title": "Release pipeline",
  "correlation_id": "rel-118",
  "live_status": {
    "state": "running",
    "category": "steps",
    "steps": ["Build", "Test", "Stage", "Ship"],
    "current_step": 0
  }
}'

curl -s "$BASE" -H 'Content-Type: application/json' -d '{
  "correlation_id": "rel-118",
  "live_status": { "state": "running", "current_step": 2, "message": "Smoke tests green" }
}'
```

A metrics template (up to 3 live counters; later updates can send just the changed values):

```bash
curl -s "$BASE" -H 'Content-Type: application/json' -d '{
  "title": "Prod health",
  "correlation_id": "health-1",
  "live_status": {
    "state": "running",
    "template": "metrics",
    "metrics": [
      { "label": "RPS", "value": "1.2k" },
      { "label": "p99", "value": "180ms" },
      { "label": "5xx", "value": "0.1%" }
    ]
  }
}'
```

A question on the lock screen (prompt + options; sticky across updates):

```bash
curl -s "$BASE" -H 'Content-Type: application/json' -d '{
  "title": "Deploy gate",
  "correlation_id": "gate-9",
  "live_status": {
    "state": "running",
    "template": "question",
    "prompt": "Promote build 118 to prod?",
    "options": [ { "value": "go", "label": "Promote" }, { "value": "hold", "label": "Hold" } ]
  }
}'
```

An urgent alert-category stream (delivered time-sensitive from the start):

```bash
curl -s "$BASE" -H 'Content-Type: application/json' -d '{
  "title": "Checkout error rate",
  "correlation_id": "incident-2201",
  "live_status": { "state": "running", "category": "alert", "message": "5xx at 3.2% and climbing" },
  "requires_ack": true
}'
```

---

## 11. Platform notes

- **iOS**: Live Activities render on iOS 16.2+; remote push-to-start needs
  iOS 17.2+ (older versions start the activity when the alert arrives with the
  app installed). The Dynamic Island shows the compact gauge / step counter on
  supported hardware. The whole APNs payload is budgeted at 4 KB — when a
  payload runs long the server first sheds the array fields in ascending render
  priority (`steps`, `metrics`, `options`, `left`, `right`), then clips the
  long strings (`message`, `statusEmoji`, `prompt`, `title`), re-checking the
  size after each drop. Clipping empties those strings rather than removing
  them; `state` and the timestamp are never touched, so the activity itself
  always survives.
- **Android**: streams render as an ongoing (sticky) live-update notification
  with the same progress bar / step tracker; the terminal ping removes it.
- **In-app**: the room feed and history render the stream as a **full inline
  card that updates in place**, and that card **is template-aware** — it reads
  the same `template` you selected and renders the same content the OS surfaces
  do (`metrics`, `deadline_at`/`eta_at`, `prompt`/`options`, `left`/`right`/
  `center`, `accent_override`, `steps`/`current_step`, `progress`). The one
  exception is a `question` stream with **3 or more** options: that collapses to
  a disclosure row which opens a sheet, matching how ordinary Questions behave.
  The card keeps a ✕ that dismisses the OS surface. Whoever produced the stream
  additionally sees owner controls on the card (§3.3).

---

## 12. Versioning

This document is **additive-only**: field names, states, categories, error
codes, and lifecycle semantics above are stable. New optional fields and new
categories may appear; unknown fields you send are ignored (except the
reserved `data.live_status` key, which only this protocol writes).

**1.1** added the agent producer (§3.2), stream read-back, the explicit
producer-scoping rule, and the free-tier new-stream budget. Nothing in
1.0 changed: the webhook endpoint, its envelope, the field table and the
lifecycle are exactly as they were.

**1.2** adds the human producer (§3.3) — a member drives all 7 templates from
the app composer over `POST /api/rooms/{inviteCode}/live`, free, gated only by
room type and ordinary send permission. Three error codes are new and scoped to
that route alone — `422 unsupported_room`, `409 correlation_in_use` and
`429 live_stream_limit_reached`; no webhook or agent response changed. Read-back
is no longer agents-only and
now returns every stored field rather than a step/progress subset (§3.4, the old
§3.3); the omitted fields were never removable from a stream, so this is
additive for existing agents. The in-app render became template-aware and gained
owner controls (§11). Section numbering shifted by one from §3.3 onward.
Unchanged: the webhook endpoint, the agent endpoint, every envelope, the field
table and the lifecycle.
