# 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`](https://data.heytownhall.com/docs/reference/meetings/get-v1-agenda-items) and [`GET /v1/events`](https://data.heytownhall.com/docs/reference/changes/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](https://data.heytownhall.com/docs/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`](https://data.heytownhall.com/docs/reference/topics/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](https://data.heytownhall.com/docs/records/search).

## The code

```python
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)
```

```js
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:

```bash
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:

```text
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:

```text
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}`](https://data.heytownhall.com/docs/reference/decisions/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.
