# A Feed for Tiny Tools: Following, Updating and Trusting AI-Built Web Apps

> Source: <https://dev.to/talal_ali_0863/a-feed-for-tiny-tools-following-updating-and-trusting-ai-built-web-apps-20ka>
> Published: 2026-11-04 17:13:29+00:00

AI coding assistants have made small, single-purpose web tools almost free to build. A JSON differ, an image resizer or a token counter now takes one prompt and a few minutes, so people like Simon Willison publish dozens of them on a single page.

The trouble starts after publishing. In his April 2026 post [Syndicating vibes](https://interconnected.org/home/2026/04/29/syndicating-vibes), Matt Webb asks a set of plain questions nobody has answered yet:

Blogs had this problem twenty years ago, and RSS fixed it. Tools have no equivalent. Matt sketches the answer as an RSS-style feed of tools, each item with an Install button. I wanted to see how much of that one person could build in a weekend.

`tool_feed.py` is a single Python script that turns any tools index page into a subscribable, change-tracked feed. You point it at a page, and it writes two files: `feed.json`, a standard JSON Feed anyone can subscribe to, and `state.json`, which remembers what it saw last time.

Each run goes through five stages:

```
python tool_feed.py https://tools.simonwillison.net --title "Simon's tools"
```

Run it daily on a schedule and the feed stays current. The rest of this post walks through how each stage works.

The script finds the tool list by looking for the element with the largest group of similar children that each contain a link. No site-specific rules are needed for a typical tools page.

It parses the page into a tree and gives every element a signature: its tag plus its sorted CSS classes, like `li.tool`. Then it walks the tree children-first, so each element already knows whether anything below it contains a link. For every parent, it groups the linked children by signature and scores the parent by its biggest group.

Navigation menus are the main trap, because they are also lists of links. Anything inside `nav`, `header` or `footer` is skipped, so the real list wins even when the menu has more links. The whole pass touches each element once, so it costs O(N) in the size of the page and takes milliseconds.

A tool counts as updated only when its own page changes in a meaningful way. The index page rarely changes when a tool is edited, so the script fetches every tool page and fingerprints it.

**Conditional requests first.** Each fetch sends the `ETag` and `Last-Modified` values from the previous run. If the server answers `304 Not Modified`, the tool is skipped without downloading anything. Most days, most tools cost one tiny request.

**Strip the noise.** Pages contain things that change on every load: HTML comments, `nonce` attributes on scripts, embedded timestamps. Hashing those would report an update every single day. So the script removes them and collapses whitespace before going further.

**Hash what remains.** The cleaned page goes through SHA-256. If the hash matches the stored one, nothing changed. If it differs, the tool gets a new version (the first 12 characters of the hash) and a changelog entry with the date.

The result is two kinds of change: listing changes from the index (a tool added or removed) and content changes from each tool page (a tool updated).

An update gets flagged `needs_review` when the page starts talking to a domain it never talked to before. That is the cheapest strong signal I could find for Matt's malicious-update worry.

For every version, the script collects the set of external hosts the page touches: `<script src>`, `<link href>`, `<img src>`, `<iframe src>`, `<form action>`, plus any `fetch('https://…')` call inside a script. Then it takes a set difference:

```
new_domains = domains(new version) − domains(old version)
```

If `new_domains` is not empty, the update is flagged and the new hosts are listed. A tool that quietly starts sending data to `evil.io` cannot slip through as a routine edit.

A second, weaker signal also triggers review: the page size changing by more than 50% in one update. A complete rewrite is not necessarily bad, but it deserves a look before you trust it.

The first time a tool is seen, its domains become the baseline and nothing is flagged.

The output is a plain [JSON Feed 1.1](https://jsonfeed.org/version/1.1), so any feed reader that supports JSON Feed can already subscribe to it. Everything tool-specific lives under one extension key, `_tool_feed`, which the spec allows and ordinary readers ignore.

One item looks like this:

```
{
  "id": "https://t.dev/tokens",
  "url": "https://t.dev/tokens/",
  "title": "Tokens",
  "content_text": "Count tokens",
  "date_modified": "2026-11-04T16:20:00+00:00",
  "_tool_feed": {
    "version": "9f2c41ab07de",
    "review": "needs_review",
    "domains": ["evil.io"],
    "changelog": [
      {"kind": "updated", "at": "2026-11-04T16:20:00+00:00", "new_domains": ["evil.io"], "review": "needs_review"},
      {"kind": "added", "at": "2026-11-03T16:20:00+00:00", "review": "ok"}
    ]
  }
}
```

| Field | What it answers | 
|---|---|
| `version` | Which exact copy of the tool am I running? | 
| `review` | Is the latest update safe to accept without looking? | 
| `domains` | Who does this tool talk to? | 
| `changelog` | What changed, and when? Newest first, last 20 kept. | 

A reader app only has to compare the `version` it installed with the one in the feed to know an update exists, and read `review` to know whether to ask first.

I wanted the code to read like the design, without comments explaining it. A few Python features did most of that work.

| Feature | Where | Why | 
|---|---|---|
| Enums | `ChangeKind` ,`ReviewStatus` ,`HttpStatus` | No magic strings; they still serialize to JSON as plain values. | 
| Frozen dataclasses | `Config` ,`Tool` ,`FetchResult` | Immutable values that cannot be changed by accident. | 
| Mutable dataclasses | `ToolState` ,`ChangeEvent` | Carry their own `to_json` and`from_json` , so saving state lives in one place. | 
| `Protocol` | `Fetcher` | The monitor depends on an interface, not on `requests` . | 
| Descriptive names | `strip_volatile_content` ,`politeness_delay_s` | The name says what a comment would have said. | 

The `Fetcher` protocol paid off immediately. To test the script without the network, I passed in a fake fetcher that returns hand-written pages, then ran it twice:

`fetch('https://evil.io/x')`. The second run flagged it `needs_review` and listed `evil.io`.
This is a first version, and it has clear gaps:

Next I plan to run the script daily with GitHub Actions and serve `feed.json` from GitHub Pages, so there is a live feed anyone can subscribe to.

The code is on GitHub at [Talal-Ali/tool-feed](https://github.com/Talal-Ali/tool-feed). If you publish small tools, I'd like to know whether this format would work for you, and what you would change.
