The public, read-only catalogue at operator.axetechnologies.ca. It answers two
audiences over the same data: JSON for agents, HTML for people. Anything true of
one has to be true of the other — a fix that reaches only one side is not a fix.
| Path | Audience | What it is |
|---|---|---|
/docs/skills | people | the catalogue index: counters, registries, categories |
/docs/skills/search?q= | people | search results, server-rendered |
/docs/skills/c/<category> | people | one category |
/v1/skills | agents | the flat list |
/v1/skills/search?q= | agents | search, returns a bare JSON array |
/v1/tools/find?q= | agents | search with a result envelope |
/v1/categories | agents | the taxonomy |
/v1/audit | agents | provenance for a tenant |
/healthz | anyone | liveness |
/docs | people | this documentation |
The origin is read-only. POST, PUT and DELETE all answer 405.
Search is one URL. A ?q= that reaches the index is redirected into
/docs/skills/search rather than being dropped, so a search can never be
silently ignored while still answering 200.
The ranker drops common filler words. That created a failure worth knowing
about: ?q=agent once returned zero results, because agent is a stopword in a
catalogue where almost everything is an agent skill — the query was stripped to
nothing *before* it was searched, and a discarded query looked exactly like an
empty catalogue.
Now, when every word in a query is a stopword, the query is matched literally
instead of being dropped, and both surfaces say so:
/v1/tools/find returns searched_literally and a note.Known gap: /v1/skills/search returns a bare JSON array, so it has nowhere
to put that signal. Changing its shape would break its published contract; the
non-breaking route is the existing ?envelope=1 mechanism. Use /v1/tools/find
if you need the reason a result set is empty.
?fragment=1/docs/skills/search?q=…&fragment=1 returns the results block alone — no
document, no navigation, no sidebar. It exists for the page's own live search.
Building the 197-row category sidebar costs about as much as running the search
itself, and live search discards it, so the fragment path short-circuits before
that work happens rather than doing it and throwing it away.
The fragment and the full page render from one function, so there is no second
implementation of a result that could drift from the first. Ranking, empty
states and the stopword notice are identical on both.
Every row carries the registry it came from, and every registry is shown — not
the largest few. A chip row that silently stops is indistinguishable from a
catalogue that holds nothing else, and the small registries are the ones
somebody is most likely to be hunting for.
| Registry | Tier |
|---|---|
| ClawHub | community |
| skills.sh | community |
| LobeHub | community |
| browse.sh | community |
| NVIDIA | community |
| gstack | community |
| OpenAI | community |
| HuggingFace | community |
| Anthropic | community |
| claude-marketplace | community |
| built-in | first-party |
| optional | first-party |
Each registry is shown with a monogram tile in that vendor's own accent colour.
These are our own marks, not the vendors' logos, and are deliberately so:
wrong, and a wrong logo is worse than an honest monogram.
parties on every page view, and would break the day any of them moves a path.
Every mark is drawn in this repository and served from this origin. No page on
this hub loads an asset from anywhere else. Names are the vendors' own and are
used to say where a row came from; the trademarks belong to their owners.
The stylesheet is served from /static/axeskills-<hash>.css with
Cache-Control: public, max-age=31536000, immutable. The hash is taken from the
contents, so the filename changes whenever the CSS does — which is what makes an
immutable cache safe. Only the current hash is served; a superseded name 404s
rather than returning styling that no longer matches the markup.
The hub has no build step. A committed change is not live until the service
restarts:
launchctl kickstart -k gui/$(id -u)/com.axe.axeskills-public
The launchd job runs /usr/bin/python3, which on the build host is not the
same interpreter as python3. A green test run proves nothing about whether the
service can start; tests/test_imports_on_production_interpreter.py is the
guard that does.
The listing used to return every version row in the database, roughly 111k rows
for a catalogue of 83k named skills. That made pagination misleading and counts
wrong. It now returns one row per skill name — the latest published version —
matching what the HTML catalogue shows. The full version history for a name is
still available and intentionally so at /v1/skills/<name>/versions.
The categories endpoint used to query the skill_categories table directly
(18,624 classified rows) and omit the 56,874 skills that resolve to "other".
An agent using it to decide where to search could not see that more than
two-thirds of the catalogue sits in the unclassified bucket, making the map
actively misleading.
It now uses the same effective-category resolution the HTML catalogue uses:
ingested category wins unless it is "other", in which case the classifier's
derived category applies. The totals now reconcile exactly to the live
catalogue size. The "other" bucket is visible and queryable.
Every skill has a stable URL at /docs/skills/s/<name>. Names that contain
colons (the axehub: vendor prefix) are percent-encoded in the URL. A
?fragment=1 parameter strips the shell to the card alone, for embedding.
A missing name returns an HTML 404 to browsers and a JSON error to API callers.
The index and search pages aggregate categories, totals and source distribution
on every request via a TEMP TABLE scan of 111k rows (~220ms per call). That
work is now cached per tenant and invalidated by the database file's mtime.
The mtime signal advances the moment the ingest job commits its first write to
the DB (journal_mode=delete, the SQLite default). The cache is never stale for
longer than one in-flight request that raced the ingest. Hot requests measure
~4ms; the per-skill search query is now the dominant cost.
If the database is ever switched to WAL mode this cache will break silently
— WAL commits land in the -wal sidecar and the main file's mtime may not
advance. The guard test tests/test_summary_cache_journal_mode.py asserts
the assumption in CI.