diff --git a/meet-bot/__tests__/chat-reader.test.ts b/meet-bot/__tests__/chat-reader.test.ts
new file mode 100644
index 00000000000..8085d500f76
--- /dev/null
+++ b/meet-bot/__tests__/chat-reader.test.ts
@@ -0,0 +1,497 @@
+/**
+ * Unit tests for `startChatReader`.
+ *
+ * We don't spin up a real Playwright browser here — instead we hand the
+ * reader a tiny fake `Page` backed by a JSDOM document. The fake implements
+ * only the subset of Playwright's Page surface the reader actually calls
+ * (`evaluate`, `exposeFunction`, `$`), which is enough to exercise:
+ *
+ * - Panel-open detection + toggle click.
+ * - In-page `MutationObserver` wiring (JSDOM provides a real
+ * `MutationObserver`, so the observer runs for real).
+ * - The `page.exposeFunction` bridge that forwards raw messages back to
+ * the bot-side callback.
+ * - Self-filter + dedupe in the bot-side handler.
+ *
+ * The fallback polling loop is covered by explicitly injecting a failing
+ * `exposeFunction` and asserting that the reader still surfaces messages.
+ */
+
+import { afterEach, beforeEach, describe, expect, test } from "bun:test";
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+import { JSDOM } from "jsdom";
+
+import type { InboundChatEvent } from "@vellumai/meet-contracts";
+import type { Page } from "playwright";
+
+import { startChatReader } from "../src/browser/chat-reader.js";
+import { chatSelectors } from "../src/browser/dom-selectors.js";
+
+const FIXTURE_DIR = join(import.meta.dir, "fixtures");
+const CHAT_FIXTURE = readFileSync(
+ join(FIXTURE_DIR, "meet-dom-chat.html"),
+ "utf8",
+);
+
+/**
+ * Shape of a bridge handler installed by `page.exposeFunction`. The fake
+ * Page routes calls through this map so `window[name](...)` in the page
+ * context calls the bot-side callback.
+ */
+type BridgeFn = (...args: unknown[]) => unknown;
+
+interface FakePageOptions {
+ /** Force `exposeFunction` to reject — triggers the polling fallback. */
+ failExposeFunction?: boolean;
+}
+
+interface FakePage {
+ page: Page;
+ dom: JSDOM;
+ document: Document;
+ /** Append a message
to the rendered chat list. */
+ appendMessage: (opts: {
+ id: string;
+ sender: string;
+ text: string;
+ datetime?: string;
+ isSelf?: boolean;
+ }) => void;
+ /** Remove the message list entirely so `ensurePanelOpen` has to click. */
+ closePanel: () => void;
+ /** Count of times the toggle button was clicked. */
+ panelToggleClicks: () => number;
+}
+
+/**
+ * Build a fake Playwright `Page` wrapping a JSDOM document. Only the subset
+ * of Page methods used by `chat-reader.ts` is implemented.
+ */
+function createFakePage(
+ html: string,
+ opts: FakePageOptions = {},
+): FakePage {
+ const dom = new JSDOM(html, { runScripts: "outside-only" });
+ const window = dom.window;
+ const document = window.document;
+
+ // The chat fixture alone doesn't carry the toolbar toggle button (that
+ // lives in the in-game fixture). Inject one here so ensurePanelOpen has
+ // something to click on the closed-panel code path.
+ if (!document.querySelector(chatSelectors.PANEL_BUTTON)) {
+ const toggle = document.createElement("button");
+ toggle.setAttribute("type", "button");
+ toggle.setAttribute("aria-label", "Chat with everyone");
+ toggle.textContent = "Chat";
+ document.body.appendChild(toggle);
+ }
+
+ // JSDOM exposes MutationObserver, DOM APIs, Element, etc. on its window —
+ // we mirror the globals the reader's `evaluate` body needs onto our own
+ // global so the evaluator below has a consistent view.
+ const pageGlobals = {
+ document,
+ window,
+ MutationObserver: window.MutationObserver,
+ Date,
+ Number,
+ Array,
+ Set,
+ } as Record;
+
+ const bridges = new Map();
+ let toggleClicks = 0;
+
+ // Wire the chat toggle so ensurePanelOpen's fallback path can recreate the
+ // message list when invoked.
+ const attachToggleHandler = (): void => {
+ const toggle = document.querySelector(chatSelectors.PANEL_BUTTON);
+ if (!toggle) return;
+ toggle.addEventListener("click", () => {
+ toggleClicks += 1;
+ // If the message list has been removed, recreate it so a subsequent
+ // query succeeds.
+ if (!document.querySelector('[role="list"][aria-label="Chat messages"]')) {
+ const aside = document.querySelector("aside");
+ const list = document.createElement("div");
+ list.setAttribute("role", "list");
+ list.setAttribute("aria-label", "Chat messages");
+ aside?.insertBefore(list, aside.firstChild);
+ }
+ });
+ };
+ attachToggleHandler();
+
+ // `fn` is what the caller passes to page.evaluate. Playwright serializes
+ // it to a string and runs it in the page; JSDOM can't re-parse arbitrary
+ // strings robustly, so we invoke the function directly but with a
+ // controlled `window`/`document` context by binding via `call`.
+ const runInPage = (
+ fn: (...args: unknown[]) => unknown,
+ arg?: unknown,
+ ): unknown => {
+ // Shadow our module globals with the JSDOM ones for the duration of the
+ // call. `fn` references `document`, `window`, etc. as free variables;
+ // because the function was defined in this Node context, those refs
+ // resolve to our Node globals — we override them on globalThis for the
+ // duration of the call.
+ const originals: Record = {};
+ for (const [k, v] of Object.entries(pageGlobals)) {
+ originals[k] = (globalThis as Record)[k];
+ (globalThis as Record)[k] = v;
+ }
+ // Also expose any installed bridge functions on globalThis so
+ // `(window as ...)[bindingName]` lookups inside the evaluator resolve
+ // them. We mirror the globalThis onto the JSDOM window.
+ for (const [name, fn] of bridges) {
+ (window as unknown as Record)[name] = fn;
+ }
+ try {
+ return arg === undefined ? fn() : fn(arg);
+ } finally {
+ for (const [k, v] of Object.entries(originals)) {
+ if (v === undefined) {
+ delete (globalThis as Record)[k];
+ } else {
+ (globalThis as Record)[k] = v;
+ }
+ }
+ }
+ };
+
+ const page: Partial = {
+ evaluate: (async (
+ fn: (...args: unknown[]) => unknown,
+ arg?: unknown,
+ ) => {
+ return runInPage(fn, arg);
+ }) as unknown as Page["evaluate"],
+ exposeFunction: (async (name: string, cb: Function) => {
+ if (opts.failExposeFunction) {
+ throw new Error("exposeFunction disabled for this fake");
+ }
+ bridges.set(name, cb as BridgeFn);
+ }) as Page["exposeFunction"],
+ $: (async (selector: string) => {
+ const el = document.querySelector(selector);
+ if (!el) return null;
+ return {
+ click: async () => {
+ (el as unknown as { click: () => void }).click();
+ },
+ } as unknown as Awaited>;
+ }) as Page["$"],
+ };
+
+ const appendMessage: FakePage["appendMessage"] = ({
+ id,
+ sender,
+ text,
+ datetime,
+ isSelf,
+ }) => {
+ const list = document.querySelector(
+ '[role="list"][aria-label="Chat messages"]',
+ );
+ if (!list) throw new Error("message list is not mounted");
+ const node = document.createElement("div");
+ node.setAttribute("role", "listitem");
+ node.setAttribute("data-message-id", id);
+ if (isSelf) node.setAttribute("data-is-self", "true");
+ const senderEl = document.createElement("span");
+ senderEl.setAttribute("data-sender-name", "");
+ senderEl.textContent = sender;
+ const timeEl = document.createElement("time");
+ timeEl.setAttribute("datetime", datetime ?? new Date().toISOString());
+ timeEl.textContent = "12:00 PM";
+ const textEl = document.createElement("p");
+ textEl.setAttribute("data-message-text", "");
+ textEl.textContent = text;
+ node.appendChild(senderEl);
+ node.appendChild(timeEl);
+ node.appendChild(textEl);
+ list.appendChild(node);
+ };
+
+ const closePanel: FakePage["closePanel"] = () => {
+ const list = document.querySelector(
+ '[role="list"][aria-label="Chat messages"]',
+ );
+ list?.remove();
+ };
+
+ return {
+ page: page as Page,
+ dom,
+ document,
+ appendMessage,
+ closePanel,
+ panelToggleClicks: () => toggleClicks,
+ };
+}
+
+/** Wait for JSDOM's MutationObserver callbacks to flush. */
+async function flushMicrotasks(): Promise {
+ // JSDOM's MutationObserver runs asynchronously on a microtask; one tick of
+ // the event loop is enough to let every pending callback run.
+ await new Promise((resolve) => setTimeout(resolve, 0));
+}
+
+describe("startChatReader", () => {
+ // Keep each test's DOM isolated from the next.
+ let reader: { stop: () => Promise } | null = null;
+
+ beforeEach(() => {
+ reader = null;
+ });
+
+ afterEach(async () => {
+ if (reader) {
+ await reader.stop();
+ reader = null;
+ }
+ });
+
+ test("emits an InboundChatEvent for pre-existing and newly-appended messages in order", async () => {
+ const fake = createFakePage(CHAT_FIXTURE);
+ const events: InboundChatEvent[] = [];
+
+ reader = await startChatReader(
+ fake.page,
+ (event) => {
+ events.push(event);
+ },
+ { meetingId: "meeting-abc", selfName: "Bot" },
+ );
+
+ // The fixture ships with one pre-existing message ("Alice: Hello
+ // everyone...") — the reader must surface it via the backfill path.
+ await flushMicrotasks();
+ expect(events.length).toBe(1);
+ expect(events[0]!.type).toBe("chat.inbound");
+ expect(events[0]!.meetingId).toBe("meeting-abc");
+ expect(events[0]!.fromName).toBe("Alice");
+ expect(events[0]!.text).toBe("Hello everyone, welcome to the meeting.");
+
+ // Append a second message via DOM mutation — the observer should pick
+ // it up and emit.
+ fake.appendMessage({
+ id: "msg-002",
+ sender: "Bob",
+ text: "Good morning.",
+ datetime: "2026-04-15T12:35:00Z",
+ });
+ await flushMicrotasks();
+
+ expect(events.length).toBe(2);
+ expect(events[1]!.fromName).toBe("Bob");
+ expect(events[1]!.text).toBe("Good morning.");
+
+ // Ordering: Alice before Bob.
+ expect(events.map((e) => e.fromName)).toEqual(["Alice", "Bob"]);
+ });
+
+ test("drops messages whose sender matches selfName", async () => {
+ const fake = createFakePage(CHAT_FIXTURE);
+ const events: InboundChatEvent[] = [];
+
+ reader = await startChatReader(
+ fake.page,
+ (event) => events.push(event),
+ { meetingId: "m1", selfName: "Alice" },
+ );
+
+ await flushMicrotasks();
+ // The fixture's pre-existing message is from "Alice" — since Alice is
+ // our self-name, it must be filtered out.
+ expect(events.length).toBe(0);
+
+ // A non-self message from Bob should still come through.
+ fake.appendMessage({
+ id: "msg-002",
+ sender: "Bob",
+ text: "Hi there.",
+ });
+ await flushMicrotasks();
+ expect(events.length).toBe(1);
+ expect(events[0]!.fromName).toBe("Bob");
+ });
+
+ test("respects an authoritative data-is-self attribute", async () => {
+ const fake = createFakePage(CHAT_FIXTURE);
+ const events: InboundChatEvent[] = [];
+
+ reader = await startChatReader(
+ fake.page,
+ (event) => events.push(event),
+ // selfName intentionally doesn't match — we're asserting that the
+ // data-is-self hint alone is enough to drop a message.
+ { meetingId: "m1", selfName: "SomebodyElse" },
+ );
+
+ // Drain the fixture's pre-existing Alice message first.
+ await flushMicrotasks();
+ events.length = 0;
+
+ fake.appendMessage({
+ id: "msg-self",
+ sender: "Renamed Bot",
+ text: "from the bot",
+ isSelf: true,
+ });
+ await flushMicrotasks();
+ expect(events.length).toBe(0);
+ });
+
+ test("dedupes identical messages within the 1-second timestamp bucket", async () => {
+ const fake = createFakePage(CHAT_FIXTURE);
+ const events: InboundChatEvent[] = [];
+
+ reader = await startChatReader(
+ fake.page,
+ (event) => events.push(event),
+ { meetingId: "m1", selfName: "Bot" },
+ );
+
+ await flushMicrotasks();
+ // Drop the fixture's pre-existing message from the comparison.
+ events.length = 0;
+
+ // Two appended messages with the same sender, text, and timestamp but
+ // different DOM IDs — bot-side dedupe should collapse them.
+ fake.appendMessage({
+ id: "msg-dup-a",
+ sender: "Bob",
+ text: "ping",
+ datetime: "2026-04-15T12:36:00Z",
+ });
+ fake.appendMessage({
+ id: "msg-dup-b",
+ sender: "Bob",
+ text: "ping",
+ datetime: "2026-04-15T12:36:00Z",
+ });
+ await flushMicrotasks();
+
+ expect(events.length).toBe(1);
+ expect(events[0]!.fromName).toBe("Bob");
+ expect(events[0]!.text).toBe("ping");
+ });
+
+ test("clicks the panel toggle when the chat panel is closed", async () => {
+ const fake = createFakePage(CHAT_FIXTURE);
+ fake.closePanel();
+ expect(fake.panelToggleClicks()).toBe(0);
+
+ const events: InboundChatEvent[] = [];
+ reader = await startChatReader(
+ fake.page,
+ (event) => events.push(event),
+ { meetingId: "m1", selfName: "Bot" },
+ );
+
+ // Exactly one click to open the panel; once open, no further clicks.
+ expect(fake.panelToggleClicks()).toBe(1);
+
+ // Now that the panel exists, appending a message should still work end
+ // to end.
+ fake.appendMessage({
+ id: "msg-after-open",
+ sender: "Carol",
+ text: "hello post-open",
+ });
+ await flushMicrotasks();
+ expect(events.length).toBe(1);
+ expect(events[0]!.fromName).toBe("Carol");
+ });
+
+ test("does not click the panel toggle when the panel is already open", async () => {
+ const fake = createFakePage(CHAT_FIXTURE);
+
+ reader = await startChatReader(
+ fake.page,
+ () => {},
+ { meetingId: "m1", selfName: "Bot" },
+ );
+
+ expect(fake.panelToggleClicks()).toBe(0);
+ });
+
+ test("stamps meetingId on every event", async () => {
+ const fake = createFakePage(CHAT_FIXTURE);
+ const events: InboundChatEvent[] = [];
+
+ reader = await startChatReader(
+ fake.page,
+ (event) => events.push(event),
+ { meetingId: "custom-meeting-xyz", selfName: "Bot" },
+ );
+
+ await flushMicrotasks();
+ fake.appendMessage({
+ id: "msg-99",
+ sender: "Dave",
+ text: "yo",
+ });
+ await flushMicrotasks();
+
+ expect(events.length).toBeGreaterThanOrEqual(1);
+ for (const e of events) {
+ expect(e.meetingId).toBe("custom-meeting-xyz");
+ }
+ });
+
+ test("falls back to polling when exposeFunction fails", async () => {
+ const fake = createFakePage(CHAT_FIXTURE, { failExposeFunction: true });
+ const events: InboundChatEvent[] = [];
+
+ reader = await startChatReader(
+ fake.page,
+ (event) => events.push(event),
+ { meetingId: "m1", selfName: "Bot" },
+ );
+
+ // Polling fires an immediate tick, so the fixture's pre-existing
+ // message should surface without waiting an interval.
+ await flushMicrotasks();
+ await new Promise((r) => setTimeout(r, 10));
+ expect(events.length).toBe(1);
+ expect(events[0]!.fromName).toBe("Alice");
+
+ // A newly-appended message surfaces on the next poll (≤ 500ms).
+ fake.appendMessage({
+ id: "msg-polled",
+ sender: "Eve",
+ text: "polled hello",
+ });
+ await new Promise((r) => setTimeout(r, 600));
+ expect(events.map((e) => e.fromName)).toContain("Eve");
+ });
+
+ test("stop() is idempotent", async () => {
+ const fake = createFakePage(CHAT_FIXTURE);
+ const events: InboundChatEvent[] = [];
+
+ reader = await startChatReader(
+ fake.page,
+ (event) => events.push(event),
+ { meetingId: "m1", selfName: "Bot" },
+ );
+
+ await reader.stop();
+ await reader.stop(); // second call must not throw
+
+ // After stop, further DOM mutations should not surface events.
+ fake.appendMessage({
+ id: "msg-after-stop",
+ sender: "Frank",
+ text: "post-stop",
+ });
+ await flushMicrotasks();
+ expect(events.map((e) => e.fromName)).not.toContain("Frank");
+
+ // Null out so the afterEach doesn't call stop again.
+ reader = null;
+ });
+});
diff --git a/meet-bot/src/browser/chat-reader.ts b/meet-bot/src/browser/chat-reader.ts
new file mode 100644
index 00000000000..7e4139ed035
--- /dev/null
+++ b/meet-bot/src/browser/chat-reader.ts
@@ -0,0 +1,415 @@
+/**
+ * In-meeting chat reader.
+ *
+ * `startChatReader(page, onMessage, opts)` wires up a streaming observer over
+ * Meet's chat panel so every new inbound message is surfaced as an
+ * `InboundChatEvent` on the assistant side. Downstream consumers (PR 17's
+ * conversation bridge, PR 22's consent monitor) rely on these events to
+ * reflect chat traffic back into the assistant conversation.
+ *
+ * ## Flow
+ *
+ * 1. Ensure the chat panel is open — Meet hides the message list and composer
+ * behind a toolbar toggle (`INGAME_CHAT_PANEL_BUTTON`). We detect the
+ * current state by querying for the message-list selector; if it's absent,
+ * we click the panel button once. This avoids toggling a panel that is
+ * already open (which would close it and break the observer).
+ * 2. Install a `MutationObserver` inside the page via `page.evaluate`. The
+ * observer watches the message container for added nodes and forwards
+ * each new `INGAME_CHAT_MESSAGE_NODE` out through a bridge function we
+ * exposed via `page.exposeFunction`. This keeps the bot-side event path
+ * push-driven (no polling, minimal latency).
+ * 3. **Fallback**: if any step of the mutation-observer path fails (e.g. the
+ * page has no `document` yet, or `exposeFunction` rejects because the
+ * binding already exists after a navigation), we fall back to polling the
+ * message list every 500ms and diffing against a seen-set.
+ *
+ * ## Dedupe
+ *
+ * Two layers:
+ * - In-page: the observer tracks message DOM IDs it has already forwarded,
+ * so re-renders of the same message don't fire twice.
+ * - Bot-side: we key on `sender + text + floor(timestampMs / 1000)` so even
+ * if a message resurfaces across a panel-close/reopen (clearing the
+ * in-page seen set), we don't double-emit within a 1-second bucket.
+ *
+ * ## Self-filter
+ *
+ * Meet renders the bot's own outbound messages back into the chat list. We
+ * drop anything whose rendered sender name matches `opts.selfName`. When the
+ * DOM exposes a more specific `data-is-self="true"` attribute (some Meet
+ * variants do) we treat that as authoritative.
+ */
+
+import type { Page } from "playwright";
+
+import type { InboundChatEvent } from "@vellumai/meet-contracts";
+
+import { chatSelectors } from "./dom-selectors.js";
+
+/** Options passed to `startChatReader`. */
+export interface ChatReaderOptions {
+ /** Meeting ID stamped on every emitted event. */
+ meetingId: string;
+ /** The bot's display name — used to drop the bot's own messages. */
+ selfName: string;
+}
+
+/** Handle returned by `startChatReader`. */
+export interface ChatReader {
+ /**
+ * Tear down the in-page observer (or polling loop) and unsubscribe the
+ * bot-side callback. Safe to call multiple times — subsequent calls are
+ * no-ops.
+ */
+ stop: () => Promise;
+}
+
+/**
+ * Raw message payload extracted from the DOM before bot-side filtering.
+ *
+ * `timestampMs` is the sender-side timestamp parsed from the
+ * `