CivicFM

For agents

Your AI agent can read everything CivicFM's shows have reported: what each local government took up, how it voted, and what was said, item by item, each with a link to its moment in the recording. Connect it through the MCP server, or have it call the API.

An agent that finds the site by itself can start at /llms.txt, which says the same in Markdown and lists the shows.

MCP

Any AI agent that takes an MCP server's address can read every item here, and answer a question about your local government with links to the moments in the recordings. The server is https://civic.fm/mcp, read-only, and needs no sign-in.

Then ask it something like “What has Pasadena decided about surveillance technology lately?” It has 8 tools:

list_showsThe places CivicFM covers, each with its bodies and the meeting dates it has reported.
list_topicsThe network's topics, by area, each with its slug, what belongs in it, and how many items it has.
search_itemsItems from public meetings that match, newest meeting first, or best match first with q.
get_itemOne item in full, by its id: everything search_items gives, and what officials and staff said about it.
find_placeThe local governments over a city, town, county, or ZIP code, each with the show that covers it, and the shows to listen to for it.
get_briefingWhat a place's local governments did lately: the stories their shows' latest episodes told in the last 14 days, as CivicFM's front page ranks them, with those episodes to listen to.
list_officialsWho decides for a show's place: each body, where it takes written comment, and who holds each of its seats today, with their office's email, phone, and page where CivicFM has them.
followFollow a search by email, as CivicFM's Follow form does: an email when something new matches, at most once a day.

The API

What local governments decided at their public meetings, item by item, as JSON: what each body took up, how it voted, and what was said, each with a link to its moment in the recording. Items are filed by topic, across every show.

Everything it gives is on the shows' episode pages, produced with AI from the public recordings and checked against them. It's read-only and open: no key, and any site may call it. Version 1 only grows: fields are added, never changed or removed.

Right now it holds 723 items from 75 meetings, across 8 shows, from September 24, 2026 to October 30, 2026. It grows each time a show publishes its weekly episode.

Every call and field is listed below, and in the reference, where you can try each. Its description, as OpenAPI, is for a tool that writes a client or imports an API.

Try it

curl 'https://civic.fm/api/v1/items?topic=surveillance&q=drone&limit=1'

Open it in your browser. Right now it answers:

