Pro

Tool approval

Pro component

The card an agent shows when a tool call needs your OK. Says what will happen in words, weighs sends, deletes and payments, and settles into one line once you answer.

Why it works this way: Ask before the agent acts.

Copper moving Saturday supper to 7:30

Move Saturday to 7:30, tell everyone, and sort out Ben’s refund.

I’ve updated the booking. Three things need your OK before I go on.

Copper needs your OK

Send a message to 12 guests

To everyone booked for Saturday supper: “We’re starting at 7:30 instead of 7. Same table, 14 Alder Lane.”

Once it’s sent, it can’t be taken back.

postbox · send_message
send_message arguments
{  "to": "booking:saturday-supper",  "recipients": 12,  "from": "mara@saturdaysupper.example.com",  "subject": "Saturday supper starts at 7:30",  "body": "Hi all, we’re starting at 7:30 instead of 7. Same table, 14 Alder Lane. Reply here if that doesn’t work. Mara"}

Enter denies and sends this to Copper.

  • Refund Ben Ito $48.00Spends money
  • Add 4 items to the shopping list

Waiting for you.

Ledger takes a moment to answer and the first refund fails on purpose, so you can see the busy button and the error.

Reads and deletes

Copper needs your OK

Read Saturday’s bookings

Names, seats and dietary notes for 12 guests.
acme · get_bookings
get_bookings arguments
{  "event": "saturday-supper",  "fields": [    "name",    "seats",    "dietary"  ]}

Enter denies and sends this to Copper.

Copper needs your OK

Delete the draft menu “Autumn v2”

An older draft nobody has seen. The menu you shared with guests stays.

Deleted for good. It can’t be undone.

acme · delete_menu
delete_menu arguments
{  "menu": "autumn-v2",  "status": "draft"}

Enter denies and sends this to Copper.

Settled, from earlier in the conversation

Add 4 items to the shopping list

acme · update_list
Lamb shoulder 2.8 kg, white beans, sourdough and lemons. The total goes from $212.00 to $236.00.
acme · update_list
update_list arguments
{  "list": 212,  "add": [    {      "item": "Lamb shoulder",      "quantity": 2.8,      "unit": "kg"    },    {      "item": "White beans",      "quantity": 5,      "unit": "jar"    },    {      "item": "Sourdough",      "quantity": 3,      "unit": "loaf"    },    {      "item": "Lemons",      "quantity": 6    }  ]}

Send a message to 12 guests

postbox · send_message
To everyone booked for Saturday supper: “We’re starting at 7:30 instead of 7. Same table, 14 Alder Lane.”

Once it’s sent, it can’t be taken back.

postbox · send_message
send_message arguments
{  "to": "booking:saturday-supper",  "recipients": 12,  "from": "mara@saturdaysupper.example.com",  "subject": "Saturday supper starts at 7:30",  "body": "Hi all, we’re starting at 7:30 instead of 7. Same table, 14 Alder Lane. Reply here if that doesn’t work. Mara"}

You told Copper: “not yet, I’m still checking with Ben”

Refund Ben Ito $48.00

ledger · create_refund
Ben cancelled 3 days ahead. The policy gives a full refund for his 2 seats.

Goes back to Ben’s card. A refund can’t be reversed.

ledger · create_refund
create_refund arguments
{  "booking": "BK-2207",  "guest": "Ben Ito",  "amount": 48,  "currency": "USD",  "reason": "cancelled_72h"}

Installation

This is a Pro component. UI Polish Pro costs $149 one time and includes every Pro component, block and template, now and later. Add your license key to your MCP connection or components.json, then install it either way below.

With UI Polish connected, ask your agent:

Add the UI Polish tool approval to this project.

Or with the shadcn CLI:

npx shadcn@latest add @uipolish/tool-approval
Package@base-ui/react
Brings inAnimated size, Button, Code block, Icons, Text field, Text, Checkbox

The card an agent puts in the conversation when it wants to run a tool that needs the person's OK. Minimal on purpose: the action as a title, a short summary in plain words, and, only for sends, deletes and spends, one line on what can't be undone. The tool's name and the raw call sit behind "Details". Then a "Don't ask again in this chat" box, and two equal buttons, Deny and Allow. Once the person answers, the card settles into one quiet line ("Allowed once · send_message") that opens again to show what was approved.

