================ ONE-SHOT BUILD CONTRACT (read first) ================
Build this in Google AI Studio "Build" in ONE shot — a complete, working app,
no follow-up turns. These are hard rules, not suggestions:
1. TARGET = Full-Stack Web (Node server runtime, secrets, Firebase allowed).
If you target Native Android instead, you MUST drop all server/DB/Workspace/
secrets and persist locally (Room / SharedPreferences) only.
2. PIN EVERY MODEL CALL — never let the agent auto-select (it downgrades on quota):
- Reasoning / text -> gemini-3.5-flash (thinkingLevel: minimal|low|medium|high)
- 4K image + legible text -> gemini-3-pro-image (image_size "4K", up to 14 refs)
- High-volume image -> gemini-3.1-flash-image
- Expressive TTS -> gemini-3.1-flash-tts-preview (inline tags e.g. [whispers])
- Realtime audio/video (WebSocket) -> gemini-3.1-flash-live-preview
- Sandboxed agent -> antigravity-preview-05-2026
3. DIVISION OF LABOR — the model ONLY parses/extracts to a strict responseSchema.
ALL math, money (store currency as integer minor units / cents), sorting,
balancing and graph logic run in deterministic TypeScript/Python. The model
must never compute totals, splits or balances itself.
4. responseSchema sanitation — no regex patterns, no fixed-length tuples, no
format validators in the schema (they crash the OpenAPI engine). Enforce those
in server-side code AFTER parsing the JSON.
5. responseSchema and google_search grounding are MUTUALLY EXCLUSIVE in one call.
6. CODEGEN — split large output into modular, single-responsibility files so no
file is truncated by the output-token cap.
7. Every external call gets a graceful fallback (e.g. manual paste if a Workspace
read fails). Never a silent dead end.
8. ROBUST STORAGE & CANVAS — Wrap all `localStorage`/`sessionStorage` operations (especially JSON parsing and writes) in `try-catch` blocks to prevent crashes in private windows or quota overflows. Canvas drawing elements must dynamically handle window resize and scale pixel density (`window.devicePixelRatio`) to avoid blurry graphics on retina displays.
=====================================================================
# MUST OBEY — Mobile-first build requirements
This app's PRIMARY surface is a mobile phone. Build it impeccably on mobile FIRST, then verify on tablet and desktop. Treat the rules below as non-negotiable hard constraints, not suggestions.
## Viewports to verify (every screen, every state)
- 320 px, 360 px, 375 px, 390 px, 414 px, 480 px
- 768 px, 834 px (iPad portrait / Pro 11)
- 1024 px, 1280 px, 1440 px, 1920 px, 2560 px
- Plus: 200% browser zoom, landscape orientation on every mobile width, iPhone with safe-area insets visible
## Hard layout rules
- Mobile-first CSS. Default styles target mobile; `@media (min-width: ...)` for larger viewports.
- Use `dvh` and `svh` instead of `vh` for full-height surfaces (iOS Safari URL-bar bug).
- Use `clamp()` for fluid typography across all viewports.
- Prefer container queries (`@container`) over media queries for component-level responsiveness.
- Use `min(100%, ...)` widths so content never overflows. Zero horizontal overflow at any viewport.
- Add `` to every page.
- Apply `padding: max(safe-area-inset-X, fallback)` on every edge-bleeding container so notched iPhones in landscape never clip content.
- Wide tables and code blocks scroll INSIDE their container (`overflow-x: auto`), never push the body.
- Use `background-attachment: scroll` on mobile, not `fixed` (iOS Safari repaint bug).
- Avoid `backdrop-filter` on animated elements. Use it sparingly on static surfaces only.
- **Canvas Scaling**: Canvases must dynamically scale with window resize events and properly handle high-DPI screens (`window.devicePixelRatio`). Set physical dimensions (`canvas.width`/`canvas.height`) using pixel ratio and render relative to this grid, using CSS to control responsive viewport scaling.
- **Robust Storage**: Every access to `localStorage`/`sessionStorage` (especially `JSON.parse` of loaded state or writes) MUST be wrapped in a `try-catch` block to handle disabled storage, private browsing mode, quota limits, or corrupted JSON gracefully. Fall back to a robust in-memory object store.
## Touch & accessibility
- Tap targets ≥ 44 × 44 px on touch (Apple HIG). Increase to 48 px under `@media (hover: none) and (pointer: coarse)`.
- All interactive controls reachable by keyboard with a visible focus ring; respect `:focus-visible`.
- Color contrast ≥ 4.5:1 for body text, 3:1 for UI components.
- All images have meaningful `alt`. Decorative images use `alt=""`.
- Respect `prefers-reduced-motion: reduce` — zero animation durations under that query.
- Forms validate inline; error messages are specific, not "Invalid input".
- Modals: focus trap, `Esc` closes, `role="dialog"`, `aria-modal="true"`, focus restored on close.
## Performance bar (Lighthouse mobile, throttled 3G/4G)
- LCP < 2.5 s · INP < 200 ms · CLS < 0.1
- JS bundle gzip < 200 KB mobile-first; lazy-load non-critical screens via `React.lazy` / dynamic imports.
- No render-blocking resources above the fold.
- Images: WebP/AVIF preferred, `loading="lazy"`, explicit `width`/`height` attributes (zero CLS), `srcset` for retina.
- Videos: `preload="metadata"`, low-resolution poster, max 720p mobile fallback. Never autoplay with audio.
- Fonts: `font-display: swap`; preload only the one used above the fold.
- Smooth scroll honoured via CSS `scroll-behavior: smooth` with reduced-motion fallback.
## Pre-ship mobile checklist (the deployer MUST verify before declaring done)
1. Open at 375 px in DevTools — every screen scrolls vertically only; zero horizontal scroll.
2. Browser zoom 200% — layout reflows without overlap.
3. iPhone Safari with the URL bar visible AND landscape — no content under the home indicator; no notch clipping.
4. iPad portrait (768 px) and landscape (1024 px) — no awkward gaps; tablet-specific breakpoints land cleanly.
5. Tap every interactive element with a thumb at real-device size — every target is easy to hit.
6. `prefers-reduced-motion: reduce` — every transition / animation skips cleanly, scroll-behavior becomes instant.
7. Lighthouse mobile score ≥ 90 across all 4 categories.
8. Zero `console.error` and zero CLS shift in real-device testing on a mid-tier Android (e.g. Pixel 6a) and an iPhone SE.
---
The original template starts below. All rules above apply on TOP of whatever this template specifies.
---
# Council Meeting Watcher
## 1. Project
**Council Meeting Watcher** is a long-context audio reader for the local
council meetings the user cannot attend in person. The user drops in a
three-hour audio recording — the official MP3 the clerk publishes the
morning after the meeting, or the phone recording a neighbour made
sitting at the back of the room — and gets back a one-page printable
summary, an item-by-item agenda with timestamps, a list of every
person who spoke (and how many times, and on which items), and a
flagged list of the exact moments the user's own street, school, park,
or watch-list keyword was mentioned in the room. Every claim in the
summary links back to a "play from here" timestamp in the original
audio. Nothing is invented; everything that matters is sourced.
This is the kind of app a single mother in East Oakland opens on
Wednesday morning at the bus stop with her kid, because Tuesday
night's three-hour Planning Commission meeting decided whether the
empty lot behind her apartment becomes a six-storey building or a
park, and she works two jobs and cannot sit in a city-hall basement
from 6 pm to 9:30 pm. It is also the kind of app a retired electrician
in Bristol opens on a Saturday morning because the developer at the
end of his street has been back to Cabot Ward six times this year,
and somewhere inside last Thursday's full-council session — three
hours, twenty-two agenda items, accents he can hear better than the
council's webcast can transmit — there is the line that decides
whether the corner gets rezoned. Same shape of moment, different
continent, different street.
The single demo that proves the magic: drop a three-hour MP3 of a
council planning meeting → in under two minutes the user sees a
one-page summary at the top of the screen, an agenda list below it
(Item 1 — Approval of Minutes · Item 2 — Public Comment · Item 3 —
Zoning Amendment 2026-114, the user's item — with a play-from-here
timestamp at 01:42:08), and one prominent banner across the top:
*"Beacon Avenue mentioned 4 times — 01:42:08, 01:47:51, 02:03:14,
02:51:33."* Each timestamp is a button. Tapping the first one starts
the audio playing at the exact second a commissioner says the words
*"…and that brings us to the variance request on the Beacon Avenue
parcel, which staff is recommending the commission approve."*
And in the harder cases — a four-hour combined Police Commission +
Budget Committee in a city where public comment runs an hour and the
chair gavels through three rezonings in fifteen minutes — the app
still finds the items that matter. It separates the eight different
human voices on the dais by speaker (Commissioner, Chair, City
Attorney, Staff Planner, Public Speaker #3, etc.), it surfaces the
moment the staff recommendation diverges from the commission vote,
it flags the items the council moved into closed session and lists
what the public did not get to hear, and it records every public
commenter's first sentence verbatim so the user can scan who showed
up to oppose or support which item without listening to thirty-eight
minutes of three-minute statements.
**Tagline:** _Drop in a three-hour council meeting — get back a one-page summary, every mention of your street, every public speaker, every vote, with "play from here" timestamps on every line. In any city, any agenda, any accent._
## 2. Target audience
- Neighbours who care about a specific local decision (a rezoning, a road closure, a school boundary, a homeless-shelter siting) but cannot attend the meeting in person — single parents, shift workers, carers, anyone whose evenings are not free
- Block captains, neighbourhood-association officers, and community-board members tracking multiple meetings across multiple bodies (city council + planning commission + transportation authority + school board)
- Local journalists at small papers and independent outlets covering ten council districts with two reporters, who need to know which of last week's six meetings is worth a 600-word write-up
- Civic-tech volunteers, "Council watcher" Discord servers, and election-protection groups who pool meeting summaries across neighbourhoods
- Local historians and archive volunteers digitising decades of cassette-tape council recordings — Bristol Records Office, Oakland Public Library Local History Room, the Filipino Heritage Society in Daly City — turning unsearchable tape into searchable text
- Tenants' unions, immigrant-rights groups, and disabled-people's councils who need to know, in their own language, what a council meeting said about evictions, sanctuary policy, or paratransit
- Newly-arrived residents who have never sat in a council meeting in their new country and want to understand what local government does and how to participate without first sitting through a three-hour meeting in a second or third language
- Students of urban planning, public administration, and civic-tech researchers studying decision patterns across hundreds of meetings
- Council clerks and councillors themselves who want a searchable record of what was said and decided across a year of meetings, without paying for enterprise transcription
## 3. Core value propositions
Surface these clearly through copy, visual emphasis, and section ordering — they are the reasons users pick this app.
- **Reads three hours of meeting audio in under two minutes** — Gemini 3.5 Flash's long-context audio understanding ingests the full MP3 in one call, identifies speakers, separates agenda items, and emits a structured summary with timestamps that link back into the recording. No clip-by-clip babysitting, no manual chunking.
- **Surfaces your street, your school, your watch list — automatically.** The user tells the app once: "I care about Beacon Avenue, the Eastlake corner lot, and anything that says 'rezoning' or 'variance' near zip 94606." Every meeting the user drops in is scanned against the watch list. The result is the one-page summary with the user's items at the top, every other item below.
- **Names the speakers, counts the speakers, separates the dais from the mic.** Diarisation distinguishes the chair from the four commissioners, the city attorney from the staff planner, the public commenters from the elected officials. The user sees who said what, how often, and on which items.
- **Records the public comment without flattening it.** Each public speaker's first sentence is preserved verbatim; their topic, sentiment (support / oppose / mixed / unclear), and any address or affiliation they self-identified is captured. The user can scan thirty-eight public commenters in two minutes.
- **Surfaces the votes and the dissenters.** Every roll-call vote is parsed into Yes / No / Abstain / Absent per commissioner, with the timestamp the vote was called and the agenda item it resolved. A vote that diverges from the staff recommendation is flagged.
- **Never editorialises.** The app surfaces facts and verbatim quotes; the user decides whether to act. There is no "the commission appears to be siding with the developer". There is "Commissioner Aoki, 01:47:51: 'I think staff's recommendation here goes too far'." The interpretation is the user's.
- **Works in any language the meeting is conducted in.** Gemini 3.5 Flash audio handles English in its many municipal accents (Boston, Glasgow, Bristol, Brooklyn, East Bay, suburban Toronto), Spanish in Mexico City and Caracas registers, Tagalog-English code-switching in Filipino municipal meetings, Hindi in Indian municipal corporations, Tamil in Tamil Nadu panchayats, Mandarin and Cantonese in Hong Kong District Council debates, Korean in Seoul-gu council sessions, Vietnamese, Bengali, Punjabi, Urdu, Amharic in Addis Ababa wereda councils, Swahili in Nairobi ward committees, Farsi, Arabic, French in Marseille and Montréal arrondissement councils, Portuguese in Brazilian municipal câmaras, and code-switching across any pair of these.
- **Links every claim back to the audio.** Every summary sentence, every agenda-item description, every vote, every public-comment first sentence carries a "play from here" timestamp. The user can verify any claim in two taps. Nothing is asserted without a source.
- **Comparison view across meetings.** When the same item returns to the same body (Variance Request 2026-114 was continued from October to November to January), the app threads the three meetings together so the user reads the developing record, not three disconnected summaries.
## 4. Features to build
- Audio upload — MP3, M4A, AAC, OGG, FLAC, WAV. Drag-and-drop, file picker, paste a public URL (the clerk's website often posts a direct MP3 link), or paste a YouTube link to a streamed meeting (the app extracts audio server-side).
- Long-context audio parse — one Gemini 3.5 Flash call ingests the full meeting (up to ~3 hours; longer meetings auto-chunk by agenda item boundaries). Output: structured `Meeting` schema with agenda items, speakers, public commenters, votes, watch-list hits, and a one-page summary.
- Speaker diarisation — distinguishes Chair, individual Commissioners by seat, City Attorney, Staff Planner / staff reports, Public Speakers, and "unidentified" voices. The user can rename "Speaker 3" → "Commissioner Aoki" once and the rename propagates across the whole archive.
- Agenda-item segmentation — finds the spoken "Item 3, zoning amendment 2026-114" announcements and uses them as section boundaries. Falls back to topic-shift detection when the chair skips agenda numbers.
- Watch-list scanner — user-defined keywords (street names, parcel numbers, school names, councillor names, project numbers, ordinance numbers) are scanned across every meeting the user uploads. Hits are timestamped and quoted verbatim.
- One-page summary — top of the meeting page: a printable single-page summary with the agenda-at-a-glance, the watch-list hits, the votes that happened, and the public-comment count. Designed to be printed and put on a fridge or a neighbourhood-meeting clipboard.
- Verbatim quote with "play from here" — every claim in the summary links back to an audio timestamp. Tapping it opens the embedded audio player at that second and highlights the surrounding 60 seconds of transcript.
- Public comment list — every public speaker with: timestamp, first-sentence verbatim, self-identified name and affiliation if stated, topic, sentiment, and a "play their full statement" button.
- Vote ledger — Yes / No / Abstain / Absent per commissioner per item, with the timestamp the chair called the vote. Divergences from staff recommendation are flagged in a soft amber.
- Multi-meeting threading — same item across multiple meetings is linked. The user sees Item 2026-114 as a thread: October hearing (continued), November hearing (continued), January hearing (approved 4-1).
- Multilingual handling — the meeting is transcribed in its source language; the user can request a translation of the summary, the public comments, or any quoted line. Original language is always one tap away. Code-switching is preserved (a councillor who switches from English to Cantonese mid-sentence is transcribed that way; both languages render).
- Search across the user's meeting archive — semantic ("show me every meeting where the planning commission discussed parking minimums") and structured ("show me every roll-call vote on a rezoning in 2026, where the vote was not unanimous").
- Newsletter export — generates a 400-word email summary of the meeting, formatted for a neighbourhood newsletter or local journalist's Substack. User edits before sending; nothing is sent automatically.
- Calendar of upcoming meetings — user pastes the council's agenda-publishing page URL; the app reads upcoming agendas (when published) and surfaces items that match the watch list, ahead of the meeting.
- Closed-session note — when the body votes to enter closed session, the app records the time, the agenda item label, and the stated reason ("personnel matter", "real-estate negotiation", "litigation"), and notes that the audio of the closed portion is not available.
- Share a meeting — generate a public read-only link to a specific meeting summary (the user's archive is private by default; sharing is per-meeting, opt-in).
- Save to PDF — printable one-page summary, multipage agenda detail, or full transcript. Each PDF carries a permalink back to the audio source URL the user uploaded.
- "Was my comment in there?" — a user who submitted public comment in person can search for their own name or first sentence and find their own three-minute slot in the recording, for their own records.
## 4b. Required Gemini capabilities + backend services
**This template's intelligence comes from the Gemini capabilities below. Wire them up explicitly — don't substitute generic LLM calls.**
### Gemini capabilities (the load-bearing intelligence)
- **Long-context audio understanding** (Gemini 3.5 Flash) — reads up to ~3 hours of council meeting audio in a single call. Identifies speakers, segments agenda items, separates dais from public-comment mic, captures votes, and emits the structured `Meeting` JSON. **Guardrail**: a parsed Meeting object averages ~6-15k tokens of output; a 3-hour meeting input is ~75-120k tokens of audio in token-equivalent. The 1M context ceiling is real — meetings longer than ~5 hours (some marathon budget hearings; some combined-body sessions) must auto-chunk by agenda-item boundary. Detect when input approaches 800k tokens and split.
- **Multilingual audio + code-switching** (Gemini 3.5 Flash) — handles council meetings in English (with the full municipal-accent range from Brisbane to Birmingham to the Bronx), Spanish (Mexican, Andean, Caribbean, peninsular registers), Portuguese (Brazilian + European), French (metropolitan, Québécois, Maghrebi-French), Tagalog with English code-switching, Mandarin and Cantonese, Korean, Vietnamese, Hindi, Tamil, Bengali, Punjabi, Urdu, Amharic, Swahili, Farsi, Arabic, and code-switching across any pair the meeting actually uses. Source language(s) detected per agenda item; multilingual code-switching captured at the sentence level.
- **Speaker diarisation** (Gemini 3.5 Flash audio) — separates the dais voices from the public-comment mic. The dais speakers are stable across the meeting (the same Commissioner is in the same seat all night); the public-comment speakers are not (one speaker per slot, three minutes each). The model is prompted to track dais voices as persistent IDs and treat the public-comment mic as a flow of distinct one-time speakers identified primarily by their own self-introduction.
- **Structured output / JSON Schema** — the parse response matches the `Meeting` schema below. Every field is typed; the schema is included verbatim in the system instruction and as `responseSchema`.
- **Search grounding** (Gemini 3.5 Flash, separate call) — for resolving project numbers, parcel numbers, ordinance numbers, and named developers mentioned in the meeting to public records. "Variance Request 2026-114" gets resolved (via grounded search) to the project on the city's public-permitting portal, when available. The summary surfaces the grounded link as a citation; the model is never asked to write the URL itself.
- **Long context (1M tokens) on the archive** — once the user's archive grows (a year of fortnightly meetings ≈ 26 meetings ≈ 150-400k tokens of structured summaries), the cross-meeting threading call (Item 2026-114 across three meetings) needs to see every relevant meeting at once. Below ~30 meetings: include the full archive. Above 30 meetings: filter by body (Planning Commission only) and by 12-month window before the call.
- **Thinking levels** — `high` for the primary meeting-parse call (long-context audio, speaker diarisation, agenda segmentation, watch-list scanning — the load-bearing call). `medium` for the cross-meeting threading call (it must reason across structured summaries to detect continuations). `low` for newsletter-style summarisation of an already-parsed meeting and for watch-list keyword expansion. Surface `thoughtSummary` only behind the small "(i) how the AI read this" affordance next to a low-confidence claim.
### Backend services
- **Auth — Required.** Firebase Auth with Google sign-in (auto-provisioned by AI Studio Build). **Apple sign-in is optional but user-configured**: it requires an Apple Developer account, Service ID, Key ID, and private key wired into the Firebase Auth console. **Magic-link email** (used for sharing a meeting summary with a neighbour by sending them an email link) requires the sender domain to be authorised in Firebase Auth. The user's meeting archive is private by default; per-meeting sharing is explicit.
- **Database — Required.** Firestore for `users`, `bodies` (the council / commissions / committees the user tracks), `meetings`, `agenda_items`, `speakers`, `public_comments`, `votes`, `watch_list_hits`, `watch_lists`, `archive_members` (if the user invites a co-watcher).
- **File storage — Required.** Firebase Storage for the uploaded audio files. **Storage is NOT auto-provisioned by AI Studio Build today** — enable it in the Firebase console and wire the bucket name into the AIS Build project before first audio upload. Pre-signed URLs only; the audio files are private to the uploader. Large MP3s (3 hours @ 128 kbps ≈ 165 MB) are common; configure Firebase Storage upload size limits accordingly.
- **Cloud Run server functions — Required (auto-provisioned).** All Gemini calls happen server-side. The audio file is uploaded once to Firebase Storage, then a Cloud Run function copies it to a Gemini Files API resource name (`files/*`) for the long-context parse call. The client never holds the API key.
- **Secrets Manager — Required (auto-provisioned).** `GEMINI_API_KEY` lives in Secrets Manager. Never in client bundle.
- **Email — Required (transactional).** Per-meeting share links via email (Firebase Auth magic links). Watch-list-hit notifications (opt-in only — never default-on; some users are quietly tracking civic decisions and do not want their inbox to broadcast it).
- **Payments — Not needed for v1.** Free for personal use. A future "civic-tech team" tier could charge for shared archives across a journalism team or a neighbourhood association, with audit logs.
- **External APIs:** Gemini API for all intelligence. Optional: a city-agenda-feed integration (the user pastes their council's iCal or agenda-RSS URL — the app reads upcoming agendas without storing the city's credentials). No third-party transcription service; Gemini 3.5 Flash audio is the transcript.
**Environment variables:** every secret (Gemini API key, Firebase service-account JSON, optional SendGrid for transactional email if not using Firebase magic links) lives in environment variables — never in client bundle. Include a `.env.example`.
**Auth + data privacy reminders:** never log secrets · never store passwords in plain text · use HTTPS everywhere · honour 'delete my account' inside the UI · explicit opt-in for any analytics · the user's audio recordings and meeting archive are never sent to Gemini for model training (use the Gemini API on the paid tier, where Google does not use your content for model training, per the Gemini API Additional Terms) · the public-comment names and addresses captured from public meeting audio remain in the user's private archive by default; the app does not surface them outside the user's own session.
**Read this first — prompt-craft rules that apply to every call in this template:**
1. **Name the model variant explicitly** in every Gemini API call. Do not let the agent pick the model. See the per-call matrix below.
2. **Pin `thinkingLevel` explicitly** per call. See the matrix.
3. **Seed the JSON Schema as a fenced TypeScript / Zod block** in the system instruction or `responseSchema` field. The literal schema is below. **Convert the Zod schema to Gemini's `Schema` type via the SDK helper** before passing to `responseSchema` — do NOT pass raw Zod. **Numeric `min`/`max` constraints are documentation only inside `responseSchema`; clamp on the server after the response arrives.**
4. **Pin the system instruction separately** from user input. Use the `systemInstruction` field for persona + behavioural rules; use `contents` for user input (audio file URI, watch-list keywords, body metadata). Never concatenate.
5. **Pre-declare tools as an enable/disable list** per call. The matrix below names which tools are enabled per call. Tools NOT listed for a call should be disabled.
6. **State negative constraints explicitly** — they are listed below. They are NOT "be careful" suggestions; they are hard rules the model must follow.
7. **Audio is passed as a `files/*` resource name via the Gemini Files API**, never as a Firebase Storage public URL. The Files API resource name is generated server-side by a Cloud Run function after the user's audio is uploaded.
8. **Grounded responses can wrap JSON in ```json fences or add prose preamble.** Server-side, strip fences and brace-extract:
```typescript
function safeExtractJSON(raw: string): T {
const clean = raw.replace(/```json\s*|```/gi, '').trim();
const s = clean.indexOf('{'); const e = clean.lastIndexOf('}');
if (s === -1 || e === -1) throw new Error('No JSON boundaries in grounded response');
return JSON.parse(clean.slice(s, e + 1)) as T;
}
```
9. **Strip unsupported Zod modifiers before passing to `responseSchema`** — Gemini's OpenAPI subset rejects `.regex()` / `pattern`, fixed-length `z.tuple()`, and other custom validators. Use a sanitizer that flattens tuples to length-2 arrays and removes regex patterns before serializing. Validate those constraints in middleware AFTER parsing.
### Per-call model + tools matrix
| Call | Model | thinkingLevel | Tools enabled |
|------|-------|---------------|---------------|
| Parse meeting audio → `Meeting` schema (long-context, primary) | `gemini-3.5-flash` | high | (none) |
| Cross-meeting threading (same item across meetings) | `gemini-3.5-flash` | medium | (none) — long-context over archive |
| Newsletter-style 400-word summary from parsed Meeting | `gemini-3.5-flash` | low | (none) |
| Resolve project / parcel / ordinance number to public-records page | `gemini-3.5-flash` | low | `google_search` grounding (no `responseSchema` on this call — see note) |
| Expand a watch-list keyword to related terms (e.g. "Beacon Ave" → "Beacon Avenue, 1200 Beacon, Beacon corridor") | `gemini-3.5-flash` | low | (none) |
| Translate a summary or quoted passage to user's preferred language | `gemini-3.5-flash` | low | (none) |
| (Optional) TTS read-aloud of the one-page summary | `gemini-3.1-flash-tts-preview` | n/a | n/a |
| (Optional) Generate a meeting-cover illustration | `gemini-3.1-flash-image` | n/a | n/a |
*Note for builders:* on TTS and image-generation calls, omit `thinkingConfig` entirely — the field is not supported on those models. The `n/a` cells in this matrix are documentation only; do not serialise them into the request body.
*Note on grounded geocoding:* on the project-number-resolution call, `responseSchema` and `google_search` grounding cannot be combined in a single Gemini call. Instruct the model to emit JSON in the text body; parse server-side; read citation URLs from `response.groundingMetadata.groundingChunks[].web.uri` — do NOT ask the model to include URLs in the JSON body, it will hallucinate them.
### Primary structured-output schema (seed this verbatim in the prompt)
```typescript
import { z } from "zod";
const Timestamp = z.object({
// ISO 8601 duration from the start of the audio, e.g. "PT1H42M8S",
// plus the integer seconds for convenience. Store both; the model
// emits both, the server validates them against the audio length.
duration_iso: z.string(),
seconds_from_start: z.number().int().min(0),
});
const Speaker = z.object({
speaker_id: z.string(), // "dais_seat_3", "public_speaker_07", "unknown_1"
source_position: z.enum([
"dais", "public_comment_mic", "staff_table",
"remote_call_in", "unknown",
]),
is_persistent_across_meeting: z.boolean(), // true for dais voices, false for public-comment slots
self_introduced_name: z.string().nullable(), // "Jamie Aoki", from "Commissioner Aoki here"
self_introduced_role: z.string().nullable(), // "Commissioner, District 4"
self_introduced_affiliation: z.string().nullable(), // for public commenters: "I'm a tenant at 1812 Beacon"
inferred_role: z.string().nullable(), // "Chair", "Staff Planner", "Member of Public"
inferred_role_confidence: z.number().min(0).max(1),
speaking_turn_count: z.number().int().min(0),
total_speaking_seconds: z.number().int().min(0),
});
const WatchListHit = z.object({
watch_list_term: z.string(), // user's term as configured: "Beacon Avenue"
matched_phrase_verbatim: z.string(), // what was actually said: "the Beacon Avenue parcel"
timestamp: Timestamp,
speaker_id: z.string(),
context_quote_verbatim: z.string(), // the sentence the phrase appeared in
agenda_item_id: z.string().nullable(),
match_confidence: z.number().min(0).max(1),
});
const Vote = z.object({
agenda_item_id: z.string(),
motion_description: z.string(), // "Motion to approve staff recommendation as amended"
motion_by: z.string().nullable(), // speaker_id of who moved it
seconded_by: z.string().nullable(),
called_at: Timestamp,
result: z.enum(["passed", "failed", "tied", "continued", "withdrawn", "unclear"]),
vote_breakdown: z.array(z.object({
speaker_id: z.string(),
vote: z.enum(["yes", "no", "abstain", "absent", "recused", "unclear"]),
})),
diverged_from_staff_recommendation: z.boolean(),
diverged_from_staff_reason: z.string().nullable(), // verbatim quote if explained on the record
});
const PublicComment = z.object({
public_comment_id: z.string(),
timestamp_start: Timestamp,
timestamp_end: Timestamp,
speaker_id: z.string(), // "public_speaker_07"
self_introduced_name: z.string().nullable(),
self_introduced_affiliation: z.string().nullable(), // "I live on the 1200 block of Beacon", "ILWU Local 6", "homeowner"
first_sentence_verbatim: z.string(), // exactly what they said in their first sentence
topic_summary: z.string(), // one short clause: "opposes the Beacon Ave variance"
sentiment: z.enum(["support", "oppose", "mixed", "informational", "unclear"]),
agenda_item_id: z.string().nullable(), // null if generic public comment slot, otherwise specific item
});
const AgendaItem = z.object({
agenda_item_id: z.string(), // schema-internal id
item_number_as_announced: z.string().nullable(), // "Item 3", "3.b", "Consent Calendar item 2"
item_title_as_announced: z.string(), // "Zoning Amendment 2026-114, 1812 Beacon Avenue"
ordinance_or_project_number: z.string().nullable(), // "2026-114", "PLN-2026-0381"
timestamp_start: Timestamp,
timestamp_end: Timestamp,
body_section: z.enum([
"call_to_order", "roll_call", "approval_of_minutes",
"public_comment_general", "consent_calendar",
"public_hearing", "action_item", "discussion_item",
"staff_report", "closed_session_announcement",
"report_back_from_closed_session", "adjournment", "other",
]),
staff_recommendation_summary: z.string().nullable(), // one sentence, neutral
staff_recommendation_verbatim_quote: z.string().nullable(),
staff_recommendation_quote_timestamp: Timestamp.nullable(),
discussion_summary_neutral: z.string(), // 2-4 sentences, neutral, no editorialising
key_quotes: z.array(z.object({
speaker_id: z.string(),
quote_verbatim: z.string(),
timestamp: Timestamp,
why_this_quote_matters: z.string(), // one short clause, factual: "first time the chair signals position"
})),
votes: z.array(z.string()), // vote ids; full Vote records in meeting.votes
public_comment_ids_for_this_item: z.array(z.string()),
outcome_one_sentence: z.string().nullable(), // "approved 4-1", "continued to next meeting", "no action taken"
continues_from_meeting_id: z.string().nullable(), // when threading detects this is a continuation
watch_list_hits_count: z.number().int().min(0),
flagged_for_user_review: z.array(z.object({
field_path: z.string(),
reason: z.string(),
})),
});
const Meeting = z.object({
meeting_id: z.string(),
body_id: z.string(), // the body this meeting belongs to (City Council, Planning Commission)
body_name_as_announced: z.string(), // "City of Oakland Planning Commission, regular meeting"
jurisdiction: z.string().nullable(), // "Oakland, California, USA"
meeting_date_as_announced: z.string().nullable(),
meeting_date_iso: z.string().nullable(),
audio_file_uri: z.string(), // gs:// URI
audio_duration_seconds: z.number().int().min(0),
source_languages: z.array(z.string()), // BCP-47, e.g. ["en-US", "es-MX"] for a code-switching meeting
speakers: z.array(Speaker),
agenda_items: z.array(AgendaItem),
votes: z.array(Vote),
public_comments: z.array(PublicComment),
watch_list_hits: z.array(WatchListHit),
one_page_summary: z.object({
headline_one_line_neutral: z.string(), // "Planning Commission approves Beacon Ave variance 4-1; opposed by Commissioner Aoki"
paragraph_neutral: z.string(), // 4-6 sentences, factual, no editorialising
items_for_user_watch_list_first: z.array(z.string()), // agenda_item_ids ordered: user's items first
items_other: z.array(z.string()),
}),
closed_session_notes: z.array(z.object({
timestamp: Timestamp,
stated_reason: z.string(), // "personnel matter (Govt Code §54957)"
audio_available: z.boolean(), // false for the closed portion
})),
cross_meeting_thread_id: z.string().nullable(), // populated by the threading call later
reading_confidence: z.number().min(0).max(1),
flagged_for_user_review: z.array(z.object({
field_path: z.string(),
reason: z.string(),
})),
});
type Meeting = z.infer;
```
### Common failure modes (and how to avoid them)
- Agent silently downgrades `thinkingLevel` on the the long-context meeting parse call to save quota — pin `gemini-3.5-flash` with the matrix-specified `thinkingLevel` explicitly. Flash on a three-hour audio drops speaker continuity around the 90-minute mark, conflates the chair with the city attorney, and misses watch-list keywords spoken in the middle hour. The whole template's load-bearing call is `gemini-3.5-flash` with `thinkingLevel: high`.
- Model summarises the meeting and editorialises ("the commission appeared sympathetic to the developer") — hard rule: every claim in `one_page_summary.paragraph_neutral`, `discussion_summary_neutral`, and `outcome_one_sentence` is factual or a verbatim quote attribution. The model is explicitly told: do NOT characterise tone or sympathy. Vote outcomes are factual. Direction-of-discussion is captured via verbatim quotes only.
- Public-comment names and addresses are surfaced too aggressively — public meetings are public, but the app respects that some commenters quietly self-identify (a tenant says "I live at 1812 Beacon Ave"). The schema captures `self_introduced_affiliation` verbatim from what was said on the record, but the printable summary view defaults to omitting public commenters' addresses; the user can opt to show them. The app never extrapolates an address from a name.
- Watch-list keyword match misses pronunciations / partials — "Beacon Avenue" said as "Beacon Ave" or "the Beacon corridor" must still hit. The expansion call (Gemini 3.5 Flash, low thinking) expands user-configured terms to phonetic and semantic variants before the parse call uses them. The match itself, in the parse call, must be done by the model over the audio, NOT by string-matching the transcript after the fact (string-matching misses the pronunciation variants and the homonyms the model can disambiguate).
- Vote breakdowns get the names wrong because the chair calls the roll quickly — the diarisation call has heard each commissioner speak many times across the three hours; the vote-call moment is when each speaker says their own name and their vote ("Commissioner Aoki?" — "No."). Pin this in the system instruction: at roll-call vote, the speaker_id MUST be the previously-established dais speaker whose voice matches, not a new unknown speaker.
- Long meetings (>3 hours) are sent in one call and run into 1M-token limits — guardrail in the server: if the audio file is longer than 3 hours, split at the longest silence near the midpoint (or at agenda-item boundary if detectable from a quick pre-pass), parse each half as a separate Meeting fragment, and stitch in code. The model never sees more than ~2.5 hours of audio per call.
- Agenda-item segmentation fails because the chair announces items out of order or skips numbers — back off to topic-shift detection. The schema records `item_number_as_announced` as nullable; the user can rename.
- The model invents votes that did not happen — hard rule: a Vote is only emitted when there is an audible roll call OR a chair declaring "the ayes have it" with a clear motion preceding. If the chair adjourns without taking a recorded vote on an item, no Vote is emitted; the item's `outcome_one_sentence` says "no action taken".
- Multi-meeting threading collapses two distinct items because they share a street — Variance 2026-114 (Beacon Ave) and Variance 2026-118 (Beacon Ave) are distinct items even though they mention the same street. Threading must match on ordinance_or_project_number first, then on title similarity, NOT on watch-list-keyword overlap alone.
- Closed-session audio is requested by the model — the closed portion of a meeting is, by definition, not in the recording. The model must not invent what was said in closed session. It records that closed session occurred and what was reported back when the body returned to open session.
- TTS read-aloud of the summary uses a robotic municipal voice — the TTS directive should ask for a neutral, unhurried newsreader voice. Pace ~140 wpm, slightly faster than the letter-reading template, because civic summaries are read with attention, not with the elder's pace.
- Cross-meeting threading is too aggressive and stitches a continuation that the chair did not actually continue — only thread when the chair explicitly says "continued to" with a date OR the next meeting's agenda re-publishes the same ordinance number. Otherwise, the items are siblings, not a thread.
### Negative constraints (hard rules)
- Do NOT editorialise. The summary is factual or quoted. No "the commission appeared frustrated", no "the developer seemed under pressure", no "the public spoke overwhelmingly against". Replace with: a count ("18 of 22 public commenters opposed; 3 supported; 1 mixed"), a verbatim quote, or a vote.
- Do NOT speculate on the sentiment of votes that have not yet happened. If an item was continued to the next meeting, the outcome is "continued to next meeting" — not "expected to pass".
- Do NOT invent. If the chair gavels through an item with no audible substance, the discussion summary says "the chair moved this item under the consent calendar with no substantive discussion." It does NOT fill in plausible discussion.
- Do NOT hallucinate URLs for ordinance lookups, project pages, or staff reports. The project-number-resolution call uses `google_search` grounding and reads URLs from `groundingMetadata.groundingChunks[].web.uri`. The model never composes a URL.
- Do NOT surface public commenters' addresses or full names in the printable summary by default. The user can opt in for their own private archive view. Public meetings are public, but the app does not amplify what the commenter said quietly into a city-hall mic.
- Do NOT identify a speaker by name unless the speaker self-introduced or unless the user has previously named them. "Speaker 3" is acceptable; "Commissioner Smith" is only acceptable after the user has renamed Speaker 3 to Commissioner Smith.
- Do NOT translate proper nouns. Commissioner Aoki stays Commissioner Aoki in every language. Street names stay verbatim ("Calle Hidalgo", "Beacon Avenue", "鰂魚涌道"). Council body names are kept verbatim ("Bristol City Council", "Câmara Municipal de Lisboa", "Oakland Planning Commission") with a parenthetical translation on first occurrence if requested.
- Do NOT collapse code-switching. If a councillor says half a sentence in English and half in Cantonese, the transcript shows both; the translation shows both with a small marker.
- Do NOT use the user's meeting recordings or summaries to train or fine-tune any model. Use the Gemini API on the paid tier, where Google does not use your content for model training, per the Gemini API Additional Terms. The capabilities-info panel says this in plain English.
- Do NOT auto-publish or auto-share. Archives are private by default. Per-meeting sharing is explicit.
- Do NOT push notifications by default. Watch-list-hit email notifications are opt-in. Some users are quietly tracking civic decisions and do not want their inbox to broadcast their interests.
- Do NOT assert that a councillor "voted against the community" or "broke with their party" — the app records the vote and the verbatim explanation if one was given. Political framing is the user's, not the app's.
- Do NOT invent attendance — if a commissioner is absent and not named, the Vote breakdown lists them as `absent` only if their absence was announced on the record.
### Per-call `systemInstruction` strings
Use these as the literal `systemInstruction` field for each Gemini API call the built app makes. They complement the series-wide rules already uploaded as the global instructions file (`00-series-instructions.txt`).
### Call: Parse meeting audio → `Meeting` schema
Model: `gemini-3.5-flash` · thinkingLevel: high · Tools: (none)
```
You are reading a recording of a local-government public meeting —
city council, planning commission, school board, neighbourhood
council, ward committee, panchayat, câmara municipal, wereda
council, district council, parish council, or any equivalent
body. Meetings typically run 90 minutes to 4 hours and follow a
published agenda. Languages encountered include English (Boston,
Glasgow, Bristol, Brooklyn, East Bay, suburban Toronto, Lagos,
Mumbai, Auckland, Sydney, Singapore-English municipal registers),
Spanish (Mexican, Andean, Caribbean, peninsular), Portuguese
(Brazilian + European), French (metropolitan, Québécois,
Maghrebi-French), Tagalog (with English code-switching common in
Filipino municipal meetings), Vietnamese, Mandarin and Cantonese
(Hong Kong District Council, Singapore town council),
Korean, Hindi, Tamil, Bengali, Punjabi, Urdu, Amharic, Swahili,
Farsi, Arabic. Many meetings code-switch between two languages
within a single sentence; capture both.
Audio is supplied via a single Gemini Files API resource name (`files/*`).
Do NOT request additional audio fetches. Do NOT request URLs.
Parse the supplied audio in one pass.
Output ONLY the Meeting JSON matching the provided schema. No
commentary. JSON only.
Speaker diarisation rules:
- The dais speakers are persistent across the meeting. The chair,
the commissioners, the city attorney, and the staff planner each
speak many times over three hours, and each has a distinguishable
voice. Track them as persistent `speaker_id` strings ("dais_seat_1"
through "dais_seat_N", "staff_planner_1", "city_attorney_1"). Set
`is_persistent_across_meeting: true`.
- The public-comment microphone is a flow of distinct one-time
speakers. Each public-comment slot is a new speaker_id
("public_speaker_01", "public_speaker_02", ...). Set
`is_persistent_across_meeting: false`.
- If a speaker self-introduces ("Commissioner Aoki here", "Good
evening, my name is María Hidalgo and I live on the 1200 block
of Beacon"), capture verbatim into `self_introduced_name`,
`self_introduced_role`, and `self_introduced_affiliation`. Do NOT
guess names for speakers who did not self-introduce.
- At roll-call vote moments, the chair calls each commissioner by
name and the commissioner responds with "yes" or "no". Match
the responding voice to the persistent dais speaker_id
established earlier — never create a new unknown speaker for a
roll-call vote response.
Agenda-item segmentation rules:
- Find the spoken transitions: "Item 3", "Moving on to the next
agenda item", "We're now on the public hearing for ordinance
2026-114", "Item 3.b on the consent calendar". These are section
boundaries.
- If the chair skips item numbers or announces them out of the
published order, fall back to topic-shift detection and record
`item_number_as_announced: null`.
- Each agenda item gets a `timestamp_start` and `timestamp_end`,
measured in seconds from the start of the audio.
Watch-list scanning:
- The user provides a list of watch-list terms in the `contents`
field (street names, project numbers, school names, neighbour
names, councillor names, ordinance numbers). For each term,
scan the audio for every spoken occurrence — including phonetic
near-variants ("Beacon Ave" matches "Beacon Avenue" and "the
Beacon corridor") and grammatical inflections.
- Each hit becomes a `WatchListHit` with a precise
`seconds_from_start` and a `context_quote_verbatim` of the full
sentence the phrase appeared in.
- Do NOT match an unrelated homonym (the proper-name "Beacon" in
a person's surname is not a match for the street "Beacon Avenue").
Vote-capture rules:
- A vote is emitted ONLY when there is an audible motion + second +
roll call OR a chair declaring an unambiguous outcome ("on a
voice vote the ayes have it") with a clearly preceding motion.
- For a roll-call vote, capture the full breakdown by dais
speaker_id. Each commissioner's vote is `yes`, `no`, `abstain`,
`absent`, `recused`, or `unclear`.
- If the vote diverges from the staff recommendation (staff
recommended approval, commission denied; or staff recommended
denial, commission approved), set
`diverged_from_staff_recommendation: true` and include any
on-the-record explanation as a verbatim quote.
One-page summary rules:
- `headline_one_line_neutral` is one factual sentence about the
most significant action of the meeting. Examples: "Planning
Commission approved Variance 2026-114 (Beacon Ave) 4-1, with
Commissioner Aoki dissenting." "Bristol City Council postponed
the Cabot Ward zoning amendment to next month." Do NOT
characterise.
- `paragraph_neutral` is 4-6 sentences. Every claim is factual or
a verbatim attributed quote. No "appeared to", no "seemed to",
no "overwhelmingly", no "narrowly". Replace with counts and
quotes.
- `items_for_user_watch_list_first`: ordered list of
agenda_item_ids where the user's watch-list terms appeared OR
where the user explicitly named items to watch. Other items
follow in agenda order.
Hard rules:
- Preserve every diacritic and every script exactly. Spanish á, é,
í, ó, ú, ñ, ü; Portuguese ã, ç, õ; French é, è, ê, ç, à; Tagalog
ñ; Vietnamese tonal marks; Hindi Devanagari; Tamil; Bengali;
Punjabi Gurmukhi; Urdu Nastaliq; Mandarin/Cantonese traditional
and simplified Chinese characters; Korean Hangul; Amharic Ge'ez;
Arabic; Hebrew.
- Do NOT translate proper nouns. Councillor names, street names,
body names, project numbers stay verbatim. Translate body name
on first occurrence only if requested in user preferences.
- Do NOT editorialise. Counts and verbatim quotes only.
- Do NOT invent. If you cannot make out a section, record
`reading_confidence < 0.7` for that field with a one-sentence
reason in `flagged_for_user_review`.
- Do NOT reveal closed-session content. The closed portion is not
in the recording. Note that closed session occurred, the stated
reason, and what was reported back when the body returned to
open session.
- Public-comment first sentence is captured verbatim. The rest of
the public comment is summarised in one clause as `topic_summary`.
Sentiment is one of the closed enum values; default to "unclear"
if not clear.
No commentary. JSON only.
```
---
### Call: Cross-meeting threading (same item across meetings)
Model: `gemini-3.5-flash` · thinkingLevel: medium · Tools: (none, long context over archive)
```
You receive the structured Meeting records for every meeting in
the user's archive that belongs to the same body (Planning
Commission, City Council, etc.) over a given window (default: the
last 12 months). Your task: detect when the same agenda item appears
across multiple meetings as a continuation, and link them.
Matching rules:
- Primary match: identical `ordinance_or_project_number`. If two
agenda items across two meetings share the same project number,
they are the same item.
- Secondary match: title similarity + same address / parcel. Two
variance requests on the same parcel number are the same item.
- Tertiary match: explicit chair continuation. If the chair in
Meeting A says "this item is continued to the meeting of November
14", and Meeting B (November 14) has a matching item, thread them.
- Do NOT thread on watch-list-keyword overlap alone. "Beacon Avenue"
appears in many distinct items; threading must match on
project/ordinance number or explicit continuation.
Output: an updated array of agenda items with
`continues_from_meeting_id` populated where applicable, and a
`cross_meeting_thread_id` shared across all meetings in the
thread. Include a brief `thread_summary` (one paragraph, factual)
that traces the item's status across meetings: "First heard October
14, continued to November 14, continued to January 9, approved 4-1
on January 9 with conditions listed in staff report exhibit B."
Hard rules:
- Conservative resolution. If unsure, do NOT thread. The user can
manually thread.
- Preserve each meeting's per-meeting record. The threading is an
overlay, never a rewrite.
- Do NOT predict future meetings. Thread only across meetings the
user has actually uploaded.
No commentary outside the structured output.
```
---
### Call: Newsletter-style 400-word summary from parsed Meeting
Model: `gemini-3.5-flash` · thinkingLevel: low · Tools: (none)
```
You receive a parsed Meeting JSON. Your task: write a 400-word
neighbourhood-newsletter-style summary that a non-specialist
neighbour can read in 90 seconds.
Hard rules:
- Lead with the headline: the action of the meeting that most
affects the user's watch list. If the user has no watch list,
lead with the most consequential action of the meeting (the
rezoning, the budget vote, the policy adoption).
- Cite every claim with a timestamp in parentheses. Format:
"(01:42:08)". Each timestamp must already exist in the parsed
Meeting; do not invent new ones.
- Quote verbatim where the quote matters. Attribute the quote to
the speaker by their established role ("Commissioner Aoki said,
01:47:51, 'I think staff's recommendation here goes too far.'").
- Keep public-commenter names omitted unless the user has opted to
include them. Use "a tenant from the 1200 block" or "a homeowner
from the Eastlake neighbourhood" instead.
- Plain language. The neighbour reading this has never sat in a
council meeting. Do NOT use the words "appurtenant", "variance"
(without a parenthetical explanation), "CEQA", "section 209.3",
"consent calendar" (without explanation), "discretionary".
Translate these into plain English the first time, then use the
technical term thereafter.
- No editorialising. No "the commission appeared frustrated". No
"the developer was under pressure". Replace with counts, votes,
and quotes.
- End with a one-line "What's next" sentence ONLY if the meeting
itself stated what's next ("This item will return to the
commission on November 14"). Do NOT speculate about future
meetings.
Output: the 400-word summary as a single string. No commentary.
```
---
### Call: Resolve project / parcel / ordinance number to public-records page
Model: `gemini-3.5-flash` · thinkingLevel: low · Tools: `google_search` grounding
```
You receive an ordinance or project or parcel number mentioned in
a council meeting (e.g. "Variance Request 2026-114", "PLN-2026-0381",
"Ordinance 13684", "parcel 1812 Beacon Avenue, APN 040-1234-567")
along with the jurisdiction (e.g. "Oakland, California, USA").
Your task: resolve the number to a public-permitting-portal or
city-records page, using `google_search` grounding.
Return JSON in the text body (NOT via `responseSchema` —
`responseSchema` and `google_search` cannot be combined in a
single Gemini call today):
{
"resolved": true | false,
"project_title_on_portal": string | null,
"applicant_name_on_portal": string | null,
"status_on_portal": string | null,
"address_on_portal": string | null,
"match_confidence": 0..1
}
Hard rules:
- Use grounded search. Do NOT compose URLs from training data —
they will be wrong. The server reads citation URLs from
`response.groundingMetadata.groundingChunks[].web.uri` and never
asks the model to include URLs in the JSON body.
- If the search does not surface a clear match, return
`"resolved": false` and stop. Do NOT guess.
- Preserve names verbatim. Do NOT translate applicant names.
- If multiple jurisdictions return a match for the same project
number, prefer the one whose name matches the jurisdiction
supplied in the input.
No commentary outside the JSON.
```
---
### Call: Expand a watch-list keyword to related terms
Model: `gemini-3.5-flash` · thinkingLevel: low · Tools: (none)
```
You receive a user's watch-list keyword (a street name, a project
number, a neighbourhood, a councillor name) and return a small set
of related terms the parse call should also scan for.
Examples of good expansion:
- "Beacon Avenue" → ["Beacon Avenue", "Beacon Ave", "Beacon
corridor", "the 1200 block of Beacon", "1812 Beacon"]
- "Variance 2026-114" → ["Variance 2026-114", "2026-114", "VR
2026-114", "the Beacon Avenue variance"]
- "Cabot Ward" → ["Cabot Ward", "Cabot", "Cabot councillor",
"the Cabot rezoning"]
Examples of bad expansion (do NOT do this):
- "Beacon Avenue" → ["roads", "transportation", "city streets"]
(too generic — would match every street discussion)
- "Variance 2026-114" → ["any variance request"] (too broad)
Output: a JSON array of strings. No commentary.
```
---
### Call: Translate a summary or quoted passage to user's preferred language
Model: `gemini-3.5-flash` · thinkingLevel: low · Tools: (none)
```
You translate a passage of council-meeting text from its source
language to a target language the user specifies (e.g. Spanish,
Cantonese, Tagalog, Vietnamese, Amharic, Arabic).
Hard rules:
- Preserve proper nouns verbatim. Commissioner Aoki stays
Commissioner Aoki. Street names stay in their original spelling
with a parenthetical translation on first occurrence only if
the target reader would not recognise it.
- Preserve numbers exactly. "$2.3 million" stays "$2.3 million" —
do not convert currencies. Vote counts ("4-1") stay verbatim.
- Preserve the meaning of technical terms. "Variance", "rezoning",
"consent calendar" are translated to the closest target-language
equivalent for that jurisdiction; if no clean equivalent exists,
keep the English term and add a one-line gloss.
- Preserve timestamps exactly. "(01:42:08)" stays "(01:42:08)" in
any target language.
- Match the register of the source: a neutral newsletter summary
reads as a neutral newsletter summary in the target language. Do
NOT make it warmer, sharper, or more colloquial than the source.
Output: the translation as a single string. No commentary.
```
---
### Call (optional): TTS read-aloud of the one-page summary
Model: `gemini-3.1-flash-tts-preview` · n/a · n/a
```
Voice: neutral, unhurried, in the style of a local-radio news
read. Pick the Gemini 2.5 Flash TTS voice whose `languageCode`
matches the meeting's primary `source_language` — pronunciation
will follow that locale automatically.
Pre-process the text before sending it to TTS:
- Read from the one-page summary's `paragraph_neutral`.
- Strip the inline timestamps "(01:42:08)" before reading — they
are visual aids, not spoken content. Or, optionally, read them
as "at one hour, forty-two minutes" if the user has opted in to
spoken timestamps.
- At line breaks, insert a single ellipsis (`…`) for a natural
pause. At paragraph breaks, insert a blank line plus an em-dash
(`—`). Gemini 2.5 TTS does not support SSML `` —
these textual cues are how you signal pace.
- Target rate: ~140 words per minute — neighbourhood-newsletter
reading pace, slightly faster than a letter, slower than a
podcast.
Style direction: prepend ONE short directive sentence to the
text input, exactly like: "Read neutrally and unhurriedly, in the
style of a local-radio news read summarising last night's council
meeting. …". There is no separate `style` API field on Gemini 2.5
TTS; the directive sentence inside the input is how style is
conveyed.
Phoneme overrides (Tagalog ng, Vietnamese tones, Cantonese tones)
are NOT exposed by Gemini 2.5 TTS — no SSML `` tag.
Pronunciation comes from the chosen voice's native locale. If the
meeting code-switches, render the summary in one voice; do NOT
attempt mid-call voice switching (not supported).
```
---
### Call (optional): Generate a meeting-cover illustration
Model: `gemini-3.1-flash-image` · n/a · n/a
```
Generate a small illustrative cover for a meeting-summary page.
Style: a clean line drawing of a city-council chamber from the
back of the room — empty dais with name placards, an empty
public-comment microphone, soft warm lighting. Restrained colour
palette: paper-cream background, sepia ink line, one accent
colour from the user's theme. No people in frame. No real
councillors. No real city seals. No real city names.
Do NOT generate text in the image (city names, body names,
ordinance numbers). The page chrome carries that text.
```
## 5. Use cases & content to include
Build dedicated UI sections or flows for each of these — they tell you what content the app must support.
- **The Tuesday-night rezoning.** A single mother in East Oakland cannot attend Tuesday's 6 pm Planning Commission because her shift at the hospital runs to 8 pm and her seven-year-old has school the next morning. Wednesday morning at the bus stop she opens the app, drops in the MP3 the city clerk posted at 9 am, and reads the one-page summary with her thumb. "Variance 2026-114 — Beacon Avenue — approved 4-1." She taps the dissent timestamp and listens to Commissioner Aoki's two-minute objection on the bus.
- **The Bristol corner shop.** A retired electrician in the Cabot Ward of Bristol drops in last Thursday's Full Council MP3. The watch list has "Cabot Ward" and "Whiteladies Road". The app surfaces seven mentions across three agenda items, including a procedural motion that would have moved a contentious item to a closed-session discussion — defeated 21-13.
- **The Filipino-Canadian neighbour.** A second-generation Filipino-Canadian in East Vancouver drops in his neighbourhood council meeting recording, which code-switches between English and Tagalog. The app transcribes both, surfaces the council's discussion of a proposed Filipino seniors' centre on Fraser Street, and lists every public commenter who spoke in Tagalog (eleven of nineteen).
- **The combined-body marathon.** A local journalist in Glasgow drops in a four-hour combined session of the Planning Applications Committee and the Licensing Committee. The app auto-chunks at the body-transition point ("the chair declared the Planning Applications Committee adjourned at 9:47 pm; the Licensing Committee will now come to order"), and produces two linked summaries.
- **The continuation thread.** A community-board member in Brooklyn has uploaded ten months of monthly meetings. The app threads "Special Permit C-240114" through three separate hearings (continued in March, continued in May, approved in July with conditions) and shows the developing record as a single timeline.
- **The closed session.** A retired civil servant in Sacramento drops in a school-board recording in which the body voted to enter closed session for forty-five minutes ("to discuss anticipated litigation, Govt Code §54956.9(d)(2)"), then returned to open session and reported "no reportable action taken." The app records this faithfully — the closed audio is not in the recording, and the app does not pretend to know what was discussed.
- **The watch list with one councillor's name.** A neighbour who is considering whether to vote for the incumbent in November loads every City Council meeting from the past 12 months and sets the watch list to "Councillor Pham". The app shows every meeting Councillor Pham spoke, the total speaking time, the votes she cast, and the items where she diverged from the staff recommendation.
- **The Sri Lankan Tamil panchayat.** A diaspora-Tamil organiser drops in a recording of a village panchayat meeting in northern Sri Lanka. The audio is Tamil with some Sinhala procedural inserts. The app transcribes both, preserves the Tamil script in the verbatim transcript, and produces an English summary the diaspora can read.
- **The Hong Kong District Council code-switch.** A Hong Kong-Canadian son in Toronto drops in a District Council recording from his old neighbourhood — Cantonese with occasional English from a councillor who studied abroad. The app preserves the code-switching and produces a bilingual summary.
- **The newsletter export.** A neighbourhood-association secretary in Atlanta drops in the monthly Neighborhood Planning Unit meeting recording, generates the 400-word newsletter summary, edits two sentences for tone, and pastes it into the Substack she sends to 240 neighbours on the first of every month.
- **The accessibility advocate.** A wheelchair-user in Manchester is tracking every council meeting where pavement maintenance or kerb cuts are discussed. Watch list: "kerb", "pavement", "accessibility", "ramp", "tactile paving". One meeting last month surfaced four hits — three of them in a single agenda item about budget reallocation she would otherwise have missed.
- **The "was my comment in there" check.** A tenant in Houston went to City Hall on Wednesday night and spoke for three minutes during public comment about her landlord's habitability violations. On Friday she drops in the meeting recording and finds her own three-minute slot at 02:14:38, with her first sentence captured verbatim. She saves the timestamp link and emails it to her lawyer.
## 6. Page structure
Build the following screens / sections in this order. Adjust copy to fit the voice, but keep the structural intent.
1. **Welcome / sign-in.** A photographed-looking image of a printed agenda sheet on a kitchen table at 6:30 am, an enamel mug of coffee blurred at the edge of frame, a phone next to it showing a one-page meeting summary. One paragraph: "Drop in last night's council meeting — get back a one-page summary, every mention of your street, every vote, with play-from-here timestamps. We don't tell you what to think; we tell you what was said." Single Google sign-in button; Apple sign-in next to it. Below: "Try with the sample meeting" → loads the demo meeting in section 8a.
2. **Empty state — "Add your first meeting".** Three big input methods: ⬆ Upload audio · 🔗 Paste a public meeting URL · 📅 Connect a council's agenda feed. A short explainer below each ("Best when the clerk has posted an MP3", "Best when the meeting is streamed on a public page or YouTube", "Best when you want to track upcoming agendas automatically").
3. **Body setup.** Before the first meeting parses, the user picks the body: "City of Oakland Planning Commission", "Bristol City Council", "Brooklyn Community Board 6", "Câmara Municipal de Lisboa" — typed freely, with a small autocomplete fed by the user's recent entries. The jurisdiction is captured ("Oakland, California, USA"). The body's voice memory will accumulate over time: the same Chair, the same Commissioners, the same staff voices, all recognised across meetings.
4. **Watch list setup.** One textarea, comma-separated or one-per-line: "Beacon Avenue, Variance 2026-114, Councillor Aoki, my school PS 282". The app expands each term (the keyword-expansion call) into phonetic and semantic variants and shows the expansion to the user for confirmation. The user can edit or remove an expansion before saving.
5. **Processing screen.** A vertical list of the night's batch (if the user uploaded multiple meetings). Each item shows the audio waveform, the body name, the meeting date, and a step-by-step honest progress bar: "Reading the audio…" → "Separating the speakers…" → "Finding agenda items…" → "Scanning your watch list…" → "Reading the votes…" → "Drafting the one-page summary…". Each step takes 15-60 seconds. The user can close the app and come back; the parse runs server-side.
6. **Meeting detail view.** The hero is the one-page summary, displayed as a printable single-page card at the top of the screen. Below it, the watch-list-hits banner: "Beacon Avenue mentioned 4 times — 01:42:08, 01:47:51, 02:03:14, 02:51:33." Each timestamp is a button that plays from that second. Below the banner, the agenda-item list. Each agenda item expands to show: staff recommendation summary, key quotes with play-from-here buttons, public-comment list, vote ledger. Sticky in the right column: the audio player with the current playhead position and the agenda item currently playing highlighted.
7. **Public-comment view.** A scrollable list of every public commenter in the meeting. Each row: timestamp, first-sentence verbatim, sentiment chip (support / oppose / mixed / informational / unclear), agenda item, "play their full statement" button. Filter by agenda item or by sentiment.
8. **Vote ledger view.** A table: agenda item × commissioner. Each cell is the vote (Yes / No / Abstain / Absent / Recused). Divergences from staff recommendation are flagged in a soft amber. Click a cell → opens the audio at the moment that vote was called.
9. **Speakers view.** The dais speakers, with their established name (or "Speaker 3" if not yet renamed), total speaking time, speaking-turn count, the agenda items they spoke on. The user can rename a speaker (long-press → "Rename to Commissioner Pham") and the rename propagates across the user's full archive of this body's meetings.
10. **Archive view.** A magazine-grid of meetings. Filter by body, date range, watch-list hit, vote outcome, language. Default sort: most recent first. Each card shows the meeting date, the one-line headline, the watch-list-hit count.
11. **Cross-meeting thread view.** When the threading call has linked an item across multiple meetings, a dedicated thread page shows the item's developing record: meeting 1 (continued), meeting 2 (continued), meeting 3 (approved 4-1). Each meeting node shows the date, the speaker breakdown for that item, and the key quotes.
12. **Newsletter / share.** Export a 400-word newsletter summary as Markdown or HTML; edit before sending. Generate a shareable read-only link to a specific meeting summary (per-meeting opt-in). Generate a printable PDF of the one-page summary plus the watch-list hits with timestamps.
13. **Settings & privacy.** A clean settings page. Toggle: include public-commenter names in summaries (off by default). Toggle: email me when a watch-list term hits in a newly-uploaded meeting (off by default). Delete this archive forever (gone in 60 seconds).
14. **Footer.** "Built for the meeting you couldn't get to." Privacy: "Your audio and your archive are yours. We never train on them." Capabilities `(i)` icon in header.
## 6b. First-visit onboarding
Show a **first-visit onboarding** the first time a visitor lands on the app (detect via `localStorage` flag; do not show on return visits). Three slides, dismissible at any time. Persistent re-entry: a `?` icon in the header reopens it.
**Slide 1 — What this is.**
- Headline: "Welcome to Council Meeting Watcher."
- Subhead: "Drop in last night's council meeting. Get back a one-page summary, every mention of your street, every vote — with play-from-here timestamps. We don't tell you what to think; we tell you what was said."
- One paragraph (≤ 60 words) explaining who this is for and what makes it different from a generic transcription tool: it reads the agenda structure, it scans your watch list across multiple hours of audio, it surfaces verbatim quotes and never editorialises.
- Visual: a small annotated illustration of a council chamber from the back of the room with the relevant zones labelled (dais, public-comment microphone, staff table, clerk's bench).
**Slide 2 — Try it now.**
- One short prompt: "Try with the sample meeting".
- A live demo input pre-loaded with the sample Oakland Planning Commission meeting from the seed content in section 8a.
- 1-2 sentences pointing at the specific page elements where the Gemini magic happens (the watch-list-hits banner with the Beacon Avenue timestamps, the dissenting commissioner's verbatim quote, the four public commenters' first sentences).
**Slide 3 — How to remix this.**
- Headline: "Make this yours."
- Three short bullets:
- "Swap the sample meeting in `/data/seed-meeting/` for your own MP3."
- "Adjust the prompts in `/server/prompts/` to fit your council's language(s)."
- "Wire up your Gemini API key and Firebase project via the env-var list in the capabilities panel."
- Primary CTA: "Use this template" → links to AI Studio Build remix entry point.
- Secondary: "Just exploring — close" (sets localStorage flag, never auto-shows again).
**Accessibility:** focus trap, `Esc` closes, `role="dialog"`, `aria-modal="true"`, `aria-labelledby`, focus restored to trigger on close. Respect `prefers-reduced-motion`.
**Don't:**
- Don't gate content behind the modal. The page beneath must be fully usable.
- Don't auto-reshow on return visits. Use `localStorage['onboarding-seen-v1']`.
- Don't include unrelated CTAs (newsletter signup, social follow). Keep it about the template only.
## 6c. Capabilities info button (persistent in header)
Add a persistent `(i)` icon in the top-right of the header (next to the primary nav). Click → opens a modal/panel titled **"What powers this app"**.
**Panel contents (in this order):**
**Gemini capabilities used (the hero list):**
- **Gemini 3.5 Flash (long-context audio)** — reads up to ~3 hours of meeting audio in a single call. Identifies speakers, segments agenda items, separates dais from public-comment mic, captures votes, and emits the structured Meeting JSON. The load-bearing call of the entire app.
- **Gemini 3.5 Flash (multilingual + code-switching)** — handles council meetings in English (all municipal-accent registers), Spanish, Portuguese, French, Tagalog with English code-switching, Mandarin, Cantonese, Korean, Vietnamese, Hindi, Tamil, Bengali, Punjabi, Urdu, Amharic, Swahili, Farsi, and Arabic, with code-switching across any pair.
- **Gemini 3.5 Flash (speaker diarisation)** — separates the dais voices from the public-comment microphone; tracks the persistent dais speakers across the meeting.
- **Gemini 3.5 Flash (long context, archive-wide)** — once your archive grows, the cross-meeting threading call reads every meeting at once to detect when an item is a continuation.
- **Gemini 3.5 Flash + grounded search** — resolves project numbers and ordinance numbers to the city's public-records portal where available.
- **Gemini 3.5 Flash** — keyword expansion, translation of summaries, newsletter-style condensation.
- **Gemini TTS** (optional) — reads the one-page summary aloud at a neighbourhood-newsletter pace.
- **Nano Banana 2 (Gemini 3.5 Flash Image)** (optional) — generates a clean line-drawing cover illustration for each meeting.
- **Firebase Auth** — Google and Apple sign-in, family / co-watcher invitations via magic links.
- **Firestore** — stores your meetings archive, syncs across devices in real time.
- **Firebase Storage** — keeps the original audio files, private to you.
- **Cost note** — see the detailed breakdown in 6d. A typical three-hour meeting parse costs about $0.35; a full year of fortnightly meetings (~26 meetings) costs about $10 total in Gemini API spend.
- **Privacy note** — your meeting recordings and your archive are private to you. This app uses the Gemini API on the paid tier, where Google does not use your content for model training, per the Gemini API Additional Terms. Public commenters' addresses and full names are captured to the structured record but hidden from the printable summary by default; you can opt to show them in your own private archive view.
**Backend services this app depends on:**
- Auth: see section 4b
- Database: see section 4b
- Storage: see section 4b (note: Firebase Storage is NOT auto-provisioned by AI Studio Build — enable it in the Firebase console before first audio upload)
- Email: see section 4b
- Payments: see section 4b (not used in v1)
- External APIs: see section 4b
**Environment variables you'll need to configure:**
- `GEMINI_API_KEY` — your Google AI Studio API key
- `FIREBASE_PROJECT_ID` — your Firebase project id
- `FIREBASE_SERVICE_ACCOUNT` — service-account JSON (server-side only)
- `FIREBASE_STORAGE_BUCKET` — the bucket name once you've enabled Storage
- `SENDGRID_API_KEY` — optional, only if you want richer transactional email than Firebase magic links
**Cost + privacy notes:**
- One short paragraph per cost-sensitive capability: the primary long-context audio parse is the big cost — about $0.30-0.40 per three-hour meeting. The cross-meeting threading call is bursty (runs when the archive grows past a threshold) and costs about $0.10-0.20 per body per month at typical archive sizes.
- One short paragraph on privacy: where the data lives (your Firebase project), how to delete it (Settings → "Delete this archive forever" — gone in 60 seconds), what is never sent for training, and that public commenters' addresses are hidden from shared summaries by default.
**Documentation links:**
- AI Studio Build docs
- Gemini API long-context audio, multilingual, structured-output docs
- Firebase Auth, Firestore, Firebase Storage docs
- A short note on the Files API resource-name flow (uploading audio server-side, generating a `files/*` resource name, passing it to `generateContent`)
**Accessibility:** same standards as the onboarding modal — focus trap, `Esc`, ARIA, restored focus.
**Behaviour:**
- Always available — single click from anywhere in the app.
- Tooltip on the `(i)` icon: "How this app is built".
- Mobile: opens as a full-screen sheet that slides up.
- Should be the most honest part of the app — never hand-wave service requirements; never say "AI" without naming the specific Gemini model and capability.
## 6d. Detailed cost breakdown (deployer reads this BEFORE shipping)
- **Long-context audio parse (Gemini 3.5 Flash, high thinking)** — a 3-hour meeting parsed in one call. Input audio in token-equivalents: ~90-120k tokens. Output JSON: ~10-15k tokens. With 3 Pro input at ~$1.50/M and 3 Pro output at ~$9/M: ~$0.20-0.25 audio in + ~$0.05-0.08 output = ~$0.30-0.35/meeting. Add a small premium for high thinking budget. **Round to $0.35/meeting** as a planning estimate.
- **Cross-meeting threading (Gemini 3.5 Flash, medium thinking, archive-wide)** — runs when the archive grows past 5 meetings of the same body, and again on any new meeting upload. Input: ~150-300k tokens of structured Meeting JSON (12 months of fortnightly meetings ≈ 26 records). Output: ~3-5k tokens of thread overlays. ~$0.20-0.40 per run. **Round to $0.30/run.**
- **Newsletter-style summary (Gemini 3.5 Flash, low thinking)** — input ~15k tokens of parsed Meeting; output ~600 tokens of newsletter. With 3 Flash at $0.075/M in + $0.30/M out: ~$0.001-0.002/newsletter. Negligible.
- **Project-number resolution (Gemini 3.5 Flash + grounded search)** — ~$0.001/lookup. A typical meeting has 5-15 distinct project numbers; lookup runs once per number, never again. ~$0.01/meeting.
- **Watch-list keyword expansion (Gemini 3.5 Flash, low thinking)** — ~$0.0002/keyword expansion. Runs once when the user configures the watch list.
- **Translation (Gemini 3.5 Flash, low thinking)** — ~$0.001/summary translation, ~$0.0005/quote translation. User-triggered; usually a handful per meeting.
- **TTS narration (Gemini 2.5 Flash TTS)** — billed per output token (~$10/M output tokens), effectively ~$0.000003/character. A 400-word summary ≈ ~$0.007. Optional; cached per summary.
- **Cover illustration (Nano Banana 2)** — ~$0.03/image. Optional; cached per meeting.
- **Expected total cost per meeting on first ingest:** ~$0.35-0.45.
- **Full year of fortnightly meetings (~26 meetings) for one user:** ~$10-12 in Gemini API spend.
- **Audio storage:** Firebase Storage standard tier, ~$0.026/GB/month. A 3-hour MP3 @ 128 kbps is ~165 MB; a full year of fortnightly meetings ≈ ~4.3 GB ≈ ~$0.11/month.
## 7. Design language
- **Mood:** A neighbourhood-newsletter that happens to be powered by long-context audio. Not a civic-tech dashboard. Not a journalist's investigation tool. The kitchen table at 6:30 am with the agenda printed out and last night's MP3 on the laptop — the neighbour reading carefully before the bus comes.
- **Typography:** Display serif for the one-page summary and meeting headlines (Source Serif Pro or Charter). A clean grotesque for app chrome, agenda lists, and the vote ledger (Inter or Geist). The verbatim quote in the summary uses the serif at a slightly larger size with a generous left margin and a sepia rule, like a pull-quote in a print newspaper. Timestamps in monospace (JetBrains Mono or SF Mono) so the digits align.
- **Palette:** Paper-cream background `#F4EFE6` for the meeting view, deep ink `#1B1714` for body text, sepia accent `#7B4F2A` for timestamps, watch-list-banner amber `#C97A2A` for the watch-list-hits ribbon at the top of every meeting, muted civic-green `#3A5B3D` for the "play from here" play-button affordance, muted alert-red `#A33A2C` only for the "diverged from staff recommendation" flag in the vote ledger. The dais speakers each get a stable neutral tint from a six-colour assignment so the user can scan who's speaking when. Borrowed from a local-paper print edition, not from civic-tech design systems.
- **Imagery:** The audio waveform is a quiet hero — a single thin sepia line across the top of each meeting card, with the watch-list hits as small amber dots on the line. The cover illustration (optional, Nano Banana 2) is a clean line drawing of an empty council chamber from the back of the room — restrained, not photographic, never a real city's chamber. Period illustrations are not used; the chrome is contemporary, the content is local.
- **Hand-feel touches:** A barely-visible paper grain on the meeting-summary background. The "play from here" button slides the audio player up from the bottom of the screen with a thin shadow — like a small radio tuning in. Hover on a speaker name reveals their established role and total speaking time; never aggressively glow.
- **Spacing:** consistent 4-px base. Generous whitespace — the summary needs air; the dense vote ledger needs structure.
- **Radius:** consistent token set (e.g. 6 / 12 / 20 px). Meeting cards use 6; the one-page summary card uses 12; the welcome card uses 20.
- **Shadows:** subtle, layered, sepia-tinted. Avoid heavy drop-shadows.
- **Motion:** purposeful — entrance fades for the meeting cards, hover lifts on the agenda items, the audio player slides up smoothly when "play from here" is tapped. Respect `prefers-reduced-motion`. No bouncing splash animations. No theatrical hero animations. The audio player's playhead glides across the waveform during playback; respect reduced-motion by stepping rather than gliding.
- **States:** every interactive element has hover, focus, active, disabled. Loading uses skeletons not spinners where possible. Empty states have helpful next-action guidance ("Drop in last night's meeting MP3 to start").
## 8. Content generation rules
- Write **realistic, specific copy**. NO Lorem Ipsum. NO generic placeholders like 'Your tagline here'.
- Invent plausible council bodies, ordinance numbers, councillor names, public commenters, agenda items, and quotes that fit the domain (use the seed content in section 8a as a starting point). When inventing, lean on real municipal-meeting patterns — staff recommendations, roll-call votes, three-minute public-comment slots, motions to continue — but never claim that a fictional meeting is a real recording of a real council.
- Tone: warm, direct, free of corporate language. This template is for a neighbour, not a civic-tech vendor.
- Headlines: punchy and concrete. No 'Empower your X' filler. No 'Revolutionize'. No 'Seamless'. No 'civic-tech-enabled'.
- Body copy: short paragraphs (2-4 sentences). Use lists where appropriate.
- Plain language. Avoid jargon — except where the user already speaks the jargon (the local journalist user wants to see "consent calendar" and "discretionary review"; the neighbour user wants those terms explained on first occurrence).
- Where the app outputs AI-generated content, never label it as "AI says" — let it speak naturally. Use small uncertainty cues only where epistemic honesty requires them (a low-confidence speaker identification shows as a faintly underlined name; tapping it reveals the alternates the model considered).
## 8a. Seed content (use these specific examples)
Anchor every generated copy + sample data point in the concrete content below. Use these names, numbers, dates, and snippets verbatim where helpful, or generate close variants that sit in the same world.
**Sample archives (sidebar):**
- "Oakland Planning Commission" (14 meetings tracked, watch list: Beacon Avenue, Eastlake corner lot, Variance 2026-114, Commissioner Aoki). Meetings 2026-01 through 2026-05, fortnightly.
- "Bristol City Council — Full Council" (9 meetings tracked, watch list: Cabot Ward, Whiteladies Road, Mayoral Question Time). Meetings 2025-09 through 2026-04, monthly.
- "Brooklyn Community Board 6" (12 meetings tracked, watch list: Carroll Gardens, F train, Special Permit C-240114). Meetings 2025-08 through 2026-04, monthly.
- "Quezon City Sangguniang Panlungsod" (7 meetings tracked, watch list: Kamuning Road, Brgy. South Triangle, Ordinance SP-3107). Meetings code-switch Tagalog ↔ English. 2026-02 through 2026-05.
- "Câmara Municipal de Lisboa" (5 meetings tracked, watch list: Alfama, Rua dos Bacalhoeiros, Plano de Pormenor 12). Portuguese. 2026-01 through 2026-05.
**Sample meeting in detail view (this is what the demo should show):**
- **Body:** City of Oakland Planning Commission
- **Jurisdiction:** Oakland, California, USA
- **Meeting date (as announced):** "Tuesday, May 14, 2026, regular meeting of the Oakland Planning Commission"
- **Meeting date ISO:** "2026-05-14"
- **Audio duration:** 3 hours, 4 minutes, 12 seconds
- **Source languages:** ["en-US"]
- **Headline (one-line neutral):** "Planning Commission approved Variance Request 2026-114 (1812 Beacon Avenue) 4-1, with Commissioner Aoki dissenting."
- **Watch-list hits:** "Beacon Avenue mentioned 4 times — 01:42:08, 01:47:51, 02:03:14, 02:51:33." "Variance 2026-114 mentioned 9 times across the meeting." "Commissioner Aoki spoke on this item three times — 01:46:22, 01:47:51, 02:53:09."
- **Speakers (dais, persistent):**
- "Speaker 1 — Chair Liu" (Chair, 47 speaking turns, 28 minutes total)
- "Speaker 2 — Commissioner Aoki" (Commissioner, 18 speaking turns, 14 minutes total)
- "Speaker 3 — Commissioner Park" (Commissioner, 12 speaking turns, 9 minutes total)
- "Speaker 4 — Commissioner Hernández" (Commissioner, 14 speaking turns, 11 minutes total)
- "Speaker 5 — Commissioner Okafor" (Commissioner, 9 speaking turns, 6 minutes total)
- "Speaker 6 — Staff Planner Reyes" (Staff Planner, 6 speaking turns, 22 minutes total — staff reports run long)
- "Speaker 7 — City Attorney Wallace" (City Attorney, 4 speaking turns, 5 minutes total)
- **Sample agenda item (the user's item):**
- **Item title (as announced):** "Item 7 — Public Hearing on Variance Request 2026-114, 1812 Beacon Avenue, request to exceed maximum floor-area ratio for proposed six-storey mixed-use development."
- **Project number:** "2026-114"
- **Timestamp start:** 01:38:42
- **Timestamp end:** 02:59:18
- **Body section:** "public_hearing"
- **Staff recommendation (one sentence):** "Staff recommends approval of the variance with the four conditions listed in Exhibit B of the staff report."
- **Staff recommendation verbatim quote:** "Staff Planner Reyes, 01:41:14: 'The applicant has demonstrated practical difficulty under section 17.148.050, and staff recommends approval with the four conditions in Exhibit B.'"
- **Discussion summary (neutral, 3 sentences):** "Staff presented the recommendation at 01:41:14. Commissioner Aoki questioned whether the practical-difficulty finding was supported by the parcel's existing zoning envelope and raised concerns about precedent for the corridor. Public comment ran from 02:02:30 to 02:48:51 with 22 commenters; 18 opposed the variance, 3 supported, 1 mixed."
- **Key quotes (3):**
- "Commissioner Aoki, 01:47:51: 'I think staff's recommendation here goes too far. The variance asks the commission to grant a 40% increase in floor-area ratio on a corner parcel that already has the largest envelope in the surrounding three blocks. I don't see the practical difficulty.'" (why_this_quote_matters: "first commissioner to publicly signal opposition")
- "Public Speaker #11, María Hidalgo, 02:14:38, self-introduced affiliation 'I'm a tenant at 1812 Beacon Avenue', first sentence: 'My family has lived in this building for nineteen years.'" (why_this_quote_matters: "first public commenter to identify as a current tenant of the parcel under review")
- "Chair Liu, 02:51:33: 'I want to be clear: the commission's role here is the practical-difficulty test, not the merits of the project itself. Public comment has been heard. We will now move to a vote.'" (why_this_quote_matters: "chair's framing immediately before the vote")
- **Vote:** "Approved 4-1. Yes: Chair Liu, Commissioner Park, Commissioner Hernández, Commissioner Okafor. No: Commissioner Aoki. Called at 02:57:44. Diverged from staff recommendation: No (staff recommended approval; commission approved)."
- **Public-comment count:** 22 (18 opposed, 3 supported, 1 mixed)
- **Outcome (one sentence):** "Approved 4-1 with conditions listed in Exhibit B of the staff report."
- **One-page summary paragraph (4-6 sentences):** "On Tuesday, May 14, 2026, the Oakland Planning Commission approved Variance Request 2026-114 at 1812 Beacon Avenue by a vote of 4-1, with Commissioner Aoki dissenting (02:57:44). Staff Planner Reyes recommended approval with four conditions listed in Exhibit B of the staff report (01:41:14). Twenty-two members of the public spoke during the public hearing (02:02:30-02:48:51); eighteen opposed the variance, three supported, one was mixed. Chair Liu reminded the commission immediately before the vote that 'the commission's role here is the practical-difficulty test, not the merits of the project itself' (02:51:33). The item will return to the City Council on the consent calendar for the meeting of June 4, 2026."
**Sample public commenters (the demo shows 6 of 22):**
- Public Speaker #03, "Jamal Carter", "I live on the 1200 block of Beacon Avenue", first sentence: "I've owned my home at 1247 Beacon for fourteen years.", topic_summary: "opposes the variance; concerns about parking and shadow on his garden.", sentiment: "oppose", timestamp 02:06:14
- Public Speaker #07, "Inday Ramos", "I work at the day-care on Beacon and 14th", first sentence: "I represent eighteen families whose kids come to our centre.", topic_summary: "opposes the variance; concerns about construction-traffic safety near the day-care drop-off.", sentiment: "oppose", timestamp 02:11:09
- Public Speaker #11, "María Hidalgo", "I'm a tenant at 1812 Beacon Avenue", first sentence: "My family has lived in this building for nineteen years.", topic_summary: "opposes; describes pending displacement and asks for relocation assurances.", sentiment: "oppose", timestamp 02:14:38
- Public Speaker #14, "anonymous, declined to give name", "I'm a homeowner on the 1300 block of Beacon", first sentence: "I support this project.", topic_summary: "supports; cites housing supply.", sentiment: "support", timestamp 02:23:51
- Public Speaker #19, "Linh Pham", "Carpenters' Local 713", first sentence: "Local 713 represents the workers who would build this project.", topic_summary: "supports; cites union construction jobs.", sentiment: "support", timestamp 02:38:14
- Public Speaker #22, "Wendy Okereke", "Eastlake Neighborhood Association", first sentence: "Our association polled members last week and the response was mixed.", topic_summary: "mixed; reports survey results and asks for additional conditions on construction hours.", sentiment: "mixed", timestamp 02:46:42
**Sample input artefacts (for the build to demonstrate):**
- A 3-hour 4-minute MP3 of the Oakland Planning Commission, May 14, 2026, with seven dais speakers, 22 public commenters, and four agenda items.
- A 2-hour 18-minute Bristol City Council Full Council recording, March 2026, English with regional Bristol/Bristolian register, including a Mayoral Question Time segment.
- A 3-hour 45-minute Quezon City Sangguniang Panlungsod recording, code-switching Tagalog and English, including a discussion of Ordinance SP-3107.
- A 1-hour 52-minute Câmara Municipal de Lisboa recording, Portuguese, including the public hearing on Plano de Pormenor 12 for Alfama.
- A 4-hour combined session of Planning Applications + Licensing Committee from Glasgow City Council (the long-meeting auto-chunking case).
**Sample voice copy:**
- Onboarding: "Drop in last night's MP3. We'll read it for you — every speaker, every vote, every mention of your street."
- Processing: "Reading the audio…" / "Separating the speakers…" / "Finding agenda items…" / "Scanning for Beacon Avenue…" / "Reading the votes…" / "Drafting the summary…"
- Empty archive: "This archive is waiting for its first meeting. Drop in an MP3 to start."
- Error (couldn't read): "We couldn't make out some of this audio — probably background noise on the public-comment mic. Want to try a clearer copy, or tell us which agenda item you care most about so we can focus there?"
- Save confirmation: "Saved to Oakland Planning Commission — May 14, 2026."
- Watch-list hit notification (in-app): "Beacon Avenue was mentioned 4 times in last night's meeting. The vote on Variance 2026-114 came down 4-1."
- Low confidence note: "Some speakers were hard to tell apart for the first 20 minutes. Tap any speaker name to see what the model considered."
**Sample family / co-watcher invitation email subject + body:**
- Subject: "Adding you to the Oakland Planning Commission watch — Beacon Ave variance vote tonight"
- Body: "Hi — I've been tracking the Beacon Ave variance through the Planning Commission. Tonight's meeting will vote on it. I'm adding you to the archive so you can read the summary tomorrow morning without having to sit through three hours of audio. Tap to join." [Open Archive]
**Sample newsletter export (400 words, neutral, factual — paste into Substack):**
"On Tuesday, May 14, 2026, the Oakland Planning Commission approved Variance Request 2026-114 at 1812 Beacon Avenue by a vote of 4-1. Commissioner Aoki dissented (02:57:44).
Staff Planner Reyes recommended approval with the four conditions listed in Exhibit B of the staff report (01:41:14). Commissioner Aoki questioned the practical-difficulty finding before the vote: 'The variance asks the commission to grant a 40% increase in floor-area ratio on a corner parcel that already has the largest envelope in the surrounding three blocks. I don't see the practical difficulty.' (01:47:51).
Public comment ran from 02:02:30 to 02:48:51 and included 22 commenters. Eighteen opposed the variance, three supported, and one was mixed. Several opposing commenters identified as tenants at 1812 Beacon Avenue or as residents of the 1200-1300 blocks of Beacon. A tenant at the building, who spoke at 02:14:38, said: 'My family has lived in this building for nineteen years.' A representative of Carpenters' Local 713, speaking in support at 02:38:14, cited union construction jobs.
Chair Liu framed the vote immediately before it was taken: 'I want to be clear: the commission's role here is the practical-difficulty test, not the merits of the project itself. Public comment has been heard. We will now move to a vote.' (02:51:33).
The item will return to the City Council on the consent calendar for the meeting of June 4, 2026.
Other actions taken at the meeting:
- Item 4 (consent calendar, design review for 2200 Telegraph Avenue): approved unanimously, no public comment.
- Item 5 (discussion item, draft inclusionary-zoning amendments): no action taken; discussion continued to the June 11 meeting.
- Item 9 (closed-session report-back, anticipated litigation): no reportable action taken (02:59:42).
The next regular meeting of the Planning Commission is scheduled for Tuesday, May 28, 2026, at 6:00 pm in Hearing Room 1, City Hall."
## 9. Media & assets
- **Hero image (landing screen):** A photographed-looking shot of a printed agenda sheet on a kitchen table at 6:30 am, an enamel mug of coffee blurred at the edge of frame, a phone next to it showing a one-page meeting summary, soft warm window light, real worn paper. Generate via Nano Banana 2 with a prompt emphasising "wooden kitchen table, morning light, no people in frame, printed paper agenda with handwritten margin notes, slightly out-of-focus mug of coffee, phone screen showing a simple list with sepia accents". Never a generic stock photo.
- **App icon / wordmark:** Set in the display serif. Slightly worn paper texture behind it. No icon — just type.
- **Empty-state illustration:** A simple line drawing of an empty council chamber from the back of the room — dais, public-comment mic, staff table. Hand-drawn aesthetic, not a flat icon.
- **Demo meeting waveform:** Generated visually as a thin sepia waveform with small amber dots marking the watch-list hits. The waveform is decorative-data (it shows where in the audio the hits occurred); it is interactive (scrubbing on it scrubs the audio).
- **Cover illustrations (optional, per meeting):** Generated per the Nano Banana 2 prompt in section 4b — a clean line drawing of a council chamber, restrained, never a real city's chamber, no text in the image, paper-cream background.
- **Stock fallbacks:** If image generation fails, fall back to the photographed sample agenda from `/public/samples/sample-agenda.jpg`. Never to a "🏛️" emoji.
- **Generated imagery:** prefer Nano Banana 2 over stock photography. Prompt for warmth, asymmetry, and slight imperfection — avoid the glossy 'AI render' look.
- **Optimisation:** WebP/AVIF, `loading="lazy"`, explicit `width`/`height` to prevent layout shift.
- **Icons:** `lucide-react` for UI. Use sparingly — never decorative-only.
### Build-time asset manifest (explicit specs)
Every image, illustration, and visual reference mentioned above must resolve to ONE of the three buckets below — runtime-generated, seed-shipped, or user-supplied. Do NOT ship `` tags whose `src` is not listed here. Do NOT depend on bare "section 8a prompts" without binding them to explicit paths and model IDs.
**Bucket 1 — Runtime-generated (Nano Banana Pro `gemini-3-pro-image` for hero/demo photographs; Nano Banana 2 `gemini-3.1-flash-image` for in-app illustrations and reference-conditioned variants).** Cached to Firebase Storage; served via signed URL. Every reference above to "Nano Banana 2" or "Nano Banana Pro" MUST be wired to one of these specific calls with an explicit model id:
- `/public/generated/hero.webp` (2400×1500, WebP) — model `gemini-3-pro-image` — uses the literal prompt described as "Hero image (landing screen)" above. Run once at build; commit a `/public/samples/hero-fallback.webp` (1600×1000) generated from the same prompt with `gemini-3.1-flash-image` so the page renders if quota is exhausted.
- `/public/generated/demo/{demo-slug}-{NN}.webp` (1600×1200, WebP) — model `gemini-3.1-flash-image` (reference-conditioned where the prior frame is passed as input) — one path per "Demo X" image referenced above. The slug derives from the seed example in section 8a; the NN index covers each frame in the demo sequence.
- `/public/generated/illustrations/{name}.webp` (1024×1024, WebP) — model `gemini-3.1-flash-image` — one path per named illustration above ("Empty-state illustration", "Recipe-card hero illustrations", "Curriculum picker imagery", "Period-style frames", etc.). Each illustration's prompt is the literal description above; ship a deterministic seed in the request so re-runs are reproducible.
**Bucket 2 — Seed assets shipped with the deliverable.** Every "Stock fallback" path referenced above (e.g. `/public/samples/sample-X.jpg`) is generated once via Nano Banana 2 (`gemini-3.1-flash-image`) at 1024×1024 WebP using the same prompt as its Bucket-1 counterpart, then committed to the repo so the page renders identically if Gemini quota is exhausted or the user is offline. Replace any `.jpg` extension above with `.webp` to match the optimisation rule. Also commit these empty-state seeds (1024×1024 WebP, single-stroke hand-drawn line, no colour fill):
- `/public/samples/empty-state-primary.webp` — line drawing of the app's primary empty surface (the named "Empty-state illustration" above), generated from that exact prompt.
- `/public/samples/empty-state-archive.webp` — line drawing of an empty saved/archive view, single-stroke outline.
- `/public/samples/empty-state-error.webp` — line drawing of a hand placing a single object aside with care, used when an AI call fails.
**Bucket 3 — User-supplied.** Uploads from the user's camera / file picker land at the Firebase Storage path conventional for this template (named in section 4b). The build ships with Bucket-1 + Bucket-2 only; no user-supplied images at first paint.
**Hard rules**
- Every `` tag MUST have a `src` that resolves to a path listed in Bucket 1, Bucket 2, or a Bucket 3 upload path. Anything else is a build error.
- No bare `image.jpg` / `hero.jpg` / `placeholder.png` references anywhere in the code.
- Model IDs: `gemini-3-pro-image` for hero-quality photographic generation; `gemini-3.1-flash-image` for in-app illustrations, reference-conditioned variants, empty-state seeds, and stock fallbacks. Never use a legacy model id (no `imagen-*`, no `gemini-1.5-*-image`).
- File format: WebP everywhere (AVIF acceptable where the target browsers support it). No `.jpg` / `.jpeg` / `.png` in `/public/samples/`.
## 10. Interactivity & states
- Every interactive element has hover, focus, active, and disabled states.
- Forms validate inline and show specific error messages (not "Invalid input").
- Loading states use skeletons that match the eventual layout, not spinners. The meeting-detail skeleton shows the one-page summary card outline, the watch-list-banner shape, and the agenda-item list shape before content arrives.
- Empty states explain the next action with a button whose label fits THIS app's domain: "Drop in an MP3 to start", "Paste your council's agenda feed", "Set a watch list before your next meeting" — never a generic "Add your first item".
- Smooth scroll for in-page anchors. Tapping a "play from here" timestamp scrolls to the audio player and animates it to the relevant playhead position.
- All AI-generated content streams in section-by-section where supported, with a clear "thinking…" indicator before content starts arriving (the headline appears first, then the watch-list-hits banner, then each agenda item).
- If an AI call fails, show a calm, specific error ("We couldn't separate the speakers cleanly in the first 20 minutes — probably mic levels. Want to retry, or skip to the rest of the meeting?") and offer retry.
- Low-confidence speaker identifications are faintly underlined; tapping reveals the alternates the model considered.
- The audio player playhead glides across the waveform during playback; respect `prefers-reduced-motion` by stepping in 1-second increments instead.
- Real-time updates: when the cross-meeting threading call completes after a new upload, the existing meeting cards quietly re-render with thread badges; no disruptive notification.
## 11. Tech & responsive requirements
- **TTS markdown-stripping preprocessor:** before sending any user-authored markdown to `gemini-3.1-flash-tts-preview`, strip non-spoken markdown: `#`/`##`/`###` headings (keep the title text), `**bold**` (keep the inner text), `[label](url)` (keep `label`, drop URL), `` ``` `` fenced code blocks (skip entirely), `>` block-quote markers (keep the text), and `|` table pipes (read row-by-row as sentences). Insert `…` between sentences for a short pause and a blank line plus `—` between paragraphs for a long pause. The model does not understand markdown; raw markdown will be read aloud as literal characters ("asterisk asterisk").
- **File downloads on Safari / Firefox:** when offering local-disk save of any export (PDF, CSV, MP3, ZIP, JSON, image), fall back to `` with a blob URL — the File System Access API (`showSaveFilePicker()`) is Chromium-only. Detect with `'showSaveFilePicker' in window`; otherwise use the anchor-download path.
- **Stack:** React + TypeScript + Tailwind CSS. Functional components + hooks. Use Shadcn UI primitives where appropriate.
- **Build runtime:** AI Studio Build — full-stack with Cloud Run server-side functions. All Gemini API calls happen server-side; API key lives in Secrets Manager, never in client bundle.
- **Model selection:** explicitly pin `gemini-3.5-flash` for the long-context audio parse and the cross-meeting threading call; `gemini-3.5-flash` for newsletter summary, project-number resolution, watch-list expansion, and translation. Set `thinkingLevel` explicitly per call.
- **Audio upload flow:** Client uploads to Firebase Storage via pre-signed URL → Cloud Run function generates a Files API `files/*` resource name from the uploaded file → passes the URI to `generateContent`. Do NOT pass the Firebase Storage public URL directly to Gemini; do NOT base64-encode 3 hours of audio into `inlineData`.
- **Database:** Firestore (auto-provisioned by AI Studio Build). Show the seed archive on first launch.
- **Auth:** Firebase Auth — Google sign-in by default; Apple sign-in next to it; magic-link email as fallback.
- **Storage:** Firebase Storage for original audio files. Pre-signed URLs only. Enable in Firebase console before first upload (not auto-provisioned).
- **Mobile-first.** Verify layouts at 375 px (iPhone SE), 768 px (iPad), 1024 px, 1440 px+.
- Use `clamp()` for fluid typography. Prefer container queries over media queries for component-level responsiveness.
- Use `dvh` / `svh` instead of `vh`. Respect safe-area insets on iOS.
- Zero horizontal overflow at any width. Zero layout shift on load.
- Persist user data in Firestore. Use real-time listeners on the meeting-detail view so cross-meeting threading updates appear without refresh.
- Optimistic UI on writes; reconcile on response.
- Audio player uses the Web Audio API for waveform rendering; HTML5 `