# CLAUDE.md — chat.spboucher.ai > Author: Simon-Pierre Boucher > Contact: contact@spboucher.ai > Project: chat.spboucher.ai > Deployment target: node **m4m64a** (Apple Silicon, macOS) exposed via **ngrok** at **https://chat.spboucher.ai** This file is the single source of truth for Claude Code when working on this repository. Read it fully before writing any code. When in doubt, prefer the decisions written here over general best practices. --- ## 0. What This Project Is chat.spboucher.ai is a **private, universal chat interface over the entire OpenRouter ecosystem** — a personal ChatGPT/Claude-class product, not an API demo. One user (Simon-Pierre Boucher) must be able to authenticate from any device (especially a smartphone), pick nearly any model OpenRouter exposes, stream responses smoothly, leave, and resume the same conversation later from another device with full model attribution on every answer. --- ## 1. Critical Architectural Decision: OpenRouter-First chat.spboucher.ai must use **OpenRouter as the primary and initially exclusive external LLM gateway**. **Do not** build separate integrations for Anthropic, OpenAI, Google, xAI, Mistral, DeepSeek, Groq, Together, Fireworks, or any other provider in the initial implementation. OpenRouter already solves that abstraction and gives access to hundreds of continuously updated models behind one API. ``` chat.spboucher.ai │ ▼ Internal Chat Engine │ ▼ OpenRouter API │ ├── Anthropic models ├── OpenAI models ├── Google models ├── xAI models ├── Meta models ├── Qwen models ├── DeepSeek models ├── Mistral models ├── Moonshot models └── hundreds of additional models ``` The UI must remain **model-centric**, never provider-integration-centric. For version one: - ONE gateway - ONE API key - ONE model catalog - ONE streaming implementation - HUNDREDS of models Exploit this. Do not overengineer provider support. --- ## 2. Single External Credential Use one server-side credential: `OPENROUTER_API_KEY`. The key must: - exist **only** server-side (loaded from `.env`, which is gitignored) - never be exposed to the browser - never appear in logs - never be committed to Git - never be embedded in static JavaScript - never be returned in API responses All LLM requests flow through the application backend. The browser **never** talks to OpenRouter directly. ``` Browser (phone or desktop) │ POST /api/chat ▼ chat.spboucher.ai backend (node m4m64a) │ authenticated request ▼ OpenRouter → selected model ``` --- ## 3. Deployment: node m4m64a + ngrok The app runs **locally on node m4m64a** and is exposed publicly through an **ngrok tunnel bound to the reserved domain chat.spboucher.ai**. ### 3.1 Runtime layout - Backend + frontend served from a single Node.js process (Next.js recommended, or Node + Vite SSR) listening on `localhost:` (default `3000`). - Database: **SQLite** (better-sqlite3 or Drizzle + SQLite) on m4m64a's local disk, e.g. `~/apps/chat.spboucher.ai/data/chat.db`. No external DB dependency — this is a single-node, single-primary-user product. Enable WAL mode. - File uploads stored under `~/apps/chat.spboucher.ai/data/uploads/` with content-hash filenames. ### 3.2 ngrok configuration Use an ngrok config file (`~/.config/ngrok/ngrok.yml`) with a named tunnel, not ad-hoc CLI flags: ```yaml version: 3 agent: authtoken: # never commit tunnels: chat: proto: http addr: 3000 domain: chat.spboucher.ai ``` Start with `ngrok start chat`. Requirements: - The app must trust `X-Forwarded-*` headers from ngrok (set `trust proxy`) so auth cookies, rate limiting, and absolute URLs work correctly. - Auth cookies: `Secure`, `HttpOnly`, `SameSite=Lax` — TLS terminates at ngrok, so the app must treat itself as HTTPS-fronted. - Streaming (SSE / ReadableStream) must be verified **through the tunnel on a real phone**: send periodic keep-alive comments (`: ping\n\n`) every ~15s so ngrok and mobile radios don't kill idle streams. - WebSockets work through ngrok, but prefer **SSE/fetch streaming** for chat — it survives mobile network transitions better. ### 3.3 Process supervision on macOS Run both the app and ngrok as **launchd LaunchAgents** (preferred on macOS) or via `pm2` if simpler: - `ai.spboucher.chat.app.plist` → runs the Node server, `KeepAlive: true`, `RunAtLoad: true`, logs to `~/apps/chat.spboucher.ai/logs/app.log`. - `ai.spboucher.chat.ngrok.plist` → runs `ngrok start chat`, `KeepAlive: true`. Provide these plists (or a `pm2 ecosystem.config.js`) plus a `deploy.sh` script in `ops/` that: pulls latest, installs deps, builds, runs DB migrations, restarts the agents. Deployment must be a single command on m4m64a. Include a nightly SQLite backup script (`sqlite3 chat.db ".backup ..."`). ### 3.4 Security posture for a public tunnel Because ngrok makes the app publicly reachable: - Every route except `/login` and static assets requires an authenticated session. - Strong auth from day one: single-user credential + optional TOTP, argon2id password hashing, session tokens in DB, aggressive login rate limiting by IP. - No unauthenticated API surface. `/api/*` returns 401 without a valid session, always. - Standard security headers: CSP, X-Content-Type-Options, Referrer-Policy, frame denial. --- ## 4. Do Not Hardcode the Model Catalog The model catalog must be **dynamically synchronized** from the OpenRouter models endpoint. Never maintain a manual list of hundreds of models. Periodically fetch and normalize: model id, display name, provider, context length, input/output modalities, supported parameters, pricing, architecture, description. ```ts interface ModelDefinition { id: string; // exact OpenRouter model ID — opaque name: string; provider?: string; description?: string; contextLength?: number; pricing?: { prompt?: number; completion?: number; image?: number; request?: number; }; capabilities: { text: boolean; vision: boolean; reasoning: boolean; tools: boolean; structuredOutput: boolean; }; metadata: { architecture?: string; tokenizer?: string; raw?: unknown; }; } ``` - Persist the normalized catalog in SQLite; the model picker loads instantly from cache. - Sync flow: `OpenRouter → server sync job → database cache → application`. Manual refresh from settings; scheduled refresh every few hours. - Adding a new OpenRouter model must **never require a deployment**. - Treat model IDs (`provider/model-name`) as **opaque strings**. Never infer behavior by string-parsing IDs. Store the exact ID in an `openrouter_model_id` column and send it back verbatim. ### Removed models Catalogs change. Historical messages must remain intact: if a past generation used a model that no longer exists, still display its original stored metadata, marked `Unavailable`. Never corrupt old conversations. --- ## 5. Model Catalog UX With hundreds of models, a basic dropdown is forbidden. Build a **searchable model browser** supporting: - fuzzy search - provider filtering - favorites (persisted in DB, shown at top) - recently used models (tracked, shown near top) - pinned models - capability filters: reasoning, vision, tool use - context-window filtering, price filtering, free-model filtering - sorts: context window, price, alphabetical, recently added The picker must remain fast with 300+ entries — use list virtualization. On mobile, the picker opens as a **full-height bottom sheet** with a sticky search field, not a tiny dropdown. --- ## 6. Conversations, Model Switching, Branching - A conversation never belongs permanently to one model. The user can switch models for any new message. - Every assistant generation stores: `model_id`, `model_name`, `provider`, `generation_id`, `created_at`. The UI labels each assistant response with the model that produced it. This is critical for auditing and comparing models. - **Regenerate** on every assistant answer, with: same model, another model, or a favorite. Regeneration **creates a branch** — it never destroys the original answer. ``` Prompt ├── Claude answer ├── GPT answer └── Gemini answer ``` - Design the data model so a future **side-by-side comparison mode** (same prompt against N checked models, columns on desktop, swipeable panes on mobile) is possible. Don't fully implement comparison in v1, but the schema must not prevent it. --- ## 7. Streaming Use OpenRouter streaming for every model that supports it. The experience must be genuinely incremental — never wait for completion before showing output. ``` request → OpenRouter stream → server stream parser → normalized application events → browser ReadableStream → incremental rendering ``` ### Internal streaming protocol The browser receives **normalized application events**, never raw OpenRouter wire format: ```ts type ChatStreamEvent = | { type: "generation.start"; generationId: string; model: string } | { type: "content.delta"; text: string } | { type: "reasoning.delta"; text: string } | { type: "tool.start"; toolCallId: string; name: string } | { type: "tool.delta"; toolCallId: string; argumentsDelta: string } | { type: "usage"; promptTokens?: number; completionTokens?: number; totalTokens?: number; cost?: number } | { type: "generation.end" } | { type: "generation.error"; message: string; retryable: boolean }; ``` This abstraction leaves the door open for local models, private endpoints, direct provider APIs, or other gateways later — without rewriting the frontend. --- ## 8. OpenRouter Client Module All OpenRouter interaction lives in one module. **No scattered `fetch()` calls anywhere else.** ``` src/lib/openrouter/ ├── client.ts ├── models.ts ├── stream.ts ├── schemas.ts ├── types.ts ├── errors.ts ├── pricing.ts ├── capabilities.ts └── index.ts ``` Every created source file in this repository must begin with: ``` // Author: Simon-Pierre Boucher // Contact: contact@spboucher.ai // Project: chat.spboucher.ai ``` ### Model routing OpenRouter can route across providers (provider preference, fallbacks, latency/price optimization, data-policy preferences). Do **not** expose these in the initial UI, but design the request layer so routing configuration can eventually be attached to an individual generation. --- ## 9. Errors, Retries, Cancellation, State ### Error normalization Normalize before anything reaches the UI: `AUTHENTICATION_ERROR · RATE_LIMIT · MODEL_UNAVAILABLE · PROVIDER_UNAVAILABLE · CONTEXT_TOO_LARGE · INVALID_REQUEST · CONTENT_REJECTED · TIMEOUT · NETWORK_ERROR · UNKNOWN` ```ts interface NormalizedAIError { code: AIErrorCode; message: string; retryable: boolean; modelId?: string; status?: number; } ``` Show useful errors without exposing sensitive internals. ### Retries Conservative, bounded exponential backoff for transient cases only (network interruption, temporary upstream failure, selected 5xx). Never retry auth failures, invalid prompts, context-too-large, or non-retryable client errors. Never create infinite retry loops. ### Cancellation "Stop" must abort the backend request **and** the upstream OpenRouter stream via `AbortController`. Never fake it by merely halting rendering while continuing to consume and pay for the generation. ### Generation state machine Each generation is an explicit state machine: `queued → starting → streaming → completed | cancelled | failed`, with a persistent `generation_id`. No grab-bag `isLoading` booleans. This identity powers retry, branching, usage accounting, cancellation, debugging, and analytics. --- ## 10. Context Management - Use model metadata to know approximate context windows. Estimate conversation token usage **before** each request; never silently exceed the limit. - Display subtle context usage where useful: `42k / 200k`. - Database history and model context are **separate concepts**. A conversation may hold thousands of messages; never ship the whole thing to OpenRouter indefinitely. ``` Conversation │ ▼ Context Compiler │ ├── system instructions │ ├── memory │ ├── recent messages │ ├── selected historical messages │ ├── file context │ └── summaries ▼ OpenRouter request ``` The context compiler must be able to: include recent messages, preserve system instructions, retain pinned context, trim low-value older context, and eventually summarize older context. --- ## 11. Usage & Cost Tracking Capture per generation whenever possible: prompt tokens, completion tokens, reasoning tokens, cached tokens, total tokens, estimated cost, actual reported cost. ``` generation_usage ---------------- id generation_id model_id prompt_tokens completion_tokens reasoning_tokens cached_tokens total_tokens estimated_cost_usd reported_cost_usd created_at ``` ### Display Each response may show subtle metadata — `Claude Opus · 2.8k tokens · $0.04` — with full details on tap/hover in an inspector. Never clutter the conversation. ### Usage dashboard A settings/usage page with Today / 7 days / 30 days / All time: total requests, input/output/total tokens, total cost, average response cost, most used models, cost by model / provider / conversation. Architect for charts. --- ## 12. Mobile-First: The App Must Feel Native on a Smartphone This is a hard requirement, not a nice-to-have. The primary usage device is a phone. ### 12.1 PWA - Full **installable PWA**: web app manifest (name, maskable icons, `display: standalone`, theme color per light/dark), service worker. - Service worker caches the app shell and model catalog for instant cold starts; conversation data stays network-first (single source of truth is the server DB on m4m64a). - App icon and splash must look deliberate, not default. ### 12.2 Layout & interaction - **Mobile-first CSS**: design for ~390px width first, then progressively enhance to tablet and desktop (where the sidebar becomes persistent). - Respect safe areas: `viewport-fit=cover` + `env(safe-area-inset-*)` padding for notch and home indicator. - The composer must handle the mobile keyboard correctly: `dvh` units / VisualViewport API so the input is never hidden behind the keyboard and the message list stays anchored to the newest message. - Touch targets ≥ 44×44px. Message actions (copy, regenerate, branch, model info) live in a tap/long-press action row — never hover-only menus. - Conversation list: swipeable left drawer on mobile, persistent sidebar ≥ 1024px. - Model picker: bottom sheet on mobile (see §5). - Swipe gestures: swipe between sibling branches of a message; pull-to-refresh on the conversation list. - Streaming text auto-follows at the bottom; the instant the user scrolls up, auto-follow pauses with a "↓ jump to latest" pill. - 60fps scrolling on a phone with a 2,000-message conversation: virtualize the message list. - `prefers-reduced-motion` respected everywhere. ### 12.3 Mobile network reality - SSE keep-alives (§3.2) so streams survive radio sleep. - If a stream drops mid-generation, the client reconnects and resyncs generation state from the server (the generation state machine makes this possible). - Optimistic send: the user's message appears instantly, marked pending until the server confirms. --- ## 13. Design Direction: Legendary, Not Templated The bar: someone seeing a screenshot should recognize this app instantly. It must not look like an AI-generated default, and explicitly **not** like a ChatGPT or Claude clone. ### 13.1 What to avoid Three looks are forbidden because they read as generated defaults: (1) warm cream background + high-contrast serif + terracotta accent; (2) near-black + single acid-green/vermilion accent; (3) broadsheet hairline-rule newspaper layout. Also avoid stock "AI product" purple-gradient glassmorphism, rainbow provider branding, and bouncing-dots loaders. ### 13.2 Direction — "Instrument Panel" The identity concept: this is a **personal instrument for operating hundreds of AI models** — closer to a beautifully machined control surface (studio console, cockpit, high-end synthesizer) than a messaging app. Precision, quiet confidence, dense information handled calmly. **Palette (dark-first, with a fully designed light theme):** | Token | Hex | Role | |---|---|---| | `--ink-950` | `#0E1116` | primary surface (deep blue-graphite, not pure black) | | `--ink-900` | `#151A22` | raised surfaces: composer, sheets, cards | | `--ink-700` | `#2A3240` | borders, dividers, inactive strokes | | `--fog-300` | `#AAB4C3` | secondary text, metadata | | `--fog-050` | `#EEF2F7` | primary text | | `--signal-500` | `#3FB6A8` | the single accent — calibrated teal, used **only** for live/active states: streaming indicator, active model, send button, focus rings | | `--amber-500` | `#E0A458` | cost/usage semantics only: token meters, price chips | One accent for "alive", one for "cost". Nothing else gets color. Providers are distinguished by small monochrome glyph badges, never by their brand colors. **Typography:** - Display/UI: **Söhne** (fallback **Inter**), tight tracking, weights 400/500/600 — labels, buttons, model names. - Chat body: **Inter** at a generous 16–17px / 1.6 line height on mobile — reading comfort is the product. - Data/mono: **Berkeley Mono** (fallback **JetBrains Mono**) for code blocks, token counts, model IDs, costs. The mono face is a first-class citizen of the identity: every model ID, cost, and context meter is set in it. That's what makes the app feel like an instrument. **Signature element — the Model Rail.** A slim, always-visible strip attached to the composer showing the current model as a machined "cartridge": mono model ID, provider glyph, live context meter (`42k / 200k` as a thin filling bar), and price-per-1M chip. Tapping it opens the model bottom sheet. During streaming, the rail's edge carries a subtle teal signal pulse. Switching models visibly swaps the cartridge with a short mechanical slide. This one element carries the entire identity; everything around it stays quiet and disciplined. **Motion:** restrained and purposeful. Streaming cursor is a soft teal caret. Bottom sheets use spring easing (~250ms). Branch switching slides horizontally. No ambient background animation. All motion honors `prefers-reduced-motion`. **Message rendering:** first-class Markdown, syntax-highlighted code blocks with copy button and language label, LaTeX (KaTeX), tables with horizontal scroll on mobile. Assistant messages are full-width text blocks with a slim attribution header (mono model name · timestamp · cost chip); user messages are compact right-aligned cards. No avatars. **Quality floor (never announce it, always meet it):** responsive down to 320px, visible keyboard focus states, WCAG AA contrast in both themes, reduced motion respected, light theme fully designed (`#F6F8FA` paper, same teal, ink text — not an inverted afterthought). ### 13.3 Copy voice Interface copy is plain, active, specific: "Save changes," not "Submit." Errors state what happened and what to do next — never vague, never apologetic. Empty states invite action ("Pick a model and ask something"). One vocabulary throughout: a "model" is always a model, a "conversation" always a conversation. --- ## 14. Future Direct Providers OpenRouter is the only provider initially, but keep a narrow gateway abstraction: ```ts interface ModelGateway { stream(request: GenerationRequest): AsyncIterable; listModels(): Promise; } ``` Initial implementation: `OpenRouterGateway`. Future possibilities (do **not** implement now): `LocalGateway`, `MLXGateway` (running directly on m4m64a's Apple Silicon), `PrivateClusterGateway`. The goal is only that the frontend never becomes inseparable from OpenRouter. --- ## 15. Product Philosophy **Simple externally, powerful internally.** The user sees: choose model → write message → receive answer. The internals handle: authentication, conversation persistence, context compilation, model metadata, streaming, error handling, usage, cost, branching, cancellation, search, security — without exposing complexity. --- ## 16. Development Priority Implement strictly in this order. Do not start secondary features before the fundamental chat loop is exceptionally stable. 1. Authentication (single user, session cookies, rate-limited login) 2. Database (SQLite schema + migrations) 3. OpenRouter server client (`src/lib/openrouter/`) 4. Dynamic model catalog (sync + cache) 5. Conversation persistence 6. Chat UI (mobile-first shell, per §12–13) 7. Streaming (SSE through ngrok, verified on a real phone) 8. Stop generation 9. Markdown + code + LaTeX rendering 10. Model search (browser/bottom sheet) 11. Favorites + recent models 12. Message regeneration 13. Conversation branching 14. Search history 15. Usage/cost tracking + dashboard 16. Settings 17. File/image support (vision-capable models) 18. Advanced context manager 19. PWA polish (install, offline shell, icons/splash) 20. Ops hardening (launchd agents, deploy.sh, log rotation, nightly DB backup) --- ## 17. Definition of Success The first production release is successful when Simon-Pierre Boucher can open **https://chat.spboucher.ai on his phone**, authenticate securely through the ngrok tunnel to m4m64a, install the app to his home screen, select almost any model exposed through OpenRouter from a fast, beautiful bottom-sheet picker, send a message, immediately receive a smooth streaming response even on cellular, leave the application, return later from another device, find the complete conversation, continue it with the same or a completely different model, and inspect which model generated every answer and what each one cost — inside an interface distinctive enough that no one could mistake it for anything else.