{
  "items": [
    {
      "id": "pasadena:13:2",
      "show": {
        "slug": "pasadena",
        "title": "Pasadena",
        "site": "https://pasadena.civic.fm"
      },
      "meeting": {
        "id": 13,
        "body": "City Council",
        "date": "2026-10-27",
        "title": "City Council Meeting: October 27, 2026 - Oct 27, 2026",
        "recording": "https://pasadena.granicus.com/MediaPlayer.php?view_id=25&clip_id=8902",
        "documents": [
          {
            "url": "https://pasadena.granicus.com/AgendaViewer.php?view_id=25&clip_id=8902",
            "kind": "agenda",
            "title": "Agenda"
          },
          {
            "url": "https://ww2.cityofpasadena.net/2026%20Agendas/Oct_27_26/Agenda.asp",
            "kind": "agenda",
            "title": "Council Agenda"
          }
        ]
      },
      "number": 2,
      "agenda_ref": "5",
      "title": "Police drone-operator training grant ($39,196), approved on consent",
      "what_and_why": "Consent item 5 authorizes the City Manager to execute documents for an Edward Byrne Memorial Justice Assistance Grant and amends the Police Department's FY2027 operating budget to recognize and appropriate $39,196 (agenda) in the General Fund. Staff said the money would train more police drone (UAS) operators to support SWAT; the item drew questions because the council has a pending discussion about expanding police drone use to 'drone as first responder' (DFR). It passed as part of the consent calendar motion covering all items except item 3. The clerk reported two letters asking staff to clarify the purpose and use of the grant funds.",
      "topics": [
        "surveillance",
        "policing",
        "contracts"
      ],
      "at": "00:07:50",
      "link": "https://pasadena.granicus.com/player/clip/8902?view_id=25&entrytime=470",
      "outcomes": [
        {
          "text": "A motion to approve all consent calendar items except item 3 was made and seconded. Result: passed.",
          "unclear": false,
          "at": "00:12:24",
          "link": "https://pasadena.granicus.com/player/clip/8902?view_id=25&entrytime=744",
          "result": "passed",
          "tally": null,
          "votes": null,
          "no": [],
          "abstain": [],
          "recused": []
        }
      ],
      "points": [
        {
          "text": "A councilmember said they had contacted the police chief beforehand because they shared public concerns about what the grant money would pay for",
          "at": "00:08:44",
          "link": "https://pasadena.granicus.com/player/clip/8902?view_id=25&entrytime=524",
          "speaker": {
            "name": null,
            "role": "a councilmember"
          }
        },
        {
          "text": "A councilmember said they are comfortable with SWAT's use of drones but not interested in expanding to drones as first responder",
          "at": "00:08:44",
          "link": "https://pasadena.granicus.com/player/clip/8902?view_id=25&entrytime=524",
          "speaker": {
            "name": null,
            "role": "a councilmember"
          }
        }
      ],
      "highlights": [
        {
          "title": "If the council rejects first-responder drones, where does this money go?",
          "context": "During the consent calendar, a member questions a police department representative about a grant to train more police drone operators, while the council still has a separate discussion pending on using drones as first responders.",
          "speakers": [
            {
              "name": null,
              "role": "a councilmember"
            },
            {
              "name": null,
              "role": "city staff"
            }
          ],
          "audio": "https://civicfm-demo-media.t3.tigrisfiles.io/pasadena/highlights/2026-10-30-95cd2ba5.mp3",
          "length_s": 61.1,
          "start": "00:10:56",
          "end": "00:11:57",
          "link": "https://pasadena.granicus.com/player/clip/8902?view_id=25&entrytime=656",
          "url": "https://pasadena.civic.fm/episodes/2026-10-30/highlights/1"
        }
      ],
      "episode": {
        "title": "Drone training grant, child mental health funds, and a work/live rethink",
        "url": "https://pasadena.civic.fm/episodes/2026-10-30",
        "item_url": "https://pasadena.civic.fm/episodes/2026-10-30?item=m13-2",
        "published_at": "2026-10-30T15:37:03Z",
        "audio": "https://op3.dev/e,pg=43cd7295-7008-5288-a5af-e4f041a7c1e5/civicfm-demo-media.t3.tigrisfiles.io/pasadena/episodes/2026-10-30-2bf34825.mp3",
        "covered": true
      },
      "updated_at": "2026-10-30T15:37:03Z",
      "comment": {
        "email": "correspondence@cityofpasadena.net",
        "form": null
      }
    }
  ],
  "total": 3,
  "next": "MQ=="
}

That's cut short for the page: the API gives this item's other 7 points too.

More to try:

Page by page

A response holds 50 items, or as many as limit asks for, up to 200. When more match, next is a cursor: send it back as cursor, with the same filters, for the page after. When next is null, that was the last page.

curl 'https://civic.fm/api/v1/items?limit=2'
curl 'https://civic.fm/api/v1/items?limit=2&cursor=Mg=='

Keeping a copy

Ask for what's changed since the latest updated_at you hold. It comes oldest change first, so you can stop anywhere and go on from the last one you read:

curl 'https://civic.fm/api/v1/items?updated_since=2026-10-30T15:37:03Z'

An item's id never changes, so a changed item replaces yours. An episode that's retracted takes its items with it: GET /api/v1/items/{id} then answers 404, which is how a copy learns of it.

When it can't answer

A mistake is answered in JSON too, {"error": …}, saying what to ask instead: 400 for a filter it can't read, or one it needs and didn't get, 404 for a path, an item, or a show it doesn't have, and 405 for anything but GET or HEAD. A parameter it doesn't know is ignored.

