Bubblewire is a submission-grade unified chat relay for the Market Bubble Vibe Code Challenge: Twitch + YouTube + X + Kick + X Live broadcast replies in one real-time feed with source labels.
It runs with no package install, starts demo mode automatically, and upgrades to live provider adapters when server-side credentials or public channel config are present.
npm test
npm run check
npm run devOpen http://localhost:3000.
Live app: https://bubblewire.xyz.
Overlay view: http://localhost:3000/overlay.html locally or https://bubblewire.xyz/overlay.html live.
Fallback Render URL: https://bubblewire-challenge.onrender.com.
Demo video: https://youtu.be/I9C0VDrWddA.
Hosted MP4 mirror: https://bubblewire.xyz/assets/bubblewire-final-cut-2026-06-05.mp4.
Health check: http://localhost:3000/healthz.
Twitch: EventSub channel.chat.message is preferred. Set TWITCH_CLIENT_ID, TWITCH_BOT_USER_ACCESS_TOKEN, TWITCH_BOT_USER_ID, and TWITCH_BROADCASTER_USER_ID. IRC remains available as a fallback: set only TWITCH_CHANNELS for anonymous read-only public chat, or add TWITCH_USERNAME and TWITCH_OAUTH_TOKEN for authenticated IRC.
YouTube: Bubblewire consumes official YouTube Data API live-chat messages. Set YOUTUBE_API_KEY (or GOOGLE_API_KEY) plus YOUTUBE_CHANNEL_HANDLE=@notthreadguy, YOUTUBE_CHANNEL_ID, YOUTUBE_LIVE_CHAT_ID, or a live video target via YOUTUBE_VIDEO_ID, YOUTUBE_LIVE_VIDEO_ID, YOUTUBE_URL, or the setup panel (s → YouTube → Go live). When given a handle, Bubblewire resolves the channel with channels.list, searches for an active broadcast with search.list, calls videos.list for liveStreamingDetails.activeLiveChatId, then polls liveChat/messages using YouTube's returned pollingIntervalMillis. Runtime config persists to data/youtube.json.
X: Bubblewire consumes X API v2 filtered stream from the server with X_BEARER_TOKEN. Create stream rules in X before starting the app.
Production defaults currently target Twitch fazebanks, YouTube @notthreadguy, the X filtered-stream rule snapshot marketbubble-live: from:marketbubble, and X Live broadcast https://x.com/i/broadcasts/1yKAPPvoZmqxb.
Important: X integration delivers filtered posts, not live chat messages. This is a current limitation of the X API v2 filtered stream.
X Live: comments on an X live video broadcast ride the same shared filtered stream — X allows only one stream connection per app, so no second connection is opened. Paste a live status URL or an /i/broadcasts/... URL in the setup panel (s → X Live → Go live), or set X_LIVE_BROADCAST_ID at boot; runtime config persists to data/xlive.json. Numeric status targets use conversation_id:<post id> rules, while broadcast URL targets use url_contains:<broadcast id> rules. Matching messages surface as the distinct xlive source ("X Live", red) across the feed, filters, analysis, overlay, and Streamer Mode.
Render deployments can briefly overlap old and new instances. Bubblewire auto-clears stale filtered-stream sessions by default in Render production after X returns TooManyConnections; set X_AUTO_TERMINATE_CONNECTIONS=off if the same X app is intentionally shared with another stream consumer.
If X returns CreditsDepleted or another usage-cap error, Bubblewire marks the source as blocked and backs off with X_USAGE_CAP_BACKOFF_MS (default 30 minutes). Replenish X API credits or move the deployment to a project/app with available usage before expecting X posts to resume.
Kick: Kick's official read-side chat path is the Events API. It delivers chat.message.sent events by webhook. Expose this app with a public tunnel or deployed URL and point Kick to /kick.webhook; /webhooks/kick is kept for local/backward-compatible ingestion. The endpoint accepts chat.message.sent payloads and normalizes them into the shared feed.
To let Bubblewire register the official Kick event subscription at startup, set KICK_AUTO_SUBSCRIBE=1, KICK_ACCESS_TOKEN, and KICK_BROADCASTER_USER_ID with a token that has events:subscribe. To reject unsigned webhook calls, set KICK_REQUIRE_SIGNATURE=1; unsigned local proof events should keep this off.
Set DEMO_MODE=off on Render for a true live-only public feed. When disabled, Bubblewire stops generating demo messages, disables the Spike control, and marks missing provider credentials as missing instead of pretending they are live.
Demo events are clearly marked as demo. Provider status pills do not pretend credentials exist: Twitch and X show demo/missing-credential status until live config is present, while Kick shows webhook-ready until the first webhook arrives.
Use DEMO_MODE=on locally or for judge-safe demos. Use DEMO_MODE=off for production/live-only monitoring.
Useful local controls:
Spikefires a burst across Twitch, YouTube, X, X Live, and Kick labels.Pauselocks the feed and increments unread count.Pinsaves judge-worthy messages in the inspector (persisted across reloads).Exportdownloads the normalized feed as NDJSON.- Selecting a message shows its raw normalized payload.
- Keyboard:
/focuses search,ptoggles pause,sopens setup,1–5switch source filters,Escclears/closes.
Bubblewire doesn't just display chat — it reads it. A zero-dependency, server-side engine (src/core/analysis.js) scores every normalized message and surfaces, over a rolling 90-second window:
- Chat mood — per-source and overall sentiment (hyped / positive / neutral / restless / negative), via a transparent rule-based lexicon with negation handling and EWMA smoothing. A mood badge sits in the feed header and a tone flip raises a toast ("chat mood turning negative").
- Moments — charged messages during activity spikes are auto-captured with their trigger message; click one to jump straight to it in the feed (the clippable highlight).
- Trending now — frequency-ranked terms with a cross-platform bonus, so a term appearing on Twitch and YouTube and X outranks single-channel spam. Click a term to filter.
- Surfaced questions — recent interrogatives the streamer may not have answered, deduped, click-to-jump.
It is labeled heuristic in the UI on purpose: it is transparent scoring, not a trained model, and it never claims otherwise. Served at /analysis.json, pushed live over the SSE analysis event, and covered by 9 unit tests.
/streamer.html (the STREAMER link in the topbar) is the second-screen glanceable view: readable from 3 feet away in 2 seconds while live on Twitch. A large NOW slot surfaces the single most important thing this moment — mood flip, volume spike, watchlist/$ticker hit, or top unanswered question — with priority decay over 90s so stale items rotate out without flicker. Around it: per-source mood with trend arrows, per-source msgs/min with spike flags, top trending terms (cross-platform marked, $cashtags gold), the top open questions with click-to-dismiss "answered" tracking, and a one-line pulse strip of raw chat so it always feels live. Source chips are data-driven, so new sources (like xlive) appear automatically. Honors all four themes via ?theme= or the dashboard's persisted choice; layouts for 1920×1080, 1280×800, and stacked mobile; respects reduced motion.
- Live proof receipt: the feed header shows per-source proof levels (
waiting,live,webhook-proof, orsigned), latest raw event type, and counts from/status.json. - Judge mode: add
?judge=1to skip the boot interstitial and make mobile viewports open feed-first. - Boot sequence: skippable terminal boot on first visit per session (respects reduced-motion).
- Signal stream: a particle strip under the tape — every message fires a source-colored streak; speed scales with heat, accelerates during spikes.
- Volume spikes: when the 10s rate hits 3× the 2-minute baseline, the tape flashes and a spike chip appears.
- Channel hero: first-time visitors get a "watch any Twitch chat live" bar (anonymous IRC, no login). Dismissible, remembered.
- Themes: gold / matrix / ice / synthwave swatches in the rail; persisted, shareable via
?theme=. - Presence: live "◉ N" viewer count from SSE connections.
- Tab pull-backs: background tab shows
(N)unread in the title + alert favicon; optional browser notifications for watchlist hits. - Deep links:
?src=&q=&priority=&theme=restore a view;Sharecopies the current one. Filters/density/theme persist locally. - Recap: downloads a 1200×630 session card (peak rate, hottest signal, top author, per-source volume).
- Compact mode and avatar/initial chips on every row.
- Watchlist: add keywords or
$tickersin the rail. Hits get flagged rows, a toast, and an optional alert beep. Stored locally, capped at 12 terms. - Author drill-down: click any author name to filter the feed to them;
Escclears. - Spam collapse: consecutive identical messages from the same source collapse into one row with a
×Nbadge. - History: the server appends every message to an NDJSON log (
data/feed.ndjson, 5 MB rotation).Load olderpages backwards through it; survives restarts. Disable withHISTORY=off.
Press s (or the Setup button) for a per-source credential checklist — it shows which env vars are set (names only, never values), the active Twitch path (EventSub/IRC/none), sanitized X filtered-stream rules, and a copyable Kick webhook URL.
In IRC mode, Twitch channels can be joined or left at runtime from the panel — anonymous read-only IRC needs no credentials. Runtime channels persist to data/twitch-channels.json and merge with TWITCH_CHANNELS on boot. Production admin routes lock by default; set ADMIN_TOKEN to allow runtime source controls with an x-admin-token header.
/overlay-setup.html builds OBS browser-source URLs with a live transparent-background preview. The overlay accepts ?max=1-12, fade=<seconds>, scale=0.6-2, align=top|bottom, and sources=twitch,youtube,x,xlive,kick.
Bubblewire installs as a PWA (manifest + service worker). The service worker caches only the static shell — live streams, status, and history are always network-fetched — so deploys propagate immediately.
- Twitch EventSub is the current preferred chat path; anonymous read-only IRC is the no-secret live fallback for public channels.
- X filtered stream is near real-time posts, not livestream chat, and may require paid/API access.
- Kick chat ingestion is official webhook-based
chat.message.sent; localhost needs ngrok, Cloudflare Tunnel, or similar. No official anonymous/public read-only Kick chat stream was found in current Kick docs. - YouTube live chat uses the official Data API and needs an API key plus a channel handle/id, live video, or liveChatId.
- Provider tokens stay server-side. The browser receives only normalized events and status.
Current local checks:
npm test
npm run check
npm run proof
npm run proof:live # with DEMO_MODE=off server running
npm run test:ui # browser smoke test; needs playwright-core + Chromium, skips otherwiseThe tests cover Twitch IRC, Twitch EventSub, YouTube live chat normalization/config/polling, X filtered stream, X rule visibility, Kick webhooks, Kick event subscription config, optional Kick signature verification, hub dedupe, proof receipts, source stats, and SSE subscriber behavior.
npm run proof writes a local evidence receipt to docs/evidence/logs/proof.json, posts a Kick webhook-shaped event, records the proof snapshot, and confirms /status.json responds.
npm run proof:live writes docs/evidence/logs/live-proof.json and confirms DEMO_MODE=off rejects synthetic demo/inject routes without creating demo messages. It records per-message evidenceLevel and the source proof snapshot. Set BUBBLEWIRE_EXPECT_SOURCES=twitch,x,kick against the deployed app to prove all three live source paths are present with source-labeled messages.
The repo includes render.yaml and Procfile.
For Render Blueprint deploys, render.yaml sets HOST=0.0.0.0, NODE_VERSION=22, npm start, and /healthz as the health check. Keep provider credentials in Render environment variables only.
If deploying elsewhere, set:
HOST=0.0.0.0
PORT=<provider port>
npm startRender free-tier services cold-start after idle. For a snappy judge demo, ping /healthz every few minutes (uptime monitor or cron) or upgrade the instance. Note that the history log and runtime channel list live on local disk — on ephemeral filesystems they reset per deploy unless you attach a persistent disk.
- Evidence manifest:
docs/evidence/manifest.md - Submission form copy:
docs/submission-form-answer.md - Demo notes:
docs/challenge-submission.md - Final Market Bubble YouTube demo:
https://youtu.be/I9C0VDrWddA - Final local video packet:
docs/evidence/video/bubblewire-market-bubble-final-2026-06-11.mp4plus.srtand.vttsidecars - Compact live-submission cut:
docs/evidence/video/bubblewire-market-bubble-live-submission-cut-2026-06-11.mp4plus.srt,.vtt, and.metadata.jsonsidecars - Capture helper:
scripts/capture-live-cold-open.mjs - Initial challenge entry: June 4, 2026 at 2:42 PM CDT
- Custom-domain resubmission: recorded June 4, 2026 at 18:17 CDT
Controlled swarm standard for future agent work:
Run a controlled swarm on Bubblewire, read-only first: max_depth=1, max_lanes=4, no lane-created children, no secrets/live/provider actions, lane receipts required; controller integrates receipts before any bounded write slice.