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.
- In Claude: open Customize → Connectors, choose Add → Add custom connector, name it CivicFM, paste the address, and Add.
- In Claude Code:
claude mcp add --transport http civicfm https://civic.fm/mcp
Then ask it something like “What has Pasadena decided about surveillance technology lately?” It has 8 tools:
list_shows | The places CivicFM covers, each with its bodies and the meeting dates it has reported. |
list_topics | The network's topics, by area, each with its slug, what belongs in it, and how many items it has. |
search_items | Items from public meetings that match, newest meeting first, or best match first with q. |
get_item | One item in full, by its id: everything search_items gives, and what officials and staff said about it. |
find_place | The 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_briefing | What 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_officials | Who 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. |
follow | Follow 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:
- Everything under a topic:
/api/v1/items?topic=surveillance - One show's items since a date:
/api/v1/items?show=pasadena&since=2026-10-27 - Every item with a motion that failed:
/api/v1/items?result=failed - This item alone, by its
id:/api/v1/items/pasadena:13:2 - What's changed since a time:
/api/v1/items?updated_since=2026-10-30T15:37:03Z - Two at a time, to see a
next:/api/v1/items?limit=2
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 giveupdated_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 -wordleaves 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, orunclear.updated_sincestring (date-time)A time, like 2026-10-01T13:00:00Z: the items whoseupdated_atis at or after it, oldest change first. A time that names no zone is UTC.cursorstringThe nexta 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.nullon the last page. GET /api/v1/items/{id}- One item, by its
id, asitemshas it.404if 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 topicfilter 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 showfilter 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, orretired.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 nullwhere it isn't listed yet.shows[].apps.spotifystring (uri) or nullIts listing in Spotify, or nullwhere it isn't listed yet.shows[].apps.youtubestring (uri) or nullIts podcast on CivicFM's YouTube channel, each episode as a video, or nullbefore 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.bodyhas it.shows[].bodies[].agendasstring (uri) or nullThe government's own page of its meetings ahead and their agendas, or nullwhere 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 nullbefore its first episode.shows[].covers.firststring (date) or nullThe earliest meeting date it has items from. nullfor 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. nullfor 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. nullwhen the words fit several places:choiceslists them.kindstring or nullWhat it is: a city,town,county, … asgovernmentshas them,unincorporatedfor a place no city or town governs, orzip.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, orcommunity.governments[].showstring or nullThe slug of the show that covers it, as showtakes it, ornullwhere 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 showtakes.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 nullwhere it isn't listed yet.listen[].apps.spotifystring (uri) or nullIts listing in Spotify, or nullwhere it isn't listed yet.listen[].apps.youtubestring (uri) or nullIts podcast on CivicFM's YouTube channel, each episode as a video, or nullbefore 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 placewould 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
placeorshow.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 ofplace. A roundup's is its shows'.It answers:
placestring or nullThe place briefed, as the Census Bureau names it, or nullwhen 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 showfilter 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.lasthas been reported yet.searched[].slugstringIts name, as showtakes it.searched[].titlestringWhat it's called. searched[].coversobject or nullWhat it has published so far, or nullbefore its first episode.searched[].covers.firststring (date) or nullThe earliest meeting date it has items from. nullfor 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. nullfor 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 showfilter 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.bodyhas it.bodies[].commentobject or nullWhere it takes written comment, as its agenda says, or nullwhere 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 nullwhere 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 nullwhere 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. Otherwisenull. 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. |
showobject | The show it's in. |
show.slugstring | Its name in addresses, and what the show filter takes. |
show.titlestring | What it's called. |
show.sitestring (uri) | Its site. |
meetingobject | The meeting it's from. |
meeting.idinteger | Its number within its show. |
meeting.bodystring | The body that met. |
meeting.datestring (date) | Its date, local, YYYY-MM-DD. |
meeting.titlestring | What its source calls it. |
meeting.recordingstring (uri) | The public recording. |
meeting.documentsarray of objects | Its agenda, and what else its source lists with it. |
meeting.documents[].kindstring | agenda, minutes, or other. |
meeting.documents[].titlestring | What its source calls it. |
meeting.documents[].urlstring (uri) | Where its source keeps it. |
numberinteger | Its place in the meeting, from 1. |
agenda_refstring or null | Its number on the agenda, or null if it has none. |
titlestring | What it is, in a line. |
what_and_whystring | What it is and why it matters, in plain language. |
topicsarray of strings | The slugs of the topics it's filed under, three at most. An item that fits none has none. |
atstring | When it starts in the recording, HH:MM:SS. |
linkstring (uri) or null | The 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 outcomes | One for each motion, in the order taken. An item with no motion has none. |
pointsarray of points | What officials and staff said, one point each. |
highlightsarray of objects | Stretches of the meeting's own audio about it, cut from the recording, the best first. Most items have none. |
highlights[].titlestring | What's at stake in it, in a line. It names no one. |
highlights[].contextstring | What a listener needs before pressing play, in a sentence: a paraphrase, never the speaker's words. It names no one. |
highlights[].speakersarray of people | Who 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_snumber | How long it runs, in seconds. |
highlights[].startstring | Where it starts in the recording, HH:MM:SS. |
highlights[].endstring | Where it ends in the recording, HH:MM:SS. |
highlights[].linkstring (uri) or null | The 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. |
episodeobject | The episode that covers its meeting. |
episode.titlestring | What 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 null | When it first went out, UTC. |
episode.audiostring (uri) or null | Its MP3, as its show's feed gives it. |
episode.coveredboolean or null | True when the hosts talk about this item, and false when it's only on the page. |
updated_atstring (date-time) or null | When 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 null | Where 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 null | The address its agenda gives for written comment, or null. |
comment.formstring (uri) or null | The 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.
textstring | The motion, its result, the tally if one was announced, and who voted no, abstained, or was recused, in a sentence. |
unclearboolean | True when the result couldn't be found in the recording. The text then says so. |
atstring | Where it is in the recording, HH:MM:SS. |
linkstring (uri) or null | The recording, opened at that moment, or null where the source's player can't open at a time. |
resultstring or null | What became of the motion: passed, failed, continued, withdrawn, no_action, or unclear. |
tallystring or null | As announced, "7-1" or "unanimous". null if none was, or the result is unclear. |
votesobject or null | The count, {"yes": 7, "no": 1}, when the tally is two numbers, and otherwise null. |
votes.yesinteger | How many voted yes. |
votes.nointeger | How many voted no. |
noarray of people or null | Who voted no. Empty when no one did. |
abstainarray of people or null | Who abstained. Empty when no one did. |
recusedarray of people or null | Who was recused. Empty when no one was. |
A point
Something an official or staff said, paraphrased and never quoted.
textstring | What was said, in a sentence that opens with who said it. |
atstring | Where it is in the recording, HH:MM:SS. |
linkstring (uri) or null | The recording, opened at that moment, or null where the source's player can't open at a time. |
speakerperson or null | Who 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 null | Their name, or null when the recording doesn't show who it was. |
rolestring or null | The titles they held that day, or how the page puts someone unnamed, a councilmember. null for someone named who held none. |