{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "agent-9",
  "title": "Agent 9: autonomy and limits",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "How much an AI agent may do alone: a plain-words autonomy level, spend caps per run and per day, a step limit and the tools it may use, with a warning for risky mixes and a save bar that holds edits until saved.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/button.json",
    "https://ui.ballmac.com/r/skeleton.json",
    "https://ui.ballmac.com/r/state-view.json",
    "https://ui.ballmac.com/r/switch.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/blocks/agent-9/agent-9.tsx",
      "content": "// Ballmac UI: Agent 9. https://ui.ballmac.com/blocks/agent-9\n\"use client\"\n\nimport * as React from \"react\"\nimport { Loader2, ShieldCheck } from \"lucide-react\"\n\nimport { Button } from \"@/components/ballmac/button\"\nimport { Skeleton } from \"@/components/ballmac/skeleton\"\nimport { frameProps, StateView, type StateProps } from \"@/components/ballmac/state-view\"\nimport { Switch } from \"@/components/ballmac/switch\"\nimport { cn } from \"@/lib/utils\"\n\ntype Agent9Autonomy = \"suggest\" | \"ask\" | \"auto\"\ntype Agent9Tool = { id: string; name: string; description: string; /** A tool that changes things outside the agent. */ writes?: boolean }\ntype Agent9Config = {\n  autonomy: Agent9Autonomy\n  /** The most one run may spend, in the currency. */\n  perRun: number\n  /** The most the agent may spend in a day. */\n  perDay: number\n  /** Tool ids the agent may use. */\n  tools: string[]\n  /** Stop after this many steps in one run. */\n  maxSteps: number\n}\n\ntype Agent9Props = Omit<React.ComponentProps<\"section\">, \"title\" | \"onChange\"> &\n  StateProps & {\n    config?: Agent9Config\n    tools?: Agent9Tool[]\n    title?: string\n    currency?: string\n    /** Called with the new settings. Reject and the edits stay with the message. */\n    onSave?: (config: Agent9Config) => void | Promise<void>\n  }\n\nconst LEVELS: { id: Agent9Autonomy; name: string; detail: string }[] = [\n  { id: \"suggest\", name: \"Suggest only\", detail: \"It proposes every step. Nothing happens until you do it yourself.\" },\n  { id: \"ask\", name: \"Ask first\", detail: \"It does safe steps alone and asks before anything that changes something.\" },\n  { id: \"auto\", name: \"Act on its own\", detail: \"It does everything inside its budget and tools, and reports after.\" },\n]\nconst defaultTools: Agent9Tool[] = [\n  { id: \"search\", name: \"Search the web\", description: \"Reads pages. Changes nothing.\" },\n  { id: \"docs\", name: \"Read documents\", description: \"Reads your workspace files. Changes nothing.\" },\n  { id: \"mail\", name: \"Send email\", description: \"Sends from your address.\", writes: true },\n  { id: \"calendar\", name: \"Edit calendar\", description: \"Creates, moves and cancels events.\", writes: true },\n  { id: \"pay\", name: \"Make payments\", description: \"Spends from the linked card.\", writes: true },\n]\nconst defaultConfig: Agent9Config = { autonomy: \"ask\", perRun: 5, perDay: 25, tools: [\"search\", \"docs\", \"mail\"], maxSteps: 30 }\n\nconst same = (a: Agent9Config, b: Agent9Config) => a.autonomy === b.autonomy && a.perRun === b.perRun && a.perDay === b.perDay && a.maxSteps === b.maxSteps && [...a.tools].sort().join() === [...b.tools].sort().join()\n\nfunction Skel() {\n  return (\n    <div aria-hidden=\"true\" className=\"grid gap-5\">\n      <Skeleton className=\"h-44 rounded-2xl\" />\n      <Skeleton className=\"h-32 rounded-2xl\" />\n      <Skeleton className=\"h-64 rounded-2xl\" />\n    </div>\n  )\n}\n\n/**\n * How much an agent may do on its own: a level of autonomy in plain words, a spending cap per run and per day, the tools it may use, and a step limit.\n * Edits are held until saved; the bar says what changed, a risky mix is warned about, and a failed save keeps everything with the reason.\n */\nfunction Agent9({ config = defaultConfig, tools = defaultTools, title = \"What the agent may do\", currency = \"USD\", onSave, state = \"ready\", onRetry, emptyState, errorState, className, ...props }: Agent9Props) {\n  const id = React.useId()\n  const [saved, setSaved] = React.useState(config)\n  const [draft, setDraft] = React.useState(config)\n  const [busy, setBusy] = React.useState(false)\n  const [error, setError] = React.useState<string | null>(null)\n  const [note, setNote] = React.useState(\"\")\n  const view = state === \"ready\" && tools.length === 0 ? \"empty\" : state\n  const money = (n: number) => new Intl.NumberFormat(\"en-US\", { style: \"currency\", currency, maximumFractionDigits: 2 }).format(n)\n  const dirty = !same(saved, draft)\n  const perRunBad = !(draft.perRun > 0) || draft.perRun > draft.perDay\n  const stepsBad = !Number.isInteger(draft.maxSteps) || draft.maxSteps < 1 || draft.maxSteps > 200\n  const writing = tools.filter((t) => t.writes && draft.tools.includes(t.id))\n  const risky = draft.autonomy === \"auto\" && writing.length > 0\n  const set = (patch: Partial<Agent9Config>) => { setNote(\"\"); setError(null); setDraft((d) => ({ ...d, ...patch })) }\n\n  async function save() {\n    if (perRunBad || stepsBad) return\n    setBusy(true)\n    setError(null)\n    try {\n      await onSave?.(draft)\n      setSaved(draft)\n      setNote(\"Saved. The agent follows these settings from its next run.\")\n    } catch (e) {\n      setError(e instanceof Error ? e.message : \"Nothing was saved. Your edits are still here.\")\n    } finally {\n      setBusy(false)\n    }\n  }\n\n  const num = \"bg-background focus-visible:ring-ring/50 aria-[invalid=true]:border-destructive h-11 w-full rounded-lg border px-3 text-sm tabular-nums outline-none focus-visible:ring-[3px]\"\n  const card = \"bg-card rounded-2xl border p-5 sm:p-6\"\n\n  return (\n    <section data-slot=\"agent-9\" data-state={state} aria-labelledby={`${id}-title`} className={cn(\"mx-auto w-full max-w-3xl px-4 py-10 sm:px-6\", className)} {...frameProps(props)}>\n      <h1 id={`${id}-title`} className=\"mb-8 text-3xl font-semibold tracking-[-0.035em] text-balance\">{title}</h1>\n      <StateView state={view} onRetry={onRetry} label=\"Loading the agent settings\" loading={<Skel />} empty={{ title: \"No tools connected\", description: \"Connect a tool to decide what the agent may use.\", ...emptyState }} error={{ title: \"The settings could not be loaded\", description: \"Nothing was changed. Check your connection and try again.\", ...errorState }}>\n        <div className=\"grid gap-5\">\n          <fieldset className={card}>\n            <legend className=\"sr-only\">Autonomy</legend>\n            <h2 className=\"mb-1 text-lg font-semibold tracking-tight\" aria-hidden=\"true\">How much it does alone</h2>\n            <div className=\"mt-4 grid gap-2.5\">\n              {LEVELS.map((l) => (\n                <label key={l.id} className=\"relative block cursor-pointer\">\n                  <input type=\"radio\" name={`${id}-level`} checked={draft.autonomy === l.id} onChange={() => set({ autonomy: l.id })} className=\"peer sr-only\" />\n                  <span className=\"peer-checked:border-foreground peer-checked:bg-accent/40 peer-focus-visible:ring-ring/50 block rounded-xl border p-3.5 transition-colors peer-focus-visible:ring-[3px] motion-reduce:transition-none\">\n                    <span className=\"block text-sm font-medium\">{l.name}</span>\n                    <span className=\"text-muted-foreground block text-sm\">{l.detail}</span>\n                  </span>\n                </label>\n              ))}\n            </div>\n          </fieldset>\n\n          <fieldset className={card}>\n            <legend className=\"sr-only\">Limits</legend>\n            <h2 className=\"text-lg font-semibold tracking-tight\" aria-hidden=\"true\">Limits</h2>\n            <div className=\"mt-4 grid gap-4 sm:grid-cols-3\">\n              <div>\n                <label htmlFor={`${id}-run`} className=\"mb-1.5 block text-sm font-medium\">Most per run ({currency})</label>\n                <input id={`${id}-run`} inputMode=\"decimal\" value={draft.perRun || \"\"} onChange={(e) => set({ perRun: Number.parseFloat(e.target.value) || 0 })} aria-invalid={perRunBad} aria-describedby={perRunBad ? `${id}-run-err` : undefined} className={num} />\n                {perRunBad && <p id={`${id}-run-err`} role=\"alert\" className=\"text-destructive mt-1.5 text-sm font-medium\">{draft.perRun > draft.perDay ? \"A run cannot cost more than the daily limit.\" : \"Enter an amount above 0.\"}</p>}\n              </div>\n              <div>\n                <label htmlFor={`${id}-day`} className=\"mb-1.5 block text-sm font-medium\">Most per day ({currency})</label>\n                <input id={`${id}-day`} inputMode=\"decimal\" value={draft.perDay || \"\"} onChange={(e) => set({ perDay: Number.parseFloat(e.target.value) || 0 })} className={num} />\n              </div>\n              <div>\n                <label htmlFor={`${id}-steps`} className=\"mb-1.5 block text-sm font-medium\">Most steps in a run</label>\n                <input id={`${id}-steps`} inputMode=\"numeric\" value={draft.maxSteps || \"\"} onChange={(e) => set({ maxSteps: Number.parseInt(e.target.value, 10) || 0 })} aria-invalid={stepsBad} aria-describedby={stepsBad ? `${id}-steps-err` : undefined} className={num} />\n                {stepsBad && <p id={`${id}-steps-err`} role=\"alert\" className=\"text-destructive mt-1.5 text-sm font-medium\">Enter a whole number from 1 to 200.</p>}\n              </div>\n            </div>\n            <p className=\"text-muted-foreground mt-3 text-sm\">It stops and asks you when it would pass a limit.</p>\n          </fieldset>\n\n          <div className={card}>\n            <h2 className=\"text-lg font-semibold tracking-tight\">Tools it may use</h2>\n            <ul className=\"mt-3 divide-y\">\n              {tools.map((t) => (\n                <li key={t.id} className=\"flex items-center justify-between gap-4 py-3\">\n                  <div className=\"min-w-0\">\n                    <p className=\"text-sm font-medium\">{t.name}{t.writes && <span className=\"text-muted-foreground ms-2 rounded-full border px-2 py-0.5 text-xs font-normal\">Changes things</span>}</p>\n                    <p className=\"text-muted-foreground text-sm\">{t.description}</p>\n                  </div>\n                  <Switch aria-label={`${t.name} is allowed`} checked={draft.tools.includes(t.id)} onCheckedChange={(on) => set({ tools: on ? [...draft.tools, t.id] : draft.tools.filter((x) => x !== t.id) })} />\n                </li>\n              ))}\n            </ul>\n            {risky && (\n              <p role=\"status\" className=\"bg-muted mt-3 flex items-start gap-2.5 rounded-lg p-3 text-sm\"><ShieldCheck aria-hidden=\"true\" className=\"mt-0.5 size-4 shrink-0\" />With &ldquo;Act on its own&rdquo;, it can {writing.map((t) => t.name.toLowerCase()).join(\", \")} without asking. Its limits are {money(draft.perRun)} a run and {money(draft.perDay)} a day.</p>\n            )}\n          </div>\n\n          <div className=\"bg-background/90 sticky bottom-4 flex flex-wrap items-center justify-between gap-3 rounded-2xl border p-3 ps-5 shadow-lg backdrop-blur\">\n            <p aria-live=\"polite\" role={error ? \"alert\" : undefined} className={cn(\"text-sm\", error && \"text-destructive font-medium\")}>{error ?? (dirty ? \"You have unsaved changes.\" : note || \"All changes saved.\")}</p>\n            <div className=\"flex gap-2\">\n              <Button variant=\"outline\" disabled={!dirty || busy} onClick={() => { setDraft(saved); setError(null) }}>Discard</Button>\n              <Button disabled={!dirty || busy || perRunBad || stepsBad} onClick={() => void save()}>{busy && <Loader2 aria-hidden=\"true\" className=\"animate-spin\" />} Save settings</Button>\n            </div>\n          </div>\n        </div>\n      </StateView>\n    </section>\n  )\n}\n\nexport { Agent9, type Agent9Autonomy, type Agent9Config, type Agent9Props, type Agent9Tool }\n",
      "type": "registry:component",
      "target": "@components/ballmac/blocks/agent-9/agent-9.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "ai",
      "agent",
      "autonomy",
      "guardrails",
      "budget",
      "limits",
      "permissions",
      "tools",
      "settings",
      "safety"
    ],
    "version": "1.0.0",
    "updated": "2026-10-24",
    "tests": true,
    "ai": {
      "summary": "Agent autonomy and limits. <Agent9 config={{ autonomy: 'suggest'|'ask'|'auto', perRun, perDay, tools: ['id'], maxSteps }} tools={[{ id, name, description, writes? }]} currency='USD' onSave={(config) => Promise} />. Edits are held until Save; Discard puts them back. A run limit above the daily limit, or a bad step count, blocks saving and says why. Acting on its own with tools that write shows a plain-words warning.",
      "whenToUse": [
        "Where an owner decides how much an agent may do without asking",
        "Spend caps, step limits and tool permissions in one place"
      ],
      "whenNotToUse": [
        "Choosing the model and prompt; use agent-7",
        "Approving one action; use agent-2"
      ],
      "composesWith": [
        "agent-2",
        "agent-7",
        "agent-8",
        "cost-1"
      ],
      "a11y": [
        {
          "keys": "Autonomy",
          "action": "A radio group; each level has its name and what it means written"
        },
        {
          "keys": "Limits",
          "action": "Labelled number fields; errors are alerts tied to the field"
        },
        {
          "keys": "Tools",
          "action": "Switches named '<tool> is allowed'; tools that change things are labelled in words"
        },
        {
          "keys": "Save bar",
          "action": "A polite live region says unsaved, saved or the failure; failures are alerts"
        }
      ],
      "customization": [
        "config, tools, currency, title",
        "onSave",
        "Autonomy levels are fixed: suggest, ask, auto"
      ]
    },
    "states": [
      {
        "id": "loading",
        "title": "Loading",
        "example": "agent-9-loading",
        "trigger": "state=\"loading\"",
        "note": "Skeleton cards under the real heading, announced as loading."
      },
      {
        "id": "empty",
        "title": "No tools connected",
        "example": "agent-9-empty",
        "trigger": "state=\"empty\"",
        "note": "Says no tools are connected; emptyState sets the copy."
      },
      {
        "id": "error",
        "title": "Could not load",
        "example": "agent-9-error",
        "trigger": "state=\"error\"",
        "note": "An alert saying nothing was changed, with Try again (onRetry). A save that fails keeps every edit and says why."
      }
    ],
    "examples": [
      "agent-9-demo",
      "agent-9-free",
      "agent-9-loading",
      "agent-9-empty",
      "agent-9-error"
    ],
    "url": "https://ui.ballmac.com/components/agent-9"
  },
  "categories": [
    "blocks",
    "agents"
  ],
  "type": "registry:block"
}