So /api/v1/items?topic=drones answers 400:

{"error": "There's no topic 'drones': /api/v1/topics lists them."}

From a web page

A script on any site can call it:

const r = await fetch('https://civic.fm/api/v1/items?topic=surveillance')
const { items, total, next } = await r.json()

Every response says so, and how long it may be kept:

Access-Control-Allow-Origin: *
Cache-Control: public, max-age=300

Endpoints

GET /api/v1/items
Items, newest meeting first, or best match first when you give q, or oldest change first when you give updated_since. Each filter is optional.
qstringWords to look for, in the item's title, what it is, its votes, and what was said. "Quotes" match a phrase, and -word leaves one out.
topicstringA topic's slug, from /api/v1/topics.
showstringA show's slug, from /api/v1/shows.
sincestring (date)The earliest meeting date to give, YYYY-MM-DD.
untilstring (date)The latest meeting date to give, YYYY-MM-DD.
resultstringThe items with a motion of that result: passed, failed, continued, withdrawn, no_action, or unclear.
updated_sincestring (date-time)A time, like 2026-10-01T13:00:00Z: the items whose updated_at is at or after it, oldest change first. A time that names no zone is UTC.
cursorstringThe next a response gave, for the page after it.
limitintegerItems per page: 50 if you don't say, and 200 at most.

It answers:

itemsarray of itemsThe items on this page.
totalintegerHow many match in all.
nextstring or nullThe cursor for the page after: send it back as cursor, with the same filters. null on the last page.
GET /api/v1/items/{id}
One item, by its id, as items has it. 404 if there's none, which is also how a copy learns that an episode was retracted.
GET /api/v1/topics
The topics, by area, each with how many items it has.

It answers:

areasarray of objectsEvery area, each with its topics, whether or not they have items yet.
areas[].slugstringIts name in addresses.
areas[].labelstringWhat it's called.
areas[].topicsarray of objectsIts topics.
areas[].topics[].slugstringIts name in addresses, and what the topic filter takes.
areas[].topics[].labelstringWhat it's called.
areas[].topics[].aboutstringWhat belongs in it.
areas[].topics[].countintegerHow many items it has.
GET /api/v1/shows
The shows, each with the place it covers, its site, and its feed.

It answers:

showsarray of objectsEvery public show, by title.
shows[].slugstringIts name in addresses, and what the show filter takes.
shows[].titlestringWhat it's called.
shows[].placestringThe place whose meetings it covers.
shows[].regionstringWhere that place is: its state.
shows[].statestringWhether it's still publishing: live, paused, or retired.
shows[].sitestring (uri)Its site.
shows[].feedstring (uri)Its podcast feed.
shows[].appsobjectWhere to subscribe to it: the podcast apps it's listed in.
shows[].apps.applestring (uri) or nullIts listing in Apple Podcasts, or null where it isn't listed yet.
shows[].apps.spotifystring (uri) or nullIts listing in Spotify, or null where it isn't listed yet.
shows[].apps.youtubestring (uri) or nullIts podcast on CivicFM's YouTube channel, each episode as a video, or null before its first is up.
shows[].digestbooleanTrue for a show of shows, made from its area's shows' episodes. Their items are listed under them, so it has none of its own.
shows[].bodiesarray of objectsThe bodies whose meetings it covers. A digest has none.
shows[].bodies[].idstringIts name in the show's config.
shows[].bodies[].labelstringWhat it's called, as an item's meeting.body has it.
shows[].bodies[].agendasstring (uri) or nullThe government's own page of its meetings ahead and their agendas, or null where CivicFM doesn't have it. CivicFM reports a meeting after it happens: this is where to see what's coming up.
shows[].coversobject or nullWhat it has published so far, or null before its first episode.
shows[].covers.firststring (date) or nullThe earliest meeting date it has items from. null for a digest, whose items are its members'.
shows[].covers.laststring (date) or nullThe latest meeting date it has items from: nothing after it has been reported yet. null for a digest.
shows[].covers.episodestring (date-time)When its latest episode went out, UTC.
GET /api/v1/places
A city, town, county, or ZIP code: the governments over it, each with the show that covers it, and the shows to listen to for it, roundups included. Words that fit several places answer only choices.
qstringA city, town, or county, with its state if you know it (Anytown, VT), or a ZIP code, which names one place.

