Agent Approval

Pro

Human-in-the-loop gate for an action an agent proposes but has not taken. Editable arguments, a risk level that arms destructive actions, a diff of what will change, a cost estimate, an expiry countdown and always-allow memory. Four variants — inline, modal, inbox queue and policy record.

</>sv-agent-approval approvalhuman-in-the-loopagentguardrailpermissionrisk
import { SvAgentApprovalComponent } from '@styloviz-pro/agent-approval';

Demo & usage

Basic usage

Pro

Usage example — open the gallery for the full interactive demo.

Pro

An interactive demo for this component lives in the gallery.

Open live demo

More examples

Read the corrected arguments

Pro

The decision carries the operator’s edits. Reading request.args instead discards them.

A queue that piled up

Pro

Agents run unattended, so approvals arrive while nobody is looking.

Blocking the irreversible

Pro

Composes over sv-modal — backdrop dismissal off, focus trapped, closes on decision.

Risk that follows the edit

Pro

An editable argument can turn a low-risk call into a destructive one. riskFor reclassifies it.

Remembering a safe tool

Pro

Always-allow reports the tool so a policy layer can persist it. Withheld above medium risk.

When to use

  • Gating any action an agent takes that spends money, emails a customer, or writes to production.
  • Giving an operator a chance to correct a nearly-right action instead of denying and re-prompting.
  • Collecting an audit trail of who approved what, with what arguments, and under which policy.
  • Queuing approvals for agents that run unattended, so nothing silently blocks or silently proceeds.

Async states

  • loading
  • error
  • retry

Inputs

NameTypeDefaultDescription
requestAgentApprovalRequest | nullnullThe action awaiting a decision. Ignored when `requests` is non-empty. THE EXPIRY COUNTDOWN IS A COURTESY, NEVER THE ENFORCEMENT POINT. It runs on `Date.now()` in the operator's browser — a skewed laptop clock, a tab that slept or a deliberately-set system time all move it. Revalidate on the server, where the action runs: that the request has not expired, that it is still pending, and that the decider is authorized to approve it. A host that treats "the button went grey" as authorization having lapsed has put its access control in the client.
requestsreadonly AgentApprovalRequest[][]A queue of requests, for the `inbox` variant. Takes precedence.
riskForAgentApprovalRiskFn | nullnullReclassify a request's risk from its arguments as the operator edits them. Without this, `request.risk` is fixed at the moment the agent asked, and the whole point of the component is that the operator can change the call. A reviewer can be shown `delete_rows(table: 'staging')` chipped low risk, change `table` to `users`, and approve a destructive call on one click — the badge, the always-allow offer and the two-step arm all still describe the call that was requested rather than the one about to run. Supply it and every one of those follows the edited arguments. Only the host knows which values are dangerous, so nothing is inferred here.
variantAgentApprovalVariant'inline'Presentation preset.
openboolean (two-way)falseTwo-way open state for the `modal` variant.
allowRememberbooleantrueOffer "always allow this tool". Never offered above medium risk.
showChangesbooleantrueShow the before/after diff when the request carries one.
showCostbooleantrueShow the cost estimate when the request carries one.
approveLabelstring'Approve'Label on the approve button.
denyLabelstring'Deny'Label on the deny button.
ariaLabelstring'Action approval'Accessible label for the gate.
decidingbooleanfalseTrue while the decision is being sent. Both buttons lock — approving twice because the first click looked like it did nothing is exactly the mistake an approval dialog must not allow.
errorSvDataError | string | nullnullFailure recording the decision. Shown in place of nothing else changing, so the user can see the request did not land and decide again.
loadingbooleanfalseTrue while the `inbox` queue is being fetched. The inbox previously had one zero state — "Nothing awaiting approval" — and showed it while the fetch was still in flight and again when the fetch had failed. On an approvals queue those three situations could not be further apart: nothing to do, not known yet, and *something is waiting and you cannot see it*. Only the last one is urgent, and it looked like the first.
loadErrorSvDataError | string | nullnullFailure **loading** the queue, as opposed to `error`, which is a failure recording a decision. Both can be live at once — a decision can fail and a later refetch can fail too — so they are separate inputs rather than one.
decidedBystring | nullnullWho is deciding, for the receipt. THE COMPONENT HAS NO IDEA WHO IS LOGGED IN, and a receipt that attributed a decision to "operator" would be worse than one admitting it does not know — so leave this unset and `decidedBy` is `null`.

Outputs

EventPayload typeDescription
retryLoadvoidThe user asked to reload the queue after a load failure.
decidedAgentApprovalResultThe operator decided. Read `args` from the payload, not from the request.
argEditedAgentApprovalArgEditAn editable argument changed.
riskReclassifiedAgentApprovalRiskChangeAn edit moved a request into a different risk class, per `riskFor`. Worth logging: it is the operator turning one call into another, and the audit trail is thinner without it.
alwaysAllowChangedAlwaysAllowChangeAlways-allow was ticked or unticked.
expiredAgentApprovalRequestThe countdown reached zero without a decision.
receiptAgentApprovalReceiptThe immutable record of a decision, for the host's audit log. SEPARATE FROM `decided`, WHICH IS THE INSTRUCTION. `decided` says what to do; this says what happened, frozen, with who and when and through which surface. A host that logged the instruction would be logging a request, not a decision.
openChangebooleanEmits the new `open` whenever it changes — the output half of `[(open)]`.

