# Configuration Every env var this app reads is parsed and validated in one place: [`src/lib/server/core/config.ts`](../src/lib/server/core/config.ts)'s `loadConfig()`. It reads `process.env` directly (not `$env/static/private`) for the same reason `tokens.ts` and `dev-plugin.ts` already do — this module tree is compiled standalone (`tsc -p tsconfig.server.json`) for `server.js` to import outside Vite's module graph. See [`.env.example`](../.env.example) for the full list with inline comments. ## `AppConfig` shape ```ts interface AppConfig { port: number; openaiApiKey: string; analytics: AnalyticsConfig; // { enabled, dbPath, messages, toolCalls, events } session: SessionRuntimeConfig; // { modelOverride?, defaultVoice?, clientToolTimeoutMs, tokenTtlMs } } ``` `AnalyticsConfig` extends `AnalyticsCategoryConfig` (defined in `analytics.ts` itself, not here — it's the recorder's own config shape, the same way `SessionDefaults` lives in `session.ts`). ## Env vars | Var | Default | Required | | -------------------------- | ----------------------------------- | -------- | | `OPENAI_API_KEY` | — | yes | | `REALTIME_TOKEN_SECRET` | — | yes | | `PORT` | `3000` | no | | `ANALYTICS_ENABLED` | `true` | no | | `ANALYTICS_DB_PATH` | `./data/analytics.db` | no | | `ANALYTICS_LOG_MESSAGES` | `true` | no | | `ANALYTICS_LOG_TOOL_CALLS` | `true` | no | | `ANALYTICS_LOG_EVENTS` | `true` | no | | `SESSION_MODEL` | `SESSION_PERSONA.model` (see below) | no | | `SESSION_DEFAULT_VOICE` | unset — provider picks | no | | `CLIENT_TOOL_TIMEOUT_MS` | `10000` | no | | `REALTIME_TOKEN_TTL_MS` | `60000` | no | 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`). ## Fail-fast, in both dev and prod `loadConfig()` collects every problem — missing required var, malformed boolean/integer, out-of-range integer — into a single thrown `Error` listing all of them, rather than failing on the first one found. It's called once, eagerly, at both composition roots (`server.js`'s top level; `dev-plugin.ts`'s `configureServer`, right after `.env` is loaded), and memoized after that (env vars don't change mid-process, and this gets called again from the session-token route to read `tokenTtlMs`). This means `npm run dev` now requires a fully-populated `.env` to start at all — previously, a missing `OPENAI_API_KEY` only produced a console warning in dev and the failure surfaced later, confusingly, inside a WS handshake attempt. Prod already threw eagerly; this just makes dev match it, consistent with this repo's existing "fail loud at boot" precedent (see `createToolRegistry`'s doc comment in `server/core/tools.ts`). `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. ## 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. Concretely: `vite.config.ts` passes only raw, env-independent pieces into the dev plugin — `realtimeDevWsPlugin({ persona: SESSION_PERSONA, toolRegistry })` — never a pre-resolved `defaults`/`config` object. The `loadConfig()` call, `resolveSessionDefaults(...)`, and the `NoopAnalyticsRecorder`/`SqliteAnalyticsRecorder` choice all happen _inside_ `configureServer`, below the `VITEST` guard. If a future change needs another env-driven value in dev, thread it through the same way — resolve it inside `configureServer`, not at `vite.config.ts` module scope — or `npm run test:unit` will start requiring a populated `.env` for no reason, and may pick up a developer's real local secrets into `process.env` during test runs. ## Session persona vs. runtime overrides The actual persona — model choice and system instructions — is project-owned content in [`src/lib/project/persona.ts`](../src/lib/project/persona.ts), not part of this config system (see [`docs/TOOLS.md`](TOOLS.md) for why core/project content is split this way). `config.ts`'s `session.modelOverride` (from `SESSION_MODEL`) lets a deployment swap the model without touching that file; `instructions` deliberately has **no** env override — it's prose, and belongs in a commented TypeScript file, not a `.env` value. ## What's not configurable (on purpose) `src/lib/server/core/providers/openai.ts`'s hardcoded upstream URL, its always-`['audio']` `output_modalities`, and its `tool_choice: 'auto'` are deliberately out of scope here — these are provider-tuning knobs nobody asked to change yet, and adding config surface for them now would be speculative.