Skip to content

Week 10 — Streaming front end and real users

Why this week: most scam messages are read on a phone, by people who will never open an API's /docs page. This week gives them a screen they can use: paste a message, see the verdict, and read why, with the explanation appearing word by word instead of after a long blank wait. Then you watch ten real people use it. One week only: enough to build a usable screen and to talk about streaming in interviews, not to become a front-end developer.

By Friday you have: a React page on GitHub Pages that works on a phone; a streaming endpoint, POST /check/stream, on your API; a "why we think this" panel with the warning signs, the matched scam pattern, a plain-language confidence label and a "not a guarantee" note; notes from ten people who used it; and fixes for the top two problems they hit.

Main online course: Full Stack Open (University of Helsinki, free), part 1 (React basics) and part 2 (forms, and talking to a server). Read the text and try the examples; skip the exercise submissions.

Concepts

Front end and back end. The back end is your API on Cloud Run: it holds the key, calls Claude and talks to the database. The front end is the page in the user's browser. It holds nothing secret. It sends the message to the back end and shows what comes back.

Static site. Your front end is just files: one HTML page, one JavaScript file and one CSS file. Any file host can serve them, and many do it free. There's no server of your own to run or pay for.

React: components, props and state. A component is a function that returns a piece of page, written in JSX (HTML-like tags inside JavaScript). Props are the inputs you hand a component, like arguments. State is data a component remembers, such as "the text in the box" or "the answer so far"; when state changes, React redraws that part of the page for you. You never edit the page directly.

npm and Vite. npm is to JavaScript what uv is to Python: package.json lists the packages, package-lock.json pins exact versions (like uv.lock), and node_modules/ holds them. Vite runs a development server that reloads the page as you type, and npm run build turns your code into the few small files a browser needs, in dist/.

Streaming and server-sent events. Normally the server sends its whole answer at once. With streaming, it sends pieces as soon as they exist. Server-sent events (SSE) is a simple text format for that: each event is an event: line, a data: line, and a blank line. Your API will send status, then result (the verdict and red flags), then many token events (the explanation, a few words each), then done. The verdict still takes as long as the agent takes; what streaming changes is that the user sees progress at once and starts reading early.

Why not the browser's EventSource? Browsers have a built-in SSE reader, but it can only make GET requests, so the message would have to go in the address. Addresses end up in server logs and browser history, which is exactly where you promised personal data wouldn't go. So the page uses fetch with POST and reads the stream itself, in about 25 lines.

CORS. A browser won't let a page from one address (https://you.github.io) read answers from another (https://...run.app) unless the second says it's allowed. That rule is CORS (cross-origin resource sharing), and it protects users from malicious pages. Your API sends an "allowed origins" list; curl and Python ignore it, only browsers enforce it. That's why everything worked last week and the browser fails until you add it.

Build-time settings are public. The API address reaches the front end as VITE_API_URL, baked into the JavaScript at build time. Anyone can read it with "View source". That's fine for an address and never fine for a key.

Usability testing. You give someone a task, then watch silently while they do it and think aloud. You don't explain, help, or defend. The places they hesitate or misread are your bugs, just as real as a failing test.

Monday — Enough React, and book your testers

Learn (90 min): Full Stack Open part 1, sections a to c: introduction to React, JavaScript, and component state. Follow along in a scratch folder, not your project. Then skim React: Quick Start (15 min): it's the same ideas from the people who make React.

