Response stream

Reaction events

Live tapbacks and custom emoji on the stream — not a command tally.

A peer (or you) tapping a reaction writes an event: reaction frame. This is chat evidence. Your own tapback also settles as event: command (reaction.tapback / reaction.apply). Those are two meanings. Do not increment a reaction tally on event: command.

Reaction and poll frames have no per-row externalId. A poll's pollExternalId names the poll origin. A reaction names the target bubble as targetExternalId.

event: reaction
data: {"conversationId":"a0000000-0000-4000-8000-000000000001","occurredAt":"2026-08-24T18:03:40.000Z","from":"+14155550100","action":"added","reaction":"love","targetExternalId":"guid-1"}

A custom emoji adds emoji and sets reaction to "custom". Classic tapbacks omit emoji.

event: reaction
data: {"conversationId":"a0000000-0000-4000-8000-000000000001","occurredAt":"2026-08-24T18:03:41.000Z","from":"+14155550100","action":"added","reaction":"custom","emoji":"🔥","targetExternalId":"guid-1"}

The frame

Prop

Type

There is no externalId on this object. Do not invent one from a hash.

Own tapbacks vs event: command

You send reaction.tapback or reaction.apply. The command settles with status and, when the Mac's delivered result carries one, outcome (applied, removed, already_reacted, not_reacted). That settlement has no actor, target or reaction name.

If chat.db later shows the tapback, an event: reaction follows. Count the reaction frame. The command frame is whether your verb finished.

add, remove, add

Live edges are not a state snapshot. Adding love, removing it, and adding it again is three frames: added, removed, added. Two custom emoji on the same bubble are two frames with distinct emoji values.

Do not dedupe on (from, targetExternalId, reaction, action). That key collapses add→remove→add (repeats the first tuple) and collapses distinct custom emoji (reaction: "custom" for both).

SSE and replay can overlap. A frame already seen live may appear again in replay. Upsert using (conversationId, from, targetExternalId, reaction, emoji if custom, action, occurredAt) as a best-effort coincidence key, not as a cursor. Two adds with identical occurredAt cannot be distinguished this release (no row id).

Catch-up

includeActivity: true on POST /v1/replay returns the newest limit reactions for that conversation (default 50, max 200), newest first then reversed to chronological. sinceExternalId does not bound activity. A reaction-only outage does not advance the inbound-message checkpoint. activityTruncated: true means a cap hit; a 201st older row is not recoverable.

Snapshot batches (reaction-state:…) are not streamed.

On this page