Elements · Tool use
Permission grant
Granting a capability rather than approving one action, with the reach spelled out.
Installation
npx shadcn@latest add "@assistant-ui/elements-permission-grant"First time? Set up a runtime
Runtime components read their state from an assistant-ui runtime. Add one to an existing project:
npx assistant-ui@latest initThen wrap your app in a runtime provider:
import { AssistantRuntimeProvider } from "@assistant-ui/react";
import { useChatRuntime, AssistantChatTransport } from "@assistant-ui/ai-sdk";
export default function App() {
const runtime = useChatRuntime({
transport: new AssistantChatTransport({ api: "/api/chat" }),
});
return (
<AssistantRuntimeProvider runtime={runtime}>
{/* your components */}
</AssistantRuntimeProvider>
);
}The installation guide covers new projects, templates, and API routes.
npx shadcn@latest add "@assistant-ui/elements-permission-grant"Props-driven: no runtime or provider required.
A gate for a capability, not a single action: what's being asked for, who's asking, and exactly what saying yes would let happen, before the user commits. With a runtime this gate comes from the tool call's approval state; standalone you hold the decision yourself.
Getting started
A runtime whose backend declares tool approval gates (an AG-UI or ACP agent, for example) attaches an approval object to the tool-call part. Read it and answer it from the same renderer that shows the tool's normal result.
Read the approval gate from the tool call
"use client";
import type { ToolApprovalResponse, ToolCallMessagePartProps } from "@assistant-ui/react";
function ShellApprovalGate({ args, approval, respondToApproval }: ToolCallMessagePartProps<{ command: string }, string>) {
if (!approval || approval.approved !== undefined || approval.resolution !== undefined) {
return null;
}
// A refused response rejects and a precondition (an unknown option, an answer
// the request does not take) throws, so `try`/`await` covers both and the
// controls stay actionable.
const answer = async (response: ToolApprovalResponse) => {
try {
await respondToApproval(response);
} catch (failure) {
console.error(failure);
}
};
return (
<div className="flex flex-col gap-3.5 rounded-[20px] border p-4">
<p className="text-sm font-medium">Run: {args.command}</p>
<div className="flex flex-wrap gap-2">
{(approval.options ?? []).map((option) => (
<button key={option.id} type="button" onClick={() => void answer({ optionId: option.id })}>
{option.label ?? option.kind}
</button>
))}
</div>
</div>
);
}approval.approved === undefined and no resolution marks the gate as still open; once the host records a decision, the part re-renders with approval.approved set and this returns null.
Answer with respondToApproval
Each declared option carries its own grants, the patterns or rules choosing it would persist. Show them before the user commits, not after:
{(approval.options ?? []).map((option) => (
<div key={option.id} className="flex flex-col gap-1">
<button type="button" onClick={() => void answer({ optionId: option.id })}>
{option.label ?? option.kind}
</button>
{option.grants?.map((grant) => <span key={grant}>{grant}</span>)}
</div>
))}Standalone, the element is fully controlled: you own the decision and what it grants.
Hold the grant decision
"use client";
import { useState } from "react";
import { PermissionGrant, type GrantScope } from "@/components/assistant-ui/elements/permission-grant";
export function ShellApproval() {
const [scope, setScope] = useState<GrantScope | "pending">("pending");
return (
<PermissionGrant
capability="Run shell commands"
requester="deploy-agent"
reach={["./workspace/**", "npm run *"]}
scope={scope}
onGrant={setScope}
/>
);
}Resolve it from onGrant
onGrant fires once, with whichever of "denied", "session", or "always" the user picked. Setting scope from it is enough; the card renders its buttons only while scope is "pending".
Anatomy
<div data-slot="permission-grant">
<div>{/* icon, capability, "requested by {requester}" */}</div>
<div>{/* "this grants", then one line per reach item */}</div>
<div>
{/* pending: Deny / This session / Always */}
{/* resolved: one badge, keyed on scope so it fades in on change */}
</div>
</div>The three pending buttons and the resolved badge are mutually exclusive: once scope is anything but "pending", the buttons are gone and the card reads either "denied" or "granted · {scope}".
Examples
Confirm a persistent grant
An option with confirm: true (or a { title, description } object) is a two-step commit: show its own confirmation before calling respondToApproval, matching what option.grants already promised.
const [confirmingId, setConfirmingId] = useState<string | null>(null);
const confirming = approval.options?.find((o) => o.id === confirmingId);
if (confirming) {
return (
<div>
<p>{typeof confirming.confirm === "object" ? confirming.confirm.title : `${confirming.label}?`}</p>
<button onClick={() => void answer({ optionId: confirming.id })}>Confirm</button>
<button onClick={() => setConfirmingId(null)}>Back</button>
</div>
);
}Plain allow or deny
approval.options is optional. When it's absent, the gate is a plain yes or no: respond with approved instead of an optionId.
<button type="button" onClick={() => void answer({ approved: true })}>
Allow
</button>
<button type="button" onClick={() => void answer({ approved: false })}>
Deny
</button>Restyle the card
Both lanes take className on the root. The root uses the shared paper surface, the "this grants" label uses mono, the resolved badge uses field and mono together, and the Always button uses inkButton, so retargeting those in surfaces.tsx restyles this card along with everything else built on them.
API reference
Approval gate
| Field | Type | Description |
|---|---|---|
approval.id | string | Identifier for this approval request. |
approval.approved | boolean | undefined | undefined while the gate is still open. |
approval.options | readonly ToolApprovalOption[] | undefined | Available decisions; absent means a plain allow or deny. |
approval.optionId | string | undefined | The option chosen at resolution, when options were present. |
approval.resolution | "cancelled" | "expired" | undefined | Set by the host when the request ended without a user decision. |
respondToApproval(response) | (response: ToolApprovalResponse) => Promise<void> | Answers the gate. Accepts { approved }, { optionId }, both, or { text } when the request takes a free-form answer. Only valid while approved and resolution are both unset. |
ToolApprovalOption
| Field | Type | Description |
|---|---|---|
id | string | Host-defined identifier. |
kind | "allow-once" | "allow-always" | "reject-once" | "reject-always" or a custom string | Known kinds resolve approved automatically; a custom kind always needs an explicit approved value. |
label | string | Optional; renderers default per kind when it's absent. |
grants | readonly string[] | Patterns or rules this option would persist, supplied by the host. |
confirm | boolean | { title?, description? } | Opt-in confirmation step before this option resolves. |
PermissionGrant
| Prop | Type | Default | Description |
|---|---|---|---|
capability | string | required | What's being granted. |
requester | string | required | Who's asking for it. |
reach | readonly string[] | required | The patterns or rules this grant would cover, shown before the user commits. |
scope | GrantScope | "pending" | required | "pending" shows the three buttons; any other value shows the resolved badge. |
onGrant | (scope: GrantScope) => void | Called once, from whichever pending button was pressed. | |
className | string | Merged onto the root. |
GrantScope is "session" | "always" | "denied".