Build:

  1. Book ten people now, because they need notice: family, friends, ex-colleagues, ideally with a mix of ages and at least three who aren't comfortable with technology. Ask each for 15 minutes on Thursday, Friday or early next week, in person or on a video call where they share their phone screen. Ten is the target; if you get six by Friday, that's still useful.

  2. Make the feedback form in Google Forms (free) or anything similar. Keep it short:

    • Which test message were you checking? (one of yours, or "my own")
    • Did the answer match what you thought? (yes / no / not sure)
    • How easy was the answer to understand? (1 to 5)
    • Was anything confusing? What?
    • Would you trust this tool? What would make you trust it more?
    • Phone type, and age band (optional)

    Don't collect names or phone numbers. Tell testers to use your sample messages, or a real one only if they're happy for its stripped version to be stored.

  3. Prepare five test messages on a card: two obvious scams, one genuine bank or government message, one tricky genuine one (like a delivery notice), and one hard case your week 5 error analysis flagged.

  4. Scaffold the front end inside your repo, in a frontend/ folder:

    npm create vite@latest frontend -- --template react --no-interactive --no-immediate
    cd frontend
    npm install
    npm run dev
    

    Open the address it prints (http://localhost:5173) and see Vite's sample page. npm create vite writes frontend/.gitignore for you, so node_modules/ and dist/ stay out of git. Commit the frontend/ folder.

  5. Your turn: change frontend/src/App.jsx so the page shows a heading, a text box and a button, and the button shows what you typed underneath. That's FSO part 1's state section applied to your page. Throw it away tomorrow; it's practice.

    Stuck? One version
    import { useState } from 'react'
    
    export default function App() {
      const [text, setText] = useState('')
      const [shown, setShown] = useState('')
    
      return (
        <main>
          <h1>Is this message a scam?</h1>
          <textarea value={text} onChange={(event) => setText(event.target.value)} />
          <button onClick={() => setShown(text)}>Check</button>
          {shown && <p>You typed: {shown}</p>}
        </main>
      )
    }
    

    Two pieces of state: what's in the box now, and what was there when you pressed the button.

Done when: npm run dev shows your own small page, the feedback form exists, and at least six testers have said yes.

Stuck?
  • npm: command not found: install Node.js with brew install node, then open a new terminal.
  • npm create asks questions anyway: answer React, then JavaScript.
  • The page is blank: open the browser's developer tools (right-click, Inspect, Console tab). The red error message names the file and line.
  • Changes don't show: check the terminal running npm run dev for an error, and save the file.

Tuesday — A streaming endpoint

Learn (60 min):

Build:

  1. A streaming helper next to call_claude() in src/scam_checker/llm.py. Add from collections.abc import Iterator to the imports, then put this above the if __name__ == "__main__": block:

    def stream_claude(
        prompt: str,
        *,
        system: str | None = None,
        model: str = "haiku",
        timeout: float = 60,
    ) -> Iterator[str]:
        """Yield the reply in small pieces of text, as the model writes it."""
        try:
            with _client().messages.stream(
                model=MODELS.get(model, model),
                max_tokens=MAX_TOKENS,
                system=system or anthropic.omit,
                messages=[{"role": "user", "content": prompt}],
                timeout=timeout,
            ) as stream:
                yield from stream.text_stream
        except anthropic.APIError as exc:  # covers timeouts, network and status errors
            raise ClaudeError(f"streaming failed: {exc}") from exc
    

    messages.stream(...) keeps the connection open, and text_stream hands you each new piece of text as it arrives. yield from passes those pieces straight on to whoever is looping over stream_claude.

  2. Test it with the same fake transport as last week: the fake sends back a recorded stream in the SSE format the real API uses. Add to tests/test_llm.py:

    def test_stream_yields_text_pieces(fake_api):
        events = [
            (
                "message_start",
                {"type": "message_start", "message": {**api_message(""), "content": []}},
            ),
            (
                "content_block_start",
                {
                    "type": "content_block_start",
                    "index": 0,
                    "content_block": {"type": "text", "text": ""},
                },
            ),
            (
                "content_block_delta",
                {
                    "type": "content_block_delta",
                    "index": 0,
                    "delta": {"type": "text_delta", "text": "Looks "},
                },
            ),
            (
                "content_block_delta",
                {
                    "type": "content_block_delta",
                    "index": 0,
                    "delta": {"type": "text_delta", "text": "risky."},
                },
            ),
            ("content_block_stop", {"type": "content_block_stop", "index": 0}),
            ("message_stop", {"type": "message_stop"}),
        ]
        body = "".join(
            f"event: {name}\ndata: {json.dumps(data)}\n\n" for name, data in events
        )
        headers = {"content-type": "text/event-stream"}
        fake_api(lambda request: httpx2.Response(200, text=body, headers=headers))
        assert list(llm.stream_claude("hi")) == ["Looks ", "risky."]
    
  3. What the panel shows. Add to src/scam_checker/api/schemas.py:

    class CheckPanel(CheckResult):
        """What the "why we think this" panel shows."""
    
        label: str  # plain-language confidence, e.g. "Likely a scam"
        pattern: str | None  # the closest known scam pattern, if any
    
  4. The new src/scam_checker/api/main.py. Three changes from last week: the CORS middleware near the top; save_check, pulled out so both endpoints share it; and the streaming endpoint at the bottom. The plain_label function is left for you.

    """The checker as a web API.
    
    Run locally with:  uv run fastapi dev src/scam_checker/api/main.py
    """
    
    import logging
    import os
    import time
    from collections.abc import Callable, Iterator
    from typing import Annotated
    
    from fastapi import Depends, FastAPI, HTTPException
    from fastapi.middleware.cors import CORSMiddleware
    from fastapi.sse import EventSourceResponse, ServerSentEvent
    from sqlalchemy.orm import Session
    
    from scam_checker.agent import AgentRun, run_agent
    from scam_checker.api.db import CheckRecord, get_session
    from scam_checker.api.schemas import CheckPanel, CheckRequest, CheckResponse
    from scam_checker.llm import ClaudeError, stream_claude
    from scam_checker.models import CheckResult
    from scam_checker.pii import strip_pii
    from scam_checker.retrieval import search_patterns
    
    logging.basicConfig(level=logging.INFO)
    logger = logging.getLogger(__name__)
    
    app = FastAPI(title="Scam checker API", version="0.2.0")
    
    # Which web pages may call this API from a browser. Set ALLOWED_ORIGINS on
    # Cloud Run to your front end's address, e.g. https://<you>.github.io
    app.add_middleware(
        CORSMiddleware,
        allow_origins=os.environ.get("ALLOWED_ORIGINS", "http://localhost:5173").split(","),
        allow_methods=["GET", "POST"],
        allow_headers=["Content-Type"],
    )
    
    Agent = Callable[[str], AgentRun]
    Explainer = Callable[..., Iterator[str]]
    
    BUSY = "The checker is busy. Please try again in a minute."
    
    EXPLAIN_SYSTEM = """You explain a scam checker's verdict to an ordinary person in Singapore.
    Write two or three short sentences in plain words: why the message looks the way it does.
    Do not repeat the advice. Do not include phone numbers, links or names from the message."""
    
    
    def get_agent() -> Agent:
        """The agent to use. A function, so tests can swap in a fake one."""
        return run_agent
    
    
    def get_explainer() -> Explainer:
        """The streaming explanation. Also swappable in tests."""
        return stream_claude
    
    
    def save_check(session: Session, text: str, run: AgentRun, start: float) -> CheckRecord:
        result = run.result
        record = CheckRecord(
            message=strip_pii(text),
            verdict=result.verdict,
            red_flags=[strip_pii(flag) for flag in result.red_flags],
            advice=strip_pii(result.advice),
            steps=len(run.steps),
            duration_ms=int((time.perf_counter() - start) * 1000),
        )
        session.add(record)
        session.commit()
        logger.info("check %d: %s in %d ms", record.id, record.verdict, record.duration_ms)
        return record
    
    
    def top_pattern(text: str) -> str | None:
        """The closest known scam pattern from week 4, if any."""
        matches = search_patterns(text, k=1)
        return matches[0].name if matches else None  # Match.name from week 4
    
    
    @app.post("/check", response_model=CheckResponse)
    def check(
        request: CheckRequest,
        session: Annotated[Session, Depends(get_session)],
        agent: Annotated[Agent, Depends(get_agent)],
    ) -> CheckResponse:
        start = time.perf_counter()
        try:
            run = agent(request.text)
        except ClaudeError as exc:
            logger.warning("check failed: %s", exc)
            raise HTTPException(503, BUSY)
        record = save_check(session, request.text, run, start)
        return CheckResponse(check_id=record.id, **run.result.model_dump())
    
    
    @app.post("/check/stream", response_class=EventSourceResponse)
    def check_stream(
        request: CheckRequest,
        session: Annotated[Session, Depends(get_session)],
        agent: Annotated[Agent, Depends(get_agent)],
        explain: Annotated[Explainer, Depends(get_explainer)],
    ) -> Iterator[ServerSentEvent]:
        """Send the answer in stages: status, result, explanation tokens, done."""
        start = time.perf_counter()
        yield ServerSentEvent(event="status", data="Checking the message...")
        try:
            run = agent(request.text)
        except ClaudeError as exc:
            logger.warning("check failed: %s", exc)
            yield ServerSentEvent(event="error", data=BUSY)
            return
    
        result = run.result
        pattern = top_pattern(request.text) if result.verdict != "not_scam" else None
        panel = CheckPanel(
            **result.model_dump(), label=plain_label(result), pattern=pattern
        )
        yield ServerSentEvent(event="result", data=panel)
    
        prompt = (
            f"Message:\n{request.text}\n\nVerdict: {result.verdict}\n"
            f"Warning signs: {'; '.join(result.red_flags) or 'none'}"
        )
        try:
            for piece in explain(prompt, system=EXPLAIN_SYSTEM):
                yield ServerSentEvent(event="token", data=piece)
        except ClaudeError as exc:  # the verdict has already arrived; carry on
            logger.warning("explanation failed: %s", exc)
    
        record = save_check(session, request.text, run, start)
        yield ServerSentEvent(event="done", data={"check_id": record.id})
    

    Read check_stream in order. It sends a status event at once, so the page can show "Checking..." within a second. It runs the agent (the slow part), then sends the whole result as one event: the panel needs the verdict and red flags together, so there's nothing to gain by trickling them. Then it streams a short plain-language explanation from a second, small Claude call, piece by piece. It stores the check last. If the explanation fails, the user still has the verdict.

    top_pattern calls your week 4 search_patterns, which returns Match objects; .name is the pattern's title.

  5. Your turn: write plain_label(result), between save_check and top_pattern. It takes a CheckResult and returns words a non-technical reader understands: never a number, and never "safe". A scam verdict with two or more red flags should read more confidently than one with a single flag.

    Stuck? One version
    def plain_label(result: CheckResult) -> str:
        """A confidence label a non-technical reader understands."""
        if result.verdict == "scam":
            return "Very likely a scam" if len(result.red_flags) >= 2 else "Likely a scam"
        if result.verdict == "not_scam":
            return "Probably genuine"
        return "Hard to tell"
    

    Week 5 gave you a way to check this: are verdicts with two or more red flags really more often right? If not, change the rule.

  6. Tests for the stream, tests/test_stream.py. They reuse last week's fake agents, so first create an empty tests/__init__.py; that lets one test file import from another.

    """Test the streaming endpoint with a fake agent and a fake explanation."""
    
    import json
    
    import pytest
    from fastapi.testclient import TestClient
    from sqlalchemy import create_engine, func, select
    from sqlalchemy.orm import Session
    from sqlalchemy.pool import StaticPool
    
    from scam_checker.api.db import Base, CheckRecord, get_session
    from scam_checker.api.main import app, get_agent, get_explainer
    from tests.test_api import broken_agent, fake_agent
    
    
    def fake_explainer(prompt, system=None):
        yield from ["This looks ", "like a bank ", "impersonation scam."]
    
    
    def read_events(body: str) -> list[tuple[str, object]]:
        """Turn the raw text/event-stream into (event, data) pairs."""
        events = []
        for block in body.strip().split("\n\n"):
            fields = dict(
                line.split(": ", 1) for line in block.splitlines() if ": " in line
            )
            if "event" in fields:
                events.append((fields["event"], json.loads(fields["data"])))
        return events
    
    
    @pytest.fixture
    def engine():
        engine = create_engine(
            "sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool
        )
        Base.metadata.create_all(engine)
        return engine
    
    
    @pytest.fixture
    def client(engine):
        def test_session():
            with Session(engine) as session:
                yield session
    
        app.dependency_overrides[get_session] = test_session
        app.dependency_overrides[get_agent] = lambda: fake_agent
        app.dependency_overrides[get_explainer] = lambda: fake_explainer
        yield TestClient(app)
        app.dependency_overrides.clear()
    
    
    def test_stream_sends_result_then_tokens_then_done(client, engine):
        response = client.post("/check/stream", json={"text": "DBS: call 9123 4567 now"})
        assert response.headers["content-type"].startswith("text/event-stream")
        events = read_events(response.text)
        names = [name for name, _ in events]
        assert names[:2] == ["status", "result"]
        assert names[-1] == "done"
        panel = dict(events)["result"]
        assert panel["label"] == "Likely a scam"
        assert panel["pattern"] == "Bank impersonation scam"
        tokens = "".join(data for name, data in events if name == "token")
        assert tokens == "This looks like a bank impersonation scam."
        with Session(engine) as session:
            assert session.scalar(select(func.count()).select_from(CheckRecord)) == 1
    
    
    def test_stream_reports_a_busy_checker(client):
        app.dependency_overrides[get_agent] = lambda: broken_agent
        events = read_events(client.post("/check/stream", json={"text": "hi"}).text)
        assert events[-1][0] == "error"
    
    
    def test_cors_allows_the_front_end(client):
        response = client.options(
            "/check/stream",
            headers={
                "Origin": "http://localhost:5173",
                "Access-Control-Request-Method": "POST",
            },
        )
        assert response.headers["access-control-allow-origin"] == "http://localhost:5173"
    
  7. Watch a real stream:

    uv run --env-file .env fastapi dev src/scam_checker/api/main.py
    

    In a second terminal (-N tells curl to print as data arrives):

    curl -N -X POST localhost:8000/check/stream -H 'content-type: application/json' -d '{"text":"DBS: your account is locked. Call 9123 4567 now"}'
    

    You should see event: status, a pause while the agent works, event: result, then a run of event: token lines arriving a few at a time, then event: done.

Done when: pytest passes, including the stream and CORS tests, and curl -N shows tokens arriving in pieces, not all at once.

Stuck?
  • ModuleNotFoundError: No module named 'tests': create the empty tests/__init__.py.
  • ImportError: cannot import name 'EventSourceResponse' from 'fastapi.sse': your FastAPI is older than 0.135. Run uv add "fastapi[standard]>=0.135".
  • curl prints everything at the end: you left out -N.
  • Tokens arrive but done never does: the check failed to save. Look in the server terminal for the error.
  • The CORS test fails: the Origin header in the test must match ALLOWED_ORIGINS exactly, including http:// and the port, with no trailing slash.

Wednesday — The screen

Learn (60 min): Full Stack Open part 2, sections a to c: rendering a list, forms, and getting data from a server. Then MDN: Using readable streams, the first example only (15 min). MDN: Learn web development is there for any HTML, CSS or JavaScript gaps.

Build:

  1. The code that talks to the API, frontend/src/api.js. It sends the message with POST, then reads the reply as it arrives and splits it into events at each blank line:

    // Talk to the checker API and read its stream of server-sent events.
    
    const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8000'
    
    // Calls onEvent(name, data) for each event: status, result, token, error, done.
    export async function checkMessage(text, onEvent) {
      const response = await fetch(`${API_URL}/check/stream`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ text }),
      })
      if (response.status === 422) {
        throw new Error('Please paste a message of up to 2,000 characters.')
      }
      if (!response.ok) {
        throw new Error('The checker is not responding. Please try again later.')
      }
    
      const reader = response.body.pipeThrough(new TextDecoderStream()).getReader()
      let buffer = ''
      while (true) {
        const { value, done } = await reader.read()
        if (done) break
        buffer += value
        const blocks = buffer.split('\n\n') // a blank line ends each event
        buffer = blocks.pop() // the last piece may be only half-received
        for (const block of blocks) {
          let name = 'message'
          let data = ''
          for (const line of block.split('\n')) {
            if (line.startsWith('event: ')) name = line.slice(7)
            if (line.startsWith('data: ')) data += line.slice(6)
          }
          if (data) onEvent(name, JSON.parse(data)) // keep-alive pings have no data
        }
      }
    }
    

    The buffer is the subtle part. The network doesn't deliver neatly one event at a time; a piece can end halfway through a line. So the code keeps whatever comes after the last blank line and waits for the rest.

  2. The page, frontend/src/App.jsx. Each piece of state is something the screen shows; each event updates one of them, and React redraws:

    import { useState } from 'react'
    import { checkMessage } from './api.js'
    import WhyPanel from './WhyPanel.jsx'
    
    export default function App() {
      const [text, setText] = useState('')
      const [status, setStatus] = useState('')
      const [result, setResult] = useState(null)
      const [explanation, setExplanation] = useState('')
      const [error, setError] = useState('')
      const [busy, setBusy] = useState(false)
    
      async function handleSubmit(event) {
        event.preventDefault()
        setResult(null)
        setExplanation('')
        setError('')
        setBusy(true)
        try {
          await checkMessage(text, (name, data) => {
            if (name === 'status') setStatus(data)
            if (name === 'result') setResult(data)
            if (name === 'token') setExplanation((sofar) => sofar + data)
            if (name === 'error') setError(data)
          })
        } catch (err) {
          setError(err.message)
        } finally {
          setStatus('')
          setBusy(false)
        }
      }
    
      return (
        <main>
          <h1>Is this message a scam?</h1>
          <form onSubmit={handleSubmit}>
            <label htmlFor="message">Paste the message you received</label>
            <textarea
              id="message"
              rows={6}
              maxLength={2000}
              value={text}
              onChange={(event) => setText(event.target.value)}
            />
            <button type="submit" disabled={busy || !text.trim()}>
              {busy ? 'Checking...' : 'Check'}
            </button>
          </form>
    
          {status && <p className="status">{status}</p>}
          {error && <p className="error">{error}</p>}
          {result && <WhyPanel result={result} explanation={explanation} />}
    
          <p className="disclaimer">
            This is a learning project, not a guarantee. If in doubt, don&apos;t click links or
            share codes, and call the ScamShield Helpline on 1799.
          </p>
        </main>
      )
    }
    

    setExplanation((sofar) => sofar + data) adds each token to what's there already; that's what makes the words appear one after another.

  3. Your turn: write frontend/src/WhyPanel.jsx. It gets result (the result event's data) and explanation as props, and shows, in this order: the plain-language label as a heading; the explanation; a "Why we think this" list of the red flags; the matched pattern if there is one; and a "What to do" heading with the advice. Give the outer element a class of danger, caution or calm depending on the verdict, so tomorrow's styles can colour it.

    Stuck? One version
    // The "why we think this" panel: verdict, explanation, warning signs, advice.
    
    const TONE = { scam: 'danger', unsure: 'caution', not_scam: 'calm' }
    
    export default function WhyPanel({ result, explanation }) {
      return (
        <section className={`panel ${TONE[result.verdict]}`} aria-live="polite">
          <h2>{result.label}</h2>
          {explanation && <p>{explanation}</p>}
    
          {result.red_flags.length > 0 && (
            <>
              <h3>Why we think this</h3>
              <ul>
                {result.red_flags.map((flag) => (
                  <li key={flag}>{flag}</li>
                ))}
              </ul>
            </>
          )}
          {result.pattern && (
            <p>
              It looks like a known type: <strong>{result.pattern}</strong>
            </p>
          )}
    
          <h3>What to do</h3>
          <p>{result.advice}</p>
        </section>
      )
    }
    

    aria-live="polite" tells screen readers to read out the panel when it appears, for users who can't see it change.

  4. Delete src/App.css and the src/assets folder (the sample's), and give the page a real title in frontend/index.html: change <title>frontend</title> to <title>Scam message checker</title>.

  5. Phone first. Replace frontend/src/index.css:

    /* Phone first: one column, big text, big buttons. Wider screens get a centred column. */
    :root {
      font-family: system-ui, sans-serif;
      font-size: 18px;
      line-height: 1.5;
      color: #1a1a1a;
      background: #fafafa;
    }
    
    main {
      max-width: 40rem;
      margin: 0 auto;
      padding: 1rem;
    }
    
    h1 {
      font-size: 1.6rem;
    }
    
    label {
      display: block;
      font-weight: 600;
      margin-bottom: 0.4rem;
    }
    
    textarea {
      width: 100%;
      box-sizing: border-box;
      font: inherit;
      padding: 0.6rem;
    }
    
    button {
      width: 100%;
      min-height: 3rem; /* easy to tap */
      margin-top: 0.6rem;
      font: inherit;
      font-weight: 600;
      color: white;
      background: #1f5fbf;
      border: none;
      border-radius: 0.4rem;
    }
    
    button:disabled {
      background: #8a9bb5;
    }
    
    .panel {
      margin-top: 1.2rem;
      padding: 1rem;
      border-radius: 0.4rem;
      border-left: 0.4rem solid;
      background: white;
    }
    
    .danger {
      border-color: #c62828;
    }
    
    .caution {
      border-color: #ef8f00;
    }
    
    .calm {
      border-color: #2e7d32;
    }
    
    .error {
      color: #c62828;
    }
    
    .status,
    .disclaimer {
      color: #555;
      font-size: 0.9rem;
    }
    

    One column, text large enough that phones don't zoom in when you tap the box, and a full-width button at least 3rem tall, roughly a fingertip. The viewport line Vite already put in index.html is what tells phones not to shrink the page to desktop size. See MDN: Responsive design for more.

  6. Try it end to end. Keep the API running from yesterday, start Vite with npm run dev in frontend/, and check a message. Then open the browser's developer tools, switch on the device toolbar (phone view), and try it at a phone's width.

  7. Build it and run the linter:

    npm run build
    npm run lint
    

Done when: on localhost:5173, a scam message shows "Checking...", then the coloured panel, then the explanation growing word by word, and it still looks right in phone view.

Stuck?
  • The browser console says blocked by CORS policy: the page's address must be in ALLOWED_ORIGINS. Locally that's http://localhost:5173, which is the default.
  • Failed to fetch: the API isn't running, or it's on a different port. The front end expects http://localhost:8000 unless you set VITE_API_URL.
  • The whole explanation appears at once: something is buffering. Check you used /check/stream, not /check.
  • Unexpected token in JSON.parse: a data: line was cut in half. Check the buffer = blocks.pop() line is there.
  • React warns about a missing key: every item in a list needs key={...}; the red flag text works.

Thursday — Live on GitHub Pages, and the first testers

Learn (45 min):

Build:

  1. GitHub Pages serves your site at https://<you>.github.io/scam-checker/, in a sub-folder named after the repo, so Vite has to know that. frontend/vite.config.js:

    import react from '@vitejs/plugin-react'
    import { defineConfig } from 'vite'
    
    // https://vite.dev/config/
    export default defineConfig({
      plugins: [react()],
      base: '/scam-checker/', // GitHub Pages serves the site at /<repo-name>/
    })
    
  2. A workflow that builds the front end and publishes it, .github/workflows/pages.yml. It follows Vite's sample, adjusted for the frontend/ folder, and only runs when something in it changes:

    # Build the React front end and publish it on GitHub Pages.
    name: Deploy front end
    
    on:
      push:
        branches: [main]
        paths: ["frontend/**", ".github/workflows/pages.yml"]
      workflow_dispatch: # also lets you run it by hand from the Actions tab
    
    permissions:
      contents: read
      pages: write
      id-token: write
    
    concurrency:
      group: pages
      cancel-in-progress: true
    
    jobs:
      deploy:
        environment:
          name: github-pages
          url: ${{ steps.deployment.outputs.page_url }}
        runs-on: ubuntu-latest
        defaults:
          run:
            working-directory: frontend
        steps:
          - uses: actions/checkout@v7
          - uses: actions/setup-node@v7
            with:
              node-version: lts/*
              cache: npm
              cache-dependency-path: frontend/package-lock.json
          - run: npm ci
          - run: npm run build
            env:
              VITE_API_URL: ${{ vars.API_URL }} # your Cloud Run address, set as a repo variable
          - uses: actions/configure-pages@v6
          - uses: actions/upload-pages-artifact@v5
            with:
              path: frontend/dist
          - id: deployment
            uses: actions/deploy-pages@v5
    
  3. Three settings, then push:

    gh variable set API_URL --body "https://scam-checker-api-xxxx.asia-southeast1.run.app"
    

    In the repo on GitHub, open Settings, then Pages, and under Build and deployment set Source to GitHub Actions. Then let the API accept requests from the new page. The origin is the scheme and host only, with no /scam-checker/ path:

    gcloud run services update scam-checker-api --region asia-southeast1 \
      --update-env-vars ALLOWED_ORIGINS=https://<you>.github.io
    

    Your deploy workflow keeps environment variables it doesn't set, so this survives future deploys.

  4. Push, watch the Actions tab, then open https://<you>.github.io/scam-checker/ on your own phone. Check a message over mobile data, not just home Wi-Fi.

  5. Run your first test sessions. For each person:

    • Give them the phone and one sentence: "Someone sent you this message; use this page to decide what to do."
    • Then stay quiet. Don't explain, help, or defend. Write down where they pause, what they tap, and what they say.
    • After all five messages, ask them to fill in the form.

    Your job is to collect evidence, not to be reassured.

Done when: the page works on your phone from the public address, and at least three people have tested it.

Stuck?
  • The page is blank and the console shows 404s for /assets/...: base in vite.config.js must be '/<your repo name>/', with both slashes.
  • The page loads but checks fail with a CORS error: ALLOWED_ORIGINS must be exactly https://<you>.github.io, with no trailing slash and no path.
  • The page calls localhost:8000: the API_URL repository variable is missing or misnamed, so the build fell back to the default. Fix it and re-run the workflow.
  • The deploy job fails with "Pages not enabled" or similar: set Source to GitHub Actions in Settings, then re-run.
  • The first check after lunch takes much longer: a Cloud Run cold start, plus Neon waking up. Tell testers it can take up to a minute; that is also a finding.

Friday — What people told you, two fixes, and demo day

Learn (30 min): read every form response and your session notes, start to finish, before you change anything.

Build (before 9:00 or after demo day):

  1. Finish the remaining sessions; it's fine if a few slip to Monday.

  2. Group what you saw, the same way you grouped model errors in week 5: one row per problem, how many of your testers hit it, and how bad it was. Typical ones: didn't notice the answer appeared below the fold; read "Hard to tell" as "safe"; didn't trust it without knowing who made it; waited too long and gave up.

  3. Your turn: fix the top two, ranked by how many people they hit and how much harm a misreading could do. A tester reading a scam as safe ranks above any layout complaint. Re-test each fix with one person.

  4. Write it up in docs/user-testing.md: who tested (ages and comfort with technology, no names), the table of problems, the two fixes, and what you'd fix next. It's evidence for your case study in week 12.

Demo day (9:00–12:00):

  1. Record a 3-minute video on your phone or in phone view: a scam message streaming in, a genuine one, and one of the problems a tester found with its fix.
  2. Write five lines on what you learned this week.
  3. Send both to Claude with the Friday check-in, including your problem table.

Friday afternoon: the cloud side track.

Deliverable checklist

  • stream_claude() with a test that uses a recorded stream
  • POST /check/stream sending status, result, tokens and done, with tests
  • CORS limited to your front end's address
  • A React page with a "why we think this" panel, a confidence label and a "not a guarantee" note
  • Usable on a phone, deployed on GitHub Pages
  • Ten testers (or as many as you managed), a feedback form, and docs/user-testing.md
  • The top two problems fixed and re-tested
  • Demo video, five lines, and the Friday check-in sent

If you have time left: show the user something useful during the slow agent step. Count the seconds since "Checking..." appeared and, after ten, add "Still checking: this one has links to look up." It's a small change, and it's the kind of detail testers notice.