Everything an agent can call, with a switch for each: what it does, how risky it is, how often it is used and fails, its input schema one click away, search and filters, and a warning when a high-risk tool is on.
Tools
5 tools shown
search_ordersLow riskFind a customer's orders by email, order number or date range.
4,210 calls this week1.2% fail340 ms typical
issue_refundHigh riskRefund all or part of an order to the original payment method.
318 calls this week3.1% fail910 ms typical
On for a high-risk tool: check it is meant to be.
send_emailMedium riskSend an email from the support address.
0 calls this week
read_knowledge_baseLow riskSearch the help centre and return the closest articles.
9,120 calls this week0.4% fail210 ms typical
update_addressMedium riskChange the shipping address on an order that has not shipped.
640 calls this week9.4% fail · fails often1,480 ms typical
"use client"
import { ToolCatalog, type CatalogTool } from "@/components/ballmac/tool-catalog"
const tools: CatalogTool[] = [
{ id: "t1", name: "search_orders", description: "Find a customer's orders by email, order number or date range.", enabled: true, risk: "low", calls: 4210, errorRate: 0.012, latencyMs: 340, schema: '{\n "type": "object",\n "properties": {\n "email": { "type": "string" },\n "order_number": { "type": "string" },\n "since": { "type": "string", "format": "date" }\n }\n}' },
{ id: "t2", name: "issue_refund", description: "Refund all or part of an order to the original payment method.", enabled: true, risk: "high", calls: 318, errorRate: 0.031, latencyMs: 910, schema: '{\n "type": "object",\n "required": ["order_number", "amount"],\n "properties": {\n "order_number": { "type": "string" },\n "amount": { "type": "number" },\n "reason": { "type": "string" }\n }\n}' },
{ id: "t3", name: "send_email", description: "Send an email from the support address.", enabled: false, risk: "medium", calls: 0 },
{ id: "t4", name: "read_knowledge_base", description: "Search the help centre and return the closest articles.", enabled: true, risk: "low", calls: 9120, errorRate: 0.004, latencyMs: 210 },
{ id: "t5", name: "update_address", description: "Change the shipping address on an order that has not shipped.", enabled: true, risk: "medium", calls: 640, errorRate: 0.094, latencyMs: 1480 },
]
export default function ToolCatalogDemo() {
return (
<div className="w-full max-w-2xl">
<ToolCatalog tools={tools} onToggle={() => new Promise((r) => setTimeout(r, 400))} />
</div>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/tool-catalogInstall the dependencies.
$ pnpm add lucide-reactAdd the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/i18n @ballmac/match-highlight @ballmac/switchCopy the source into your project.
components/ballmac/tool-catalog.tsx// Ballmac UI: Tool Catalog. https://ui.ballmac.com/components/tool-catalog "use client" import * as React from "react" import { ChevronDown, Search, ShieldAlert, Wrench } from "lucide-react" import { MatchHighlight, matchesAll, queryTokens } from "@/components/ballmac/match-highlight" import { Switch } from "@/components/ballmac/switch" import { cn } from "@/lib/utils" import { useMessages } from "@/lib/ballmac/i18n" type CatalogTool = { id: string /** The name the agent calls it by, for example "search_orders". */ name: string description: string enabled: boolean /** How much harm a mistake could do. */ risk?: "low" | "medium" | "high" /** Calls in the last seven days. */ calls?: number /** Share of calls that failed, 0 to 1. */ errorRate?: number /** Typical time a call takes, in milliseconds. */ latencyMs?: number /** The input the tool takes, as JSON text. Shown when the tool is opened. */ schema?: string } type ToolCatalogProps = Omit<React.ComponentProps<"section">, "title"> & { tools: CatalogTool[] /** Called when a tool is switched on or off. Reject and the switch goes back with the message. */ onToggle?: (tool: CatalogTool, enabled: boolean) => void | Promise<void> /** Heading. */ title?: string } type Filter = "all" | "on" | "off" | "attention" const needsAttention = (t: CatalogTool) => (t.errorRate ?? 0) >= 0.05 || (t.risk === "high" && t.enabled) /** * Everything an agent can call, with a switch for each: what it does, how risky it is, how often it is used and how often it fails, and its input * schema one click away. High-risk tools that are on, and tools that fail often, are gathered under "Needs attention". A switch that fails to save goes back * with the reason. */ function ToolCatalog({ tools: toolsProp, onToggle, title, className, ...props }: ToolCatalogProps) { const msg = useMessages() const uid = React.useId() title ??= msg("tool-catalog.title", "Tools") const [tools, setTools] = React.useState(toolsProp) React.useEffect(() => setTools(toolsProp), [toolsProp]) const [query, setQuery] = React.useState("") const [filter, setFilter] = React.useState<Filter>("all") const [open, setOpen] = React.useState<string | null>(null) const [busy, setBusy] = React.useState<string | null>(null) const [error, setError] = React.useState<{ id: string; text: string } | null>(null) const tokens = queryTokens(query) const shown = tools.filter((t) => (filter === "all" || (filter === "on" ? t.enabled : filter === "off" ? !t.enabled : needsAttention(t))) && (!tokens.length || matchesAll(`${t.name} ${t.description}`, tokens))) const count = (f: Filter) => tools.filter((t) => (f === "all" ? true : f === "on" ? t.enabled : f === "off" ? !t.enabled : needsAttention(t))).length async function toggle(t: CatalogTool, enabled: boolean) { setBusy(t.id) setError(null) setTools((l) => l.map((x) => (x.id === t.id ? { ...x, enabled } : x))) try { await onToggle?.(t, enabled) } catch (e) { setTools((l) => l.map((x) => (x.id === t.id ? t : x))) setError({ id: t.id, text: e instanceof Error ? e.message : msg("tool-catalog.failed", "{name} could not be changed, so it is as it was.", { name: t.name }) }) } finally { setBusy(null) } } const risk = (r: NonNullable<CatalogTool["risk"]>) => msg(`tool-catalog.risk.${r}`, r === "low" ? "Low risk" : r === "medium" ? "Medium risk" : "High risk") return ( <section data-slot="tool-catalog" aria-labelledby={`${uid}-t`} className={cn("grid w-full min-w-0 gap-4 rounded-xl border bg-card p-4 text-card-foreground sm:p-5", className)} {...props}> <div className="flex flex-wrap items-center justify-between gap-3"> <h3 id={`${uid}-t`} className="flex items-center gap-2 text-sm font-medium"><Wrench aria-hidden="true" className="size-4" /> {title}</h3> <div role="group" aria-label={msg("tool-catalog.filter", "Filter tools")} className="flex flex-wrap gap-1.5"> {([["all", msg("tool-catalog.all", "All")], ["on", msg("tool-catalog.on", "On")], ["off", msg("tool-catalog.off", "Off")], ["attention", msg("tool-catalog.attention", "Needs attention")]] as const).map(([k, l]) => ( <button key={k} type="button" aria-pressed={filter === k} onClick={() => setFilter(k)} className={cn("h-8 rounded-full border px-3 text-xs font-medium outline-none transition-colors focus-visible:ring-[3px] focus-visible:ring-ring/50 motion-reduce:transition-none", filter === k ? "border-foreground bg-foreground text-background" : "hover:bg-accent")}> {l} <span className={cn("tabular-nums", filter === k ? "opacity-70" : "text-muted-foreground")}>{count(k)}</span> </button> ))} </div> </div> <div className="flex h-10 items-center gap-2 rounded-lg border bg-background px-3 focus-within:ring-[3px] focus-within:ring-ring/50"> <Search aria-hidden="true" className="size-4 text-muted-foreground" /> <input type="search" aria-label={msg("tool-catalog.search", "Search tools")} placeholder={msg("tool-catalog.searchPlaceholder", "Search by name or description")} value={query} onChange={(e) => setQuery(e.target.value)} className="min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground" /> </div> <p aria-live="polite" className="text-xs text-muted-foreground">{msg("tool-catalog.count", "{count} tools shown", { count: shown.length })}</p> {shown.length === 0 ? ( <p className="rounded-lg border border-dashed px-4 py-10 text-center text-sm text-muted-foreground">{msg("tool-catalog.none", "No tools match.")}</p> ) : ( <ul className="grid gap-2.5"> {shown.map((t) => { const isOpen = open === t.id const flaky = (t.errorRate ?? 0) >= 0.05 return ( <li key={t.id} data-enabled={t.enabled} className="rounded-lg border"> <div className="flex items-start justify-between gap-4 p-3.5"> <div className="min-w-0"> <p className="flex flex-wrap items-center gap-x-2 gap-y-1"> <code className="font-mono text-sm font-semibold text-foreground" dir="ltr"><MatchHighlight text={t.name} query={tokens} /></code> {t.risk && ( <span className={cn("inline-flex items-center gap-1 rounded-full border px-2 py-px text-[11px]", t.risk === "high" ? "border-destructive/50 text-destructive" : "text-muted-foreground")}> {t.risk === "high" && <ShieldAlert aria-hidden="true" className="size-3" />} {risk(t.risk)} </span> )} </p> <p className="mt-1 text-[13px] leading-snug text-muted-foreground"><MatchHighlight text={t.description} query={tokens} /></p> {(t.calls !== undefined || t.errorRate !== undefined || t.latencyMs !== undefined) && ( <p className="mt-2 flex flex-wrap gap-x-3 gap-y-0.5 text-xs tabular-nums text-muted-foreground"> {t.calls !== undefined && <span>{msg("tool-catalog.calls", "{count} calls this week", { count: t.calls.toLocaleString("en-US") })}</span>} {t.errorRate !== undefined && <span className={cn(flaky && "font-medium text-destructive")}>{msg("tool-catalog.errors", "{rate}% fail", { rate: Math.round(t.errorRate * 1000) / 10 })}{flaky ? ` · ${msg("tool-catalog.flaky", "fails often")}` : ""}</span>} {t.latencyMs !== undefined && <span>{msg("tool-catalog.latency", "{ms} ms typical", { ms: t.latencyMs.toLocaleString("en-US") })}</span>} </p> )} {t.risk === "high" && t.enabled && <p className="mt-1.5 flex items-center gap-1.5 text-xs font-medium text-foreground"><ShieldAlert aria-hidden="true" className="size-3.5 text-destructive" /> {msg("tool-catalog.highOn", "On for a high-risk tool: check it is meant to be.")}</p>} </div> <Switch aria-label={msg("tool-catalog.switch", "{name} is on", { name: t.name })} checked={t.enabled} disabled={busy === t.id} onCheckedChange={(v) => void toggle(t, v)} /> </div> {error?.id === t.id && <p role="alert" className="px-3.5 pb-3 text-sm font-medium text-destructive">{error.text}</p>} {t.schema && ( <div className="border-t"> <button type="button" aria-expanded={isOpen} aria-controls={`${uid}-${t.id}`} onClick={() => setOpen(isOpen ? null : t.id)} className="flex w-full items-center justify-between gap-2 px-3.5 py-2 text-start text-xs font-medium text-muted-foreground outline-none transition-colors hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:ring-inset motion-reduce:transition-none"> {msg("tool-catalog.schema", "Input schema")} <ChevronDown aria-hidden="true" className={cn("size-4 transition-transform motion-reduce:transition-none", isOpen && "rotate-180")} /> </button> {isOpen && ( <pre id={`${uid}-${t.id}`} dir="ltr" tabIndex={0} aria-label={msg("tool-catalog.schema", "{name} input schema", { name: t.name })} className="max-h-56 overflow-auto border-t bg-muted/30 px-3.5 py-3 font-mono text-xs leading-relaxed outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:ring-inset">{t.schema}</pre> )} </div> )} </li> ) })} </ul> )} </section> ) } export { ToolCatalog, needsAttention, type CatalogTool, type ToolCatalogProps }Update the import paths to match your project setup.
Usage
import { ToolCatalog, needsAttention } from "@/components/ballmac/tool-catalog"The full example is in the Code tab above.
Examples
A switch that fails
Tools for the billing agent
2 tools shown
lookup_invoiceLow riskOpen an invoice by its number.
1,200 calls this week1% fail280 ms typical
void_invoiceHigh riskCancel an invoice that has not been paid.
0 calls this week
"use client"
import { ToolCatalog } from "@/components/ballmac/tool-catalog"
export default function ToolCatalogFailing() {
return (
<div className="w-full max-w-2xl">
<ToolCatalog
title="Tools for the billing agent"
onToggle={() => Promise.reject(new Error("The tool service is unreachable, so nothing changed."))}
tools={[
{ id: "a", name: "lookup_invoice", description: "Open an invoice by its number.", enabled: true, risk: "low", calls: 1200, errorRate: 0.01, latencyMs: 280 },
{ id: "b", name: "void_invoice", description: "Cancel an invoice that has not been paid.", enabled: false, risk: "high", calls: 0 },
]}
/>
</div>
)
}API reference
| Prop | Type | Default |
|---|---|---|
tools* | CatalogTool[] | — |
onToggleCalled when a tool is switched on or off. Reject and the switch goes back with the message. | (tool: CatalogTool, enabled: boolean) => void | Promise<void> | — |
titleHeading. | string | — |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Tab, Space | Each switch is named '<tool> is on'; filters are toggle buttons with counts |
| Enter / Space on Input schema | Opens the schema (aria-expanded); it is a focusable, labelled region that stays left to right |
| Failures | A failed switch is an alert beside the tool and the switch goes back |
| Risk and failure rate | Written as words ('High risk', 'fails often'), never colour alone |
Use with AI
tools is [{ id, name, description, enabled, risk?: low | medium | high, calls?, errorRate?: 0..1, latencyMs?, schema?: JSON text }]. onToggle(tool, enabled) may return a promise; reject and the switch goes back with the message. Filters: All, On, Off, Needs attention (error rate of 5% or more, or a high-risk tool that is on). Exports needsAttention. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Tool Catalog (@ballmac/tool-catalog) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Letting people turn an agent's tools on and off and see how each behaves
- A settings page for an MCP server's tools
Not for
- Allow, ask or deny per tool; use permission-grid
- One level of freedom; use autonomy-dial
Registry JSON: https://ui.ballmac.com/r/tool-catalog.json
Credits
Free to use in personal and commercial projects.
- npm
- lucide-react
Pairs well with
Permission Grid
A grid of everything an agent could do, each row set to Allow, Ask or Deny with native radios, risk written out, a warning when a high-risk action is allowed, grouped rows and one-click presets.
Autonomy Dial
A stepped dial for how much an agent may do on its own, from suggest-only to full auto, with what it does alone and what it stops to ask about written out for every step.
Tool Call Card
Shows one agent tool call: the tool name, a pending, running, success or error status with icon and text, duration, and collapsible pretty-printed input and result.
Agent Graph
A layered graph of how agents, tools, memory and people hand work to each other: nodes that say what they are and what they are doing in words, labelled arrows, the active path drawn heavier, and arrow keys that follow the edges.
Ballmac UI Pro
Need the whole screen, not one piece?
150 premium blocks (dashboards, settings, calendars, mail, shops, onboarding), a private shadcn registry and Figma tokens. Blocks that show data come with loading, empty and error states built in.
Not ready? Try three Pro blocks free
Leave your email and the blocks appear right here, ready to copy. Yours to use in any project.