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:
-
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.
-
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.
-
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.
-
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 devOpen the address it prints (
http://localhost:5173) and see Vite's sample page.npm create vitewritesfrontend/.gitignorefor you, sonode_modules/anddist/stay out of git. Commit thefrontend/folder. -
Your turn: change
frontend/src/App.jsxso 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 withbrew install node, then open a new terminal.npm createasks 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 devfor an error, and save the file.
Tuesday — A streaming endpoint¶
Learn (60 min):
- Streaming messages, the Python examples (20 min)
- FastAPI: Server-Sent Events (20 min)
- FastAPI: CORS and MDN: CORS, the introduction only (20 min)
Build:
-
A streaming helper next to
call_claude()insrc/scam_checker/llm.py. Addfrom collections.abc import Iteratorto the imports, then put this above theif __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 excmessages.stream(...)keeps the connection open, andtext_streamhands you each new piece of text as it arrives.yield frompasses those pieces straight on to whoever is looping overstream_claude. -
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."] -
What the panel shows. Add to
src/scam_checker/api/schemas.py: -
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. Theplain_labelfunction 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_streamin order. It sends astatusevent at once, so the page can show "Checking..." within a second. It runs the agent (the slow part), then sends the wholeresultas 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_patterncalls your week 4search_patterns, which returnsMatchobjects;.nameis the pattern's title. -
Your turn: write
plain_label(result), betweensave_checkandtop_pattern. It takes aCheckResultand 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.
-
Tests for the stream,
tests/test_stream.py. They reuse last week's fake agents, so first create an emptytests/__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" -
Watch a real stream:
In a second terminal (
-Ntells 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 ofevent: tokenlines arriving a few at a time, thenevent: 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 emptytests/__init__.py.ImportError: cannot import name 'EventSourceResponse' from 'fastapi.sse': your FastAPI is older than 0.135. Runuv add "fastapi[standard]>=0.135".curlprints everything at the end: you left out-N.- Tokens arrive but
donenever does: the check failed to save. Look in the server terminal for the error. - The CORS test fails: the
Originheader in the test must matchALLOWED_ORIGINSexactly, includinghttp://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:
-
The code that talks to the API,
frontend/src/api.js. It sends the message withPOST, 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
bufferis 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. -
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'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. -
Your turn: write
frontend/src/WhyPanel.jsx. It getsresult(theresultevent's data) andexplanationas 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 ofdanger,cautionorcalmdepending 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. -
Delete
src/App.cssand thesrc/assetsfolder (the sample's), and give the page a real title infrontend/index.html: change<title>frontend</title>to<title>Scam message checker</title>. -
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
viewportline Vite already put inindex.htmlis what tells phones not to shrink the page to desktop size. See MDN: Responsive design for more. -
Try it end to end. Keep the API running from yesterday, start Vite with
npm run devinfrontend/, 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. -
Build it and run the linter:
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 inALLOWED_ORIGINS. Locally that'shttp://localhost:5173, which is the default. Failed to fetch: the API isn't running, or it's on a different port. The front end expectshttp://localhost:8000unless you setVITE_API_URL.- The whole explanation appears at once: something is buffering. Check you used
/check/stream, not/check. Unexpected tokeninJSON.parse: adata:line was cut in half. Check thebuffer = blocks.pop()line is there.- React warns about a missing
key: every item in a list needskey={...}; the red flag text works.
Thursday — Live on GitHub Pages, and the first testers¶
Learn (45 min):
- Vite: Deploying a static site, the GitHub Pages section (15 min)
- Vite: Env variables and modes, the first section (10 min)
- About GitHub Pages (10 min). Pages is free for public repositories, needs no new account, and your code is already on GitHub, which is why it's the host here.
Build:
-
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: -
A workflow that builds the front end and publishes it,
.github/workflows/pages.yml. It follows Vite's sample, adjusted for thefrontend/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 -
Three settings, then push:
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.ioYour deploy workflow keeps environment variables it doesn't set, so this survives future deploys.
-
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. -
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/...:baseinvite.config.jsmust be'/<your repo name>/', with both slashes. - The page loads but checks fail with a CORS error:
ALLOWED_ORIGINSmust be exactlyhttps://<you>.github.io, with no trailing slash and no path. - The page calls
localhost:8000: theAPI_URLrepository 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):
-
Finish the remaining sessions; it's fine if a few slip to Monday.
-
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.
-
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.
-
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):
- 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.
- Write five lines on what you learned this week.
- 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/streamsending 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.