Saj Chandoo
← Back to work

CivicRadar

Live

CivicRadar 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

SOURCESOpenStatesLegiScanCongress.govLegistarLOGICKeyword scorer(27 + 26 tags)Advocacy-positionjoinDESTINATIONSRanked bill feedLegislator profilesPush & email alertsHaiku-draftedscripts
Four sources that do not speak the same language, matched by a deterministic scorer, not a model call.

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.

AKMEVTNHMAWAMTNDSDMNWIMINYCTRIORIDWYNEIAILINOHPANJCANVUTCOKSMOKYWVDCMDDEAZNMOKARTNVANCTXLAMSALGASCHIFL
46 states plus DC carry verified advocacy positions. 19 municipal jurisdictions are wired for local bill data, 17 of them live.
State covered Not yet covered Municipal, live Municipal, wired
  • 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.

Stack

Status & links

Live at civicradar.co.