Docs
From nothing to a tracker that tells you what changed, on your own machine, in about five minutes. Then what each part is, when you want to know.
- Install
- Your first tracker, in five minutes
- What is on disk
- Sources
- Trackers: things, disagreements, changes
- Asking
- Watches, and keeping it current
- The assist
- Publishing
- Hosted
- Every command
1. Install
One command, on macOS (Apple silicon or Intel) or Linux on x86-64:
curl -fsSL https://zetlyn.com/install.sh | sh
It fetches the binary for your machine from the
latest release, checks it against
the release's checksums, and puts zetlyn in ~/.local/bin. Nothing else:
no sudo, no service, nothing that calls home. Read it before
you run it; it is fifty lines.
By hand: download zetlyn-macos-arm64.tar.gz, zetlyn-macos-x86_64.tar.gz
or zetlyn-linux-x86_64.tar.gz from the release, check it against
SHA256SUMS, and put the zetlyn inside anywhere on your
PATH. On macOS, a file downloaded by a browser is quarantined until you allow it:
xattr -d com.apple.quarantine zetlyn.
From source, anywhere Rust 1.80 or later runs:
cargo install --git https://github.com/zetlynhq/zetlyn
2. Your first tracker, in five minutes
Two bookshops sell mostly the same books, and each keeps its list its own way.
Leafline Books publishes a price list as a CSV file. Bücherstube Lindenhof has no
file at all, only its shop's web page, in German, with prices written 13,49 €. One
writes the ISBN 978-0-451-52493-5, the other 9780451524935; one calls
the book 1984, the other Nineteen Eighty-Four. Put the two side by side by hand and
you spend an afternoon. Give them to Zetlyn and in a minute you know where they disagree, and from
then on, what either of them changes.
zetlyn
With no arguments it opens your workspace in a browser, on http://127.0.0.1:4747,
reachable from this machine only. Click Start from the bookshop example.
1. The price list
The address of Leafline's list is filled in:
leafline-books.csv. Click
Read it. Zetlyn reads the file and finds on its own that the isbn column holds
ISBNs: it checks the check digit and does not rely on the column's name. Looks right
makes the tracker.
2. The shop's page
Lindenhof's page is filled in:
hub.zetlyn.com/examples/lindenhof/. Click
Read it. It is a page for people, so Zetlyn looks for a list on it and shows what it
found: one item per book, its EAN in an attribute, its title and its price. Click Read this
list. Before anything is connected, Zetlyn says what the two shops share: 8 books in
both (9, if the page has just changed: it is live, see below), matched by ISBN though they
are written differently and half the titles differ. Connect, and Zetlyn says what it
compares from now on: price, and in_stock, which Lindenhof's page
calls data_available. It paired those because their values agree for most books.
leafline-books.csv lindenhof/ (a web page)
isbn title data-ean title
978-0-451-52493-5 1984 <--> 9780451524935 Nineteen Eighty-Four
978-0-14-143951-8 Pride and ... <--> 9780141439518 Pride & Prejudice
978-0-261-10221-7 The Hobbit (not at Lindenhof)
(not at Leafline) 9780747532699 Harry Potter ...
8 books in both, joined on the ISBN
3. Where they disagree
Open the tracker. The Conflicts tab lists every book where the shops say different
things, in one form whatever each wrote: the page's 17,99 € is 17.99 here.
Conflicts 4 open
Book Property Leafline Lindenhof
------------------------ --------- ---------- ----------
To Kill a Mockingbird price 12.49 17.99
Sapiens price 16.99 22.00
The Catcher in the Rye in stock no yes
Thinking, Fast and Slow in stock yes no
Every value keeps its receipt: open a book and each price says which source it came from, when it was read, and what the source said word for word.
4. What changed
Lindenhof's page is live: it changes every two minutes, as a shop's does. Wait two minutes, open Changes and click Update now:
Changes [ Update now ]
Read again just now. Leafline Books: nothing changed
Bücherstube Lindenhof: 1 new, 2 changed
Today
Dune price at Lindenhof 11.99 -> 13.49
Thinking, Fast and Slow in stock at Lindenhof no -> yes
The Martian now listed by Lindenhof
conflict Dune the shops now disagree about price
conflict The Martian the shops now disagree about price
resolved Thinking, ... the shops agree again about in stock
A price went up, a book came in, two disagreements began and one ended. That is Zetlyn: several sources about the same things, joined on what names them, compared where they overlap, and watched for what changes. With automatic updates on, nobody has to click Update now either, and a watch sends the same news by mail or to a feed, so you hear about it without opening anything.
Your own topic
The same four steps. On the start page, name what you want to track, then paste an address or choose a file: a CSV or Excel file, a feed, a JSON API, or a list on a web page. Then a second source about the same things. The more they overlap, the more Zetlyn has to tell you.
A real one to try next: CISA's list of exploited vulnerabilities and Exploit-DB both carry the CVE number. Together they say which exploited vulnerabilities have public code.
3. What is on disk
A workspace is a directory, and everything Zetlyn knows is in it. Move the directory and you
have moved the workspace; put it in git and a team shares it. The first time, zetlyn
makes one at ~/zetlyn, but any folder can be one: run zetlyn . inside it
once, and from then on zetlyn and every other command there works on it without being given a path.
/path/to/bookshops/
workspace.yaml its name, its address, its mailer
sources/
leafline-books/
source.yaml what the source is: where, what one claim is, how often
claims.db what it holds, with every earlier value and its receipt
lindenhof/
source.yaml
claims.db
trackers/
two-bookshops/
tracker.yaml which sources, what they meet on, what is compared
tracker.db the things, their disagreements, what changed
watches/
dune-price.yaml a thing or a question, and where to tell what changed
Every file you are meant to read or change is YAML. The .db files are SQLite and
are rebuilt from the sources whenever needed.
4. Sources
A source is one publisher: one directory, one source.yaml. From the page, a source
is an address or an uploaded file. From a terminal:
zetlyn source new --from https://example.org/list.csv --at sources/list
zetlyn source update sources/list
| kind | what it reads |
|---|---|
csv, xlsx | a file or an address; one row is one claim |
feed | RSS or Atom; an identifier the items mention (a CVE, a DOI) is found in their text |
folder | files on disk, or a git checkout it keeps pulled |
web | a list on a page for people: search results, a category, below |
http | a JSON API: paging by offset, page or Link header, only what changed since last time |
github: | --from github:advisories, github:owner/repo/releases, …/advisories, or github:owner/repo for its files |
sql | a query against PostgreSQL; the connection string is a variable, never the file |
webhook | pushed to: signed POSTs are kept and read |
hub | somebody else's source, subscribed: zetlyn source subscribe zetlyn/cve-kev |
An identifier is found without being told where: fifteen schemes are known by their pattern and check digit: CVE, GHSA, CWE, CPE, purl, DOI, arXiv, ISBN, ISSN, ORCID, LEI, ISIN, CELEX, ISO and RFC. Two sources meet on the identifier they share, so that is what a source needs most.
It is written at the top of a source's file, under the same word a tracker uses for what its sources meet on: which identifier names each record, and the column it is in.
identified_by:
isbn: field:EAN # each row is one book, named by the ISBN in column EAN
refers_to: # what each record is about, several each
cve: { from: field:codes, separator: ";" }
Files written before 0.2.11 say claims: id:. They are read as they are, with the
same record ids, and zetlyn migrate writes them in this form.
Every value keeps a receipt: what the source handed over, the words it used, when, and every value it had before. A source that suddenly answers with far fewer claims, loses a property it always had, or stops naming its claims by their identifier (a column renamed, say) is rolled back rather than believed.
A list on a web page
A page for people, not for programs (search results, a category, new releases), is read as a
list: every item is one claim, and what it says in each field are the values. Zetlyn finds the lists
itself and offers the likeliest first; --pick N takes another.
The first read is a trial of one page, so you see in seconds whether the fields are right. Then Zetlyn measures the list by asking for a few of its pages (2, 4, 8, … and halving back), and says what each choice costs before anything more is read: the newest 500, back to the first of January, or all of it, each in pages, items and minutes. It reads about a page a second. In the app a bar at the bottom of every page shows what runs in the background, how far it is and how long is left, and stops it. A read that was stopped, or that failed, keeps what it read and goes on from its page. After that an update reads only what is new.
zetlyn source new --from "https://store.steampowered.com/search/?tags=492&sort_by=Released_DESC" --at sources/steam
zetlyn source measure sources/steam # how long the list is, from a few pages
zetlyn source update sources/steam --go-on # a stopped read, on from where it stopped
fetch:
type: web
url: https://store.steampowered.com/search/?tags=492&sort_by=Released_DESC
items: a.search_result_row # one element per item, a CSS selector
fields:
appid: "@data-ds-appid" # an attribute of the item
title: .title # the text of an element in it
released: .search_released
tags: ".tag[]" # the text of every match
page: { offset: page, by: page } # &page=2, 3, … until a page has nothing new
since: field:released # newest first: stop at what was read before
since_default: "2026-01-01"
limit: 25 # a trial: one page; remove it to read the list
pause_ms: 1000 # between pages, the default: a polite reader
Read a site's terms before you read it on a clock. A page answers in its own words: the names of the fields are whatever the page calls them, and the claims keep them.
5. Trackers: things, disagreements, changes
A tracker is a topic: several sources, joined on an identifier. Each source is listed with one sentence on what it adds, and that sentence is required.
name: local/exploited
title: Exploited vulnerabilities
sources:
- source: local/known-exploited-vulnerabilities
why: Which vulnerabilities are being exploited right now.
- source: local/files-exploits
why: Whether working code exists for a vulnerability.
identified_by:
- cve
align:
severity:
scale: [critical, high, medium, low]
local/redhat: { important: high, moderate: medium }
A thing is one of what the tracker is about, and its page shows what each source says of
it. A disagreement is two sources saying different things about a property the tracker
aligns, after their words are mapped onto one scale; everything else is shown, not
compared. Connecting a source in the app aligns what both sources carry as a number, a yes or no,
or a date: under one name, or under different names where the values agree for most things both
know, the way Leafline's in_stock and Lindenhof's data_available were. The pairing
is written out, for every source, so the file says which column each property is read from:
align:
in_stock:
from:
local/leafline-books: in_stock
local/lindenhof: data_available
local/third-shop: available
A third source joins what the tracker compares already: its column that agrees with the
others for most things is added under from:. A source that has no such column is
shown on the tracker's Compared table as not said.
Words (a severity, a status) are for you to map, or for the assist to propose. A change is a signal: a new thing, a source speaking about one for the first time, a value changed, a disagreement starting or ending. The Changes page is the inbox of them, by day.
A tracker can also say what a thing is to something else, where a source states both
identifiers: a vulnerability affects a product, named by a CPE. Where a source only
names the other side in words, the page offers the words, and a person may confirm the match; it
is then kept as theirs, signed, in matches.jsonl.
6. Asking
On a tracker's Things page, or zetlyn tracker things trackers/exploited
"<question>":
has:kev, only:nvd | a source speaks about it, or only that one does |
conflict:severity | its sources disagree about an aligned property |
nvd.cvss >= 9, redhat.severity = critical | what one source says, on the scale where there is one |
redhat.severity != ghsa.severity | two sources compared |
appeared:exploit<7d, changed:cvss<24h | something new, or changed, that recently |
affects:microsoft/* | a relation |
Joined with and, or, not and parentheses. A source,
property or kind the tracker does not have is refused by name, with what there is, rather than
quietly finding nothing. With the assist set up, Ask takes a question
in words and shows the filters it became, as chips you can remove.
7. Watches, and keeping it current
Watch it on the Things page keeps a question, or a thing, and tells you when its answer changes. A watch is a file:
name: disputed-and-exploited
tracker: local/exploited
query: conflict:severity and has:kev
deliver:
- to: feed
- to: mail
address: you@example.org
- to: webhook
url: ${OPS_WEBHOOK}
With automatic updates on, the app delivers every watch itself, once a minute, while it runs. Its first look remembers what the question holds and says nothing; after that it tells what entered, what left and what changed. A delivery that failed moves nothing, and the next check says it again.
Automatic updates
Off until you turn it on. The switch is at the top right of every page of the app,
Auto-update: off, and Zetlyn also offers it once, right after a second source is connected.
Every hour, every 6 hours or once a day; it is kept in workspace.yaml:
update:
every: 1h
It works for as long as Zetlyn runs, with or without a browser page open. While it works, the bar at the bottom of every page says so and can stop it. What it found shows as a count on the Changes tab, on the start page and in the browser tab's title:
( ) Off You update with Update now.
(o) Every hour
( ) Every 6 hours
( ) Once a day
Two bookshops Overview Things Changes · 6 Conflicts
* Current 2 sources checks every hour · next in 57 minutes [Update now]
What it never does by itself:
- read a source for the first time, or a trial of one page;
- go on with a read you stopped, or read a web list further back;
- ask any source more often than every 15 minutes;
- keep asking a source that failed three times running. It waits, with the reason, until you click Try again or Update now.
A source can have its own rhythm, under Each source on the same page, or in its
declaration as schedule: { every: 1d }; never leaves it to you.
On a machine that should keep watching without the app, the same is one process:
zetlyn run
Like every command that is about a whole workspace, it takes the one you are in, from any
folder inside it, else ~/zetlyn, the one zetlyn opens; or name another:
zetlyn run ~/work/suppliers.
It updates each source on its own rhythm, or the workspace's, has each tracker look again, and asks every watch.
Mail goes through the mail: of workspace.yaml:
mail:
smtp:
host: smtp.example.org
port: 465
user: you@example.org
password: ${SMTP_PASSWORD}
from: Zetlyn <you@example.org>
8. The assist
Tables and feeds need no model: Zetlyn reads them itself. For what patterns cannot answer, a model proposes and Zetlyn checks: teaching it a JSON API from its address, which words of two sources mean the same thing, a sentence on why a source is in a tracker, a declaration mended from what its updates complained about, a question in words turned into filters.
zetlyn assist key anthropic # or ANTHROPIC_API_KEY
zetlyn assist teach https://example.org/api/items --at sources/items
Or a model of your own, anything that speaks the OpenAI chat API (Ollama, llama.cpp, vLLM),
in workspace.yaml:
assist:
provider: openai
url: http://127.0.0.1:11434/v1
model: gemma3
Nothing is sent before you have seen what: the page, or the command without
--send, says to whom and what goes, once per source, and what was sent is written in
the source's assist.yaml.
9. Publishing
A tracker's Publish page decides who may see it. Private: every page for the accounts it gives access to. Public: its overview and things open to anyone, which needs every source it names to have said it may be shown:
licence:
republish: yes # or summary: titles, values and a link, not the text; or no
terms: https://example.org/terms
Public is refused, by name, while any source says no or says nothing. Published to
a hub, a source travels as its claims and receipts, signed, and anybody can take it without its
credentials:
zetlyn tracker subscribe zetlyn/cve
Sealed: what it answers with, not how it was made
Defining sources and a tracker is work: which source to read, where, how one claim is cut out of it, which column of which source is which property, what its words mean. Published sealed, a tracker travels as one signed file that keeps all of that with you:
bookshops $ zetlyn tracker publish trackers/two-bookshops --sealed
on your machine in the package
sources source.yaml, the URL the name, the title, the terms
their own field names the tracker's names
data_available in_stock
claims every claim, history every claim, history
the receipts a hash per receipt
tracker align: from:, words scale and tolerance only
conflicts, changes conflicts, changes
The claims are translated into the tracker's names and words before they are packed, so
whoever subscribes explores everything (every thing, every earlier value, every conflict, what
changed) and finds nothing of where it was read. A sealed tracker is not built where it
arrives and is not published again from there; zetlyn tracker pull, or
zetlyn run, takes the next version, and a version not signed by the key pinned on
the first is refused.
zetlyn tracker subscribe owner/two-bookshops # from a hub
zetlyn tracker subscribe two-bookshops@f9b039c2.zetlyn # or as a file
zetlyn tracker pack trackers/two-bookshops # make that file
What sealing cannot do: data that is public somewhere can often be traced back to where it is, and a source whose licence asks to be named is named, sealed or not.
10. Hosted
The same program, run for you, at hub.zetlyn.com/<name>/: you sign in with a
link sent to your address and have the same pages as on your machine; what you make public is read
there by anyone. It is not open for sign-up yet; write if you
want one.
11. Every command
zetlyn help
lists them, each with a line on what it does. The most used:
zetlyn | the workspace in a browser |
zetlyn source new | update | check | describe | a source |
zetlyn tracker things | refresh | check | publish [--sealed] | pack | pull | a tracker |
zetlyn watch check --deliver | every watch, once |
zetlyn run | all of it, on schedule |
zetlyn serve | one tracker or source, as pages and JSON |
zetlyn source check and zetlyn tracker check exit non-zero when a
declaration claims something untrue, so a workspace in git can be checked in CI. The code is at
github.com/zetlynhq/zetlyn, under Apache-2.0.