diff --git a/.env.example b/.env.example
index 8f7f1ce..0120741 100644
--- a/.env.example
+++ b/.env.example
@@ -51,3 +51,20 @@ REALTIME_TOKEN_SECRET=
# TTL for the short-lived token minted by POST /api/realtime/session.
# REALTIME_TOKEN_TTL_MS=60000
+
+# --- Debug logging (see docs/LOGGING.md) ---
+
+# Shared secret a browser must present (via the debug panel's key field) to
+# receive this app's server-side debug logs in production. Separate from
+# REALTIME_TOKEN_SECRET on purpose — a different blast radius if leaked.
+# In dev, forwarding is always on and this is ignored entirely. Leave unset
+# in prod to disable server-log forwarding for everyone (the default).
+# Generate one with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
+# DEBUG_ACCESS_KEY=
+
+# Set to false to stop the `messages` log category (full conversation text,
+# including the system prompt) from mirroring to console/stdout — useful if
+# server logs are shipped somewhere less trusted than the process's own
+# stdout. Independent of DEBUG_ACCESS_KEY: the debug panel itself is
+# unaffected either way, since that path is already key-gated in prod.
+# DEBUG_LOG_MESSAGES=true
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 9103c61..0eac51d 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -38,5 +38,6 @@ Routes (`src/routes/`) stay thin and delegate into these — a route file is glu
| Analytics recording (sessions, messages, tool calls, errors) to a local SQLite database | [ANALYTICS.md](ANALYTICS.md) |
| Configuration (env vars, validation, the core/project split for persona content) | [CONFIGURATION.md](CONFIGURATION.md) |
| `humanoid` package — avatar-state derivation and animation-channel composition for humanoid avatars | [HUMANOID.md](HUMANOID.md) |
+| Ephemeral, in-memory debug/logging system (open categories, level filtering, session-scoped WS forwarding to a client debug panel) — distinct from analytics | [LOGGING.md](LOGGING.md) |
Add a row here (and a corresponding doc) for each new subsystem as it's built — see the "Documentation" section in [AGENTS.md](../AGENTS.md) for when and how.
diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md
index 792501e..bdafdd0 100644
--- a/docs/CONFIGURATION.md
+++ b/docs/CONFIGURATION.md
@@ -11,6 +11,7 @@ interface AppConfig {
openaiApiKey: string; // only required (and only meaningful) when realtimeProvider is 'openai'
analytics: AnalyticsConfig; // { enabled, dbPath, messages, toolCalls, events }
session: SessionRuntimeConfig; // { modelOverride?, defaultVoice?, clientToolTimeoutMs, tokenTtlMs }
+ debug: DebugConfig; // { messages }
}
```
@@ -33,6 +34,8 @@ interface AppConfig {
| `SESSION_DEFAULT_VOICE` | unset — provider picks | no |
| `CLIENT_TOOL_TIMEOUT_MS` | `10000` | no |
| `REALTIME_TOKEN_TTL_MS` | `60000` | no |
+| `DEBUG_ACCESS_KEY` | unset (server-log forwarding off) | no — see below and [LOGGING.md](LOGGING.md) |
+| `DEBUG_LOG_MESSAGES` | `true` | no — see below and [LOGGING.md](LOGGING.md) |
Booleans accept exactly `"true"`/`"false"`; anything else (if the var is set at all) is a validation error. Integers are validated for range where one applies (e.g. `PORT` must be `1–65535`).
@@ -44,6 +47,10 @@ This means `npm run dev` now requires a fully-populated `.env` to start at all
`REALTIME_TOKEN_SECRET` is a deliberate partial exception: `loadConfig()` checks it's _present_ (so a missing secret is caught at the same startup point as everything else) but does not store its value on `AppConfig` — `tokens.ts` remains the sole reader of the actual secret, reading `process.env` directly and lazily, exactly as before. This is what keeps `tokens.spec.ts`'s existing pattern of mutating `process.env.REALTIME_TOKEN_SECRET` per test working unchanged.
+`DEBUG_ACCESS_KEY` goes further still: it isn't even presence-checked here. Unlike every other var in the table above, leaving it unset is a valid, intentional deployment state — the debug panel's server-log forwarding is simply off for everyone in prod — not a misconfiguration worth failing loudly about. `src/lib/server/core/logger.ts`'s `verifyDebugAccessKey` is the sole reader, same lazy-`process.env` pattern as `REALTIME_TOKEN_SECRET`, but a deliberately separate secret — see [LOGGING.md](LOGGING.md).
+
+`DEBUG_LOG_MESSAGES` is not a secret, so it doesn't follow either of those two exceptions — it's presence-checked and validated like any other boolean (`ANALYTICS_ENABLED`, etc.) and its value (`config.debug.messages`) flows through `AppConfig` normally. It gates a narrower thing than `DEBUG_ACCESS_KEY`: not whether the debug panel can see a session's logs at all, but whether the `messages` category (full conversation content) additionally gets mirrored to server console/stdout unconditionally. See [LOGGING.md](LOGGING.md).
+
## The `vite.config.ts` / Vitest gotcha
**Do not** move a throwing, env-validating call to `vite.config.ts`'s top level, and don't have it build an already-resolved session-defaults object either. Both Vitest projects in `vite.config.ts`'s `test.projects` use `extends: './vite.config.ts'`, so this file's top-level code — and any plugin `configureServer` hook — runs on every `npm run test:unit`, not just `npm run dev`. `dev-plugin.ts`'s `configureServer` already has to guard itself with `if (process.env.VITEST) return` for exactly this reason.
diff --git a/docs/LOGGING.md b/docs/LOGGING.md
new file mode 100644
index 0000000..d1685b5
--- /dev/null
+++ b/docs/LOGGING.md
@@ -0,0 +1,66 @@
+# Debug logging
+
+An ephemeral, in-memory debug-logging system: a shared `Logger` contract other code (session orchestration today; the `humanoid` package, `analytics.ts`, and future project surfaces potentially) can call into, plus a browser-side debug panel that shows entries live, filterable by category and level.
+
+**This is not analytics.** [ANALYTICS.md](ANALYTICS.md) is a permanent, SQLite-backed record of business data (sessions/messages/tool-calls/events), written to disk, meant to be queried later. This system is the opposite on every axis: nothing is ever persisted, it exists purely to help a developer see what's happening in a live session, and a `debug.log` entry is explicitly never routed through `AnalyticsRecorder` (see `session.spec.ts`'s regression test for this).
+
+## The contract
+
+`src/lib/core/logger.ts` defines `LogLevel` (`debug | info | warn | error`), `LogEntry { level, category, message, data?, timestamp }`, and the `Logger` interface (`debug/info/warn/error(category, message, data?)`). `category` is an intentionally open string, never a closed enum — a package like `humanoid`'s `eye` sub-package owns its own namespace (e.g. `"humanoid.eye"`) without core needing to know about it ahead of time, the same "no registration step" rule as the package system itself (see [ARCHITECTURE.md](ARCHITECTURE.md)'s "Optional packages").
+
+## Server side
+
+`src/lib/server/core/logger.ts`'s `createLogger(onEntry?, options?)` builds a `Logger` that mirrors every call to the matching `console.*` method (visible in plain stdout/container logs with zero setup) and, if given, also invokes `onEntry` synchronously. `options.logMessages` (default `true`) controls only whether the `messages` category participates in that console mirror — see below.
+
+`RealtimeSession` (`session.ts`) — the one object every session event in both directions already funnels through (see REALTIME_SESSION.md's connection flow) — owns one such logger per connection, wired so `onEntry` forwards to its own `browserSocket` as a `debug.log` `ServerEvent`, gated by a `debugSubscribed` flag. Because each `RealtimeSession` instance owns exactly one browser connection, this scoping is structural, not filtered: there's no session-lookup or broadcast step, so one session's logs can't reach another session's browser by construction (see `session.spec.ts`'s two-concurrent-sessions test).
+
+Logger calls are added at existing hook points — the same "attach where the plumbing already exists" approach `ANALYTICS.md` describes — covering session lifecycle (`start()` success/failure, `close()`), tool-call start/unknown-tool/timeout/completion, and WS-level protocol errors (malformed JSON, binary frames, a message sent before `session.start`). Deliberately **not** added on `input_audio.chunk`/`output_audio.chunk` — high-volume binary blobs, not debug material.
+
+**Conversation content, under its own `messages` category.** Unlike the rest of this system, this one intentionally _does_ carry conversation content — the system instructions (once, on session start) and the full text of every user/assistant message — precisely so a developer can see the whole conversation, including the system prompt, while debugging a live session. It reuses the exact points `AnalyticsRecorder.messageLogged` already hooks (typed `input_text.send`, a voice user transcript, the assembled assistant transcript on `transcript.done`) rather than the raw `transcript.delta` stream — one entry per complete message, not one per chunk. Each entry's `data` is `{ role: 'system' | 'user' | 'assistant', text }`.
+
+Because `createLogger`'s console mirror otherwise runs for every category unconditionally (see above), full conversation text — including the system prompt — would always reach server stdout/container logs, every session, regardless of whether any browser is subscribed to `debug.log` at all. That's a different sensitivity tier than the rest of this system's operational logging, closer to what `ANALYTICS_LOG_MESSAGES` gates independently in `analytics.ts`. `DEBUG_LOG_MESSAGES` (`config.debug.messages`, default `true`) is the equivalent toggle here: set it to `false` and `createLogger` skips the console mirror specifically for the `messages` category, while every other category is unaffected. This only controls the console/stdout copy — `onEntry` (browser forwarding to a subscribed debug panel) still always fires either way, since that path is already independently gated by `debugSubscribed` (dev/`DEBUG_ACCESS_KEY`, see below). Worth setting to `false` in any deployment where server logs are shipped somewhere less trusted than the process's own stdout.
+
+**What's deliberately deferred**: provider adapters (`provider.ts`, `providers/openai.ts`) are session-agnostic — they have no `sessionId` at all — so they don't get logger calls in this version. The one concrete case this leaves uninstrumented, `providers/openai.ts`'s silently-dropped unmapped OpenAI events, is exactly the case REALTIME_SESSION.md's "OpenAI Realtime API gotcha" section already documents debugging by hand (a temporary `console.debug` + dev-server restart). Threading a `Logger` through the provider interface (plus the mock provider and their tests) is a reasonable fast-follow, not done here to keep this change reviewable.
+
+## Auth: `debug.subscribe` and `DEBUG_ACCESS_KEY`
+
+Server-log forwarding rides the _same_ realtime WebSocket connection as everything else, as a new pair of protocol events (`protocol.ts`):
+
+- **Client → server**: `debug.subscribe { key?: string }`
+- **Server → client**: `debug.subscribe.result { ok, reason? }`, `debug.log` (spreads `LogEntry`)
+
+This deliberately does **not** reuse `REALTIME_TOKEN_SECRET`/the session token: that token proves "you got this from our server a moment ago," which every ordinary visitor to a public deployment gets automatically just by loading the page — it's not a developer credential. Server logs need a second, independent gate:
+
+- **Dev** (`isDev: true`, passed as a literal from `dev-plugin.ts` — that file only ever runs in dev): every connection starts already subscribed. No `debug.subscribe` needed at all.
+- **Prod** (`isDev: false`, passed as a literal from `server.js` — that file only ever runs in prod): a connection starts unsubscribed. The browser must send `debug.subscribe { key }` with a key matching `DEBUG_ACCESS_KEY`, verified by `verifyDebugAccessKey()` (timing-safe comparison, reusing `tokens.ts`'s `timingSafeCompare` helper). `DEBUG_ACCESS_KEY` is read directly from `process.env` at comparison time, same lazy pattern as `REALTIME_TOKEN_SECRET`, but a deliberately separate secret with its own blast radius — see [CONFIGURATION.md](CONFIGURATION.md). **Unset in prod means the feature is fully off for everyone** — not presence-checked in `loadConfig()`'s fail-fast validation, since that's an intentional, valid state, not a misconfiguration.
+
+`debug.subscribe` is handled unconditionally in `handleBrowserMessage` — alongside `session.start`/`session.end`, before the "must be active" gate — so a browser can subscribe before, during, or independent of an active session, and the message is never mistakenly forwarded to the upstream provider.
+
+## Client side
+
+There's no ambient/shared realtime client anywhere in this codebase — each page (e.g. `/demo/realtime`) constructs its own `ResilientRealtimeClient` ad hoc. So the client side splits into two independent pieces:
+
+- **`src/lib/client/core/logger.ts`** — an _ambient_ client-side logger. `clientLogger` (any browser code, with or without a realtime connection, can call `clientLogger.warn('ws', ...)`) and `debugLogStore` (a hand-rolled, bounded (~500-entry) ring buffer with a Svelte-store-compatible `subscribe`, the same idiom as `$lib/client/humanoid/avatar-state.ts`'s `deriveAvatarState` — no `svelte/store` import anywhere in this repo, and this doesn't start one) always exist, regardless of any client.
+- **`src/lib/client/core/server-log-forwarder.ts`** — _opt-in_ per page. `attachServerLogForwarding(client, { isDev })` takes a structurally-typed client (same idiom as `tool-dispatcher.ts`'s `ToolDispatchClient`) and returns a `ServerLogForwarder`: it listens for `debug.log` (pushing into the same shared buffer, tagged `source: 'server'`) and `debug.subscribe.result`, and exposes `subscribeWithKey(key)` for the prod flow.
+
+**Reconnects**: `ResilientRealtimeClient` reconnecting creates a **brand-new** server-side `RealtimeSession` (fresh `sessionId`, fresh unsubscribed state — see REALTIME_SESSION.md's "Resilience"). `attachServerLogForwarding` remembers the last key that worked and resends it on every transition to `'open'`, not just the first — otherwise forwarding would silently and permanently stop after any dropped connection.
+
+`DebugPanel.svelte` (`src/lib/client/core`, **not** `/demo` — see below) renders `debugLogStore` unconditionally (so the ambient client-side buffer is always visible) and, given an optional `forwarder` prop, a subscription-status badge plus a key-entry field when in prod and not yet subscribed.
+
+## Where the panel lives, and why it's shippable in prod
+
+Unlike `/demo/**`, which [ARCHITECTURE.md](ARCHITECTURE.md) frames as dev-only-gated verification scaffolding (404s via `+layout.server.ts` when `!dev`), `DebugPanel.svelte` lives in `src/lib/client/core/` — the same production-shippable tier as `client.ts`/`resilient-client.ts`. The key-based auth above, not route removal, is what protects server-log visibility in prod; the panel's client-side-only view has nothing sensitive to gate at all. `/demo/realtime/+page.svelte` is simply one caller of it (wired to its own client, the same way it's already a caller of `ResilientRealtimeClient`); a branched project mounts `` the same way, wherever _it_ has a client.
+
+## What's deliberately not built yet
+
+- Provider-level logging (see above).
+- Flood control on the forwarding path — a chatty `logger.debug()` call site could in principle spam a subscribed browser's WS at an unbounded rate. Acceptable for a v1 developer tool; this shares the same connection as real session traffic, so a future version might want batching/throttling.
+- A keyboard shortcut to toggle the panel — v1 is a visible floating button only, to avoid introducing global `keydown` handling (a second brand-new UI pattern for this repo) without a stated need for it.
+- Log export/copy, and any cross-session or whole-server-process log view (by design — see "The hook point" above).
+
+## Verifying changes
+
+- `npm run check` / `npm run lint`.
+- `npm run test:unit` — `logger.spec.ts` (server: console mirroring, `verifyDebugAccessKey`, `safeSerialize`; client: ring-buffer bounding, store semantics), the extended `session.spec.ts` (dev auto-forward, prod key gating, `debug.subscribe` before `session.start`, never reaching `AnalyticsRecorder`, and the cross-session-isolation test), `server-log-forwarder.spec.ts` (dev/prod state machine, reconnect resend), and `DebugPanel.svelte.spec.ts` (the first `.svelte.spec.ts` in this repo — kept smoke-level: mount, toggle, filter).
+- **Live smoke test**: `npm run dev`, open `/demo/realtime`, click the debug-log toggle in the bottom-right corner, connect a session — server-side entries (session start, a tool call from the demo project's registered tools) should appear live with no manual subscribe step, since dev is frictionless.
+- **Prod-gating check**: this template has no in-repo production page to click through the key-gated flow live (`/demo/realtime` is itself dev-only-gated, and the root page has no realtime client at all) — that path is covered by `session.spec.ts`'s prod-mode tests and `server-log-forwarder.spec.ts` instead. To eyeball the key-entry UI itself, temporarily pass `isDev: false` to `attachServerLogForwarding` on the demo page, reload, and confirm the key field appears and gates forwarding; revert afterward.
diff --git a/docs/REALTIME_SESSION.md b/docs/REALTIME_SESSION.md
index 211e167..dcae9b4 100644
--- a/docs/REALTIME_SESSION.md
+++ b/docs/REALTIME_SESSION.md
@@ -101,8 +101,10 @@ Both call the same [`handleRealtimeUpgrade`](../src/lib/server/core/ws-handler.t
See [`protocol.ts`](../src/lib/core/protocol.ts) for the authoritative, current list — this is a summary, not a copy to keep in sync by hand.
-- **Client → server**: `session.start`, `input_audio.chunk`, `input_audio.commit`, `input_text.send`, `response.cancel`, `session.end`
-- **Server → client**: `session.ready`, `output_audio.chunk`, `output_audio.done`, `transcript.delta`, `transcript.done`, `response.done`, `error`, `session.closed`
+- **Client → server**: `session.start`, `input_audio.chunk`, `input_audio.commit`, `input_text.send`, `response.cancel`, `session.end`, `debug.subscribe`
+- **Server → client**: `session.ready`, `output_audio.chunk`, `output_audio.done`, `transcript.delta`, `transcript.done`, `response.done`, `error`, `session.closed`, `debug.subscribe.result`, `debug.log`
+
+`debug.subscribe`/`debug.subscribe.result`/`debug.log` are a separate concern from the rest of this vocabulary — ephemeral developer debug-log forwarding, not session content. See [LOGGING.md](LOGGING.md).
## OpenAI Realtime API gotcha (verified live, Aug 2026)
diff --git a/server.js b/server.js
index 2d49aa4..9703214 100644
--- a/server.js
+++ b/server.js
@@ -38,7 +38,15 @@ server.on('upgrade', (req, socket, head) => {
provider,
defaults,
recorder,
- clientToolTimeoutMs: config.session.clientToolTimeoutMs
+ clientToolTimeoutMs: config.session.clientToolTimeoutMs,
+ // This entrypoint only ever runs in production (see dev-plugin.ts for
+ // the dev side) — debug-log forwarding here requires a valid
+ // DEBUG_ACCESS_KEY on each connection. See docs/LOGGING.md.
+ isDev: false,
+ // Gates the `messages` category's console mirror only — browser
+ // forwarding above is unaffected. See DEBUG_LOG_MESSAGES in
+ // docs/CONFIGURATION.md.
+ logMessages: config.debug.messages
});
});
diff --git a/src/lib/client/core/DebugPanel.svelte b/src/lib/client/core/DebugPanel.svelte
new file mode 100644
index 0000000..cff6b78
--- /dev/null
+++ b/src/lib/client/core/DebugPanel.svelte
@@ -0,0 +1,300 @@
+
+
+
+
+{#if open}
+