About

How Civic Ledger works, and where it stops

The problem it's built for

Almost everything a city council does is public: agendas are posted, minutes are filed, votes are recorded by name. Almost nobody reads them. A single Pittsburgh council meeting can carry forty items, each titled with a sentence like “Resolution further amending Resolution No. 858 of 2014, effective January 1, 2015, as amended, entitled…”. Local newsrooms that used to translate this have shrunk or closed. The result is that decisions about your street, your rent and your taxes are technically transparent and practically invisible.

Civic Ledger reads the record for you and rewrites it in the language people actually use, without losing the link back to the original.

Where the data comes from

Legistar cities. A few hundred US cities and counties publish their meetings through Legistar (Granicus), which has a public JSON API. For those cities we read every meeting in the window, every agenda item, the action taken, the roll-call vote by member, sponsors, and the full legislative text. Nothing is scraped; it's the same data the city's own portal shows. Adding one of these cities is a one-line entry and one command.

Everyone else. On the Read any agenda page, Firecrawl turns any agenda or minutes URL, HTML or PDF, into clean text, and the model lists the decisions it finds. This is inherently less certain than the structured path, so those items are labelled as extracted, carry the sentence they were drawn from, and don't claim votes the document doesn't state.

What the model does, and doesn't

An open-weight language model (Llama 3.3 70B or a comparable model, served through Groq today and Featherless AI when configured) receives each item's official title, its actions and votes, and up to about 1,600 characters of the legislative text. It returns a one-to-three sentence plain-language summary, who the item affects, a topic, tags, the headline dollar figure and any named place. The response is validated against a schema; if the model fails, the item falls back to a rule-based topic and the official title, and is marked accordingly.

The model never decides how a vote went, what an item's file number is, or when it happened. Those come from the record. Every item page shows the source excerpt and links to the original file so you can check the summary against it. The Ask feature answers only from the ten most relevant items retrieved by keyword search and cites them by number; it will tell you when the record doesn't answer the question.

Known limits

Dollar figures are the largest amount named in an item and are best read as an index of activity, not an audited budget: an amendment that restates a contract's total counts again, and multi-year totals count once. Topic labels are the model's judgement (rule-based when the model is off). Map pins depend on the record naming a street address, intersection or park; citywide items have no pin. Council-member sponsorship is matched by surname. Snapshots cover roughly the trailing 90 days and are refreshed by re-running the ingest command, not continuously.

Running it yourself

The whole site is a Next.js app that reads JSON snapshots committed to the repository, so it deploys to Vercel or Render's free tier with no database. npm run ingest -- --city pittsburgh rebuilds a city; set LLM_API_KEY (Featherless, Groq or any OpenAI-compatible endpoint) for explanations and FIRECRAWL_API_KEY for the live reader and city search. Details are in the README.

Currently tracked