It answers:

placestring or nullThe place as the Census Bureau names it, or the ZIP code. null when the words fit several places: choices lists them.
kindstring or nullWhat it is: a city, town, county, … as governments has them, unincorporated for a place no city or town governs, or zip.
governmentsarray of objectsThe governments over it: its own, then its county's. An unincorporated place has only its county's, and a ZIP code each one it falls in. No school or special districts.
governments[].namestringIts name, as the Census Bureau's list has it.
governments[].kindstringWhat that list calls it: city, town, village, borough, township, county, or community.
governments[].showstring or nullThe slug of the show that covers it, as show takes it, or null where no show covers it yet.
notestring or nullWhat a person there should know of who governs them, where it's not plain: that a place is unincorporated, so its county governs it. Or null.
listenarray of objectsThe shows to hear for it: those that cover its governments, then the roundups that carry them. Empty where no show covers it yet.
listen[].slugstringIts name in addresses, and what show takes.
listen[].titlestringWhat it's called.
listen[].sitestring (uri)Its site, with each episode.
listen[].feedstring (uri)Its podcast feed.
listen[].appsobjectWhere to subscribe to it: the podcast apps it's listed in.
listen[].apps.applestring (uri) or nullIts listing in Apple Podcasts, or null where it isn't listed yet.
listen[].apps.spotifystring (uri) or nullIts listing in Spotify, or null where it isn't listed yet.
listen[].apps.youtubestring (uri) or nullIts podcast on CivicFM's YouTube channel, each episode as a video, or null before its first is up.
listen[].digestbooleanTrue for a roundup of several shows' weeks, which carries the place's own.
choicesarray of stringsWhen the words fit several places, up to five of them, as place would name each: ask again with one. Otherwise empty.
GET /api/v1/briefing
What a place's local governments did lately: the stories their shows' latest episodes told in the last 14 days, as the front page ranks them, with those episodes. Give place or show.
placestringA city, town, or county, with its state if you know it (Anytown, VT), or a ZIP code, which names one place.
showstringA show's slug, from /api/v1/shows, in place of place. A roundup's is its shows'.

It answers:

placestring or nullThe place briefed, as the Census Bureau names it, or null when a show was asked for.
storiesarray of itemsThe stories, eight at most, from the shows' latest episodes out in the last 14 days: a statewide roundup's top stories that are theirs, then an area roundup's, then each show's own, its first before its second. Empty with nothing out in 14 days.
episodesarray of objectsThose episodes, to listen to: each show's own, then the roundups'.
episodes[].showobjectIts show.
episodes[].show.slugstringIts name in addresses, and what the show filter takes.
episodes[].show.titlestringWhat it's called.
episodes[].show.sitestring (uri)Its site.
episodes[].titlestringWhat it's called.
episodes[].published_atstring (date-time)When it went out, UTC.
episodes[].urlstring (uri)Its page, with the player.
episodes[].audiostring (uri) or nullIts MP3, as its show's feed gives it.
episodes[].digestbooleanTrue for a roundup of several shows' weeks.
searchedarray of objectsEach show briefed, with what it covers. Nothing after a show's covers.last has been reported yet.
searched[].slugstringIts name, as show takes it.
searched[].titlestringWhat it's called.
searched[].coversobject or nullWhat it has published so far, or null before its first episode.
searched[].covers.firststring (date) or nullThe earliest meeting date it has items from. null for a digest, whose items are its members'.
searched[].covers.laststring (date) or nullThe latest meeting date it has items from: nothing after it has been reported yet. null for a digest.
searched[].covers.episodestring (date-time)When its latest episode went out, UTC.
GET /api/v1/shows/{slug}/officials
A show's bodies: where each takes written comment, and who holds each of its seats today, with their office's email, phone, and page where CivicFM has them. A committee drawn from another body's seats names that body.

