# Live-tail wire format — field reference

The Cyclone live-tail NDJSON contract is owned by the frontend parser
(`src/lib/tail-stream.ts:22-44`) and the backend emitter
(`backend/src/cyclone/api_helpers.py:tail_events()` + the three
endpoints in `backend/src/cyclone/api.py:1357-2006`). This file is the
quick reference; the canonical prose lives in the README and the
source-of-truth type definitions.

## Source

Wire format excerpt copied verbatim from `README.md:76-94` (the
"Live updates → Wire format" section). The README is the user-facing
exposition; this reference adds the per-line field semantics and the
parser-tolerance rules from the code.

> Each stream endpoint emits newline-delimited JSON. The first batch
> is the **snapshot** of currently-known rows; after that comes
> **`snapshot_end`** with the count, then the **live** events.
>
> ```json
> {"type":"item","data":{"id":"CLM-1", "...":"..."}}
> {"type":"item","data":{"id":"CLM-2", "...":"..."}}
> {"type":"snapshot_end","data":{"count":2}}
> {"type":"item","data":{"id":"CLM-3", "...":"..."}}   ← live
> {"type":"heartbeat","data":{"ts":"2026-06-20T23:17:09Z"}}   ← idle keep-alive
> ```
>
> Lines are `{"type": ..., "data": ...}`; known types are `item`,
> `snapshot_end`, `heartbeat`, and (rare) `item_dropped` /
> `error`. Heartbeats keep the connection alive when nothing is
> happening — clients flip to `stalled` after 30s of total silence
> (heartbeat or otherwise) and surface a **↻ Reconnect** button.

## Per-line field reference

| `type`         | `data` shape                              | Required? | Emitted by                                  | Parser behavior                              |
| -------------- | ----------------------------------------- | --------- | ------------------------------------------- | -------------------------------------------- |
| `item`         | resource-specific row (`Claim` / `Remittance` / `Activity`) | yes, in `data` (the per-row envelope) | snapshot loop + `_tail_events` forwarding `claim_written` / `remittance_written` / `activity_recorded` | `dispatch(resource, ev.data)` → `useTailStore.addClaim` / `addRemittance` / `addActivity` (first-write-wins dedup on the id-keyed slices — `tail-store.ts:104,120`). |
| `snapshot_end` | `{"count": N}` (integer ≥ 0)              | yes, on every stream                       | `api.py:1395,1889,1999` (one per stream, after the snapshot loop) | Flips `<TailStatusPill>` from `connecting` to `live`, resets the reconnect backoff counter to 0 (`useTailStream.ts:174-180`). |
| `heartbeat`    | `{"ts": "<iso-8601 UTC>"}`                | yes, but only when idle                    | `_tail_events` in `api_helpers.py:241-245` (cadence from `heartbeat_seconds()`, default 15s) | Re-arms the stall timer (`useTailStream.ts:124-140`); no state change. |
| `item_dropped` | `{"id": "<string>"}`                      | optional (rare; queue overflow)            | EventBus drop-oldest path (per the spec at `docs/superpowers/specs/2026-06-20-cyclone-live-tail-design.md:299`) | Re-arms the stall timer; no state change. The `id` field is informational — the hook does not refetch on drop, the page does (see Spec §3.6). |
| `error`        | `{"message": "<string>"}`                 | optional (server-side failure)            | Reserved for future server-emitted errors (currently only thrown client-side) | Hook promotes to a thrown `Error(message)` so the catch block runs the reconnect machinery (`useTailStream.ts:190-194`). |

## Parser tolerance

The shared parser at `src/lib/tail-stream.ts` is intentionally
forgiving so a single bad frame doesn't kill the stream:

- **Trailing `\r`** is stripped per line (`tail-stream.ts:116`) so a
  CRLF-terminated stream still parses.
- **Malformed JSON** (`JSON.parse` throws) → `console.warn` + skip the
  line; the iterator continues (`tail-stream.ts:122-131`).
- **Unknown `type`** (not in `KNOWN_TYPES`) → `console.warn` + skip
  (`tail-stream.ts:141-148`). Adding a new event type is
  forward-compatible: old clients see warn lines, new clients see the
  typed event.
- **Empty lines** (consecutive `\n`s) → silently skipped
  (`tail-stream.ts:118`).
- **No trailing newline** → flushed as a final partial line on
  stream close (`tail-stream.ts:154-178`).
- **Abort signal** → iterator exits cleanly without throwing
  (`tail-stream.ts:75,98,107`).

## Endpoint inventory

| Method | Path                      | Subscribes to        | Default sort        | Defined at                          |
| ------ | ------------------------- | -------------------- | ------------------- | ----------------------------------- |
| GET    | `/api/claims/stream`      | `claim_written`      | `-submission_date`  | `backend/src/cyclone/api.py:1357`   |
| GET    | `/api/remittances/stream` | `remittance_written` | `-received_date`    | `backend/src/cyclone/api.py:1858`   |
| GET    | `/api/activity/stream`    | `activity_recorded`  | `-timestamp` (limit 50) | `backend/src/cyclone/api.py:1971` |

All three accept the same query params as their non-streaming
counterparts (`status`, `payer`, `date_from`, …) so a frontend can
swap a one-shot fetch for a tail with no URL surgery. Responses are
`Content-Type: application/x-ndjson`.