The problem: tiny tools have no RSS
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, Matt Webb asks a set of plain questions nobody has answered yet:
- How do I keep a list of the tools I actually use?
- How do I follow a maker to see what they build next?
- How do I know when a tool improves, or when someone pushes a malicious update?
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.
What I built
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:
- Extract the list of tools from the index page.
- Identify each tool by its canonical URL.
- Detect which tools changed since the last run.
- Check each changed tool for risky new behaviour.
- Publish the feed with a changelog per tool.
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.
Finding the tools on a page
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.
Detecting real changes
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).
Catching risky updates
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 feed format
The output is a plain JSON Feed 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.
Design choices
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:
- The first run found 3 tools and correctly ignored a navigation menu with 4 links.
- Between runs I changed one page's comment and script nonce. The second run reported no update, so the noise filter worked.
- I made another page add
fetch('https://evil.io/x'). The second run flagged itneeds_reviewand listedevil.io.
Limits and what's next
This is a first version, and it has clear gaps:
- JavaScript-rendered pages. If a tools list is built by JavaScript after load, the raw HTML is nearly empty and nothing is found. Those sites need a headless browser or a per-site adapter.
- Renames. A tool that moves to a new URL currently shows up as one removal and one addition. Matching near-identical titles would fix that.
- No reader yet. The feed exists, but Matt's Install button needs an app that tracks which version you installed and asks before applying a flagged update.
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. If you publish small tools, I'd like to know whether this format would work for you, and what you would change.


