Working name: CrossChat. A browser extension that replaces YouTube’s live chat with a chat panel sourced from another streaming platform (Twitch first, Kick later), rendered with full third-party emote support (7TV, BTTV, FFZ) so the viewing experience matches what native Twitch/Kick users see.
Status: Implemented for Twitch — anonymous read, 7TV/BTTV/FFZ emotes, and authenticated send. Kick and YouTube-as-target are deferred but interface-ready. This document describes the architecture as built and is the reference for how the application is structured. Last updated: 2026-06-29
An existing extension (“Twitch Chat for YouTube”) embeds Twitch chat onto YouTube live pages
by injecting an <iframe src="https://www.twitch.tv/embed/.../chat"> into YouTube’s
ytd-live-chat-frame container. This works, but the iframe is Twitch’s own cross-origin
document, and locally-installed emote extensions (7TV, BTTV, FFZ) do not inject into it.
The result: the user sees raw emote text while everyone else sees emote images.
This cannot be patched onto the iframe approach — it is structural. The fix is to stop embedding Twitch’s chat UI and render our own chat panel, over which we have full control of message rendering and can therefore composite in third-party emotes ourselves.
| Decision | Rationale |
|---|---|
| Render our own chat panel instead of embedding an iframe | The iframe is the root cause of the emote gap. Self-rendering is the only way to composite 7TV/BTTV/FFZ. |
| Source/Sink abstraction from day one | Lets Twitch be v1 while keeping Kick (and a YouTube target) addable without rewrites. Designing against the shape of two APIs keeps the abstraction from being secretly Twitch-shaped. |
| Anonymous IRC for read, OAuth only for send | Reading should need no account — most viewers lurk. Anonymous IRC-over-WebSocket delivers full chat with zero auth, so the login cost falls only on users who actually want to talk. Behind the ChatSource/ChatSink interfaces, an authenticated EventSub read can replace IRC later without touching the UI. |
| Sockets and tokens owned by the background service worker | Content scripts are the wrong place for a long-lived socket or an access token. The worker owns connection lifecycle, identity and tokens; the content script is a thin renderer. |
| Twitch first, Kick second, YouTube-source last/never | Ranked purely by integration friendliness. Twitch wants to be read; Kick has an official API; YouTube read is polling + quota-capped and doesn’t scale to a published extension. |
| Markdown design doc in-repo | Versioned with code, directly readable by Claude Code. |
┌─────────────────────────────────────────────────────────────────┐
│ YouTube live page (content script context) │
│ │
│ ┌────────────────────┐ injects/sizes ┌──────────────┐ │
│ │ YouTube DOM Adapter │ ───────────────────────▶│ Chat Panel UI │ │
│ │ - channel handle │ │ - message │ │
│ │ - chat container │ │ list │ │
│ │ - video height │ │ - input box │ │
│ │ - dark mode │ │ - emote │ │
│ └────────────────────┘ │ picker │ │
│ │ └──────┬───────┘ │
│ │ chrome.runtime messaging (port) │ │
└────────────┼───────────────────────────────────────────-─┼─────────┘
│ │
┌────────────▼──────────────────────────────────────────---▼─────────┐
│ Background Service Worker │
│ │
│ ┌──────────────┐ ┌─────────────────┐ ┌─────────────────────┐ │
│ │ Auth Manager │ │ Chat Engine │ │ Emote Registry │ │
│ │ - OAuth flow │ │ (Source/Sink) │ │ - Twitch native │ │
│ │ - token store│──▶│ - TwitchSource │◀──│ - 7TV (global+chan)│ │
│ │ - implicit │ │ - TwitchSink │ │ - BTTV (global+chan)│ │
│ └──────────────┘ │ - [KickSource] │ │ - FFZ (global+chan)│ │
│ │ - [KickSink] │ │ merged name→img map │ │
│ └─────────────────┘ └─────────────────────┘ │
└────────────────────────────────────────────────────────────────────┘
The content script never holds tokens or sockets. It renders and forwards user input. The worker resolves identities, owns the live connection, normalizes inbound messages, applies the emote registry, and pushes render-ready message objects to the panel.
A platform-agnostic inbound stream of normalized messages.
interface ChatSource {
platform: "twitch" | "kick"; // (youtube deferred)
connect(channel: ResolvedChannel): Promise<void>;
disconnect(): void;
onMessage(cb: (msg: NormalizedMessage) => void): void;
onState(cb: (s: ConnectionState) => void): void;
}
A platform-agnostic outbound path.
interface ChatSink {
platform: "twitch" | "kick" | "youtube"; // youtube allowed as TARGET only
send(channel: ResolvedChannel, text: string): Promise<SendResult>;
capabilities(): { canSend: boolean; rateLimitHint?: number };
}
The single internal shape every source must produce. Rendering and emote compositing operate only on this, never on platform-specific payloads.
interface NormalizedMessage {
id: string;
author: { displayName: string; color?: string; badges: Badge[] };
fragments: Fragment[]; // text + platform-native emotes, pre-split by the source
timestamp: number;
platform: string;
}
type Fragment =
| { type: "text"; value: string }
| { type: "emote"; name: string; url: string; provider: "twitch"|"7tv"|"bttv"|"ffz" };
Important: the source is responsible only for splitting out platform-native emotes
(e.g. Twitch emotes, which arrive identified in the event payload). The Emote Registry
(§5) does a second pass over remaining text fragments to substitute 7TV/BTTV/FFZ emotes,
since those are client-side overlays not present in the wire payload.
interface ResolvedChannel {
platform: string;
login: string; // e.g. twitch username
userId: string; // numeric/string platform ID — needed for emote sets (and send/EventSub)
displayName?: string;
}
The differentiator. Builds a merged name → emote lookup per channel.
emotes tag today, the
EventSub payload later); the source emits them as emote fragments directly. No registry
lookup needed.userId for the channel with no auth via FFZ’s room endpoint (Helix /users is used
only on the send path, where a token is available). An empty userId still allows IRC read;
only the per-channel emote sets fail to load.userId.Map<emoteName, {url, provider}>. Define precedence on name collisions
(suggested: channel-specific over global; provider order configurable, default 7TV > BTTV > FFZ).For each text fragment in a NormalizedMessage, tokenize on whitespace, and for each token
that exactly matches (case-sensitive) a registry key, replace it with an emote fragment.
Emotes are whole-word matches — do not substitute inside larger words.
Note: exact, case-sensitive whole-word matching mirrors how the real extensions behave and how Twitch itself treats emote names on send.
(platform, userId), refresh on reconnect or TTL.justinfan<random>, JOIN the
channel, and parse tagged PRIVMSGs into NormalizedMessage, splitting native Twitch emotes out
of the IRCv3 emotes tag. Sits behind ChatSource, so an authenticated EventSub read can
replace it later without touching the UI. The socket lives in the service worker (MV3 keeps the
worker alive while a WebSocket is open).chrome.identity.launchWebAuthFlow.
Twitch does not offer auth-code + PKCE for public clients (its token exchange requires a client
secret, which an extension cannot ship), so implicit is the only no-secret browser path. Scope:
user:write:chat only. Reading needs no token, so logged-out users keep the full read path.POST https://api.twitch.tv/helix/chat/messages with broadcaster_id, sender_id
(the logged-in user) and message. Emotes are sent as their case-sensitive names in plain
text — there is no special emote payload. This is why sending “just works”: every platform’s
client-side emote extension beautifies the same plain words on the receiving end. Sent messages
echo back through the IRC read path and render like any other.userId comes from FFZ’s room endpoint (no auth). On send, the
numeric broadcaster_id is resolved/confirmed via Helix /users?login= using the user token./oauth2/validate; on expiry or a 401 from Helix it is dropped and the panel prompts a
fresh login. Device Code Flow is the no-secret, refresh-capable alternative if silent renewal
later proves worth its clunkier “enter a code” UX.NOTE for the implementer: the anonymous IRC read, FFZ-based user-id resolution and anonymous GraphQL badges are deliberate no-auth shortcuts; verify their (undocumented, churn-prone) shapes against current behavior when touching them. They sit behind the source/sink/registry interfaces so an official EventSub/Helix read can drop in later without reworking the engine or panel.
ChatSource/ChatSink the same way Twitch does.liveChatMessages.list
is polling (laggy, not streaming) and quota-metered (~10k units/day default, exhausted fast
by live-chat polling). Does not scale to a published, multi-user extension.liveChatMessages.insert requires Google
OAuth + YouTube scope + write quota, and introduces UX ambiguity (which audience am I
talking to?). Kept behind the ChatSink interface so it can be added without touching
the core, but flagged experimental.The original extension already solved the YouTube-side glue; port these specifics:
ytd-video-owner-renderer a.yt-simple-endpoint.style-scope.yt-formatted-string, strip the
https://www.youtube.com/@ prefix, lowercase.ytd-live-chat-frame — empty it and inject the panel.document.querySelector('video').clientHeight; re-sync on resize.window.matchMedia('(prefers-color-scheme: dark)') to theme the panel.chrome.storage.local, keyed by YouTube
handle. Prompt the user when unknown. (The original also pulled a shared mapping JSON from a
GitHub raw URL — optional; consider shipping our own or relying on storage + prompt.)SPA caveat: YouTube is a single-page app; navigations don’t reload the content script. The
adapter must observe URL/navigation changes (e.g. yt-navigate-finish event or a
MutationObserver) and re-inject/reconnect when the user moves between streams.
crosschat/
├── manifest.json # MV3; permissions, host permissions, OAuth redirect
├── src/
│ ├── content/
│ │ ├── youtube-adapter.ts # DOM detection, injection, height/dark-mode, SPA nav
│ │ └── panel/ # chat panel UI (message list, input, emote picker)
│ ├── background/
│ │ ├── service-worker.ts # entry; message routing between panel and engine
│ │ ├── auth/ # OAuth flows + token storage/refresh (per platform)
│ │ ├── engine/ # ChatSource/ChatSink orchestration, normalization
│ │ ├── sources/ # twitch-source.ts [kick-source.ts]
│ │ ├── sinks/ # twitch-sink.ts [kick-sink.ts] [youtube-sink.ts]
│ │ └── emotes/ # registry, provider fetchers (7tv/bttv/ffz), cache
│ ├── shared/ # NormalizedMessage, interfaces, types, constants
│ └── ui/ # shared components/styling
├── ARCHITECTURE.md # this document
└── README.md
storage — username mapping, token, emote cache.identity — chrome.identity.launchWebAuthFlow for the OAuth redirect (send login).*.youtube.com (content script), *.twitch.tv (IRC, Helix, id/gql) and
the emote provider origins/CDNs.key fixes the extension ID so the registered OAuth redirect URL stays
valid across reloads (the Web Store reassigns it on publish).Inbound (read + render):
userId via FFZ.TwitchSource (anonymous IRC) and JOINs the channel.NormalizedMessage with native-emote fragments split out.Outbound (send):
TwitchSink.send() → Helix POST /chat/messages with the user’s token.key for dev; reassigned by
the Chrome Web Store on publish).The order the layers were built (and the order a fresh platform should follow):
ChatSource interface and the anonymous IRC source;
get messages rendering as plain text first. This is kept as the read path, not a throwaway —
login-free reading is a goal (§2), and the interface lets an EventSub read replace it later.chrome.identity, token storage + validation. No refresh token, no client secret.TwitchSink over Helix POST /chat/messages; reads
stay on IRC and sent messages echo back through it.Future additions behind the existing interfaces: authenticated EventSub read, an emote picker/autocomplete in the composer, and a YouTube send target.
Implement behind the Source/Sink/Registry interfaces throughout so that step 7 — and any future YouTube target — is additive rather than invasive.