chat-aggregator

Twitch and YouTube live chat in one overlay for your stream.

View the Project on GitHub iSneeze/chat-aggregator

Chat events API

Live chat from Twitch and YouTube, merged into one stream of JSON events, for programs that want to react to chat: games, bots, sound boards, custom overlays.

Try it without writing code (the dev shell includes websocat):

cargo run -p chat-engine --example run -- --demo --api
websocat ws://127.0.0.1:7878/api/v1/ws

Connecting

query effect
(none) live events only, from the moment you connect
?history=true first the most recent messages (default: up to 20), then live

History is off by default on purpose: a game that reacts to !jump must not replay old commands every time it reconnects. Use ?history=true if you display chat.

The server pings idle connections every 30 seconds (WebSocket libraries answer automatically) and sends a Close frame with code 1001 when it shuts down or the API is switched off (the reason says which). While it’s switched off, connecting fails with HTTP 403 Forbidden. Reconnect when the connection drops: nothing is lost while you’re connected, but events that happen while you’re disconnected are not queued.

Events

Every event is a JSON object with a type field.

message

A chat message or a platform event (donation, sub, raid, …).

{
  "type": "message",
  "id": "LCC.ExampleMessageId0001",
  "platform": "youtube",
  "author": {
    "id": "UCxxxxxxxxxxxxxxxxxxxxxx",
    "name": "Viewer42",
    "color": null,
    "badges": ["member"],
    "avatar_url": "https://yt3.ggpht.com/…"
  },
  "text": ":_hypeWave:",
  "emotes": [{ "code": ":_hypeWave:", "url": "https://yt3.ggpht.com/…" }],
  "timestamp": "2026-09-25T18:40:44.516Z",
  "kind": { "type": "emote_only" }
}
field meaning
id platform message id; delete events refer to it
platform "twitch", "youtube" (later "rplay")
author id (platform user id), name, color (#rrggbb, Twitch only, else null), badges (e.g. moderator, subscriber, member, vip), avatar_url (YouTube only, else null)
text what the user typed; may be empty (e.g. a raid). Event descriptions are in kind
emotes emotes used in text, each listed once; in text they appear exactly as code
timestamp when it was sent, RFC 3339 in UTC
kind what kind of message, see below

kind.type and its extra fields:

kind.type fields examples
text   normal chat
emote_only   only emotes (and spaces)
donation amount, tier Twitch bits, YouTube Super Chat: "100 bits", "€5.00"
special image_url, amount, info, tier (each may be null) YouTube Super Sticker (info = sticker description), YouTube gift
membership info, months new sub/member, resub, milestone; months = how long they’ve been a member (resub, milestone), null for a new one
gift count gifted subs (Twitch) or memberships (YouTube)
notice info raid, announcement, watch streak, new chatter, … (Twitch events without a kind of their own)

amount is a display string as the platform formats it (currency and all); it’s meant for showing, not for arithmetic.

tier says how big the amount is on the platform’s own scale, from 1 (the smallest) up, or null where the platform has none (YouTube jewel gifts). Compare tiers only within one platform:

platform tiers
youtube YouTube’s Super Chat/Sticker tier, as YouTube reports it, whatever the currency: 1 blue, 2 cyan, 3 teal, 4 yellow, 5 orange, 6 magenta, 7 red (its colour in YouTube’s chat)
twitch the cheer tier of the bits: 1 (1–99), 2 (100–999), 3 (1,000–4,999), 4 (5,000–9,999), 5 (10,000 and more)

delete, clear_user, clear_all

Moderation: remove messages you already showed.

{ "type": "delete",     "platform": "twitch",  "message_id": "7c4e9a61-…" }
{ "type": "clear_user", "platform": "youtube", "user_id": "UCxxxxxxxx" }
{ "type": "clear_all",  "platform": "twitch" }

Always match on the platform too: ids of different platforms are unrelated.

lagged

{ "type": "lagged", "missed": 12 }

Your client read too slowly and fell behind the server’s buffer (256 events); missed events were dropped for you. Live events continue right after it. Only happens if your program blocks while handling events.

Compatibility rules

Security

The server only listens on 127.0.0.1, so other machines can’t connect. On the same machine, the API accepts:

Browsers let any page open a WebSocket to localhost, but they always send the page’s Origin, and pages can’t fake it. Without a check, any website open in the streamer’s browser could read their chat, including members-only chat, and learn from it which channel is theirs. So when a web site connects for the first time, the app asks the streamer:

Let https://game.example read your chat? [Allow] [Deny]

A page opened straight from disk in a desktop browser is refused without asking: browsers send Origin: null for it, and so can any website (from a sandboxed frame), so the question couldn’t say who is asking. Open it through OBS, or serve it from localhost.

The overlay and the API also refuse any request whose Host isn’t 127.0.0.1, localhost or [::1]. That stops “DNS rebinding”, where a site points its own domain at 127.0.0.1 to read the server’s answers.

Chat is untrusted input. Every text field (text, author.name, amounts, descriptions, emote codes) is exactly what someone typed or sent, unescaped. If you show it in a web page or an OBS browser source, insert it as text (element.textContent = …), never as HTML (innerHTML, insertAdjacentHTML, template strings put into the page). An overlay that did exactly that let chatters run code on streamers’ PCs through OBS (CVE-2024-7971): OBS runs its browser sources without Chromium’s sandbox. The same goes for URLs (url, avatar_url): use them as image sources only.

Example clients

JavaScript (browser, Node 22+, Deno, Bun)

function connect() {
  const ws = new WebSocket("ws://127.0.0.1:7878/api/v1/ws");
  ws.onmessage = (frame) => {
    const event = JSON.parse(frame.data);
    switch (event.type) {
      case "message":
        console.log(`[${event.platform}] ${event.author.name}: ${event.text}`);
        if (event.kind.type === "donation") console.log(`  donated ${event.kind.amount}!`);
        break;
      case "delete":
        console.log(`remove message ${event.message_id}`);
        break;
      case "lagged":
        console.warn(`missed ${event.missed} events`);
        break;
      // other types: ignore
    }
  };
  ws.onclose = () => setTimeout(connect, 2000); // reconnect
}
connect();

Python (pip install websockets)

import asyncio, json
import websockets

async def main():
    # Iterating over connect() reconnects automatically when the connection drops.
    async for ws in websockets.connect("ws://127.0.0.1:7878/api/v1/ws"):
        try:
            async for frame in ws:
                event = json.loads(frame)
                if event["type"] == "message" and event["text"].startswith("!jump"):
                    print(f"{event['author']['name']} wants to jump!")
        except websockets.ConnectionClosed:
            continue

asyncio.run(main())