Philadelphia City Council Events — Browser Skill
Purpose
Extract Philadelphia City Council meeting events (City Council + all standing/joint committees) from the Legistar calendar — returning each event's name (meeting body), date, time, location, agenda/minutes status, and Legistar detail-page URL. Filterable by year and/or by specific body. Read-only — never opens agenda PDFs in write-mode, never modifies state.
The output is a Zod-validated array of event records. A reference Zod schema is included in Expected Output.
When to Use
- Building a roster of upcoming or historical City Council and committee meetings for a given year.
- Backfilling a database of Philadelphia legislative meetings (the catalog goes back to 2000).
- Cross-referencing agenda packets / minutes URLs with a specific meeting date + body.
- Anywhere you'd otherwise scrape
phila.legistar.com/Calendar.aspxHTML — the public Granicus Legistar WebAPI is faster, cheaper, paginatable, and structurally more reliable.
Workflow
The phila.legistar.com Calendar.aspx page is a Telerik RadGrid built on top of an unauthenticated, public Granicus Legistar WebAPI at https://webapi.legistar.com/v1/phila/. The browser UI is a thin client over this API — every record visible in the grid is queryable directly via OData. Lead with the API; the browser flow is the fallback for when the API is unreachable from your network sandbox (no auth, no anti-bot — but some sandboxes block raw DNS for webapi.legistar.com).
Recommended — Legistar WebAPI
-
Endpoint discovery. The Philadelphia tenant slug is
phila. The Events endpoint is:GET https://webapi.legistar.com/v1/phila/EventsNo API key, no cookies, no Referer required. Verified 2026-05-20 — Microsoft IIS/10.0 +
Granicusserver: gasmp-legapi1/2, ASP.NET WebAPI OData v3. -
Filter by year. OData v3
$filtersyntax — the field isEventDate(datetime, midnight UTC):GET https://webapi.legistar.com/v1/phila/Events ?$filter=EventDate ge datetime'2025-01-01' and EventDate lt datetime'2026-01-01' &$orderby=EventDate &$top=200URL-encode the
$as%24and the apostrophes/spaces as needed; literal+for spaces works inside$filter. Thedatetime'YYYY-MM-DD'literal is the supported OData v3 form (NOTdatetimeoffset'...'— that 400s). -
Filter by body. Each meeting carries
EventBodyId(integer) andEventBodyName(string). To restrict to "CITY COUNCIL" only (no committees), add:and EventBodyId eq 10Bodies discovered 2026-05-20 (sample):
BodyId=10 → "CITY COUNCIL",4 → "Committee on Public Health and Human Services",39 → "Committee of the Whole",44 → "Committee on Legislative Oversight",50 → "Committee on Law and Government". The full body list is atGET https://webapi.legistar.com/v1/phila/Bodies(filter onBodyActiveFlag eq 1for currently-meeting bodies). -
Paginate. Default page size for the Events endpoint is large but you should still cap with
$topand step with$skipwhen iterating multi-year ranges:&$top=1000&$skip=0 # batch 1 &$top=1000&$skip=1000 # batch 22025 has 154 records (verified against the browser grid). A single
$top=1000covers a full year safely. -
Parse the response. Default content-type is XML (
application/xml; charset=utf-8) — the OData$formatquery option is rejected ("Query option 'Format' is not allowed"), and theAccept: application/jsonheader is silently ignored on this tenant (still returns XML). Parse the XML envelope<ArrayOfGranicusEvent>→<GranicusEvent>elements with these fields:XML element Type Maps to EventIdint Stable meeting ID (e.g. 6115)EventGuidUUID Alternate stable ID EventBodyIdint See body-id table above EventBodyNamestring Meeting body / committee name EventDateISO datetime (date-only, midnight) Meeting date EventTimestring Display time, e.g. "10:00 AM","2:00 PM"EventLocationstring e.g. "Room 400, City Hall"EventAgendaFileURL or nil Agenda PDF (may be i:nil="true")EventAgendaStatusNamestring "Final"/"Draft"EventMinutesFileURL or nil Minutes PDF EventMinutesStatusNamestring "Final"/"Draft"EventCommentstring or nil Free-text annotations (cancellations, tabled-until notes) EventInSiteURLURL Legistar detail page: https://phila.legistar.com/MeetingDetail.aspx?LEGID={EventId}&GID=30&G=...EventVideoStatusstring "Public"etc.EventVideoPathURL or nil Recorded-video URL -
Zod-validate. See the schema in
Expected Output. CoerceEventDatetoDate, parseEventTimeseparately, treat any element withi:nil="true"attribute asnull.
Browser fallback
Only use this when the WebAPI is blocked at the network layer (e.g. some sandboxes refuse outbound DNS for webapi.legistar.com). The same browse cloud fetch proxy path that works for phila.legistar.com also works for webapi.legistar.com — so 99% of the time the API path is reachable.
-
Session setup. A bare session is sufficient — no Akamai, no Cloudflare.
--proxiesadds resilience if your egress is rate-limited;--verifiedis not required.sid=$(browse cloud sessions create --keep-alive --proxies | node -e "let s='';process.stdin.on('data',c=>s+=c).on('end',()=>process.stdout.write(JSON.parse(s).id))") export BROWSE_SESSION="$sid" -
Open the calendar.
browse open "https://phila.legistar.com/Calendar.aspx" --remote browse wait load --remote browse wait timeout 2000 --remoteDefault state:
Calendar Year = "This Month",Body = "City Council and All Committees". The page is a Telerik RadGrid (ctl00_ContentPlaceHolder1_gridCalendar) inside an ASP.NET WebForms postback model. -
Open the Year dropdown by clicking the
cell: selectnext to the year combobox (ref~117on a fresh snapshot — refs are NOT stable across postbacks, always re-snapshot):browse snapshot --remote browse click "@<select-cell-ref-next-to-year>" --remote browse wait timeout 1500 --remote -
Click the target year. Options:
All Years,2026,2025, ...,2000,Last Year,Last Month,Last Week,This Year,This Month,This Week,Today,Next Week,Next Month,Next Year. Usebrowse snapshotto locate thelistitemref for the target year, thenclick. The page does a full ASP.NET postback — wait forloadand an extra 3000ms before re-snapshotting. -
Extract the grid. Each grid row is a
[1-X] rowcontaining 11 cells in order: BodyName, MeetingDate, ExportToCalendar (iCal), MeetingTime, MeetingLocation, MeetingDetails (link), Agenda (link or "Not available"), AccessibleAgendaHTML, AgendaPacket, Minutes, AccessibleMinutesHTML. Iterate[1-X] cell:children inside each row. Read the record-count header (menuitem: NNN records) to validate completeness before parsing. -
Paginate. The grid defaults to 100 rows per page. If the record count > 100, click the pager's "Next page" button at the bottom and re-snapshot. 2025 = 154 records = 2 pages.
-
Body filter (optional). Same pattern as year: click the body combobox
selectcell, click the desired body in the dropdown list. The body dropdown is ~115 options long. -
Release the session.
browse cloud sessions update "$sid" --status REQUEST_RELEASE
Site-Specific Gotchas
- No JSON.
$format=jsonquery option is explicitly rejected by the Granicus WebAPI ("Query option 'Format' is not allowed"400). TheAccept: application/jsonheader is silently ignored. Parse XML. Don't waste time looking for a JSON toggle — there isn't one. $inlinecount=allpagesis rejected on Events. To get a total count, fetch with$top=1000and count entries in the response (or scrape the browser grid'smenuitem: NNN recordsheader). Verified 2026-05-20 —$inlinecountreturns 200 but the count metadata is not present in the XML output.- OData v3 datetime literal form. Use
datetime'2025-01-01'(no time portion, no Z, no offset).datetimeoffset'...'400s. ISO-8601 raw strings 400. EventDateis date-only (midnight UTC). The actual meeting wall-clock time is inEventTimeas a display string ("10:00 AM"). To produce a single canonical timestamp, combineEventDate+EventTimein America/New_York (Philadelphia's timezone) — do NOT addEventTimetoEventDateas UTC.i:nil="true"attribute = null. Any GranicusEvent field can be empty; the API marks empties with<EventAgendaFile i:nil="true" />rather than omitting the element. Map tonullin your Zod schema.- Browser refs are NOT stable across postbacks. Every Telerik RadComboBox interaction is a full ASP.NET
__doPostBack, which regenerates the entire a11y tree (the grid re-snapshot grew from[1-940]to[1-5306]after a single year-filter click). Always re-browse snapshotbefore eachbrowse clickin the fallback flow. - Default page state is "This Month". First page load returns 2 records (the current week's meetings). Don't conclude "the calendar is empty" — change the year filter to
Allor a specific year first. - Body dropdown is ~115 entries including standing committees, joint committees ("Joint Committees on X and Y"), and special committees. The full enum is in the
RadComboBoxitemDataarray embedded in Calendar.aspx HTML; the WebAPIBodiesendpoint is the canonical list (filterBodyActiveFlag eq 1, BodyMeetFlag eq 1for currently-meeting bodies). - Cookie-based settings. Calendar.aspx persists filter state in cookies (
Setting-30-Calendar Year,Setting-30-Calendar Body,Setting-30-Calendar Options). These travel across page reloads in the same session. Useful if you want to lock a year selection without re-clicking the dropdown — but irrelevant when using the WebAPI. EventInSiteURLincludes session-bound parameters. TheG=A5947DFE-...GUID is a tenant-static identifier, NOT a per-user session token — safe to cache and reuse across runs. TheLEGID=parameter is the canonicalEventId.EventCommentcarries meeting-state metadata. Look for strings like"No Calendar for Today","Council President tabled meeting until ...","CANCELLED". The grid UI displays these as inline notes. Surface them in the output schema so consumers can distinguish a scheduled-but-cancelled meeting from one that actually occurred.- Joint committees have free-text names in the dropdown but normalized names in the API. The Calendar.aspx itemData has explicit
textoverrides like"Joint Committees on Children & Youth and Education"for some joint bodies; the WebAPI returns the same string inEventBodyName. TreatEventBodyNameas the source of truth. - Catalog depth. Years 2000 through 2026 are queryable. Pre-2000 events return empty.
Expected Output
A Zod-validated array of event records:
import { z } from "zod";
export const PhilaCouncilEventSchema = z.object({
eventId: z.number().int(),
eventGuid: z.string().uuid(),
bodyId: z.number().int(),
bodyName: z.string(), // e.g. "CITY COUNCIL", "Committee on Law and Government"
date: z.coerce.date(), // EventDate, midnight UTC
time: z.string(), // EventTime display string, e.g. "10:00 AM"
location: z.string(), // e.g. "Room 400, City Hall"
agendaFile: z.string().url().nullable(),
agendaStatus: z.enum(["Draft", "Final"]),
minutesFile: z.string().url().nullable(),
minutesStatus: z.enum(["Draft", "Final"]),
comment: z.string().nullable(), // e.g. "CANCELLED", "tabled until ..."
videoStatus: z.string(), // e.g. "Public"
videoPath: z.string().url().nullable(),
detailUrl: z.string().url(), // EventInSiteURL
});
export const PhilaCouncilEventsSchema = z.array(PhilaCouncilEventSchema);
Example output (2 records from year=2025):
[
{
"eventId": 6115,
"eventGuid": "0C3EC3DE-5220-4E73-8D14-47FA1D2C4EFA",
"bodyId": 50,
"bodyName": "Committee on Law and Government",
"date": "2025-01-22T00:00:00.000Z",
"time": "10:00 AM",
"location": "Room 400, City Hall",
"agendaFile": "https://philadelphia.legistar1.com/philadelphia/meetings/2025/1/6115_A_Committee_on_Law_and_Government_25-01-22_Public_Hearing_Notice.pdf",
"agendaStatus": "Final",
"minutesFile": null,
"minutesStatus": "Draft",
"comment": null,
"videoStatus": "Public",
"videoPath": null,
"detailUrl": "https://phila.legistar.com/MeetingDetail.aspx?LEGID=6115&GID=30&G=A5947DFE-5A17-435B-A57D-5F0923C2343D"
},
{
"eventId": 6093,
"eventGuid": "BCAFB815-DC0D-4423-AAFE-44150A03BBFA",
"bodyId": 10,
"bodyName": "CITY COUNCIL",
"date": "2025-01-23T00:00:00.000Z",
"time": "10:00 AM",
"location": "Room 400, City Hall",
"agendaFile": "https://philadelphia.legistar1.com/philadelphia/meetings/2025/1/6093_A_CITY_COUNCIL_25-01-23_City_Council_Calendar.pdf",
"agendaStatus": "Final",
"minutesFile": "https://philadelphia.legistar1.com/philadelphia/meetings/2025/1/6093_M_CITY_COUNCIL_25-01-23_Meeting_Minutes_%28Long%29.pdf",
"minutesStatus": "Final",
"comment": null,
"videoStatus": "Public",
"videoPath": null,
"detailUrl": "https://phila.legistar.com/MeetingDetail.aspx?LEGID=6093&GID=30&G=A5947DFE-5A17-435B-A57D-5F0923C2343D"
}
]
If the requested year has no events (e.g., a year before 2000), return [].