It answers:

showobjectThe show.
show.slugstringIts name in addresses, and what the show filter takes.
show.titlestringWhat it's called.
show.sitestring (uri)Its site.
todaystring (date)The day the seats are read for, where the show is, YYYY-MM-DD.
bodiesarray of objectsEach body whose meetings it covers.
bodies[].idstringIts name in the show's config.
bodies[].labelstringWhat it's called, as an item's meeting.body has it.
bodies[].commentobject or nullWhere it takes written comment, as its agenda says, or null where CivicFM doesn't have it.
bodies[].comment.emailstring or nullThe address its agenda gives for written comment, or null.
bodies[].comment.formstring (uri) or nullThe page its agenda gives to send written comment from, or null.
bodies[].agendasstring (uri) or nullThe government's own page of its meetings ahead and their agendas, or null where CivicFM doesn't have it. CivicFM reports a meeting after it happens: this is where to see what's coming up.
bodies[].membersarray of objectsWho holds each of its seats today, as the show's roster has them. Staff, who hold no seat, aren't listed. Empty for a committee drawn from another body's seats, and where the roster lists no one.
bodies[].members[].namestringTheir name.
bodies[].members[].titlestring or nullThe titles they hold today.
bodies[].members[].contactobject or nullTheir office's contact, or null where CivicFM doesn't have it.
bodies[].members[].contact.emailstring or nullThe office's email, or null.
bodies[].members[].contact.phonestring or nullThe office's phone, as the government writes it, or null.
bodies[].members[].contact.pagestring (uri) or nullThe office's page on the government's site, or null.
bodies[].drawn_fromstring or nullFor a committee whose members are some of another body's, that body's label. Otherwise null.
GET /api/v1
Where each of these is, with the page for agents, the reference, and the description.

It answers:

docsstring (uri)The page for agents: the MCP server, then the API.
itemsstring (uri)The items.
topicsstring (uri)The topics.
showsstring (uri)The shows.
referencestring (uri)The API's reference, to try each call in.
openapistring (uri)The API's description, as OpenAPI.
mcpstring (uri)The MCP server, for an AI assistant to read the same items: add it as a connector.
placesstring (uri)A place's governments, and the shows to hear for it.
briefingstring (uri)What a place's governments did lately.

An item

One thing a body took up at a meeting: what it is, how it was voted on, what was said about it, and where each of those is in the recording.

