118 / Agent UI
Tool Approval
Asks before an agent runs a tool. Shows what the call will do, offers Allow once, Always allow and Deny, and leaves a one-line record of the answer.
The agent is cleaning up after the 0.8 release.
Allow read_file to run?
- path
- src/app/settings/page.tsx
- lines
- 1-120
{
"path": "src/app/settings/page.tsx",
"lines": "1-120"
}Allow delete_branch to run?
This can make changes that are hard to undo.
Deletes release/0.7 on origin. Anyone who has not pulled it loses the branch.
{
"branch": "release/0.7",
"remote": "origin",
"force": false
}Press and hold to allow. With a keyboard, hold Enter or Space.
"use client"
import * as React from "react"
import { RestartButton } from "@/components/demos/restart-button"
import { ToolCall } from "mischief-ui/tool-call"
import {
ToolApproval,
type ToolApprovalDecision,
} from "mischief-ui/tool-approval"
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
const readInput = { path: "src/app/settings/page.tsx", lines: "1-120" }
const deleteInput = { branch: "release/0.7", remote: "origin", force: false }
export function ToolApprovalDemo() {
const [run, setRun] = React.useState(0)
const [read, setRead] = React.useState<ToolApprovalDecision | null>(null)
const [removal, setRemoval] = React.useState<ToolApprovalDecision | null>(
null
)
return (
<div className="grid w-full max-w-md gap-3" key={run}>
<p className="text-muted-foreground text-sm">
The agent is cleaning up after the 0.8 release.
</p>
<ToolApproval
input={readInput}
name="read_file"
onDecision={async (decision) => {
await wait(500)
setRead(decision)
}}
/>
{read && read !== "deny" ? (
<ToolCall
durationMs={84}
input={readInput}
name="read_file"
output={<p>120 lines read.</p>}
status="success"
/>
) : null}
<ToolApproval
destructive
input={deleteInput}
name="delete_branch"
summary={
<p>
Deletes <code>release/0.7</code> on origin. Anyone who has not
pulled it loses the branch.
</p>
}
onDecision={async (decision) => {
await wait(500)
setRemoval(decision)
}}
/>
{removal && removal !== "deny" ? (
<ToolCall
durationMs={1320}
input={deleteInput}
name="delete_branch"
output={<p>Deleted release/0.7 on origin.</p>}
status="success"
/>
) : null}
{read && removal ? (
<div className="flex justify-center pt-2">
<RestartButton
onClick={() => {
setRead(null)
setRemoval(null)
setRun((value) => value + 1)
}}
/>
</div>
) : null}
</div>
)
}Installation
Copy the source into your project, or keep it behind a package.
npx shadcn@latest add Tinkerers-Labs/mischief-ui/tool-approvalimport { ToolApproval } from "mischief-ui/tool-approval"Or paste it in yourself. The source imports the shared cn helper from @/lib/utils, so point that at your own copy.
"use client" import * as React from "react"import { Ban, Check, ChevronRight, Loader, ShieldCheck } from "lucide-react" import { cn } from "@/lib/utils" export type ToolApprovalDecision = "allow-once" | "allow-always" | "deny" export type ToolApprovalProps = Omit< React.HTMLAttributes<HTMLDivElement>, "children" | "input"> & { /** The tool the agent wants to run, such as `delete_branch`. */Usage
export function Permission({ call }: { call: PendingCall }) {
return (
<ToolApproval
name={call.tool}
input={call.arguments}
destructive={call.tool === "delete_branch"}
onDecision={(decision) => agent.respond(call.id, decision)}
/>
)
}Three answers, then a record
onDecision receives "allow-once", "allow-always" or "deny". Remembering an Always allow is your job; the card only reports it. When the callback returns a promise the card shows it is sending, disables every choice, and waits. A rejection puts the choices back with a short error, so a dropped request never reads as an answer.
Once the answer lands the card does not vanish. It shrinks to one line, such as Allowed once read_file, so the transcript still says who let what run. Focus moves to that line rather than falling to the page. Pass decision to draw the same line for a card restored from history.
Saying what the call will do
A plain object input is listed key by key, and the raw input sits behind a disclosure for anyone who wants the exact payload. When keys are not enough, pass summary with a sentence of your own.
<ToolApproval
name="delete_branch"
input={{ branch: "release/0.7", remote: "origin" }}
summary={<p>Deletes release/0.7 on origin.</p>}
destructive
onDecision={respond}
/>Destructive calls
destructive turns both allow buttons into press and hold. Letting go early cancels. From the keyboard, holding Enter or Space does the same, and a quick press does nothing, so a stray Enter cannot delete a branch. Assistive technology that activates a button directly, with no key or pointer behind it, gets a two-step confirmation instead: the first activation asks, the second allows. Deny always takes one press.
API
namestringThe tool the agent wants to run.inputunknownThe raw arguments. A plain object is also listed key by key.summaryReactNodeSays what the call will do, in place of the key list.onDecision(decision: ToolApprovalDecision) => void | Promise<void>Receives the answer. A rejection puts the choices back.destructivebooleanAllowing takes a press and hold. Defaults to false.decisionToolApprovalDecision | nullAn earlier answer, drawn as the resolved line.allowAlwaysbooleanOffer Always allow. Defaults to true.holdDurationnumberMilliseconds a destructive allow is held. Defaults to 1200.decision (ToolApprovalDecision)"allow-once" | "allow-always" | "deny"The three answers.classNamestringClasses for the card and the resolved line....divPropsHTMLAttributesNative div attributes.Accessibility
The card is a group named by its question, Allow read_file to run?, and described by the argument list or summary. Every choice is a native button. Destructive allows are described by the hold instruction, announce when holding has started, and offer a two-step confirmation to assistive technology that activates without a key press. The raw input is a disclosure with aria-expanded. The resolved line is a status that takes focus after a decision, so keyboard users are not dropped to the top of the page. Reduced motion stops the sending spinner and the press scale; the hold fill still tracks the press because it is the only measure of how long is left.