CivicRadar
LiveCivicRadar takes every identity and issue you actually hold, queer, immigrant, on Medicaid, whatever the combination, and gives you one feed of the bills that touch any of them, each with the relevant advocacy orgs' positions attached. No following a separate org for every part of your life.
- 27
- identity tags
- 162
- org files
- 46
- states covered
- 17
- jurisdictions live
The problem
Nobody is just one issue. If you are queer and an immigrant and on Medicaid, staying informed the honest way means following an LGBTQ org, an immigration org, and a healthcare org separately: checking each site, joining each list, working out for yourself what actually applies to you. Almost nobody keeps that up. CivicRadar takes your full set of identity tags at once, scores every bill against all of them together, and pulls in whichever advocacy orgs are relevant to whichever tags a bill touches. Three identities get you one dashboard, not three you have to build by hand.
Architecture
Ingestion
Bills come from sources that do not speak the same language. OpenStates serves live keyword search for state bills; LegiScan bulk-ingests full session datasets, hash-checked before re-downloading, diff-merged into Cloudflare R2; Congress.gov covers federal bills, routed around a real regression I hit in their /summaries endpoint by pulling from /bill/{congress} instead. None of these clients touch the cache directly. Callers layer that on: Redis for the hot live-search path, R2 for anything bulk or long-lived.
Matching
Matching a user to a bill is not a model call, it is a deterministic scorer: keyword hits against a bill's title, subjects, and abstract across 27 identity tags and 26 issue tags, weighted so identity tags (lived experience) count more than issue tags (ally interest), plus about nine more additive bonuses and penalties for jurisdiction, legislative stage, recency, and advocacy validation. Identities are scored as a set, so someone holding several gets one ranked list that already accounts for all of them. I tried replacing the keyword scorer with pure semantic search twice. Both times it made results worse, once by dropping partial-coverage states to zero recall, once by reranking correct matches out of the top ten (37.5% down to 12.5% on my eval). What shipped is a union: keyword results are the floor, semantic search only adds what it would otherwise miss, reranking off by default.
AI, on a leash
AI enters at exactly one point: drafting the email and call script, on Claude Haiku, with a hard rule that ally-only selections never say "as someone directly affected." Bill summaries run on Gemini Flash instead, a straight cost call once I confirmed the cheaper model was not losing accuracy.
Tracking & alerts
Alerts run on a weekly GitHub Actions cron, not a client poller. When the bulk refresh runs for a state, it diffs each tracked bill's stage against what's cached and fires push and email only on a real forward move, committee to floor and so on, with a 14-day dedup key so the same change never double-fires. Push runs through a real service worker; email is double opt-in with its own verify and unsubscribe tokens. Legislator profiles filled a gap from a competitive review: every other tool treated "who represents me" as a name and a phone number. CivicRadar builds full profile pages: sponsored bills, committee membership, recent votes, a permalink.
Advocacy join
Advocacy positions live as 162 scraped and reviewed JSON files, from 490 orgs across five networks: Equality Federation, the League of Conservation Voters, the League of Women Voters, Planned Parenthood, and Sierra Club. Real positions now cover 46 states plus DC and federal bills, joined to bills through an exact bill-ID index with a fallback for when the ID scheme shifts between the live source and the bulk cache. That fallback exists because it broke in production: OpenStates and LegiScan do not agree on bill IDs. The join returns every org with a position, not one, because the point is not picking a favorite group's opinion, it is showing all of them at once. On Maryland's HB 219, that means six orgs on the same card, Brennan Center, Common Cause, League of Women Voters, Maryland Legal Aid, NAACP Legal Defense Fund, and Sierra Club, each with a one-line reason. That is a real multi-chip row on real legislation, not one group's take.
Municipal (the hard part)
Federal and state bills come through relatively uniform APIs. Cities do not. Most run on Granicus Legistar, if they run on anything standardized, but there is no public directory of which cities use it, so coverage means hand-compiling a candidate list and probing each one. Plenty are dead ends: agenda tools with no bill data, wrong-slug 500s that take reverse-engineering to fix, instances frozen years out of date (Chicago moved off Legistar in 2023), a few that run it internally but never turned on the public API. Nineteen jurisdictions are wired in, seventeen carry live bill data, from a few thousand bills in Denver down to a couple dozen in the smallest councils. The two still dark: Gainesville, whose instance returns nothing, and New York City, blocked on a Granicus API token. Municipal took the most manual work of any layer, because municipal open-data infrastructure is genuinely inconsistent, not because the pipeline is thin.
- Albuquerque, NM · live
- Ann Arbor, MI · live
- Boston, MA · live
- Columbus, OH · live
- Denver, CO · live
- Fresno, CA · live
- Gainesville, FL · wired, dark
- Louisville, KY · live
- Madison, WI · live
- Maricopa County, AZ · live
- Milwaukee, WI · live
- Nashville, TN · live
- New York City, NY · wired, dark
- Oakland, CA · live
- Pittsburgh, PA · live
- Prince George's County, MD · live
- Richmond, VA · live
- Seattle, WA · live
- Washington, DC · live
The deliberate choice
Nothing a user tells the app is stored server-side. ZIP code, identity tags, the personal sentence they write, all of it lives in localStorage only. For a queer immigrant on Medicaid, the risk of a civic tool is not that it fails to help. It is that it becomes a list somewhere with your name next to "trans" or "undocumented." I built the privacy model as a constraint on the architecture, not a policy on top of it. There is no server-side table to subpoena because it does not exist.
- The watchlist is a client-side list; the endpoint that refreshes saved-bill status takes IDs from the client and never learns who is asking
- The Redis search cache is keyed by state, a keyword hash, and a time bucket, with no user association possible even in principle
- The only thing that reaches a server is cookieless, aggregate analytics, with no user data in it at all
Stack
- Next.js 16 (App Router), React 19, TypeScript (strict)
- Tailwind v4, CSS-based config
- Upstash Redis (hot-path search cache), Cloudflare R2 (bill text, bulk snapshots, summaries), Upstash Vector (hybrid retrieval)
- Claude Haiku 4.5 (message drafting), Gemini 2.5 Flash (summaries), Voyage 3.5 embeddings (reranking off by default)
- OpenStates (live state search), LegiScan (bulk state ingestion), Congress.gov (federal), Granicus Legistar (municipal, partial)
- Vercel. AGPL v3 once the repo goes public
Status & links
Live at civicradar.co.