Skip to main content

Events

Overview​

The Events module handles platform event logging and alerting for streaming events such as followers, subscribers, cheers, raids, channel point redemptions, and tips. Events are stored in a TimescaleDB hypertable with automatic compression for efficient time-series queries. Events are broadcast in real time via Redis pub/sub to WebSocket clients for overlay alerts and dashboard panels. The module supports paginated queries with filters by type, platform, date range, and full-text search.

Architecture​

Backend​

  • GraphQL (apps/api/src/graphql/events.rs) -- Queries for listing/fetching events with filters. Mutation for emitting test/manual events.
  • Crate (crates/lo-events/src/) -- Core event logic including EventFilter, EventInput, EventRow types, list_events, get_event, insert_event, and broadcast_event functions.
  • Database -- Events are stored in TimescaleDB (platform_event_logs hypertable) with time-based partitioning and compression.
  • Real-time -- Events are broadcast via RedisPubSub using the GqlPubSub wrapper to all WebSocket subscribers for the account.

Frontend​

  • Next.js API proxy routes call GraphQL internally.
  • A shared EventsShell component (apps/web/src/components/events-shell.tsx) renders the live feed with filters by type and platform. It backs three surfaces: the dedicated Events page (/dashboard/events), the compact panel on the Dashboard home, and the /popout/events window (cookie or popout-token auth).
  • Event categories are feature- and permission-gated (ZAF-622 §72). A category's filter chips and its rendered rows appear only when the account holds the feature that category depends on and any RBAC permission its backend guard requires. The rule is the shared EVENT_GROUP_ACCESS map in apps/web/src/lib/event-access.ts, read by both the events feed and the chat event settings so the two never drift. Copyright is the only category with a permission today: its chips and lines show only with feature:copyright_detection and copyright:read (the exact pair the copyright GraphQL/REST guard enforces). The API still returns a disabled category's historical rows (GET /v1/events filters only on feature:events + events:read, never per event type), so the feed drops them client-side in the rendered list, not just from the filter chips — a category the account cannot see never appears.
  • Expired-connection alert. Events are produced by the account's channel connections, so a dead grant makes the feed stop silently. When a channel connection is flagged reconnect_required, the header's per-platform pill shows an icon-only warning marker and a red entry appears in the connection notices directly under the topbar, carrying a Reconnect button that starts that platform's OAuth flow in place. It is shown only to viewers holding connections:create (Owner / Administrator among the default roles). See Connections → Connection notices.
  • User info card. Clicking a username opens EventUserInfoModal (apps/web/src/components/user-info-modal.tsx), which resolves the event's user via the shared resolveEventUser and renders the same full-page user info card the chat uses (see Chat and User Info Card) - without the chat-only Highlight/Hide filter buttons. Moderation from the card goes through /api/chat/moderate, gated by the session's own permission subset (also in the events popout).
  • Overlay alerts consume events via WebSocket for real-time display.
  • Localized subject names show the login (ZAF-1452). A row's subject renders as display (login) when the platform display name is genuinely localized - the reported Twitch case is こさむん (pocom_2026), where the display name is non-Latin but the login stays the ASCII handle. The decision is the shared three-condition AND rule in @lumio/ui's formatPlatformName: the display name differs from the login by more than case, and carries a non-Latin letter, and the source platform is one where the display name is a freely-editable field over an immutable login — Twitch only today (founder ruling ZAF-1452, 2026-09-27). So an ordinary Latin follower like Cruex stays un-bracketed, umlaut and accent names (Ömer, Renée) are not bracketed, and — crucially — the same localized values render as こさむん (no bracket) on a YouTube event, where "channel title ≠ handle" is the normal case and bracketing would fire on every non-Latin channel. The events feed (event-row.tsx) and the inline chat event line (chat-event-row.tsx) read one shared resolver (apps/web/src/lib/event-user.ts → resolveEventDisplayName, which returns both the display name and the login) and thread event.platform into the helper, so the two surfaces can never drift and an unknown platform is fail-closed (no bracket).
  • First-time chatter & watch streak (Twitch). Both appear as rows in the feed and are toggleable in the type filter. A twitch:first_message row shows the chatter and their first message. A twitch:watch_streak row shows the viewer, a flame streak-count badge, and - resub-style - their attached chat message when they left one (otherwise Twitch's "watched N streams in a row" line). The first-time-chatter signal additionally tags the matching inline row in Multichat.

API​

GraphQL Queries​

Every account-facing event operation additionally requires the feature:events feature flag. On GraphQL the guard is FeatureGuard("feature:events") combined with the permission below; the four REST handlers call the same require_feature after their permission check.

QueryPermissionDescription
events(filter: EventFilterInput)feature:events + events:readPaginated event list with filters for types, platform, search, date range. Returns PaginatedEventResult!. Without from it reads the 30 days before to (or now). total is capped at 10 000: totalCapped: true means more events match, and pages covers the capped total. The count runs only when total, totalCapped or pages is selected.
event(id: UUID!)feature:events + events:readGet a single event by ID (account-scoped)

GraphQL Mutations​

MutationPermissionDescription
emitEvent(input: EmitEventInput!)feature:events + events:createEmit a broadcast-only test event without persisting it. Returns EmitEventResult!.
emitBotEvent(accountId: ID!, eventType: String!, platform: String!, username: String!, raw: JSON!)System key with bots:internalBot-worker ingest path with an explicit account id. Broadcast-only - like a test event, it is not persisted.

REST Endpoints​

All paths live under /v1. Bodies are snake_case.

MethodPathPermissionDescription
GET/v1/eventsfeature:events + events:readPaginated event list (accepts the same filters as EventFilterInput). Same 30-day default window and 10 000 cap as GraphQL; _meta.total_capped: true when more events match
GET/v1/events/{id}feature:events + events:readFetch a single event by ID (account-scoped)
POST/v1/events/emitfeature:events + events:createEmit a manual event (insert + broadcast)
POST/v1/events/testfeature:events + events:createEmit a test event - broadcast only, never persisted

The dashboard's Test Event side sheet and /popout/test-events send through the broadcast-only test path. The popout's delegated token must contain events:create; without it the catalog is replaced by a permission notice. Both surfaces list only the event sources the session could receive: the category's feature and RBAC rule (the same map that gates the events feed) and, for Twitch, YouTube, Kick, Spotify and StreamElements, a connected source. An unreadable connection list keeps those sources listed. This is display filtering only; the send path itself checks feature:events + events:create. Tests appear in the account's overlays and dashboard activity with a TEST badge, but the Events page and Events popout filter them out. They do not enter stored history, metrics or milestones. POST /v1/events/emit is the separate persisted manual-event route.

WebSocket​

ChannelGateFeature flag
events:{account_id}events:read on the account in the channel keynone

Subscribing streams every event inserted or broadcast for that account in real time - the same payloads the events query returns. The gate is resolved by channel_gate_for / channel_feature_for in crates/lo-websocket/src/gate.rs; events carries no feature flag, so an account without feature:events can still stream (the REST/GraphQL read paths remain flag-gated).

Filter Options (EventFilterInput)​

FieldTypeDescription
types[String!]Filter by event types (e.g., ["twitch:follower", "twitch:subscribe"])
platformStringFilter by platform (comma-separated for multiple)
userIdStringFilter by platform user ID (matches raw->>'user_id')
searchStringCase-insensitive text search in raw JSON data
fromStringISO 8601 timestamp -- only events after this time
toStringISO 8601 timestamp -- only events before this time
pageIntPage number (1-based, default 1)
limitIntItems per page (default 25, max 100)

Permissions​

PermissionDescription
events:readRead/list events. Enforced on events, event, GET /v1/events, GET /v1/events/{id}.
events:createCreate events (test events, emit). Enforced on emitEvent, POST /v1/events/emit, POST /v1/events/test.
events:userinfoView user cards/profiles from event entries. Enforced client-side by the EventsShell component (<Gate permission="events:userinfo">); the underlying profile lookup is guarded by chat:userinfo.

There is no events:delete permission: no manual event-deletion surface exists on either protocol. Events are aged out automatically by the per-plan retention sweep (see Database below), not on demand - so the former events:delete account permission was removed in ZAF-1094 (it gated nothing).

Database​

TableDatabaseDescription
platform_event_logsTimescaleDBHypertable, segmented by account_id, compressed after 7 days. Columns: id (UUID), account_id, type (the platform:action string - GraphQL exposes it as eventType), platform, external_id, raw (JSONB), created_at. Primary key (id, created_at). A partial unique index on (account_id, external_id, created_at) where external_id IS NOT NULL makes webhook re-delivery idempotent.

Retention​

Events are retained on the same per-plan window as chat messages - the privacy policy groups "chat messages and events" under one rolling, plan-dependent window. A background sweep (the chat/event retention worker, gated by chat.retention_enforcement_enabled) hard-deletes each account's platform_event_logs rows older than its effective plans.chat_retention_days window (a per-account account_limits.chat_retention_days override takes precedence). Because the window is per-plan, it is enforced by a per-account DELETE sweep rather than a table-wide TimescaleDB retention policy. An account whose effective window is 0 keeps its events indefinitely. Events belong to the channel: the channel's instructed data-subject erasure (POST /v1/admin/privacy/chat/erase and the YouTube member erasure, see Chat › Erasure) does not delete them, so they leave only through this window or channel dissolution (founder ruling ZAF-2626).

Event Types​

Events follow the platform:action naming convention. There are two distinct lists and they are not identical - quote the right one for your use case.

Extension-subscribable catalogue​

LUMIO_EVENT_TYPES in packages/protocol/src/events.ts is the closed enum an extension may pass to useLumioEvent(). It is validated by a Zod schema, so a type outside this list is rejected at the SDK boundary:

  • Twitch: twitch:follower, twitch:subscribe, twitch:gift_sub, twitch:resub, twitch:cheer, twitch:raid, twitch:reward, twitch:reward_status, twitch:reward_add, twitch:reward_update, twitch:reward_remove, twitch:hype_train, twitch:hype_train_end, twitch:poll, twitch:poll_end, twitch:prediction, twitch:prediction_lock, twitch:prediction_end, twitch:goal, twitch:goal_end, twitch:ad_break, twitch:ban, twitch:unban, twitch:stream_online, twitch:stream_offline, twitch:stream_update
  • YouTube: youtube:subscribe, youtube:member, youtube:superchat, youtube:supersticker, youtube:gift_membership, youtube:gift_membership_received, youtube:poll, youtube:stream_online, youtube:stream_offline
  • Kick: kick:follower, kick:subscribe, kick:resub, kick:gift, kick:kicks, kick:reward, kick:reward_status, kick:stream_online, kick:stream_offline, kick:stream_update
  • Spotify: spotify:track, spotify:play, spotify:pause, spotify:skip, spotify:volume, spotify:queue_add, spotify:device, spotify:playlist_add, spotify:playlist_remove, spotify:playlist_create, spotify:playlist_edit, spotify:playlist_delete
  • StreamElements: streamelements:tip
  • Chat (cross-platform): chat:message (fires for all platforms)

Reward lifecycle and redemption status (raw)​

Lumio typeSourceStoredraw
twitch:rewardEventSub channel.channel_points_custom_reward_redemption.addyesid, user_id, user_login, user_name, user_input, status (unfulfilled), redeemed_at, reward { id, title, cost, prompt }
twitch:reward_statusEventSub channel.channel_points_custom_reward_redemption.updateyesthe twitch:reward shape with status fulfilled or canceled
twitch:reward_add / twitch:reward_update / twitch:reward_removeEventSub channel.channel_points_custom_reward.add / .update / .removeno (broadcast_only)the Twitch reward: id, title, cost, prompt, is_enabled, is_paused, is_in_stock, is_user_input_required, should_redemptions_skip_request_queue, max_per_stream, max_per_user_per_stream, global_cooldown, cooldown_expires_at, redemptions_redeemed_current_stream, background_color, image, default_image, broadcaster_user_*. .remove carries the reward as it was.

The three lifecycle types are configuration changes, not audience events: they reach extensions, overlays and automations live on events:{account_id} and are never written to the event history, so the dashboard feed and chat (which drop broadcast_only frames) do not show them. The Twitch subscriptions need channel:read:redemptions or channel:manage:redemptions; Lumio already requests both for every Twitch channel connection, so no reconnect is needed. Kick sends no reward lifecycle webhook, so there is no kick:reward_add/update/remove. Lumio's own rewards module emits no events; changes made there are not platform rewards.

Kick payloads (raw)​

The Kick webhook receiver normalizes every delivery before it is stored and broadcast (lo_kick_api::normalize_event). The original Kick payload stays complete, and Lumio's canonical fields are added at the top level, so Kick events render and filter through the same fields as Twitch:

Lumio typeKick eventTop-level fields added
every type with an actor—user_id (string), user_login (channel slug), user_name, avatarUrl (omitted when Kick sends none), is_anonymous
kick:followerchannel.followedonly the actor fields
kick:subscribe / kick:resubchannel.subscription.new / .renewalcumulative_months, expires_at
kick:giftchannel.subscription.giftsgiftCount, total, is_gift, giftRecipients; recipient_user_{id,login,name} for exactly one giftee; gifter_is_anonymous for an anonymous gifter
kick:kickskicks.giftedkicks (amount, a counter like Twitch bits, not money), message, kick_gift_name, kick_gift_type, kick_gift_tier, kick_pinned_time_seconds; for a known gift also kick_gift_id, kick_gift_image and kick_gift_color (see below)
kick:reward / kick:reward_statuschannel.reward.redemption.updatedid, status, user_input, redeemed_at, reward { id, title, cost, prompt }. The first delivery of a redemption id is kick:reward, every later status change is kick:reward_status.
kick:stream_online / kick:stream_offlinelivestream.status.updatednone for kick:stream_online (title, started_at, ended_at are already top level); kick:stream_offline adds duration_seconds, and started_at when Kick sent none (see Stream offline payloads)
kick:stream_updatelivestream.metadata.updatedtitle, category_id, category_name, language, has_mature_content, title_changed, category_changed, language_changed, and old_title / old_category_name / old_language for a changed field (see below)

Mapping, deduplication and live-status handling: Webhooks → Kick.

KICKs gift graphics. Kick's webhook names the gift but carries no gift id and no image, and Kick has no image API. lo_kick_api::kicks_gifts holds a static table of Kick's 10 gifts; normalize_event matches gift.name against it (case-insensitive) and adds:

FieldExampleMeaning
kick_gift_id"rage_quit"Kick's gift id
kick_gift_image{"static": "https://files.kick.com/kicks/gifts/rage-quit-static.webp", "animated": "https://files.kick.com/kicks/gifts/rage-quit.webp"}Gift sticker. animated is null for the four BASIC gifts, which have a static image only
kick_gift_color"#45ADFC"Kick's accent colour for the gift

A gift name the table does not know adds none of the three fields; the event then renders as before (coin icon). An #[ignore] smoke test (cargo test -p lumio-api --test kick_gifts_smoke -- --ignored) compares the table with Kick's catalog.

Twitch cheer payloads (twitch:cheer)​

channel.cheer delivers message as plain text without fragments. The EventSub worker matches every word of the form <prefix><amount> (case-insensitive, only prefixes the channel's cheermote catalog knows) and adds:

FieldMeaning
emotesOne entry per cheermote word in message, in the chat cheermote entry shape (kind: "cheermote"). Absent when nothing matched
cheermoteThe image set for the total bits: the highest global Cheer tier with min_bits <= bits, as {prefix, bits, min_bits, color, urls{dark,light}{animated,static}}; a theme also carries the scale-4 animated_hd / static_hd pair when Twitch has both (see Twitch cheermotes)

Both fields are render data and fail-open: when the catalog cannot be loaded they are absent and the event is stored unchanged.

Twitch Shared Chat session events​

The four Twitch-only session types are twitch:shared_chat_begin, twitch:shared_chat_join, twitch:shared_chat_leave and twitch:shared_chat_end. They appear at their event time in both chat and the Events feed. Start shows the host-first channel stack, the channel sum and partner details; join shows the joining channel; leave shows its duration and message count; end shows the duration, peak sum and every partner seen in the session. Missing Helix enrichment omits only that value. A join found during reconciliation is marked Detected later. Repeated pushes do not create duplicate rows.

The four switches live in the existing Twitch type group in chat, the Events feed and popout settings and are on by default. The feed also offers a Shared Chat filter chip. Chat shows session rows only in All and Twitch, with start/end details collapsed; the feed expands those details. These events do not open chat alerts. They are separate from partner-channel subs, Bits and raids described below.

Twitch Shared Chat partner events​

During a Twitch Shared Chat session, events that happen in a partner channel of the session are stored and broadcast as the same event types as the own channel's, labelled with the channel they happened in:

Partner eventTwitch sourceLumio event type
Sub, resubchannel.chat.notification shared_chat_sub / shared_chat_resubtwitch:subscribe / twitch:resub
Gift sub, community giftshared_chat_sub_gift / shared_chat_community_sub_gifttwitch:gift (bundled like an own community gift)
Gift / Prime upgradeshared_chat_gift_paid_upgrade / shared_chat_prime_paid_upgradetwitch:gift_upgrade / twitch:prime_upgrade
Pay it forwardshared_chat_pay_it_forwardtwitch:pay_it_forward
Raid into the partnershared_chat_raidtwitch:raid (raider in user_* / from_broadcaster_user_*)
Bitsthe channel.chat.message cheer field of a partner-channel message (channel.cheer fires only for the own channel)twitch:cheer (external_id chat_cheer:<message_id>)

raw carries the same flattened fields as the own-channel event plus:

FieldMeaning
is_shared_chattrue
source_channelThe partner channel, {platform_channel_id, login, display_name, avatar_url} - exactly the chat-message source_channel shape
notice_typeThe base type (sub_gift, not shared_chat_sub_gift), so gift bundling and the bundled-recipient history filter treat it like an own notice
shared_chat_notice_typeTwitch's original shared_chat_* notice type (notices only)

A notice counts as a partner event only when its notice_type starts with shared_chat_ and source_broadcaster_user_id is set and differs from the receiving channel. An own cheer derives nothing from the chat message - it stays the single channel.cheer event. Shared-chat announcements, like own announcements, produce no event.

Some documented channel.chat.notification notice types produce no event, whether they come from the own channel or as a shared_chat_* twin: announcements, unraids, charity donations, modiversaries and gifted Drops summaries (gifted_drops_summary; its payload is only a recipient_count). Watch streaks reach Lumio through the IRC path as twitch:watch_streak. Only a notice type Twitch has not documented is logged for capture (see Logging).

Partner events are displayed only. They are not the own channel's revenue or activity, so they never count toward stream history and summary stats, never fire automation event triggers, and never touch the own channel's gift-sub straggler safeguard. Channel-points redemptions in a partner channel are not delivered by Twitch; see Channel-points messages.

In the dashboard and popouts a partner event (a row whose raw carries is_shared_chat and a named source_channel) leads with a channel chip: the square origin avatar plus the channel name, truncated at about 12em, with the full name in the tooltip and accessible name. It renders on the chat event line, in the events feed (full and compact, and the dashboard home activity) and, as name next to the avatar, in the stream report's announcement list, independent of the label, badge and avatar settings. Shared-chat messages keep the bare origin avatar. Twitch does not deliver follows of partner channels, so there is no partner follow row.

The display setting Events from Shared Chat partners (showSharedChatPartnerEvents in the popout-settings:chat and popout-settings:events blobs, absent = on) hides every event with raw.is_shared_chat: true in the chat event lines and the events feed. The four session types twitch:shared_chat_begin|join|leave|end are always shown. It filters at display time only: buffered events reappear when it is switched back on, and history, counts and alerts are unaffected.

Stream update payloads (twitch:stream_update, kick:stream_update)​

Both platforms report a channel-metadata change as the new state only. Lumio diffs it against the last known state (the baseline) and adds what changed, so the Events page and the chat inline event can show it:

FieldTwitchKickMeaning
title, category_nameyesyesNew title / category (game)
languageyesyesNew broadcast language as the platform sends it (Twitch ISO 639-1 or other, Kick e.g. en)
title_changed, category_changed, language_changedyesyesWhether that field changed
old_title, old_category_name, old_languageyesyesPrevious value — only present when that field changed
tagsyes—Current channel tags (Helix GET /channels)
tags_changedyes—Whether the tags changed (compared as a set — reordering alone is no change)
old_tagsyes—Previous tags — only present when they changed

Rules (apps/api/src/services/stream_update_diff.rs):

  • No empty events. An update where every *_changed flag is false (a redelivery, a change Lumio does not diff such as Kick's mature flag or Twitch's content-classification labels) is neither stored nor broadcast.
  • First observation is not a change. A field is compared only when the baseline already knows it; the first observation records the baseline and emits nothing.
  • Twitch baseline is seeded from Helix GET /channels (title, category, language, tags) every time the EventSub session (re)connects, so the first change after an API restart is reported. If that seed fails, the previous baseline stays.
  • Twitch tags are not part of the channel.update payload; Lumio reads them from Helix GET /channels when channel.update arrives. If that call fails, the tags* fields are omitted — the title/category/language diff is still emitted. Known limitation: Twitch fires channel.update only for a change to the category, title, content classification labels or broadcast language — a tag-only edit fires nothing, so it is reported together with the next change that does fire.
  • Kick baseline lives in Postgres (platform_channel_metadata, one row per account and platform), independent of the live status: Kick lets a streamer edit title and category while offline, and those edits are reported too. The baseline survives restarts; the start reconcile seeds title and category for a new connection (never overwriting an existing baseline), the first webhook fills the language. Kick has no tags.

Stream offline payloads (*:stream_offline)​

twitch:stream_offline, youtube:stream_offline and kick:stream_offline carry how long the stream ran. The two fields are added to the platform's raw payload; no other field changes:

FieldTypeMeaning
started_atstring, RFC 3339 (UTC)When the stream started. For Kick it is Kick's own value, kept exactly as Kick sent it.
duration_secondsinteger ≥ 0ended − started, in whole seconds. For Kick, ended is Kick's ended_at; for Twitch and YouTube it is the moment Lumio processed the offline transition.

Both or neither. When the start is unknown, or the computed duration would be negative, both fields are absent - never null and never 0. (For Kick, a started_at Kick sent itself stays even then; only duration_seconds is absent.)

Where the start comes from:

  • Twitch / YouTube - the channel_status.started_at of the platform's online rows (MIN over them), read before the offline write clears it. Twitch sets it from EventSub stream.online started_at and, when the API starts mid-stream, from Helix GET /streams started_at, so a restart keeps the duration. YouTube discovery (InnerTube, no Data API quota) has no start time; Lumio stores the moment it first sees the broadcast live, so the YouTube duration can be up to one discovery poll short. The stored start does not move while the broadcast stays online (a title change does not reset it). After an API downtime in which a YouTube stream ended, the startup correction emits the offline event with the duration up to the correction.
  • Kick - Kick's own started_at / ended_at from the livestream.status.updated webhook. If Kick's started_at is missing or unusable (Kick's zero timestamp), the start captured in channel_status fills it; if ended_at is missing, the time of receipt is the end.

The chat line and the events feed show the duration as a clock plaque (3h 12m).

Types the platform workers actually emit​

The type column on a platform_event_logs row is a free-form VARCHAR(100), so the ingest workers write several types that are not in the extension catalogue above:

SourceAdditional emitted types
Twitch EventSub (workers/twitch_eventsub.rs)twitch:gift (sub gifts - the worker's name for what the extension catalogue calls twitch:gift_sub), twitch:gift_upgrade, twitch:prime_upgrade, twitch:pay_it_forward, twitch:bits_badge, twitch:poll_delete, twitch:raid_out, twitch:raid_done, twitch:unraid, twitch:mod_added, twitch:mod_removed, twitch:vip_added, twitch:vip_removed
Chat pin worker / Kick chat sockettwitch:pin_added, twitch:pin_updated, twitch:pin_removed, kick:pin_added, kick:pin_removed
Twitch IRC-tags listener (workers/twitch_irc_tags.rs)twitch:first_message (first-time chatter - PRIVMSG first-msg=1), twitch:watch_streak (USERNOTICE viewermilestone / watch-streak; see payload)
Discord bot (apps/discord-bot)discord:member_join, discord:member_leave, discord:boost
7TV EventAPI (workers/seventv_events.rs)7tv:emote_added, 7tv:emote_removed, 7tv:emote_renamed, 7tv:emote_set_switched

7TV emote-set changes (raw)​

The 7TV listener watches active emote sets belonging to connected Twitch, Kick, and YouTube channels. Each change is stored once per account with platform = "7tv" and an external_id that deduplicates replayed dispatches. These types are stream events in platform_event_logs, not extension-subscribable types in LUMIO_EVENT_TYPES. They use the existing events:{account_id} WebSocket channel and its events:read guard; account-facing reads also require feature:events. They are not audit events.

Typeraw fields
7tv:emote_added, 7tv:emote_removed, 7tv:emote_renamedemote_id, emote_name, animated, emote_set_id, emote_set_name, actor, platforms; rename also has old_name
7tv:emote_set_switchedNew emote_set_id, emote_set_name, emote_count, actor, platforms

actor contains the 7TV editor's display_name and login when known, without an avatar or linked Lumio user. platforms lists the connected chat platforms whose channels use the set. A 7TV row appears under All and those platform tabs in chat, including server-merged history. Without platforms it appears only under All. On the Events page, the 7TV platform filter matches these rows; a Twitch, Kick, or YouTube Events-page filter does not. The Events-page type filters include all four types. The 7TV listener guide explains the subscription and cache behaviour.

History keeps each change separately and follows the account's normal event retention window. Changes made before the listener ran, or during a complete listener outage, are not backfilled by 7TV.

Twitch watch-streak payload​

The raw payload of a twitch:watch_streak event:

KeyTypeNotes
user_id, user_login, user_namestringThe viewer who shared the streak.
streak_countnumberConsecutive streams watched (msg-param-value).
categorystringAlways watch-streak.
message_idstringThe IRC id tag.
message_textstring | nullThe viewer's optional shared message.
emotesarray | nullTwitch emotes in the attached message, in the same shape as a chat message.
system_messagestringTwitch's system text for the notice.
badgesChatBadge[]Same shape as chat messages: set_id, id, info, image_url. Built from the badges / badge-info tags; info holds the badge-info value (the subscriber's cumulative months), "" otherwise. Always present - a viewer without badges gets []. image_url comes from the channel's Twitch badge cache (the one the EventSub worker builds) and is null when no image is known.
colorstring | nullName colour as #RRGGBB; null when the viewer never set one.
avatar_urlstringBest effort, from the profile cache. Omitted when the profile is unknown, has no avatar, or the lookup fails or times out - the event is emitted either way.

Events stored before badges existed have no badges key; consumers that gate on badges (for example moderation actions) treat a missing key as "unknown" and [] as "no badges".

Pin and Twitch role events​

The five pin types are twitch:pin_added, twitch:pin_updated, twitch:pin_removed, kick:pin_added and kick:pin_removed. The platform worker writes a row only on a state transition; the platform_event_logs rows are also the pin history protocol. They follow the account's ordinary event retention. Every pin event has raw.sender; an added event also has raw.pinned_by and, on replacement, raw.replaced_sender. Those people use the shape {platform, user_id, login, display_name, avatar_url, color, badges}. Every event has raw.message_id and starts_at. An added event also has text, emotes and ends_at; raw.sent_at is the message's write time (Twitch stored chat message or Kick socket event), or null when unknown. An update has old_ends_at, new_ends_at and updated_at. A removal has ends_at, ended_at, duration_seconds and reason: unpinned, expired or message_deleted; replacement identifies the old message. Twitch removals observed by polling may have an approximate event time. A viewer's support-tool erasure does not delete these channel-owned event rows. See Stream History for the frozen report and its separate person-data masking rules.

Twitch channel.moderate v2 yields twitch:mod_added, twitch:mod_removed, twitch:vip_added and twitch:vip_removed for the connected channel. Shared Chat partner actions are ignored. Every grant and removal gets its own event, including quick toggles; the actor's action on Twitch creates no Lumio audit event. raw carries the target as top-level user_id, user_login, user_name, avatarUrl and color, and the actor as initiated_by, initiated_by_login and initiated_by_id. Twitch does not expose the Lead Moderator distinction, so the role is always Moderator.

Conversely, twitch:ban and twitch:unban appear in the extension catalogue but are not written to platform_event_logs - Twitch ban/unban is handled on the chat path (see Chat).

Community gift-sub bundling (Twitch)​

A community gift of N subs arrives as N+1 EventSub notifications: one community_sub_gift header plus N individual sub_gift notices that each carry the same community_gift_id. The events panel and popout show only the header row - the individual sub_gift rows are persisted for raw data but hidden by the history filter (a sub_gift row with a community_gift_id). The header line itself is broadcast the moment it is stored, so a bundle always reaches the live feed even if no per-recipient notice ever arrives. The recipient names render from a giftRecipients array patched onto the header - all of them, on every surface (events feed, inline chat event line, and the user card): the list is never shortened to the first few names with a "+N", and never clipped. It wraps and grows the row instead, so a gift of any size names everyone who received one. The header also carries an index-aligned giftRecipientLogins array with each recipient's login/handle, so the client can show name (login) per recipient (Twitch only - YouTube gift memberships have no per-recipient handle, so their bundles stay display-name only).

YouTube login twin (user_login)​

Every InnerTube-sourced YouTube event (youtube:superchat, youtube:supersticker, youtube:member, youtube:gift_membership, youtube:gift_membership_received) carries a user_login field next to displayName, mirroring the Twitch login twin so the events feed resolves the subject's login the same way on both platforms (apps/web/src/lib/event-user.ts). It holds the channel @handle with the @ stripped. This twin still feeds bot variables, automations and moderation (ZAF-1478); it is not shown as a render bracket, because the localized-name bracket is gated to Twitch (ZAF-1452 / ZAF-1549 — see the localized-subject-names note above). YouTube "channel title ≠ handle" is the normal case, so surfacing こさむん (@kosamun) would fire on every non-Latin channel title — exactly the "not on every chatter" the founder rejected. formatPlatformName therefore renders a YouTube subject as the bare display name (こさむん). The @-prefix rendering is retained in the helper (decorateLoginForPlatform) but unreachable while the allowlist is Twitch-only; adding "youtube" to LOCALIZED_DISPLAY_NAME_PLATFORMS would restore it with no other change.

That recipient list is derived from the database, not from in-memory correlation state: every individual sub_gift row is persisted with its community_gift_id regardless of arrival order, and finalisation reads the names back from those rows (lo_events::gift_recipient_names, with the index-aligned logins from lo_events::gift_recipient_logins). The worker keeps only a lightweight in-memory arrival counter per bundle; it patches giftRecipients (and giftRecipientLogins) onto the stored header and re-broadcasts it - reusing the header's id + created_at so the live line and the history row stay one event - when the counter reaches the header's total or when a periodic timer ages the bundle out (so the enrichment fires even on a quiet channel with no further traffic). The bundle tracker outlives WebSocket reconnects. A bundle therefore lands its complete recipient list even when the individual notices arrive before the header, when Twitch under-delivers them, or across a reconnect. If no individual rows are persisted yet when a bundle is finalised, the header is left un-enriched (its line already showed) and a startup backfill (lo_events::backfill_gift_recipients, paginated over the full backlog) repairs it once the rows land.

Internal WebSocket channel messages​

These share the namespace:action shape but are transient pub/sub payloads, not platform_event_logs rows: chat:message, chat:delete, chat:clear_user, chat:moderation_log, chat:user_treatment_update, channel:online, channel:offline, copyright:blocked/:warning/:clear, obs:action/:connection_lost/:low_bitrate/:high_dropped_frames/:recovery, history:session_started/:session_updated, sound:play, overlay:update, widget:update, storage:update, extension:updated.

Data Flow​

  1. Platform adapter detects an event (e.g., Twitch EventSub webhook receives a follow).
  2. Event is inserted into TimescaleDB via lo_events::insert_event().
  3. Event is broadcast via lo_events::broadcast_event() through Redis pub/sub.
  4. WebSocket server forwards the event to all connected clients for the account.
  5. Overlay alerts render the event. The Events page, home event panel, and popout update in real time.
  6. Users can query historical events with filters and pagination via events query.

Motion preferences​

Events follows the user's Animations in Lumio preference. Its settings panel can tighten motion for this view per browser and user/account pair, and ?motion=reduced|off can tighten one popout URL. New event rows fade briefly at Reduced and appear immediately at Off; scrolling is instant at either level. The seasonal animation row affects decoration only. See the Motion developer guide.

Settings export/import (browser-local)​

The events display options (popout-settings:events) and the event filters (lumio:event-filters, which platforms/types are shown) are part of the shared, browser-local settings export/import feature (ZAF-622). A user can save them and re-import them on another device or an OBS source; it is purely client-side (no API/RBAC/audit). Four transports carry the same envelope - a copy-pasteable LS1… code string, a share link (the same code in a URL fragment, so still no server), Copy JSON, and a downloaded file - and the import auto-detects a pasted file, code, or link in one field. The events.filters payload keeps its allPlatforms/allTypes markers so newly shipped event types auto-enable on the next load rather than being silently hidden by an older file. See the full format, transports, section list, exclusions and import semantics in the Multichat feature doc.

Key Files​

PathDescription
apps/api/src/graphql/events.rsGraphQL queries and mutations
apps/api/src/routes/events.rsREST endpoints
crates/lo-events/src/Core events crate - TimescaleDB insert/list plus Redis broadcast and the filter types
apps/api/src/workers/event_relay.rsRelays platform-worker events onto the account's WebSocket channel
packages/protocol/src/events.tsLUMIO_EVENT_TYPES / ACTION_TYPES enums consumed by the extension SDK
apps/web/src/components/events-shell.tsxShared live-feed component behind the Events page, dashboard panel, and popout
apps/web/src/hooks/use-event-filters.tsBrowser-local event-filter state; also exports parseStoredEventFilters/expandEventFilters reused by the settings export/import registry