---
title: "AI Lab Project Building Part 1: AI Chat"
date: 2026-03-02
summary: The chat on this site was the first AI Lab app and it set the rules for everything after it. Server-side prompts, a three-tier model selector with local parity, a fallback chain, and a test suite that grew from real bugs.
status: published
tags: ai lab, ai chat, firebase functions, openrouter, ollama, playwright, model routing
mediaSourcePath: /ai-lab/chat/
mediaSourceSelector: main, #messages, .chat-container
mediaSourceTheme: dark
imageAlt: Chat About Ben, the AI Lab chat interface with its tier selector and status badge.
imageCaption: The first app had to carry every first impression, so it got hardened first.
---

# AI Lab Project Building Part 1: AI Chat

For a while the chat was the only live thing in the AI Lab, which meant it carried every first impression the site made. That pressure was useful. It forced me to get the unglamorous parts right before adding anything else: how prompts are managed, how models are chosen, how failures are handled, and how all of that is tested.

Every app that came later, Promptpad, Problem Solver, Agent Agenda, inherited its conventions from this one. So this is where the series starts.

## What it is

[Chat About Ben](/ai-lab/chat/) is an interview assistant. It answers questions about my background, projects, and working style, in first person, for people deciding whether to reach out. It's a static page talking to a Firebase Function, which talks to OpenRouter in production or to Ollama on my machine in development.

## The rules it established

**The system prompt never leaves the server.** The client sends `promptKey: "aiLab"`, and the function resolves that to the actual prompt. A visitor can't read the prompt, can't override it, and can't send their own system message. Every later app followed the same pattern with its own keys, and the validation is an own-property check now, after I found that a key like `"constructor"` would slip past a plain object lookup and quietly drop the prompt.

**Only allowlisted models run.** A short list of verified free-tier models on OpenRouter, each with a verification date. Anything not on the list throws before a request is made. This is the cost-safety mechanism, and it's why I can leave a public chat running without watching a bill.

**Every production model has a local stand-in.** The three tiers visitors see map to three OpenRouter models, and each of those maps to an Ollama model with roughly the same character. I test locally against the stand-in before I trust the production model. That parity rule is written into the project's `CLAUDE.md` because I've broken it by accident and paid for it in surprises.

**Failures fall through, they don't surface.** The function walks a model chain. If the first model returns a retryable error, it tries the next, and the last entry is OpenRouter's managed free router. A circuit breaker tracks failures per model and skips models that are failing repeatedly. When a model gets retired, which happened in August, the chat keeps answering.

## The tier selector

Visitors get three buttons: Fast, Balanced, and Thoughtful. That's the entire model-selection UI. Behind it, a stored tier name and a mapping table in `functions/model-config.js`. When Ollama is detected in development, a fourth Custom tier appears with whatever local models are installed.

The selector had to handle its own history. Earlier versions stored a raw model ID. When those IDs were retired, the migration mapped old IDs onto tiers so nobody's saved preference turned into an error. Small thing, easy to skip, and it's exactly the kind of detail that separates a demo from a product.

One honest note: when the Balanced model is unavailable and the chain falls through, the response badge shows the model that actually answered. I'd rather the visitor see "Nemotron" under a reply than believe Gemma wrote it.

## Where my time went

Not on the model. On the surrounding behavior:

- Rate limiting per IP, with the IP hashed under a secret salt before it's stored, and a shared Firestore-backed window so multiple function instances agree
- Retry logic on the client for the status codes that are worth retrying, and only those, because the server has already walked the whole chain by the time the client sees a 400
- A markdown renderer that escapes rather than executes, after a review flagged `innerHTML` as a risk surface
- Visible loading states, a retry path, and error messages that say something useful

## How it's tested

The chat UI spec alone is sixty tests, all against a mocked API so they're deterministic: rendering, markdown, the tier selector's ARIA radiogroup pattern, keyboard focus after selecting a tier, contrast in both themes, mobile viewport behavior. Separate specs cover the shared modules, the rate limiter's behavior under unique IPs, and the request validation.

The rule that built that suite was simple. Every bug I found by hand got an automated check so it couldn't come back. Over time that produces a suite shaped like how people actually break things, rather than how I imagined they might.

## Why start here

Promptpad inherited the tier selector and the request contract. Problem Solver and Agent Agenda inherited the explicit, user-triggered inference pattern and the test layering. Getting the first one steady made every one after it cheaper.

- Next: [AI Lab Project Building Part 2: Promptpad](/blog/ai-lab-project-building-part-2-promptpad/)
