Agent Permissions
A capability-scoping panel for one agent — per-tool switches with risk bands, a spend cap metered against what is already spent, an add-and-remove domain allowlist, an ordered file-access scale with policy ceilings, and an unsaved-changes bar that counts every change that expands the agent's authority.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/agent-permissions.jsonPrompt
Build a React + TypeScript + Tailwind "AgentPermissions" component with zod and
lucide-react. Four unlike controls in one panel, one shared staging model.
Contract
- A zod contract file is the single source of truth and exports four data shapes
plus the draft:
- Tool: { id, name, label?, description?, risk: "low"|"medium"|"high",
allowed: boolean, locked?, lockedReason?, calls?, warning? }
- SpendCap: { capUsd, usedUsd, currency?, periodLabel?, maxCapUsd?, maxReason?,
updatedBy?, updatedAt? }
- Domain: { host, locked?, note? }
- FileAccess: { scope: "none"|"read"|"read-write", root?, maxScope?,
maxReason?, locked?, lockedReason? }
- Draft, the sparse overlay: { tools?: Record<string, boolean>,
capUsd?: number, domains?: string[], fileAccess?: scope }
- The envelope is { status: "loading"|"empty"|"error"|"ready", tools, spend:
SpendCap | null, domains, fileAccess }. `spend: null` means the deployment
meters nothing — a missing BLOCK, not a cap of zero, and the two must never
look alike.
- Each saved value is the BASELINE. The component never mutates it; the consumer
flips it when the write lands, and that flip is what clears the unsaved bar.
- The file-access scale is ORDERED (none, then read, then read-write) and so are
the risk bands. Ordering is what makes "this change expands authority"
computable instead of editorial.
- Export helpers next to the schemas: fileAccessScopeIndex, clampFileAccessScope
(lowers only, never raises), clampSpendCap (rounds to cents, floors at 0,
clamps to a max), normalizeDomainHost, domainHostError.
- Props: status; tools; spend; domains; fileAccess; value? (controlled draft);
onValueChange?(patch, changes); onSave?(patch, changes); onDiscard?; saving?;
disabled?; sections? (default tools, spend, domains, files); maxDomains? (20);
validateDomain?(host); formatMoney?(amount, currency); fileAccessMeta?;
heading?; description?; domainPlaceholder?; emptyState?; errorMessage?;
onRetry?; label?; saveLabel?; className plus the rest of the element props,
ref forwarded to the <section>.
Behavior
- THE DRAFT IS A SPARSE OVERLAY on the saved data. A key exists only while it
differs, so "changed" is derived rather than tracked and the object handed to
onSave IS the patch body. Moving anything back onto its saved value deletes the
key again; an empty object means nothing to save. `tools` is a sparse map
because a grant is per-tool; `domains` is the WHOLE desired list, because a set
has no stable index to patch and a remove-list races with a concurrent editor.
- EVERY CHANGE HAS A DIRECTION. Turning a tool on, raising the cap, adding a host
and moving the file rung up all "expand"; the opposites "restrict". The save
bar reads "6 changes staged · 2 expand what it can do", each row carries an
arrow whose shape (not only colour) shows the direction, and the sr-only status
line says the same thing in words.
- POLICY LIMITS ARE CONSTRAINTS ON THE CONTROL, not validation afterwards. Rungs
above maxScope are struck through, unclickable AND skipped by the arrow keys; a
cap above maxCapUsd is clamped as you type with the ceiling named; a locked
tool, host or scope has no control at all, just a reason. If a SAVED value sits
outside its limit (a ceiling added later), the panel arrives with the reduction
already staged instead of displaying a value the server does not have — the
same clamps run inside the patch builder, so that staged fix is really sent.
- THE MONEY FIELD IS TEXT, NOT type=number: a number input reports an empty
string for "12abc" and swallows the keystroke, so it can never explain itself.
Parse it yourself: strip currency symbols, commas and spaces, accept digits
with at most one dot, round to cents, refuse negatives. Invalid text is KEPT on
screen with aria-invalid and a message while the committed value stays
untouched — a half-typed number must not become a real budget. On blur the
field snaps back to the canonical rendering of the committed value.
- The field keeps its own text in state, but that text is only used while it
still belongs to the value on screen: store { value, text } and compare value
with the current draft cap. If the cap moves for any other reason — Discard, a
save landing, a controlled value — the override stops matching and the
canonical rendering takes over by itself. No effect, no stale text.
- HOSTS ARE A SET. Normalise on entry (lowercase, strip scheme, userinfo, path,
query, port and a trailing dot) so `https://API.Example.com/v1` and
`api.example.com` are one entry; validate the syntax, allow a leading `*.`, and
reject a bare `*` explicitly — an allowlist whose only member is the whole
internet is not an allowlist. A duplicate is not an error to fix: say it is
already allowed and flash a ring on the chip that proves it. Enter AND comma
commit (people paste comma-separated lists), Escape clears the field.
- REMOVING A SAVED HOST IS STAGED, NOT DESTRUCTIVE. The chip stays in place,
struck through, with an undo — a chip that vanishes takes its undo with it.
Removing a host you just added simply deletes it. Re-adding a removed host
makes the set equal the saved one again, so the change leaves the bar by
itself.
- SAVING IS ONE-SHOT. The lock lives in a ref that is read and written
synchronously inside the click handler: several clicks dispatched in one task
all read the same stale state, so a state-only guard would POST the change set
twice. The lock is keyed to a generation that ticks when the baseline moves and
when an in-flight save ends, so it expires by itself — including after a
rejection — instead of leaving a dead button.
- The baseline identity deliberately EXCLUDES usedUsd. The meter ticks on its own
while the panel is open, and a tick must never wipe out an edit in progress.
Limits and locks ARE included: a policy tightened server-side is a new baseline
and the draft has to be re-derived against it (adjust state during render, no
effect, so there is never a frame where the old draft is applied to new data).
- Disabled is READ-ONLY, not invisible: aria-disabled everywhere, the native
attribute nowhere, and the text inputs are readOnly rather than disabled so a
member without the permission can still read, select and copy the budget they
are under. Everything stays in the tab order.
- The four envelope states are first-class branches: loading is a skeleton with
aria-busy and an sr-only status; error is role="alert" with an optional retry
and NO half-open editor; empty says the agent has nothing to scope, which is
not the same as an editor with nothing in it; ready is the panel.
- Timer discipline: the only timer is the chip flash. It is started from an event
handler, cleared before it is restarted, and cleared on unmount.
Rendering & styling
- Semantic tokens only: bg-card / border / text-muted-foreground for chrome,
bg-primary + text-primary-foreground for the switch track and Save, bg-primary
/5 for a selected rung and a newly added chip, text-destructive with
bg-destructive/10 for high risk, an exhausted budget and every "expands"
marker. No hard-coded colours anywhere, so the panel is dark-mode-correct for
free and re-themes with the host project's tokens.
- Semantics per control, never one generic widget: role="switch" for a grant,
role="radiogroup" with roving tabindex, arrow keys, Home/End and
selection-follows-focus for the ordered scale, role="progressbar" with
aria-valuetext for the meter, a real label plus aria-invalid and
aria-describedby for both inputs.
- One persistent sr-only role="status", mounted for the whole ready branch,
states the summary; a live region that appears at the same instant as its own
content is usually not announced at all.
- Long tool identifiers and long hosts use `wrap-anywhere` plus `min-w-0`, not
`break-words`: only overflow-wrap:anywhere lowers the element's min-content
width, which is what actually stops an 80-character MCP tool name from making
the panel wider than its container.
- Every transition is motion-reduce-guarded and the skeleton pulse stops under
prefers-reduced-motion; the chip highlight is a ring rather than an animation,
so it survives there too. cn() merges the consumer's className into the root,
which also spreads the remaining element props and forwards its ref.
Customization levers
- Blocks: `sections` picks which of tools / spend / domains / files render and in
what order; `spend: null` drops the budget block from the data side. A surface
with no network layer simply ships two sections.
- Policy: `maxDomains` caps the allowlist, `validateDomain` adds your own host
rule (internal suffixes only, no wildcards, a blocklist) on top of the syntax
check, and `locked` + `lockedReason` on any tool, host or file scope removes
the control and explains itself.
- Wording: `fileAccessMeta` re-words any rung (label and effect sentence),
`formatMoney` swaps currency rendering or shows credits instead of money, and
`heading` / `description` / `label` / `saveLabel` / `domainPlaceholder` own the
chrome. `emptyState`, `errorMessage` and `onRetry` own the envelopes.
- Control: pass `value` + `onValueChange` to hoist the draft (a wizard, a diff
preview, a confirm dialog that lists the expansions before writing), or omit
them and let the panel keep it. `saving` drives the in-flight state; falling
back to false re-arms the gate after a rejection.
- Copy per row: `description` explains a tool in the user's words, `warning`
replaces the sentence shown while a high-risk tool is being switched ON,
`calls` supplies the usage figure that makes a revoke decidable, and `note` on
a host says why it is on the list.Concepts
- Sparse draft overlay — the panel stores only what differs from the saved permissions, so "changed" is derived instead of tracked and the object it hands to
onSaveis already the patch body. Put a value back where it started and the key disappears again; an empty overlay means there is nothing to save, which is also why the unsaved bar can never linger over a no-op. - Direction of change — every edit either expands what the agent may do or restricts it, and only one of those halves is safe to skim past. Because the file-access rungs and the risk bands are ordered, the panel computes the direction rather than guessing it, and the bar leads with the number that matters: how many of these changes hand the agent more authority.
- Policy ceiling as a constraint, not a complaint — an unreachable rung is struck through, unclickable and skipped by the arrow keys; a cap above the plan limit is clamped while you type. A value that is already outside its limit when the data arrives is shown as a staged correction, never as a number the server does not have — and the same clamps run inside the patch builder, so the correction is really sent.
- Staged removal with undo — taking a host off the allowlist leaves the chip in place, struck through, with a one-click restore. A chip that disappears the moment you click the X takes its undo with it and turns a two-second mistake into a re-typing exercise; here the removal only becomes real when the write does.
- Text field, owned parsing — the money input is text, because a number input reports an empty string for half-typed junk and swallows the keystroke. The panel keeps the characters on screen with
aria-invalidwhile refusing to commit the value, so a stray letter can never become a real budget, and the local text steps aside by itself as soon as the committed value moves for another reason. - One-shot save gate — the guard is a ref read and written synchronously in the click handler, because several clicks dispatched in one task all observe the same stale state. The lock is keyed to a generation that ticks when the baseline moves or an in-flight write ends, so it expires on its own instead of leaving a button that can never be pressed again.
Conversation Tree
The branching map of a whole chat — a flat parentId list nested into a vertical tree, the current path lit as a continuous spine, abandoned forks folded into counted stubs, WAI-ARIA tree keyboard navigation, and four data states.
Latency Breakdown
Per-request phase bars — queue, TTFT, generation and tool time on one shared scale, with a floor that keeps sub-pixel phases visible, a legend that ranks the runs by a phase, and a p50/p95 footer that names the population it came from.