SPB Git

spb/chat-spboucher Public

Private universal chat interface over the OpenRouter ecosystem — 400+ models, branching, streaming, usage tracking. Next.js 16 + SQLite, PWA, deployed on m4m64a at chat.spboucher.ai

TypeScript 78.8% CSS 15.1% JavaScript 4.9% Shell 1.2%
21.8 KB

# CLAUDE.mdchat.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.

text
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.

text
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:<PORT> (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: <NGROK_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.
text
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.

text
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.

text
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:

text
// 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.
text
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.

text
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<ChatStreamEvent>;
  listModels(): Promise<ModelDefinition[]>;
}

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.