Approval Request

The agent pauses before one specific action and asks permission, with its scope, cost and consequences visible.

Pattern

≈ 1,849 tokens per use (tool 1,310 · guidance 302 · typical payload 237)How token costs are estimated

Example: Approval Request

Send the renewal email to all customers whose plan ends this month.

Send renewal email

Medium risk

Expires in 14 min

October renewal reminder to customers whose plan ends this month.. Requested by Billing agent

Recipients
240 customersPlans ending Oct 1–31
Template
Your plan renews soon
From
billing@acme.example

Can't be undone

What happens

  • Customers get it immediately.
  • Each links to renewal at their current price.

Adjust

In each customer's time zone.

Or instead

  • 5 internal addresses at acme.example

Waiting for your decision

When to use

  • The agent is about to take one specific action that sends, charges, grants access, deletes or runs something, and the user should decide first.
  • The action has no before/after diff worth showing: sending an email, paying, running a job, granting a role.

When not to use

  • The agent is changing field values the user should see before and after. Use the Change Preview.
  • The action is read-only or trivially reversible, and the user already asked the agent to go ahead.
  • The agent hasn't read the real targets yet. Fetch the recipients, account or environment first.

How the agent should use it

  • Wait for the result. Never act before receiving approve, and apply the returned adjustments exactly. Anything not listed stays as proposed.
  • Scope comes from tool results: real counts, names and environments, never estimates from memory.
  • Set risk honestly. Use high plus a confirmPhrase (such as the environment name) for production, deletes and large payments.
  • High risk means press and hold. At risk: "high" with no confirmPhrase, Approve becomes Hold to approve: the user holds the button (or Enter or Space) for about 1.2 seconds, and releasing early cancels. Set hold yourself to force it on or off. A confirmPhrase already asks for a deliberate step, so it replaces the hold unless you set hold: true.
  • Offer alternatives when a safer path exists, like "Send to a test list first" or "Run on staging first".
  • Lifecycle: set expiresAt on the call when the approval only holds for a while (a quote, a maintenance window, a held price), and pass the same value as the expiresAt prop so the card shows "Expires in 14 min". After that moment the Renderer turns the card inert. Use supersede when the agent issues a revised request for the same action. fetchedAt isn't needed.

Interaction

ControlSendsNotes
ApproveapprovePress and hold when hold is on (see above): the fill grows, then it sends. A screen reader's "activate" approves at once, since it can't hold. Reads Approve with changes once an adjustable field differs from the proposal. Disabled until the confirm phrase matches (trimmed, case-insensitive) and every field is valid
RejectrejectAsks for an optional reason inline before sending
Request changesreviseThe message icon. Opens a text box for a question or a requested change
An alternativealternativeSends the alternative's id right away. Its description is the button's accessible description

The card leads with the cost when there is one, then the scope, the reversibility line, "What happens", any adjustable fields and the alternatives. The risk level and the expiry share the one line under the title; summary, requestedBy and each field's help are read to screen readers but not shown, to keep the card short.

After a decision the card locks and the footer shows a compact record, such as "Approved at 10:42 · with 1 change". Focus moves to that record, and the footer announces it politely.

Installation

npx shadcn@latest add https://your-site.com/r/approval-request.json

Usage

app/api/chat/route.ts
import { tool } from "ai"
import { approvalRequestTool } from "@/components/genui/approval-request.schema"

const tools = {
  // No execute(): the call is completed by the user's decision in the UI.
  [approvalRequestTool.name]: tool({
    description: approvalRequestTool.description,
    inputSchema: approvalRequestTool.inputSchema,
  }),
}
components/message.tsx
<ApprovalRequest
  {...part.input}
  status={part.state === "input-streaming" ? "streaming" : undefined}
  result={part.state === "output-available" ? part.output : undefined}
  onResult={(result) =>
    addToolResult({ tool: "request_approval", toolCallId: part.toolCallId, output: result })
  }
/>

The agent receives one of:

{ action: "approve", decidedAt: "2026-10-08T10:42:00.000Z" }
{ action: "approve", adjustments: { recipient_cap: 50 }, decidedAt: "…" }
{ action: "reject", reason: "Wait until the pricing page is updated", decidedAt: "…" }
{ action: "revise", feedback: "Exclude customers who already renewed", decidedAt: "…" }
{ action: "alternative", id: "test_list", decidedAt: "…" }

adjustments holds only the fields the user changed, typed (numbers as numbers, toggles as booleans). On revise, do nothing: answer the question or send a new request. On reject, stop.

The helpers in approval-request-utils.ts (diffAdjustments, matchesConfirmPhrase, formatCost, expiryText, riskMeta) are pure, so the server can reuse them, for example to check a returned adjustment against the field's min and max before acting.

With LangGraph

Push it from a graph node with ui.push({ name: "request_approval", props: { toolCallId, ...args } }, { message }), then interrupt() until the user's result arrives. Render the thread's UI messages with the Renderer.

Restoring from history

Pass the stored result back as result. The card renders the decision record (with the adjusted values) and never calls onResult again, so a decision can't be made twice. The record's time comes from decidedAt.

<ApprovalRequest
  {...part.input}
  result={part.state === "output-available" ? part.output : undefined}
/>

Schema

PropTypeNotes
actionstringImperative: "Send renewal email"
summarystring?One sentence on what will happen. Read to screen readers
risk"low" | "medium" | "high"Shown as an icon and as text ("High risk")
reversibleboolean | "partially""Can't be undone", "Can be undone" or "Partly reversible: …"
undoWindowstring?"Undo within 30 seconds"
reversibleDetailstring?What can be reversed: "Refunds are prorated within 14 days"
scope{ label, value, detail? }[]Data-bound. 1–8 rows: recipients, account, environment
consequencesstring[]?Up to 5 plain sentences
cost{ amount, currency, period? }Data-bound. period: one-time, day, week, month, quarter, year
requestedBystring?"Billing agent". Read to screen readers
expiresAtstring?ISO 8601. Shown after mount as "Expires in 14 min"
adjustableAdjustableField[]?Up to 6 fields the user may change before approving
holdboolean?Press and hold Approve. Default: on for risk: "high" without a confirmPhrase, off otherwise
confirmPhrasestring?The user must type it to unlock Approve
alternatives{ id, label, description? }[]?Up to 3 safer options

AdjustableField

FieldTypeNotes
idstringKey in adjustments
labelstring
typenumber | text | select | toggleInput, Input, Select or Switch
valuestring | number | booleanThe proposed value
optionsstring[]?Required for select
min, maxnumber?For number
helpstring?One line, read as the field's description

Host props: status (streaming, ready, submitting, approved, rejected, revised, alternative), onResult, result, now (a fixed time for the expiry text) and className.

Source

On this page