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're 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 of their sites or joining each of their email lists, and 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.

The app pulls bills from sources that do not speak the same language. OpenStates serves live keyword search for state bills. LegiScan is a separate bulk-ingest pipeline that downloads full session datasets, checks a dataset hash before re-downloading anything, and diff-merges into Cloudflare R2. Congress.gov covers federal bills through its own client, working around a real regression I hit in their /summaries endpoint by pulling from /bill/{congress} instead. None of these clients touch the cache directly. That is layered on by the callers: Redis for the hot live-search path, keyed by state and a hash of the keywords and bucketed into four-hour windows, and R2 for anything bulk or long-lived.

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), with about nine more additive bonuses and penalties on top for jurisdiction, legislative stage, recency, and whether the bill has any advocacy validation at all. Identities are stored and scored as a set, so someone holding several gets a single 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 candidates it would otherwise miss, and reranking is off by default.

AI enters at exactly one point after that, 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 a bill over time and acting on it are their own systems. 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, introduced to committee, committee to floor, and so on, with a 14-day dedup key so the same change never double-fires. Push goes through a real service worker and the web-push package; email is double opt-in with its own verify and unsubscribe tokens. Legislator profiles were a similar case of filling a gap I found in a competitive review, where every other tool treated "who represents me" as a dead end, a name and a phone number and nothing else. CivicRadar builds full profile pages: sponsored bills, committee membership, recent votes, a permalink.

Advocacy positions live as 162 scraped and reviewed JSON files, from a set of 490 orgs configured 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. They join to bills through an exact bill-ID index with a fallback for when the ID scheme changes between the live search source and the bulk cache. That fallback exists because it broke in production: OpenStates and LegiScan do not agree on bill IDs, and the advocacy data had already migrated to LegiScan's format. The join returns every org with a position on a bill, not one, because the point is not picking a favorite group's opinion, it is showing you all of them at once. On Maryland's HB 219, the Maryland Voting Rights Act, 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 the actual answer to not having time to check what every group you trust thinks: a real multi-chip row on real legislation.

Local government has been the hardest layer, and I want to be honest about how it got built. 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 or what their client slug is, so coverage means hand-compiling a candidate list and probing each one. Plenty are dead ends: some are agenda tools with no bill data, some return a 500 from a wrong slug guess and you have to reverse-engineer the real client ID from the city's own traffic, some are frozen years out of date (Chicago moved off Legistar in 2023), and a few run it internally but never turned on the public API. Nineteen jurisdictions are wired in, and seventeen now carry live bill data, from a few thousand bills in Denver down to a couple dozen in the smallest councils. The two still dark are Gainesville, whose instance returns nothing, and New York City, the flagship, blocked on a Granicus API token. Municipal took by far the most manual work of any layer, and it took that work 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. The watchlist works the same way: saving a bill is a client-side list, and the one endpoint that refreshes saved-bill status takes the IDs from the client and never learns who is asking. The Redis cache that makes search fast is keyed by state, a hash of the keywords, and a time bucket, with no user association possible even in principle. The only thing that reaches a server is cookieless, aggregate analytics that never sees any of it. 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.