Tool Catalog
A searchable registry of the tools an agent may call — source facets with live counts, per-tool allow switches, parameter tables that open themselves when the query lands inside a signature, and four data states.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/tool-catalog.jsonPrompt
Build a React + TypeScript + Tailwind "ToolCatalog" component with zod and
lucide-react, reusing shadcn's Input, Button and Badge primitives.
Contract
- A zod schema (`toolCatalogSchema` in a sibling contract file) is the single
source of truth: { status: "loading" | "empty" | "error" | "ready",
items: ToolCatalogItem[] }.
- ToolCatalogItem = { id, name, source: { id, name, kind: "builtin" | "server" },
description, params: ToolCatalogParam[], enabled: boolean }.
- ToolCatalogParam = { name, type, required, description?, defaultValue? }.
`type` is a STRING, never an enum: signatures arrive from JSON Schema, a Python
annotation or a TS type, so "string[]", "'json' | 'ndjson'" and
"Record<string, unknown>" all have to render verbatim. Parsing them would
quietly lie about half the registry.
- `source.id` is the facet key. Chips are built by grouping items on it, so two
servers may share a display name without collapsing into one chip.
- `enabled` is DATA, not component state. The switch reports intent through
onToggleEnabled(id, next) and only moves when the consumer hands back a new
items array — a catalog that flips its own switch lies the moment the write
fails and drifts from the policy the backend actually enforces.
- Props: status; items; query?/defaultQuery?/onQueryChange? (controlled or
uncontrolled search); selectedSources?/defaultSelectedSources?/
onSelectedSourcesChange? (controlled or uncontrolled facets, source ids, empty
= every source); onToggleEnabled?; defaultOpen? (false); onRetry?; emptyState?;
errorMessage?; label? ("Tool catalog"); skeletonRows? (4, clamped to 1..8);
className plus the rest of the element props, ref forwarded to the <section>.
Behavior
- SEARCH SPANS THE SIGNATURE, not just the label. The haystack of a row is its
name + description + source name + source kind + every parameter's name, type,
description and default. "which tool takes a repo?" is the question a catalog
exists to answer, and a search that only reads names cannot.
- The query is split on whitespace into tokens that are ANDed. Adding a word must
never ADD results, or the reader learns to distrust the box.
- Matching uses indexOf on lowercased text, never a RegExp: the query is text a
human typed, and "a|b(" would either throw or match something nobody asked for.
Every occurrence of every token is highlighted; overlapping ranges are MERGED
before rendering so <mark> never nests inside <mark>.
- A row whose parameters were hit OPENS ITSELF and says how many matched ("2
matches in parameters"). Otherwise it stays collapsed. An explicit toggle by the
reader wins over that rule and is remembered per tool id — a row that re-opens
under the cursor on the next keystroke is a row nobody can read. Those overrides
are scoped to the current items array and pruned when it changes (adjust state
during render, no effect), so a refreshed registry can never apply the previous
one's disclosure to a recycled id.
- Facet chips are multi-select aria-pressed toggles, not tabs: several sources can
be on at once and a tablist would promise exactly one. Their counts follow the
TEXT search but ignore which chips are pressed — counts that reacted to the
chips would read 0 for every source you did not pick, which is arithmetic, not
information. A chip at 0 stays on screen; it is the answer to "is it worth
looking there".
- NO-RESULTS IS NOT THE EMPTY STATE. status="empty" (or ready with zero items)
means the registry is genuinely empty and the advice is to connect a server. A
filter that excluded everything is a different screen inside `ready`: it names
what was searched and offers one button that clears the query and the chips and
returns focus to the search box. Conflating them sends the reader to fix the
wrong thing.
- The parameter panel is UNMOUNTED when closed (a height-0 collapse leaves every
cell reachable by Tab — an invisible keyboard trap), and aria-controls is only
set while open so it never points at an id that is not in the document. A tool
with no parameters renders no disclosure button at all: no chevron, no empty
panel, just the row.
- The switch is a <button role="switch" aria-checked> with a visible On/Off word
next to it, so state never depends on the track's colour. With no
onToggleEnabled the switch is replaced by the words Enabled / Disabled — a
control that moves under your finger and changes nothing is worse than a label.
- The component owns no clock, no fetch and no timers: filtering is derived during
render from the props plus the query, the chip selection and the disclosure
overrides, so there is nothing to clean up on unmount and the server and the
browser render the same markup. Do not add a debounce — the work is a substring
scan over a list a human is expected to read, and a delayed list is a list that
feels broken.
Rendering & styling
- Semantic tokens only: bg-card / border / text-muted-foreground for chrome,
bg-primary for the pressed chips and the on-state track, bg-primary/20 for the
highlight <mark> (the UA yellow is a hard-coded colour that survives no theme),
bg-muted/30 for a disabled row, bg-muted-foreground/40 for the off-state track,
text-destructive for "Required" and for the error branch. No hard-coded colours.
- Required is a WORD in its own column, never a red dot; the source badge carries
a shape (server vs wrench) plus an sr-only expansion of what the kind means. All
three survive a monochrome theme and a colour-blind reader.
- Long names use `wrap-anywhere` (overflow-wrap: anywhere) plus `min-w-0`, NOT
break-words: only the former lowers the element's min-content width, which is
what stops a 70-character tool name from widening the whole panel. The parameter
table keeps its own horizontal scroll so four columns of signature text can be
read in a narrow sidebar without pushing the card wider.
- One element carries the counts and is also role="status" ("Showing 3 of 12
tools · 8 enabled"): visible for everyone, announced for a screen reader, and
the only thing that tells a non-sighted reader their keystroke changed the list.
The search input is aria-describedby that same element.
- loading is a skeleton with aria-busy plus an sr-only role="status"; error is
role="alert" with the reason rendered whitespace-pre-wrap and a Retry that
appears only when onRetry was passed. Every transition and pulse is
motion-reduce-guarded; nothing decorative is load-bearing.
- cn() merges the consumer's className into the root <section>, which also spreads
the remaining element props and forwards its ref.
Customization levers
- Ownership: pass query/onQueryChange and selectedSources/onSelectedSourcesChange
to put the filters in the URL (?q=&source=) or in a store; omit them and the
catalog owns both. The two are independent — controlled search with uncontrolled
chips is fine.
- Policy surface: omit onToggleEnabled for a read-only capability list (docs, a
support view); pass it to make the catalog the place where access is granted.
Add a `pending` look by rendering the row from an items array your mutation
layer marks — the component never guesses.
- Disclosure density: defaultOpen=true for a documentation page where every
signature should be visible, false (default) for a settings panel where the
automatic parameter-match rule does the opening.
- Grouping: items are rendered in the exact order given and never re-sorted, so
sort by source, by name or by "recently used" in your data layer and the chips
follow automatically.
- Chrome: label names the region, emptyState replaces the empty body with your own
connect-a-server CTA, errorMessage + onRetry own the envelope failure,
skeletonRows matches the placeholder count to your typical registry size.
- Columns: the parameter table is four columns (name / type / required /
description); drop the description column for a dense panel, or add one for
scopes or cost by extending the param schema — the contract is the only place
that has to change.Concepts
- Search reaches into the signature — the haystack is the name, the description, the source and every parameter's name, type, description and default. "Which tool takes a
repo?" is the question a catalog exists to answer, and a search that reads only labels answers none of them. - The row explains why it matched — when the hit is inside a parameter list the row opens itself and states the count, so the reader never has to expand ten rows hunting for the word they typed. One explicit toggle outranks that rule and is remembered per tool id, because a panel that re-opens on the next keystroke is unreadable.
- Facet counts ignore the facets — chip counts follow the text search only. If pressing "GitHub" rewrote every other chip to 0, the numbers would describe the click you just made instead of where else the answer might be; a chip sitting at 0 is information, and it stays on screen.
- No results is not empty — an empty registry is a connection problem ("connect a server"); a filter that excluded everything is a reading problem ("clear the search"). Same rectangle, opposite advice, so they are two different screens and only one of them is a contract state.
- The switch reports intent, the data decides —
onToggleEnabled(id, next)says what the human asked for; the switch only moves when a newitemsarray comes back. That is the difference between a catalog that shows the policy your backend enforces and one that shows what somebody clicked before the request failed. - State is a word, never a colour — Required, On/Off, Enabled/Disabled and the built-in/server shapes are all readable in a monochrome theme, by a colour-blind reader and in a screenshot. Colour is the second signal here, never the first.
MCP Server List
A four-state MCP connections manager — transport and live status per server, an enable switch that is intent while status stays reality, cached tool chips with an honest count, and per-row reconnect or authorize.
Agent Handoff
An agent-to-agent handoff marker — from/to avatars, a track that marches while the transfer is in flight, the reason control moved, chips for the context passed along, and who holds the run; compact divider and detailed card variants.