Tool Approval
Ask before an AI agent runs a risky tool: the arguments, the risk, and Allow once, Always allow or Deny.
Installation & usage
npm i @youj/reactFor an app already set up with npx @youj/cli init, which installs the peers (@youj/core, @youj/motion, motion and Base UI) and wires the provider. New app? Start at Installation.
Use one before an agent runs anything people would want to check first: sending an email, deleting rows, deploying, spending money. It sits in the conversation like a Tool Call, and once answered it becomes that call's quiet record.
import { ChatToolApproval } from '@youj/react'
<ChatToolApproval
name="delete_rows"
summary="Delete 1,204 rows from the orders table"
reason="They are test orders older than 90 days."
risk="high"
args={{ table: 'orders', limit: 5000 }}
scope="this project"
onDecision={({ decision, scope, args }) => answer(call.id, decision, scope, args)}
/>It is uncontrolled by default: it starts pending, and a decision resolves it to approved or denied (with approver and the time on the resolved line). To drive it yourself, pass status (pending, approved, denied, expired, running, done or failed) and resolution ({ by, at, reason }); it then stays as it is until you change it. error and duration fill in the failed and done states. An expired request is a state you set, since only your app knows when its time ran out.
risk is low, medium (the default) or high, shown as an icon and a word, and a pending high-risk card also draws its border in danger. scope adds Always allow for ‹scope›; without it there is no such button. onDecision receives { decision: 'allow' | 'deny', scope: 'once' | 'always', args }, where args are the arguments to run with: the originals, or the person's edits.
Arguments show as readable key/value rows. Values over about 140 characters, or with line breaks, fold behind Show more. Keys named like password, token, secret or api_key, and any key in sensitive, are masked until revealed and can't be edited.
Edit turns the editable arguments into fields. Each is parsed back to the type it had (a number must be a number, an object valid JSON), and validate(key, value, args) can refuse a value with a message. Errors appear under the field, and focus jumps to the first one. Escape cancels and returns focus to Edit. Pass editable={false} to hide it.
Pass autoFocus to move focus to the summary when a request appears. The summary is deliberately not a button: Enter on it allows once for low and medium risk, and is never bound on high risk, so a stray keypress can't approve a dangerous action.
Description
A high-risk delete. Focus starts on the summary, not on Allow, and Enter does nothing here. Edit opens the arguments as fields (try limit above 10,000 or a word instead of a number), then Allow once runs with what you changed.
scope adds Always allow for messages to finance@acme.test. onDecision reports scope: 'always', and your app remembers it and skips the card next time. The long body folds behind Show more, and api_key is masked until you reveal it.
Resolved requests as history: approved and done, denied with a reason, expired, and failed after approval. Each is one line with who decided and when; open Arguments to see what was asked.
Code
tool-approval-demo.tsx, the Demo story. The component itself comes from the package.
Reference
Propsname, summary, args… (18)
Props
| Prop | Type | |
|---|---|---|
nameRequired | stringThe tool's name, e.g. "send_email". | – |
icon | ReactNodeThe tool's icon; a terminal by default. | – |
summaryRequired | ReactNodeWhat the agent wants to do, in plain words. | – |
argsRequired | Record<string, unknown>The arguments it would run with. | – |
reason | stringThe agent's reason for asking. | – |
risk | ToolApprovalRisk'low''medium''high'default 'medium' | 'medium' |
scope | stringWhat "Always allow" covers, e.g. "this project". Without it there is no such button. | – |
sensitive | string[]Argument keys to mask until revealed (names like password, token and api_key are always masked). | – |
editable | booleanOffer Edit (default true). default true | true |
validate | (key: string, value: unknown, args: Record<string, unknown>) => string | undefinedRefuse an edited value: return a message. Types are checked first. | – |
status | ToolApprovalStatus'pending''approved''denied''expired''running''done''failed'Controlled. Without it, the card starts pending and a decision resolves it. | – |
defaultStatus | ToolApprovalStatus'pending''approved''denied''expired''running''done''failed'default 'pending' | 'pending' |
resolution | ToolApprovalResolutionWho decided and when, for the resolved line. Uncontrolled, the card fills it in from | – |
approver | stringThe person answering, shown on the resolved line of an uncontrolled card. | – |
onDecision | (decision: ToolApprovalDecision) => void | – |
error | stringWhy it failed. | – |
duration | numberHow long it ran, in ms. | – |
autoFocus | booleanMove focus to the summary when it mounts pending. | – |
Other props pass through: extends ComponentProps<'div'>. | ||
Data attributesdata-status, data-yj-risk
Data attributes
On the root (data-slot="chat-tool-approval"):
data-statusis the current status:pending,approved,denied,expired,running,doneorfailed.data-yj-riskislow,mediumorhigh.
MotionStatus badge, status badge width, check… · reduced motion
Motion
| Moment | Tokens | ||
|---|---|---|---|
| Status labelcrossfades between states (Morph Label) | duration.fastease.standard140ms · cubic-bezier(0.2, 0, 0, 1) | 140mscubic-bezier(0.2, 0, 0, 1) | |
| Status badge widththe box morphs to the new label | spring.snappy0.2s, bounce 0 | 0.2s, bounce 0 | |
| Checkdraws itself in on approved and done | duration.slowease.emphasized340ms · cubic-bezier(0.6, 0, 0.1, 1) | 340mscubic-bezier(0.6, 0, 0.1, 1) | |
| Warningrocks once on failed | duration.deliberateease.standard560ms · cubic-bezier(0.2, 0, 0, 1) | 560mscubic-bezier(0.2, 0, 0, 1) | |
| Note and errorheight and fade under the resolved line (Collapse) | duration.baseease.standard220ms · cubic-bezier(0.2, 0, 0, 1) | 220mscubic-bezier(0.2, 0, 0, 1) | |
Arguments open and closeon a resolved request (yj-collapse) | duration.baseease.standard220ms · cubic-bezier(0.2, 0, 0, 1) | 220mscubic-bezier(0.2, 0, 0, 1) |
Reduced motion
Every swap and fold is instant, and the marks appear already drawn.
Reduced motion
The status badge changes without crossfading, the marks appear without playing, and the arguments open and close at once.
AccessibilityKeyboard, focus, announcements
Accessibility
- The card is a group named by the tool. The risk is an icon and a word ("High risk"), never colour alone, and so is the status.
- Focus lands on the summary, not on Allow, when
autoFocusis set. Tab then reaches Edit, Deny, Always allow and Allow once in that order, with Allow last. - Enter on the focused summary allows once for low and medium risk. On high risk Enter is never bound to approve.
- In Edit, focus moves to the first field, Enter saves, Escape cancels and returns focus to Edit, and an invalid field is
aria-invalidwith its message linked and focus on the first one. - A masked value is read as "Hidden"; its reveal button is named by the key ("Show password") and is a toggle. Show more and Show less set
aria-expanded. - A new request and each status change are announced once: through the conversation's live regions inside a Chat Message List, or its own status region on its own. A high-risk request and a failure are assertive. A request that mounts already resolved, as history, isn't announced.
- All text comes from the
chatToolApprovalLabels, so it follows the provider's locale and reads right to left in Arabic and Hebrew.