Recipes
Follow one topic across towns
For a newsroom or research team: every new agenda item on one topic, in every town, from the change feed, ready for a brief.
After this recipe you can run one script on a schedule that finds every new agenda item on a topic, in every town, and hands them to your own language model with their links to write a brief.
For: a newsroom, a research team or an analyst covering one subject across many towns.
Uses: GET /v1/agenda-items and GET /v1/events, one records request a page.
Steps
- The first run lists every agenda item already tagged with the topic, in every town. Then it reads the change feed to its end and saves the last event id in a small file.
- Every later run reads the feed after that id, with
kind=meeting. For each meeting whose agenda was posted since, it lists that meeting's agenda items with the topic, then saves the new last id. - When it is rate limited, it waits the seconds
Retry-Aftergives and tries again. - What it found is numbered with its links, ready for your language model.
The example follows data_centres. GET /v1/topics lists the topic ids. A record that has no topic yet is not in the results, so to find words rather than tags, use search.
The code
import json
import os
import time
from pathlib import Path
import requests
BASE = "https://data.heytownhall.com"
TOPIC = "data_centres"
STATE = Path("topic-feed.json")
HEADERS = {"Authorization": f"Bearer {os.environ['HTD_KEY']}"}
QUESTION = "Write a short brief on what these councils have on their agendas about data centres. Name each town and meeting date."
def get(path, params):
while True:
res = requests.get(f"{BASE}{path}", params=params, headers=HEADERS, timeout=30)
if res.status_code == 429:
time.sleep(int(res.headers.get("Retry-After", "10")))
continue
page = res.json()
if not res.ok:
raise RuntimeError(f"{page['error']['code']}: {page['error']['message']}")
return page
def every(path, params):
params = {**params, "limit": 100}
while True:
page = get(path, params)
yield from page["results"]
if page["next_cursor"] is None:
return
params["cursor"] = page["next_cursor"]
last_id = json.loads(STATE.read_text())["last_event_id"] if STATE.exists() else None
found = []
if last_id is None:
found = list(every("/v1/agenda-items", {"topic": TOPIC}))
for event in every("/v1/events", {"kind": "meeting"}):
last_id = event["id"]
else:
for event in every("/v1/events", {"kind": "meeting", "after": last_id}):
last_id = event["id"]
if event["kind"] == "meeting.agenda_posted":
found += every("/v1/agenda-items", {"meeting": event["subject_id"], "topic": TOPIC})
STATE.write_text(json.dumps({"last_event_id": last_id}))
if found:
prompt = "\n".join([
QUESTION,
"Answer only from these records. Cite each claim by its number and keep its link.",
"",
*(f"[{n}] {i['meeting_date']}, {i['jurisdiction_id']}: {i['title']}. {i['plain_summary'] or i['recommendation']} ({i['source_url']})" for n, i in enumerate(found, 1)),
])
print(prompt) # Send this to your own language model.
else:
print("Nothing new on", TOPIC)import { existsSync, readFileSync, writeFileSync } from 'node:fs';
const BASE = 'https://data.heytownhall.com';
const TOPIC = 'data_centres';
const STATE = 'topic-feed.json';
const headers = { Authorization: `Bearer ${process.env.HTD_KEY}` };
const QUESTION = 'Write a short brief on what these councils have on their agendas about data centres. Name each town and meeting date.';
async function get(path, params) {
const url = new URL(path, BASE);
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
for (;;) {
const res = await fetch(url, { headers });
if (res.status === 429) {
await new Promise((r) => setTimeout(r, Number(res.headers.get('retry-after') ?? 10) * 1000));
continue;
}
const page = await res.json();
if (!res.ok) throw new Error(`${page.error.code}: ${page.error.message}`);
return page;
}
}
async function* every(path, params) {
const q = { ...params, limit: 100 };
for (;;) {
const page = await get(path, q);
yield* page.results;
if (page.next_cursor === null) return;
q.cursor = page.next_cursor;
}
}
let lastId = existsSync(STATE) ? JSON.parse(readFileSync(STATE, 'utf8')).last_event_id : null;
const found = [];
if (lastId === null) {
for await (const item of every('/v1/agenda-items', { topic: TOPIC })) found.push(item);
for await (const event of every('/v1/events', { kind: 'meeting' })) lastId = event.id;
} else {
for await (const event of every('/v1/events', { kind: 'meeting', after: lastId })) {
lastId = event.id;
if (event.kind === 'meeting.agenda_posted') {
for await (const item of every('/v1/agenda-items', { meeting: event.subject_id, topic: TOPIC })) found.push(item);
}
}
}
writeFileSync(STATE, JSON.stringify({ last_event_id: lastId }));
if (found.length > 0) {
const prompt = [
QUESTION,
'Answer only from these records. Cite each claim by its number and keep its link.',
'',
...found.map((i, n) => `[${n + 1}] ${i.meeting_date}, ${i.jurisdiction_id}: ${i.title}. ${i.plain_summary ?? i.recommendation} (${i.source_url})`),
].join('\n');
console.log(prompt); // Send this to your own language model.
} else {
console.log('Nothing new on', TOPIC);
}The two requests a later run makes, one at a time:
curl "https://data.heytownhall.com/v1/events?kind=meeting&after=88" \
-H "Authorization: Bearer $HTD_KEY"
curl "https://data.heytownhall.com/v1/agenda-items?meeting=900022&topic=data_centres" \
-H "Authorization: Bearer $HTD_KEY"What the sandbox prints
The first run finds the 15 agenda items already tagged, newest first. The start of its prompt:
Write a short brief on what these councils have on their agendas about data centres. Name each town and meeting date.
Answer only from these records. Cite each claim by its number and keep its link.
[1] 2026-10-07, marlow-county: Data centre servicing study terms of reference. Staff ask the board to consider: data centre servicing study terms of reference. (https://marlow-county.escribe.example/meetings/900015)
[2] 2026-10-04, port-ellery: Data centre servicing study terms of reference. Staff ask council to consider: data centre servicing study terms of reference. (https://port-ellery.civicweb.example/meetings/900007)
[3] 2026-09-03, tolland-township: Data centre servicing study terms of reference. Staff ask council to consider: data centre servicing study terms of reference. (https://tolland-township.primegov.example/meetings/900022)
...The sandbox does not change, so a second run prints "Nothing new on data_centres". To see a later run find something, save {"last_event_id": "88"} as topic-feed.json first. Two agendas were posted after event 88, and one of them has a data centre item:
Write a short brief on what these councils have on their agendas about data centres. Name each town and meeting date.
Answer only from these records. Cite each claim by its number and keep its link.
[1] 2026-09-03, tolland-township: Data centre servicing study terms of reference. Staff ask council to consider: data centre servicing study terms of reference. (https://tolland-township.primegov.example/meetings/900022)Going further
- Run it on a schedule. The feed is cached for 15 seconds, so checking more often than that returns the same page. Every few minutes is plenty.
- Follow motions too. Read the feed with
kind=motion, thenGET /v1/motions/{id}: each motion carries itstopics. - Follow several topics. Read each new meeting's agenda items once, with no
topic, and match theirtopicsin your code: one request a meeting instead of one per topic.
What councils will decide on an issue
For an advocacy group or membership platform: the agenda items on one issue across a province, when each comes up, and who sits on each council.
A meeting digest
Turn one council meeting into a short digest of what was on the agenda and what was decided, with links to the minutes.