The facets mechanism provides the small, cheap "what values exist?" answer that filter-picker widgets in the UI need — the set of tags, namespaces, and lifecycle states currently present across a collection — decoupled from listing the collection itself.
Before server-side pagination the UI could compute pickers itself. Every
page mount loaded the full slim list of skills / tools / snippets /
vMCPs / vNFSs and derived the tag / namespace / state picker options
client-side with a Set walk.
Server-side pagination broke that pattern: the UI now only sees one page at a time. A tag picker built from the current page would list only the values that happen to appear on that page — the user could not select a tag whose only items live on page 7. Refetching the full list just to enumerate values would defeat pagination.
Facets solve this with a dedicated small endpoint that returns only the distinct values, not the items:
- One small round trip. Payload size is proportional to the number of distinct tags / namespaces / states, not to the number of items. At 5,000 snippets the paged list is ~20 KB per page; facets is typically a few KB total and cached for 60s in the UI.
- Every value in the store — not just the ones on the current page.
- Independent lifecycle from the paginated list — the pickers do not churn when the user pages, sorts, or filters.
GET /facets/{snippets|skills|tools|vmcp_servers|vnfs_servers}
Response:
{
"tags": ["python", "search", "utility"],
"namespaces": ["prod", "staging"],
"states": ["approved", "draft"]
}Each list is deduplicated and sorted. The namespace: prefix used
internally on tags is stripped in the namespaces list — namespace:prod
in an item's tags surfaces as "prod" under namespaces, not tags.
Layered like the existing list / search endpoints:
- services/facets.py — pure
helper
compute_facets(items)that walks a sequence of item dicts once, splitsnamespace:*tags into the namespaces bucket, collectsstate, and returns sorted lists. No I/O. - Each service (skills, tools, snippets, vMCP, vNFS) exposes a
.facets()method that hands the full list of cached dicts to the helper. Nothing is enriched —running/runtime/_populatenever run for a facets call, so the endpoint does no server-manager fan-out and no cross-service lookups. - The FastAPI handler is one line: translate exceptions to HTTP,
return
service.facets().
Because compute_facets walks the raw cache dicts and does not touch
the runtime, the cost is one handler.list_all_dicts() plus a linear
scan. There is no separate index; the cost is bounded by the number of
items, and the payload it produces is bounded by the number of distinct
values.
Each page that has a tag / namespace picker mounts one useQuery for
facets, keyed by object type, alongside its paged list query:
const facetsQuery = useQuery({
queryKey: ['skills', 'facets'],
queryFn: skillsApi.facets,
staleTime: 60_000,
});
const allTags = facetsQuery.data?.tags ?? [];
const allNamespaces = facetsQuery.data?.namespaces ?? [];The 60-second staleTime matters: paging, searching, sorting, and
filtering the main table do NOT refetch facets. TanStack Query serves
the cached value until the store mutates (create / update / delete
invalidates ['skills'] and friends) or the stale window elapses.
Facets, ?fields, and pagination are three orthogonal concerns:
?fieldstrims the projection of each returned item — narrower wire payload per item.?limit/?offsettrim which items are returned — page instead of full list./facets/{type}trims what "list" means entirely — return only the distinct filter-picker values, no items.
The UI combines them: the main table uses listPaged with fields=list
and a page window; the pickers use /facets/{type}; semantic search
uses searchProjected on /search/{type}. Every widget requests only
what it needs.
- Small / bounded collections that already fit in one page. The UI can safely enumerate values from the loaded list; adding a facets round trip is pure overhead.
- A picker over a field with no dedicated facet. Only
tags,namespaces, andstatesare exposed today. A picker over, say,authorwould need either a new facet bucket or a different design. - A picker that must reflect the current filter set (e.g. "tags
present among items also tagged
prod"). Facets are computed over the full collection, not over a filtered subset. Post-filter facets would need query params on/facets/{type}, which is not implemented.