Philadelphia City Council Events — Calendar Extraction
Purpose
Return an array of Philadelphia City Council meetings — event name (meeting body), meeting date, meeting time, body, location, and links to agenda / minutes / iCal / meeting-detail — optionally filtered by year and/or by a specific body (e.g., CITY COUNCIL, Committee on Finance, Committee of the Whole). Output is shaped to validate cleanly against a Zod schema. Read-only — never click an iCal/Agenda link to submit anything; only follow links to read PDFs/HTML.
Scope note. "Philadelphia City Council events" is ambiguous between (a) strictly meetings of the CITY COUNCIL body and (b) the council's full calendar (City Council + all committees + joint committees + special committees). This skill defaults to (b) — the same superset the public Calendar.aspx page shows — and exposes an optional body filter for callers that want (a). Document which interpretation you applied if the caller didn't pin it.
When to Use
- Building a yearly archive of all Philadelphia City Council and committee meetings.
- Monitoring upcoming agenda postings for a specific committee (e.g., Finance, Rules, Appropriations).
- Producing a Zod-validated
MeetingEvent[]for downstream pipelines (calendar import, civic-tech dashboards, agenda-diff bots). - Anywhere you'd otherwise scrape
phila.legistar.com/Calendar.aspx— the public Legistar REST API is faster, more stable, and returns more fields than the rendered table.
Workflow
phila.legistar.com is a Granicus Legistar deployment and exposes the standard Legistar Web API at https://webapi.legistar.com/v1/phila/. No auth, no API key, no cookies, no anti-bot stealth required — verified 2026-05-26 with both direct browse cloud fetch and --proxies paths returning 200 OK in <1s. Lead with the API; the browser path works as a fallback but pays a ~100× cost premium (ASP.NET WebForms postbacks per filter change, ~154 rows for a full year requires sorting + table parsing). Residential proxies are not required but harmless.
Recommended path — Legistar Web API (webapi.legistar.com/v1/phila/Events)
-
Build the OData URL. Base:
https://webapi.legistar.com/v1/phila/Events. Compose query options:- Year filter — use the OData v3
year()function onEventDate:$filter=year(EventDate) eq 2026 - Body filter (optional — only when caller asks for a specific body):
The body name must match exactly, case-sensitive ($filter=EventBodyName eq 'CITY COUNCIL'CITY COUNCILis uppercase; committees likeCommittee on Financeare mixed-case). Discover canonical names viaGET /v1/phila/Bodies(enumeratesBodyId,BodyName,BodyTypeName). - Combined:
$filter=year(EventDate) eq 2026 and EventBodyName eq 'CITY COUNCIL' - Date-range filter (alternative to
year(), e.g. for partial years):$filter=EventDate ge datetime'2026-01-01' and EventDate lt datetime'2027-01-01' - Sort:
$orderby=EventDate desc(newest first) orEventDate asc(chronological). - Pagination:
$top=N(page size) and$skip=N(offset). The API does not enforce a max; ~150 events per year is well within a single page. - URL-encode
$as%24when your HTTP client mangles literal$.browse cloud fetchaccepts unescaped$directly.
- Year filter — use the OData v3
-
Fetch the URL. Example one-liner for a full-year, all-bodies pull, newest first:
browse cloud fetch \ "https://webapi.legistar.com/v1/phila/Events?\$filter=year(EventDate)+eq+2026&\$orderby=EventDate+desc&\$top=500" \ --proxiesResponse shape (default
application/xml; charset=utf-8):<ArrayOfGranicusEvent xmlns="http://schemas.datacontract.org/2004/07/LegistarWebAPI.Models.v1"> <GranicusEvent> <EventId>6383</EventId> <EventGuid>DAB5038D-4E7C-4547-8C4C-44B6166E5F8E</EventGuid> <EventBodyId>10</EventBodyId> <EventBodyName>CITY COUNCIL</EventBodyName> <EventDate>2026-05-28T00:00:00</EventDate> <EventTime>10:00 AM</EventTime> <EventLocation>Room 400, City Hall</EventLocation> <EventComment>PLEASE USE THE AGENDA PDF...</EventComment> <EventAgendaFile>https://philadelphia.legistar1.com/.../Calendar.pdf</EventAgendaFile> <EventAgendaStatusName>Final</EventAgendaStatusName> <EventMinutesFile i:nil="true"/> <EventMinutesStatusName>Draft</EventMinutesStatusName> <EventInSiteURL>https://phila.legistar.com/MeetingDetail.aspx?LEGID=6383&GID=30&G=...</EventInSiteURL> <EventItems/> <EventVideoStatus>Public</EventVideoStatus> </GranicusEvent> ... </ArrayOfGranicusEvent> -
Prefer JSON if your HTTP client can set headers. Send
Accept: application/jsonto receive a JSON array with the same field names. Do not use$format=json— the API rejects it (Query option 'Format' is not allowed). Note thatbrowse cloud fetchhas no header-setting flag (verified 2026-05-26 —--helpshows only--proxies,--allow-insecure-ssl,--allow-redirects,--output), so when usingbrowse cloud fetchyou must parse XML. Any other HTTP client (Nodefetch, Pythonhttpx,curl -H 'Accept: application/json') gets JSON natively. -
Combine
EventDate+EventTimeinto a full timestamp.EventDateis always midnight (e.g.2026-05-28T00:00:00); the wall-clock meeting time lives in the separateEventTimestring ("10:00 AM","1:30 PM","9:30 AM"). ParseEventTimeand overlay it ontoEventDatein America/New_York (Philadelphia's local zone) to produce a single ISO-8601 instant. Don't naively concatenate the strings —EventTimeis human-formatted, not 24-hour. -
Shape into the Zod-validated array. Recommended schema:
import { z } from "zod"; export const MeetingEvent = z.object({ event_id: z.number().int(), // EventId event_guid: z.string().uuid(), // EventGuid (uppercase) name: z.string(), // EventBodyName — the body that is meeting body_id: z.number().int(), // EventBodyId body: z.string(), // alias of name (kept for backward-compat) date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), // EventDate truncated to YYYY-MM-DD time: z.string(), // EventTime verbatim, e.g. "10:00 AM" datetime_local: z.string().datetime(), // ISO 8601 in America/New_York (no offset suffix) location: z.string().nullable(), // EventLocation comment: z.string().nullable(), // EventComment (e.g. "BUDGET", "PUBLIC COMMENT") agenda_url: z.string().url().nullable(), // EventAgendaFile agenda_status: z.enum(["Final","Draft","Other"]).nullable(), minutes_url: z.string().url().nullable(), // EventMinutesFile minutes_status: z.enum(["Final","Draft","Other"]).nullable(), ical_url: z.string().url(), // synth: phila.legistar.com/View.ashx?M=IC&ID={EventId}&GUID={EventGuid} detail_url: z.string().url(), // EventInSiteURL video_status: z.string().nullable(), }); export const MeetingEvents = z.array(MeetingEvent);Synthesize
ical_urlashttps://phila.legistar.com/View.ashx?M=IC&ID={EventId}&GUID={EventGuid}— these are the exact links the Calendar.aspx page exposes per row. -
Discover available bodies once, cache locally. Hit
GET /v1/phila/Bodiesand persistBodyId → BodyNameto avoid round-tripping for every body filter. Philadelphia has ~110 bodies; the canonical Council isBodyId=10, BodyName="CITY COUNCIL", BodyTypeName="LEGISLATIVE BODY".
Browser fallback (phila.legistar.com/Calendar.aspx)
Use this only if the Web API is unreachable from your runtime. The calendar page is an ASP.NET WebForms grid; filter state lives in __VIEWSTATE, not in the URL — there is no deep-link to "calendar showing 2025". Steps:
- Open
https://phila.legistar.com/Calendar.aspxwith stealth + proxy session (--verified --proxiesrecommended even though the page is not aggressively anti-bot — defends against the rare Akamai-403 retry). - The page defaults to "This Month" (e.g. 18 records for May 2026). To get a full year, click the date-range combobox (the leftmost dropdown showing the current scope, label
Date Range Dropdown List) → wait for the listitem panel to render → click the target year (2026,2025, ..., back to2000), or one of the relative options (This Year,Last Year,Last Month,Last Week,Today,Next Week,Next Month,Next Year,All Years). Selecting a year triggers an ASP.NET postback that refreshesgridCalendarin place — the URL stays/Calendar.aspx. - (Optional) To narrow by body, click the second combobox (
Departments Dropdown List, default textCity Council and All Committees) and pick a specific committee from the long listitem panel. This also fires a postback. - After filters settle (typically 1–2s), capture data. Two reliable approaches:
browse get markdown body→ the calendar table renders as a clean pipe-table with columnsName | Meeting Date | (iCal) | Meeting Time | Meeting Location | Meeting Details | Agenda | Accessible Agenda | Agenda Packet | Minutes | Accessible Minutes. Each row contains aView.ashx?M=IC&ID=...&GUID=...link (iCal),MeetingDetail.aspx?ID=...&GUID=...(details),View.ashx?M=A&ID=...&GUID=...(agenda PDF). ExtractIDandGUIDquery params per row to reconstruct the same identifiers the API returns.browse snapshot→ each row is a[N] rowwithcellchildren — iterate the rows under[N] tbody, ignore the header row, and pullcelltext in column order. Caveat: snapshot refs are extremely large for a full year (~150 rows × ~10 cols).
- The "records" count is shown above the table as
<N> records— parse it to know how many rows to expect.
The browser fallback's primary failure mode is truncation when the row count exceeds the default page size; the Legistar grid does client-side rendering of all rows, but the snapshot may exceed token budget for large years. Prefer the API.
Site-Specific Gotchas
webapi.legistar.com/v1/phila/is the canonical Legistar REST API for this jurisdiction — no auth, no cookies, no rate limit observed. Thephilaslug is the Granicus client identifier; other Legistar jurisdictions use the same URL shape with their own slug (e.g.,nyc,sfgov,chicago). Discoverable by inspecting Legistar SDK docs or the page's<meta>tags.$format=jsonis rejected withQuery option 'Format' is not allowed. To get JSON, set theAccept: application/jsonrequest header. Default response is XML (application/xml; charset=utf-8).browse cloud fetchcannot set custom headers — when using it, expect XML and parse accordingly; any other HTTP client (curl -H, Nodefetch, Pythonhttpx) can negotiate JSON.- OData v3 syntax — not v4. Use
datetime'2026-01-01'(with thedatetimeliteral prefix and single quotes), not2026-01-01T00:00:00Z. Theyear(),month(),day()functions on date fields work as expected. /v1/phila/Events/$countdoesn't work the standard OData way — the controller interprets$countas a literalEventIdint and 400s withEventId of non-nullable type 'System.Int32'. To count rows, fetch with$top=1000and count the array client-side, or use$orderby+ binary search if you need precise counts at scale.- Body-name matching is case-sensitive and exact.
EventBodyName eq 'CITY COUNCIL'matches;'city council'returns zero rows. Pull the canonical names fromGET /v1/phila/Bodiesand cache them. EventDateis date-only at midnight;EventTimeis a separate human-readable string. Always combine the two againstAmerica/New_Yorkto produce a real timestamp — don't trust either field in isolation. Example:EventDate=2026-05-28T00:00:00,EventTime="10:00 AM"→2026-05-28T10:00:00-04:00.EventGuidis uppercase in API responses (e.g.DAB5038D-4E7C-4547-8C4C-44B6166E5F8E) — the calendar grid uses the same uppercase form inView.ashx?M=IC&ID={EventId}&GUID={EventGuid}. Don't lowercase before constructing the iCal URL; the page is forgiving but the canonical form is upper.- Two interpretations of "City Council events". The
CITY COUNCILbody (BodyId=10) holds the formal legislative sessions only — typically Thursday mornings, ~30/year. The full council calendar (no body filter) includes 100+ committee meetings per year (Finance, Rules, Appropriations, Public Safety, Joint Committees, Special Committees, etc.). Pick one explicitly and document the choice. Calendar.aspxfilter state is in__VIEWSTATE— no URL parameters. You cannot bookmark "2025 view" or share a deep-link to a filtered calendar. Every filter change is a WebForms postback against the form's serialized state. This is why the API is strongly preferred.- The
Date Range Dropdown Listdefaults to "This Month" — without changing it, the rendered grid shows only the current month (~15–25 rows). Year-scope queries always require a dropdown interaction first. EventItems,EventMedia,EventVideoPathare usuallyi:nil="true"in the list response. To get agenda items per event, hitGET /v1/phila/Events/{EventId}/EventItems(separate call) — but that's outside this skill's scope; this skill returns the calendar metadata only.EventAgendaFile/EventMinutesFilemay benilfor upcoming meetings (agenda not yet posted) or recently-completed ones (minutes still in draft). Always checkEventAgendaStatusName(Final/Draft) andEventMinutesStatusNamebefore treating either URL as authoritative.- Meeting comments encode meaningful context.
EventCommentcarries values like"BUDGET"(budget hearing),"PLEASE USE THE AGENDA PDF to select an item for PUBLIC COMMENT, not the MEETING DETAILS."(public-comment instructions),"Council President tabled meeting until ..."(rescheduling notes). Surface these in your output rather than discarding. - Joint committees have long, human-readable body names like
"Joint Committees on Legislative Oversight and Transportation & Public Utilities". Ampersands are literal&in XML — your parser must decode XML entities (&) before validating against Zod. - No site-specific anti-bot wall observed. One iteration of testing surfaced no captchas, IP blocks, WAF challenges, or rate-limits on either the REST API or
Calendar.aspx.--verified --proxiesis recommended belt-and-suspenders, not mandatory.
Expected Output
[
{
"event_id": 6383,
"event_guid": "DAB5038D-4E7C-4547-8C4C-44B6166E5F8E",
"name": "CITY COUNCIL",
"body_id": 10,
"body": "CITY COUNCIL",
"date": "2026-05-28",
"time": "10:00 AM",
"datetime_local": "2026-05-28T10:00:00",
"location": "Room 400, City Hall",
"comment": "PLEASE USE THE AGENDA PDF to select an item for PUBLIC COMMENT, not the MEETING DETAILS.",
"agenda_url": "https://philadelphia.legistar1.com/philadelphia/meetings/2026/5/6383_A_CITY_COUNCIL_26-05-28_City_Council_Calendar.pdf",
"agenda_status": "Final",
"minutes_url": null,
"minutes_status": "Draft",
"ical_url": "https://phila.legistar.com/View.ashx?M=IC&ID=6383&GUID=DAB5038D-4E7C-4547-8C4C-44B6166E5F8E",
"detail_url": "https://phila.legistar.com/MeetingDetail.aspx?LEGID=6383&GID=30&G=A5947DFE-5A17-435B-A57D-5F0923C2343D",
"video_status": "Public"
},
{
"event_id": 1409826,
"event_guid": "F6B19F6A-828E-4C00-9DB6-FE3BE3FEF351",
"name": "Committee on Licenses and Inspections",
"body_id": 35,
"body": "Committee on Licenses and Inspections",
"date": "2026-05-27",
"time": "1:30 PM",
"datetime_local": "2026-05-27T13:30:00",
"location": "Room 400, City Hall",
"comment": null,
"agenda_url": "https://philadelphia.legistar1.com/.../1409826_A_..._Agenda.pdf",
"agenda_status": "Final",
"minutes_url": null,
"minutes_status": "Draft",
"ical_url": "https://phila.legistar.com/View.ashx?M=IC&ID=1409826&GUID=F6B19F6A-828E-4C00-9DB6-FE3BE3FEF351",
"detail_url": "https://phila.legistar.com/MeetingDetail.aspx?ID=1409826&GUID=F6B19F6A-828E-4C00-9DB6-FE3BE3FEF351&Options=info|&Search=",
"video_status": "Public"
}
]
Verified shapes observed during testing: 18 events for This Month (May 2026, no body filter), 154 events for year(EventDate) eq 2025 (all bodies), single-row response for year(EventDate) eq 2026 and EventBodyName eq 'CITY COUNCIL' with $top=1&$orderby=EventDate desc returning the most recent CITY COUNCIL meeting (2026-05-28, 10:00 AM, Room 400). All shapes validate cleanly against the Zod schema above after combining EventDate + EventTime into datetime_local.