Skip to Content
Hey Town Hall For Businessdocs

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.

View as Markdown

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

  1. 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.
  2. 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.
  3. When it is rate limited, it waits the seconds Retry-After gives and tries again.
  4. 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

Python 3.11 or later
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)
JavaScript (Node 18 or later)
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
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:

First run from the sandbox
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:

Later run from the sandbox
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, then GET /v1/motions/{id}: each motion carries its topics.
  • Follow several topics. Read each new meeting's agenda items once, with no topic, and match their topics in your code: one request a meeting instead of one per topic.

On this page