TSX
<ToolApproval  agent="Copper"  request={{    id: call.id,    title: "Send a message to 12 guests",    server: "postbox",    tool: "send_message",    effect: "send",    summary: "To everyone booked for Saturday supper: “We’re starting at 7:30 instead of 7.”",    arguments: call.input,  }}  status={status} // "waiting" | "allowed" | "denied" | "expired"  scope={scope}  reason={reason}  onDecide={({ decision, scope, reason }) => answer(call.id, decision, scope, reason)}/>

It goes in the thread where the agent stopped, between the reply before it and whatever the agent does next. Pair it with message-thread and tool-steps.

When to use it

  • An agent about to call an MCP tool, or any function, that the person has said should ask first: sending, paying, deleting, changing shared data.
  • Anything that leaves the person's hands: a message to guests, a refund, a post. The person should read what goes out before it does.

When not to

  • Connecting the agent to a server in the first place. That's a standing grant of scopes: use authorize.
  • Calls the person has already allowed. Don't ask twice; show them in tool-steps as they run.
  • Questions the agent has for the person ("Which Saturday?"). Those are messages; let the person answer in the prompt.

The request

FieldWhat to put there
titleThe action as a verb phrase, with the count or amount: "Refund Ben Ito $48.00", not "create_refund".
server, toolAs the agent knows them. Shown in mono under Details, so the person can match it to settings later.
effectWhat the call does: read, change, send, delete or spend. Decides the note and what "Don't ask again" can remember.
summaryOne or two sentences: who, what, how much. Quote what will be sent.
consequenceOptional. Replaces the effect's note when you know more: "Goes back to Ben’s card. A refund can’t be reversed."
argumentsThe raw call. Objects are shown as indented JSON in a code block behind "Details".

Effects. Reads and changes say nothing extra: the title is enough. Sends, deletes and spends get one line with a warning mark saying what can't be taken back, and a delete's Allow is the danger button. There are no badges: the words carry it.

effectNote"Don't ask again" remembers
readnoneThis chat
changenoneThis chat
send"Once it’s sent, it can’t be taken back."This chat
delete"Deleted for good. It can’t be undone." Allow is the danger buttonThis chat
spend"The money moves as soon as you allow it."This chat

States

status is controlled. Your agent owns the call and decides when it stops waiting.

StatusWhat you see
waitingThe card: "Copper needs your OK", the title, the summary, the note for sends, deletes and spends, Details, then "Don't ask again in this chat" and Tell Copper why on one line, then Deny and Allow
waiting, onDecide returned a promiseThe chosen button shimmers its label; other presses are ignored until it settles
waiting, that promise rejected"Copper didn’t get your answer. Try again." in red above the buttons; the buttons work again
allowedA check and "Allowed once · send_message", "Allowed for this conversation · …" or "Always allowed · …", from scope
deniedA cross and "Denied · send_message", or "Denied · you said: not yet" with a reason
expiredA dash and "Copper stopped waiting after 5 minutes · send_message", from waited

The settled line is a button. It opens the request below it: title, tool, summary, note, Details, and for a denial "You told Copper: “not yet”".

Deciding. onDecide gets { id, decision, scope, reason }. decision is "allow" or "deny". scope is "once", "conversation" or "tool"; a denial is always "once". reason is set only when the person typed one. Set status (and scope or reason) from it right away: the agent is waiting on the answer, not on your server. If sending the answer can fail, return the promise and the card shows busy and the error for you.

Don't ask again. A checkbox, not a third button, so the answer is always Deny or Allow. Ticked, Allow decides with scope: "conversation" ("Don't ask again in this chat"); unticked, with scope: "once". If remember offers only "tool", the box reads "Don't ask again for send_message" and decides with that. remember={[]} leaves the box out.

Tell Copper why. A quiet link on the box's line, at its end, opens a short field (200 characters). Enter or Deny sends the denial with the text as reason; Escape closes it. Denying never needs the field: Deny is one press. Turn it off with askReason={false}.

Several at once. When the agent asks for more than one call, show the first as the card and pass the rest as queue. "2 more waiting" opens them as rows, each with Deny and Allow (sends, deletes and spends say so under the title); deciding one calls onDecide with that row's id. Render answered requests as their own settled ToolApproval, in order, and the first one still waiting with the rest as its queue:

