Skip to content

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/react

For 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.

Reference

Propsname, summary, args… (18)

Props

ChatToolApproval props
PropType
nameRequiredstring

The tool's name, e.g. "send_email".

–
iconReactNode

The tool's icon; a terminal by default.

–
summaryRequiredReactNode

What the agent wants to do, in plain words.

–
argsRequiredRecord<string, unknown>

The arguments it would run with.

–
reasonstring

The agent's reason for asking.

–
riskToolApprovalRisk
'low''medium''high'

default 'medium'

'medium'
scopestring

What "Always allow" covers, e.g. "this project". Without it there is no such button.

–
sensitivestring[]

Argument keys to mask until revealed (names like password, token and api_key are always masked).

–
editableboolean

Offer Edit (default true).

default true

true
validate(key: string, value: unknown, args: Record<string, unknown>) => string | undefined

Refuse an edited value: return a message. Types are checked first.

–
statusToolApprovalStatus
'pending''approved''denied''expired''running''done''failed'

Controlled. Without it, the card starts pending and a decision resolves it.

–
defaultStatusToolApprovalStatus
'pending''approved''denied''expired''running''done''failed'

default 'pending'

'pending'
resolutionToolApprovalResolution

Who decided and when, for the resolved line. Uncontrolled, the card fills it in from approver.

–
approverstring

The person answering, shown on the resolved line of an uncontrolled card.

–
onDecision(decision: ToolApprovalDecision) => void–
errorstring

Why it failed.

–
durationnumber

How long it ran, in ms.

–
autoFocusboolean

Move 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-status is the current status: pending, approved, denied, expired, running, done or failed.
  • data-yj-risk is low, medium or high.
MotionStatus badge, status badge width, check… · reduced motion

Motion

Motion tokens
MomentTokens
Status labelcrossfades between states (Morph Label)
duration.fastease.standard

140ms · cubic-bezier(0.2, 0, 0, 1)

140mscubic-bezier(0.2, 0, 0, 1)
Status badge widththe box morphs to the new label
spring.snappy

0.2s, bounce 0

0.2s, bounce 0
Checkdraws itself in on approved and done
duration.slowease.emphasized

340ms · cubic-bezier(0.6, 0, 0.1, 1)

340mscubic-bezier(0.6, 0, 0.1, 1)
Warningrocks once on failed
duration.deliberateease.standard

560ms · 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.standard

220ms · 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.standard

220ms · 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 autoFocus is 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-invalid with 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.