idstring‹show›:‹meeting›:‹number›. It never changes, so it's safe to keep.
showobjectThe show it's in.
show.slugstringIts name in addresses, and what the show filter takes.
show.titlestringWhat it's called.
show.sitestring (uri)Its site.
meetingobjectThe meeting it's from.
meeting.idintegerIts number within its show.
meeting.bodystringThe body that met.
meeting.datestring (date)Its date, local, YYYY-MM-DD.
meeting.titlestringWhat its source calls it.
meeting.recordingstring (uri)The public recording.
meeting.documentsarray of objectsIts agenda, and what else its source lists with it.
meeting.documents[].kindstringagenda, minutes, or other.
meeting.documents[].titlestringWhat its source calls it.
meeting.documents[].urlstring (uri)Where its source keeps it.
numberintegerIts place in the meeting, from 1.
agenda_refstring or nullIts number on the agenda, or null if it has none.
titlestringWhat it is, in a line.
what_and_whystringWhat it is and why it matters, in plain language.
topicsarray of stringsThe slugs of the topics it's filed under, three at most. An item that fits none has none.
atstringWhen it starts in the recording, HH:MM:SS.
linkstring (uri) or nullThe recording, opened at that moment, or null where the source's player can't open at a time. meeting.recording is always there.
outcomesarray of outcomesOne for each motion, in the order taken. An item with no motion has none.
pointsarray of pointsWhat officials and staff said, one point each.
highlightsarray of objectsStretches of the meeting's own audio about it, cut from the recording, the best first. Most items have none.
highlights[].titlestringWhat's at stake in it, in a line. It names no one.
highlights[].contextstringWhat a listener needs before pressing play, in a sentence: a paraphrase, never the speaker's words. It names no one.
highlights[].speakersarray of peopleWho speaks in it, in order, each once, named as a point's speaker is: an official only when the recording shows who it was, and a member of the public never.
highlights[].audiostring (uri)The clip, as an MP3.
highlights[].length_snumberHow long it runs, in seconds.
highlights[].startstringWhere it starts in the recording, HH:MM:SS.
highlights[].endstringWhere it ends in the recording, HH:MM:SS.
highlights[].linkstring (uri) or nullThe recording, opened where it starts, or null where the source's player can't open at a time.
highlights[].urlstring (uri)Its page, to share.
episodeobjectThe episode that covers its meeting.
episode.titlestringWhat it's called.
episode.urlstring (uri)Its page.
episode.item_urlstring (uri)This item on that page, at an address whose link preview is the item's card: its headline and vote.
episode.published_atstring (date-time) or nullWhen it first went out, UTC.
episode.audiostring (uri) or nullIts MP3, as its show's feed gives it.
episode.coveredboolean or nullTrue when the hosts talk about this item, and false when it's only on the page.
updated_atstring (date-time) or nullWhen anything here last changed: its episode going out, a correction, new topics, new links. UTC, as every time here is: 2026-10-02T13:00:00Z.
commentobject or nullWhere its body takes written comment now, as the body's agenda says, or null where CivicFM doesn't have it. It reaches every member and goes into the record; on an item already decided, it reaches the body as correspondence.
comment.emailstring or nullThe address its agenda gives for written comment, or null.
comment.formstring (uri) or nullThe page its agenda gives to send written comment from, or null.

An outcome

One motion and what became of it: a vote, as data. Its fields say what its text says in a sentence. On an outcome that went out before the API had them they're null, if its meeting's notes have changed since. Its text is always there.

textstringThe motion, its result, the tally if one was announced, and who voted no, abstained, or was recused, in a sentence.
unclearbooleanTrue when the result couldn't be found in the recording. The text then says so.
atstringWhere it is in the recording, HH:MM:SS.
linkstring (uri) or nullThe recording, opened at that moment, or null where the source's player can't open at a time.
resultstring or nullWhat became of the motion: passed, failed, continued, withdrawn, no_action, or unclear.
tallystring or nullAs announced, "7-1" or "unanimous". null if none was, or the result is unclear.
votesobject or nullThe count, {"yes": 7, "no": 1}, when the tally is two numbers, and otherwise null.
votes.yesintegerHow many voted yes.
votes.nointegerHow many voted no.
noarray of people or nullWho voted no. Empty when no one did.
abstainarray of people or nullWho abstained. Empty when no one did.
recusedarray of people or nullWho was recused. Empty when no one was.

A point

Something an official or staff said, paraphrased and never quoted.

textstringWhat was said, in a sentence that opens with who said it.
atstringWhere it is in the recording, HH:MM:SS.
linkstring (uri) or nullThe recording, opened at that moment, or null where the source's player can't open at a time.
speakerperson or nullWho said it. null on a point that went out before the API had it, if its meeting's notes have changed since.

A person

Someone who voted or spoke, named as the episode's page names them: an official only when the recording shows who it was, and a member of the public never. So a person is {"name": "Ana Lee", "role": "Councilmember, District 5"} when the recording shows who, and {"name": null, "role": "a councilmember"} when it doesn't.

namestring or nullTheir name, or null when the recording doesn't show who it was.
rolestring or nullThe titles they held that day, or how the page puts someone unnamed, a councilmember. null for someone named who held none.