TSX
const waiting = approvals.filter((a) => a.status === "waiting");{approvals.map((a) =>  a.status !== "waiting" || a === waiting[0] ? (    <ToolApproval      key={a.request.id}      agent="Copper"      request={a.request}      status={a.status}      scope={a.scope}      reason={a.reason}      queue={a === waiting[0] ? waiting.slice(1).map((w) => w.request) : undefined}      onDecide={answer}    />  ) : null,)}

Keep key on the request's id, so a card that's answered becomes its own line instead of being replaced.

Expired. When your agent gives up on an answer, set status="expired" and waited to how long it waited, in words. The card settles like any other answer. To try again, have the agent ask again: that's a new request.

Motion

WhatDurationEasing
Card arrives: its space opens, then it fades in and rises 4px, 60ms later--ui-dur-panel (280ms) height, --ui-dur-base (200ms) fade--ui-ease-expand, --ui-ease-enter
Card settles: the line fades in and rises 4px as the height eases to one line--ui-dur-base (200ms) closing, line 60ms later--ui-ease-collapse, --ui-ease-enter, via animated size
Details, reason, queue and a reopened line open--ui-dur-panel height, --ui-dur-fast fade 60ms later--ui-ease-expand, --ui-ease-enter
They close--ui-dur-base height, --ui-dur-instant fade--ui-ease-collapse, --ui-ease-exit
Chevron turns a quarter turn, right to down--ui-dur-base (200ms)--ui-ease-glide
A queued row leaves; the rest close the gapinstant; --ui-dur-base heightanimated size
Busy button labelshimmer, --ui-dur-sweep per passlinear, from the button
Error appears: fade and 4px rise--ui-dur-fast (160ms)--ui-ease-enter

Every height change eases, so the thread under the card moves with it and never jumps. A card that's already settled when it mounts, from history, shows as its line with no motion.

Under prefers-reduced-motion: reduce the card and the line appear without rising or growing, panels open and close at once, and the height of the card changes in one step.

Accessibility

  • The card is a group named by "Copper needs your OK" and its title.
  • Focus. By default the card takes focus only when nothing else has it (focus is on the page body): it lands on the title, not on a button, so a stray Enter allows nothing. When the person is typing in the prompt, focus stays there. Pass autoFocus to always take it, or autoFocus={false} to never.
  • When the card settles and focus was in it (or nowhere), focus moves to the settled line, since the buttons it was on are gone.
  • A polite role="status" region says "Copper needs your OK: Send a message to 12 guests." when the card appears and "Allowed once: send_message." when it's answered. Inside message-thread the log also reads new content, so it may be heard twice; that's the safe side.
  • What can't be undone is said in words; the warning color only repeats it.
  • "Don't ask again" is a real checkbox, so its state is read with it.
  • Details, Tell Copper why, the queue and the settled line are buttons with aria-expanded and aria-controls. Closed panels are inert.
  • Each queued row's Deny and Allow are described by that row's title, so a screen reader hears "Allow, Refund Ben Ito $48.00".
  • The arguments block is focusable when it scrolls.
  • Buttons and the disclosures get a 40px tall hit area on touch screens.
  • Deny and Allow stay side by side at every width, each half the card; the box and Tell Copper why wrap onto two lines when they don't fit. Tab order follows the reading order.

Before / after

Before: a browser confirm with the tool's name and a JSON dump, no idea what leaves the person's hands, and nothing left in the thread afterwards.

TSX
if (window.confirm(`Allow ${call.name}?\n${JSON.stringify(call.input)}`)) run(call);

After: the action in words, what can't be undone, the JSON one click away, a "don't ask again" box, a reason Copper can use, and a line in the thread that says what was decided.

TSX
<ToolApproval  agent="Copper"  request={{    id: call.id,    title: "Refund Ben Ito $48.00",    server: "ledger",    tool: "create_refund",    effect: "spend",    summary: "Ben cancelled 3 days ahead. The policy gives a full refund for his 2 seats.",    consequence: "Goes back to Ben’s card. A refund can’t be reversed.",    arguments: call.input,  }}  status={status}  scope={scope}  reason={reason}  waited="5 minutes"  onDecide={async (answer) => {    await agent.respond(call.id, answer); // shows busy; a rejection shows the error    setAnswer(answer);  }}/>