{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "tool-catalog",
  "title": "Tool Catalog",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "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.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/i18n.json",
    "https://ui.ballmac.com/r/match-highlight.json",
    "https://ui.ballmac.com/r/switch.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/tool-catalog.tsx",
      "content": "// Ballmac UI: Tool Catalog. https://ui.ballmac.com/components/tool-catalog\n\"use client\"\n\nimport * as React from \"react\"\nimport { ChevronDown, Search, ShieldAlert, Wrench } from \"lucide-react\"\n\nimport { MatchHighlight, matchesAll, queryTokens } from \"@/components/ballmac/match-highlight\"\nimport { Switch } from \"@/components/ballmac/switch\"\nimport { cn } from \"@/lib/utils\"\nimport { useMessages } from \"@/lib/ballmac/i18n\"\n\ntype CatalogTool = {\n  id: string\n  /** The name the agent calls it by, for example \"search_orders\". */\n  name: string\n  description: string\n  enabled: boolean\n  /** How much harm a mistake could do. */\n  risk?: \"low\" | \"medium\" | \"high\"\n  /** Calls in the last seven days. */\n  calls?: number\n  /** Share of calls that failed, 0 to 1. */\n  errorRate?: number\n  /** Typical time a call takes, in milliseconds. */\n  latencyMs?: number\n  /** The input the tool takes, as JSON text. Shown when the tool is opened. */\n  schema?: string\n}\n\ntype ToolCatalogProps = Omit<React.ComponentProps<\"section\">, \"title\"> & {\n  tools: CatalogTool[]\n  /** Called when a tool is switched on or off. Reject and the switch goes back with the message. */\n  onToggle?: (tool: CatalogTool, enabled: boolean) => void | Promise<void>\n  /** Heading. */\n  title?: string\n}\n\ntype Filter = \"all\" | \"on\" | \"off\" | \"attention\"\nconst needsAttention = (t: CatalogTool) => (t.errorRate ?? 0) >= 0.05 || (t.risk === \"high\" && t.enabled)\n\n/**\n * 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\n * 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\n * with the reason.\n */\nfunction ToolCatalog({ tools: toolsProp, onToggle, title, className, ...props }: ToolCatalogProps) {\n  const msg = useMessages()\n  const uid = React.useId()\n  title ??= msg(\"tool-catalog.title\", \"Tools\")\n  const [tools, setTools] = React.useState(toolsProp)\n  React.useEffect(() => setTools(toolsProp), [toolsProp])\n  const [query, setQuery] = React.useState(\"\")\n  const [filter, setFilter] = React.useState<Filter>(\"all\")\n  const [open, setOpen] = React.useState<string | null>(null)\n  const [busy, setBusy] = React.useState<string | null>(null)\n  const [error, setError] = React.useState<{ id: string; text: string } | null>(null)\n  const tokens = queryTokens(query)\n  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)))\n  const count = (f: Filter) => tools.filter((t) => (f === \"all\" ? true : f === \"on\" ? t.enabled : f === \"off\" ? !t.enabled : needsAttention(t))).length\n\n  async function toggle(t: CatalogTool, enabled: boolean) {\n    setBusy(t.id)\n    setError(null)\n    setTools((l) => l.map((x) => (x.id === t.id ? { ...x, enabled } : x)))\n    try {\n      await onToggle?.(t, enabled)\n    } catch (e) {\n      setTools((l) => l.map((x) => (x.id === t.id ? t : x)))\n      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 }) })\n    } finally {\n      setBusy(null)\n    }\n  }\n  const risk = (r: NonNullable<CatalogTool[\"risk\"]>) => msg(`tool-catalog.risk.${r}`, r === \"low\" ? \"Low risk\" : r === \"medium\" ? \"Medium risk\" : \"High risk\")\n\n  return (\n    <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}>\n      <div className=\"flex flex-wrap items-center justify-between gap-3\">\n        <h3 id={`${uid}-t`} className=\"flex items-center gap-2 text-sm font-medium\"><Wrench aria-hidden=\"true\" className=\"size-4\" /> {title}</h3>\n        <div role=\"group\" aria-label={msg(\"tool-catalog.filter\", \"Filter tools\")} className=\"flex flex-wrap gap-1.5\">\n          {([[\"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]) => (\n            <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\")}>\n              {l} <span className={cn(\"tabular-nums\", filter === k ? \"opacity-70\" : \"text-muted-foreground\")}>{count(k)}</span>\n            </button>\n          ))}\n        </div>\n      </div>\n      <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\">\n        <Search aria-hidden=\"true\" className=\"size-4 text-muted-foreground\" />\n        <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\" />\n      </div>\n      <p aria-live=\"polite\" className=\"text-xs text-muted-foreground\">{msg(\"tool-catalog.count\", \"{count} tools shown\", { count: shown.length })}</p>\n\n      {shown.length === 0 ? (\n        <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>\n      ) : (\n        <ul className=\"grid gap-2.5\">\n          {shown.map((t) => {\n            const isOpen = open === t.id\n            const flaky = (t.errorRate ?? 0) >= 0.05\n            return (\n              <li key={t.id} data-enabled={t.enabled} className=\"rounded-lg border\">\n                <div className=\"flex items-start justify-between gap-4 p-3.5\">\n                  <div className=\"min-w-0\">\n                    <p className=\"flex flex-wrap items-center gap-x-2 gap-y-1\">\n                      <code className=\"font-mono text-sm font-semibold text-foreground\" dir=\"ltr\"><MatchHighlight text={t.name} query={tokens} /></code>\n                      {t.risk && (\n                        <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\")}>\n                          {t.risk === \"high\" && <ShieldAlert aria-hidden=\"true\" className=\"size-3\" />}\n                          {risk(t.risk)}\n                        </span>\n                      )}\n                    </p>\n                    <p className=\"mt-1 text-[13px] leading-snug text-muted-foreground\"><MatchHighlight text={t.description} query={tokens} /></p>\n                    {(t.calls !== undefined || t.errorRate !== undefined || t.latencyMs !== undefined) && (\n                      <p className=\"mt-2 flex flex-wrap gap-x-3 gap-y-0.5 text-xs tabular-nums text-muted-foreground\">\n                        {t.calls !== undefined && <span>{msg(\"tool-catalog.calls\", \"{count} calls this week\", { count: t.calls.toLocaleString(\"en-US\") })}</span>}\n                        {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>}\n                        {t.latencyMs !== undefined && <span>{msg(\"tool-catalog.latency\", \"{ms} ms typical\", { ms: t.latencyMs.toLocaleString(\"en-US\") })}</span>}\n                      </p>\n                    )}\n                    {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>}\n                  </div>\n                  <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)} />\n                </div>\n                {error?.id === t.id && <p role=\"alert\" className=\"px-3.5 pb-3 text-sm font-medium text-destructive\">{error.text}</p>}\n                {t.schema && (\n                  <div className=\"border-t\">\n                    <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\">\n                      {msg(\"tool-catalog.schema\", \"Input schema\")}\n                      <ChevronDown aria-hidden=\"true\" className={cn(\"size-4 transition-transform motion-reduce:transition-none\", isOpen && \"rotate-180\")} />\n                    </button>\n                    {isOpen && (\n                      <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>\n                    )}\n                  </div>\n                )}\n              </li>\n            )\n          })}\n        </ul>\n      )}\n    </section>\n  )\n}\n\nexport { ToolCatalog, needsAttention, type CatalogTool, type ToolCatalogProps }\n",
      "type": "registry:ui",
      "target": "@components/ballmac/tool-catalog.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "ai",
      "agents",
      "tools",
      "mcp",
      "function calling",
      "permissions",
      "catalog",
      "settings"
    ],
    "version": "1.0.0",
    "updated": "2026-10-24",
    "tests": true,
    "ai": {
      "summary": "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.",
      "whenToUse": [
        "Letting people turn an agent's tools on and off and see how each behaves",
        "A settings page for an MCP server's tools"
      ],
      "whenNotToUse": [
        "Allow, ask or deny per tool; use permission-grid",
        "One level of freedom; use autonomy-dial"
      ],
      "composesWith": [
        "permission-grid",
        "autonomy-dial",
        "tool-call-card",
        "agent-graph"
      ],
      "a11y": [
        {
          "keys": "Tab, Space",
          "action": "Each switch is named '<tool> is on'; filters are toggle buttons with counts"
        },
        {
          "keys": "Enter / Space on Input schema",
          "action": "Opens the schema (aria-expanded); it is a focusable, labelled region that stays left to right"
        },
        {
          "keys": "Failures",
          "action": "A failed switch is an alert beside the tool and the switch goes back"
        },
        {
          "keys": "Risk and failure rate",
          "action": "Written as words ('High risk', 'fails often'), never colour alone"
        }
      ],
      "customization": [
        "tools and their stats",
        "title",
        "onToggle"
      ]
    },
    "statesNone": "A control with no data of its own: it shows the tools you pass in and reports the changes, and it has its own no-match message.",
    "examples": [
      "tool-catalog-demo",
      "tool-catalog-failing"
    ],
    "url": "https://ui.ballmac.com/components/tool-catalog"
  },
  "categories": [
    "ai"
  ],
  "type": "registry:ui"
}