Wire it to your API

This component is headless about transport: it renders what you give it and reports what the user asks for. Nothing below requires @styloviz/data — drive it from whatever you already use.

Template

Pro

Bound to the names this component actually exposes.

Host class

Pro

Drive the three async inputs from your own fetch.

  • This component does not emit a query, so it renders what you give it. Fetch on your own trigger and bind `requests`, `loading` and `error`.
  • The load-level retry is `retryLoad` here, because `retry` already means a per-item action on this component.
  • Normalize failures with `toSvDataError()` from `@styloviz/core`. It derives `retryable` from the status, so a 404 does not get a "Try again" button that can only fail again.

Full walkthrough: Connecting to a backend.

Customization & theming

  • `(receipt)` is the immutable record of a decision, emitted beside `(decided)`: who (`[decidedBy]` — the component has no idea who is logged in, so it is `null` rather than a guess), when, through which variant, the risk class it was approved under, the policy that routed it, and **the arguments as executed**. Frozen, arguments included: 'immutable' is a property of the object rather than a promise in a doc comment. `decided` is the instruction; this is what happened.
  • `inline`, `modal` and `inbox` are three shapes for **one act**, and the receipt is identical apart from `through` — an audit trail whose contents depend on which shape the operator happened to be looking at cannot be reconciled with anything. One function (`svApprovalReceipt`) builds it for every variant, which is what keeps that true.
  • `policySource` on a request answers 'why am I being asked?' in the dialog, in every variant. An operator who cannot tell a genuine escalation from a misconfigured threshold learns to approve everything, and the rule’s own name is the only thing that tells them apart.
  • The expiry countdown is a **courtesy, never the enforcement point** — it runs on `Date.now()` in the operator’s browser, which a skewed laptop clock, a slept tab or a deliberately-set system time all move. The component says so beside the timer and folds it into the timer’s `aria-label`. A host that treats 'the button went grey' as authorization having lapsed has put its access control in the client, where anybody can win an argument with it.
  • Redaction, from the pack-wide policy (`X-2`): argument values matching `provideSvAiPrivacy({ patterns })` are masked on screen — and a masked argument becomes READ-ONLY, because an input showing `[API KEY]` submits `[API KEY]`, so the operator would edit the mask and approve it as the real argument. Where the policy sets `reveal: true`, a Reveal control restores both the value and the input, and hides again. The decision receipt and the risk classifier always see the REAL arguments.
  • `variant` chooses the shape: `inline` in a thread, `modal` for the irreversible, `inbox` for a queue, `policy` for a read-only record of an auto-approval.
  • `approveLabel` and `denyLabel` carry domain wording — "Issue refund" reads better than "Approve".
  • `showChanges` and `showCost` hide sections a given request does not need.
  • `allowRemember` turns always-allow off globally; it is withheld automatically above medium risk regardless.
  • `riskFor` owns the risk classification. Editing an argument re-runs it, and a class change re-arms the confirmation, emits `riskReclassified`, and lands in `decided.risk` — the class approved under, not the one requested.
  • Each argument’s `description` renders as help text under the field, and `policyReason` explains an auto-approval.
  • The `RISK_ORDER`, `msRemaining`, `isExpired` and `formatCountdown` helpers are exported for sorting or thresholding a queue yourself.

Best practices

  • Do: Read the argument values from the `decided` payload, never from the original request — that is where an operator’s correction lives.
  • Do: Set `expiresAt` on anything time-sensitive. A stale approval is how agents take actions nobody wanted any more.
  • Do: Mark an argument `editable` when a plausible-but-wrong value is the likely failure. An amount or a recipient usually qualifies; an id usually does not.
  • Do: Pass `riskFor` alongside any `editable` argument that can change what the call destroys. Without it the badge, the two-step arm and the always-allow offer all describe the call as *requested*, not the one about to run.
  • Do: Fill in `changes` — an operator approving a diff they can see makes better decisions than one approving a function name.
  • Don't: Do not use it for a yes/no on an action already decided — that is Confirm Dialog. This exists for actions that are still proposals.
  • Don't: Do not raise `risk` to make something look important. It changes behaviour: high and critical arm behind a second press and lose always-allow entirely.
  • Don't: Do not persist always-allow without scoping it — the payload names the tool, but the policy layer should bound it by agent, environment and amount.

Accessibility

  • The countdown is `role="timer"` with a spoken label, so remaining time is available without watching it tick.
  • Arming a high-risk action surfaces a `role="alert"` explaining that it cannot be undone, before the confirm is possible.
  • Editable arguments are real `input` elements with `aria-label`, reachable and correctable by keyboard.
  • The inbox pending count is `role="status"`, so a queue that grows while the operator is elsewhere is announced.
  • Status is never colour-only — approved, denied and expired each carry a distinct glyph and a text label.
  • The modal variant inherits sv-modal’s focus trap, Escape handling and scroll lock rather than reimplementing them.

Feedback on Agent Approval

Spotted a gap in this page, or something that didn't work as described? Comments are GitHub Discussions in the public styloviz/community repo, so posting needs a GitHub account.

Loads giscus.app and GitHub — nothing is fetched until you click.