# Approval Card

> Ask the person one or more questions before an agent acts, and send nothing until they choose to.

Source: https://ai.honestui.com/docs/components/approval-card

```tsx
"use client"

import * as React from "react"

import {
  ApprovalCard,
  type ApprovalAnswers,
  type ApprovalQuestion,
} from "@/components/ai/approval-card"
import { Button } from "@/components/ui/button"

// Sample questions.
const sampleQuestions: ApprovalQuestion[] = [
  {
    id: "scope",
    prompt: "Which invoices should the export include?",
    type: "single",
    required: true,
    options: [
      { value: "paid", label: "Paid only" },
      { value: "all", label: "Paid and open" },
    ],
    allowCustom: true,
  },
  {
    id: "columns",
    prompt: "Which extra columns do you want?",
    type: "multiple",
    options: [
      { value: "tax", label: "Tax amount" },
      { value: "region", label: "Billing region" },
      { value: "po", label: "Purchase order number" },
    ],
  },
  {
    id: "format",
    prompt: "Which format should I write?",
    type: "single",
    options: [
      { value: "csv", label: "CSV" },
      { value: "xlsx", label: "Excel workbook" },
    ],
  },
]

/** This preview sends nothing. It shows the object your `onSubmit` receives. */
export function ApprovalCardDemo() {
  const [received, setReceived] = React.useState<ApprovalAnswers | null>(null)
  const [isDismissed, setIsDismissed] = React.useState(false)
  const [resetKey, setResetKey] = React.useState(0)

  const restart = () => {
    setReceived(null)
    setIsDismissed(false)
    setResetKey((key) => key + 1)
  }

  return (
    <div className="flex w-full max-w-sm min-w-0 flex-col items-start gap-[var(--hui-space-4)]">
      {isDismissed ? (
        <p className="m-0 text-[var(--hui-color-foreground-base-secondary)] [font-size:var(--hui-font-size-small)] [line-height:var(--hui-line-height-small)]">
          You closed the card without sending.
        </p>
      ) : (
        <ApprovalCard
          key={resetKey}
          questions={sampleQuestions}
          onSubmit={setReceived}
          onDismiss={() => setIsDismissed(true)}
        />
      )}
      {received && (
        <pre
          aria-label="Answers received by onSubmit"
          tabIndex={0}
          className="m-0 w-full overflow-x-auto rounded-[var(--hui-radius-2)] bg-[var(--hui-color-background-base-secondary)] p-[var(--hui-space-4)] font-mono text-[var(--hui-color-foreground-base-primary)] outline-none [font-size:var(--hui-font-size-mono-mini)] [line-height:var(--hui-line-height-regular)] focus-visible:[outline:var(--hui-focus-ring)]"
        >
          {JSON.stringify(received, null, 2)}
        </pre>
      )}
      {(received || isDismissed) && (
        <Button variant="secondary" size="sm" onClick={restart}>
          Show the questions again
        </Button>
      )}
    </div>
  )
}

```

## Overview [#overview]

Approval Card puts an agent's questions in front of the person one at a time. A question offers one choice or several, and can take a typed answer when none of the options fit. The person can go back, skip a question that is not required, and send from the last question.

Take an agent that is about to write an export. It needs to know which invoices to include before it starts. The card asks, the person picks "Paid only", and your `onSubmit` receives that answer and resumes the run.

Choosing an option never sends and never moves to the next question. The card sends only when the person presses Send. This is on purpose: the card exists so the agent waits for a decision.

Approval Card does not talk to your agent. It collects the answers and passes them to `onSubmit`.

## Anatomy [#anatomy]

<Anatomy title="ApprovalCard">
  <AnatomyItem name="Step" description="Which question this is, when there is more than one" />

  <AnatomyItem name="Prompt" description="The question, in the agent's words" />

  <AnatomyItem name="Options" description="A radio group for one choice, or checkboxes for several" />

  <AnatomyItem name="Custom answer" description="A text field, when the question allows one" />

  <AnatomyItem name="Footer" description="Back, then Skip or Continue, and Send on the last question" />

  <AnatomyItem name="Dismiss" description="A close button, shown only when you handle it" />
</Anatomy>

`ApprovalCard` is one component that takes data. Installing `approval-card` brings [Button](https://www.honestui.com/docs/components/button), [Checkbox](https://www.honestui.com/docs/components/checkbox), [Radio Group](https://www.honestui.com/docs/components/radio-group), and [Input](https://www.honestui.com/docs/components/input) along as registry dependencies.

## Behavior [#behavior]

### Moving between questions [#moving-between-questions]

An unanswered question that is not required offers Skip. Once it has an answer, the same button becomes Continue. A required question offers only Continue, which stays disabled until there is an answer. Back appears from the second question on, and answers are kept when the person moves either way.

### Typed answers [#typed-answers]

With `allowCustom`, a text field sits under the options. A single-choice question holds one answer, so typing clears the chosen option and choosing an option clears the text. On a multiple-choice question, the typed answer is added to the chosen options.

### Sending [#sending]

Send is enabled when at least one question has an answer and every required question has one. While it is disabled, a line beside it says why.

`onSubmit` receives an object with an entry for every question, keyed by question `id`. A skipped question's entry has an empty `selected` array and an empty `custom` string. Typed answers are trimmed.

If `onSubmit` returns a promise, the card shows "Sending…" and locks its controls until the promise settles. When it resolves, the card is replaced by a confirmation. When it rejects, the card shows an error, keeps every answer, and offers Send again.

### Dismissing [#dismissing]

Pass `onDismiss` to show a close button. The card does not hide itself: remove it from your tree when the handler runs, and decide what the agent does with no answer.

## Accessibility [#accessibility]

The options are a radio group or a group of checkboxes, named by the prompt. Each option is a native label around its control, so the whole row is the target. The custom field has its own name, "Another answer".

The step line is a polite live region, so moving to another question announces "Question 2 of 3". The confirmation is a status. The error is an alert.

Sending replaces the Send button with the confirmation. When that happens, the card takes focus so the next <kbd>Tab</kbd> continues from the card instead of the top of the page.

Every string comes from `labels`, including the dismiss button's name. The prompt and the option labels are yours.

| Key                                  | Action                                                            |
| ------------------------------------ | ----------------------------------------------------------------- |
| <kbd>Tab</kbd>                       | Move across the options, the custom field, and the footer buttons |
| Arrow keys                           | Move between radio options and choose the focused one             |
| <kbd>Space</kbd>                     | Check or clear the focused checkbox                               |
| <kbd>Enter</kbd> or <kbd>Space</kbd> | Activate the focused button                                       |

## Installation [#installation]


  

  
    <CliBlock commands="[&#x22;@honestai/approval-card&#x22;]" />
  

  
    
      
        Add the components it is built from.
      

      <CliBlock commands="[&#x22;button&#x22;, &#x22;checkbox&#x22;, &#x22;radio-group&#x22;, &#x22;input&#x22;]" />

      
        Copy and paste this file into your project.
      

      ### components/ai/approval-card.tsx

```tsx
"use client"

import * as React from "react"
import {
  CircleAlert as CircleAlertIcon,
  CircleCheck as CircleCheckIcon,
  LoaderCircle as LoaderCircleIcon,
  X as CloseIcon,
} from "honestui/icons"

import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { Input } from "@/components/ui/input"
import { Radio, RadioGroup } from "@/components/ui/radio-group"

export type ApprovalOption = {
  value: string
  label: string
}

export type ApprovalQuestion = {
  id: string
  /** The question, in the agent's words. */
  prompt: string
  /** "single" offers one choice. "multiple" offers any number. */
  type: "single" | "multiple"
  options: ApprovalOption[]
  /** Adds a text field for an answer that is not among the options. */
  allowCustom?: boolean
  /** A required question cannot be skipped, and Send waits for it. */
  required?: boolean
}

export type ApprovalAnswer = {
  /** The `value` of each chosen option. */
  selected: string[]
  /** The typed answer, trimmed. Empty when nothing was typed. */
  custom: string
}

/** Every question's answer, keyed by question `id`. Skipped questions are present and empty. */
export type ApprovalAnswers = Record<string, ApprovalAnswer>

export type ApprovalCardLabels = {
  back: string
  skip: string
  continue: string
  send: string
  sending: string
  dismiss: string
  customAnswer: string
  customPlaceholder: string
  step: (current: number, total: number) => string
  /** Shown beside a disabled Send, so the reason is never a guess. */
  incomplete: string
  submitted: string
  error: string
}

export const defaultApprovalCardLabels: ApprovalCardLabels = {
  back: "Back",
  skip: "Skip",
  continue: "Continue",
  send: "Send",
  sending: "Sending…",
  dismiss: "Dismiss",
  customAnswer: "Another answer",
  customPlaceholder: "Something else…",
  step: (current, total) => `Question ${current} of ${total}`,
  incomplete: "Answer at least one question to send.",
  submitted: "Answers sent.",
  error: "The answers were not sent. Your choices are still here, so you can send again.",
}

const bodyText =
  "[font-size:var(--hui-font-size-small)] [letter-spacing:var(--hui-letter-spacing-small)] [line-height:var(--hui-line-height-small)]"

const emptyAnswer: ApprovalAnswer = { selected: [], custom: "" }

function isAnswered(answer: ApprovalAnswer | undefined) {
  return Boolean(answer && (answer.selected.length > 0 || answer.custom.trim() !== ""))
}

export type ApprovalCardProps = Omit<React.ComponentProps<"div">, "children" | "onSubmit"> & {
  questions: ApprovalQuestion[]
  /**
   * Receives every answer when the person presses Send. Return a promise to
   * show progress; if it rejects, the card keeps the answers and offers Send again.
   */
  onSubmit: (answers: ApprovalAnswers) => void | Promise<void>
  /** Called when the person closes the card. Without it, no close button is shown. */
  onDismiss?: () => void
  labels?: Partial<ApprovalCardLabels>
}

/**
 * Questions an agent asks before it acts, one at a time. Nothing is sent
 * until the person presses Send on the last question.
 */
function ApprovalCard({
  questions,
  onSubmit,
  onDismiss,
  labels: labelsProp,
  className,
  ...props
}: ApprovalCardProps) {
  const labels = React.useMemo(
    () => ({ ...defaultApprovalCardLabels, ...labelsProp }),
    [labelsProp]
  )
  const [index, setIndex] = React.useState(0)
  const [answers, setAnswers] = React.useState<ApprovalAnswers>({})
  const [phase, setPhase] = React.useState<"editing" | "submitting" | "submitted" | "error">(
    "editing"
  )
  const rootRef = React.useRef<HTMLDivElement>(null)
  const promptId = React.useId()

  const question = questions[Math.min(index, questions.length - 1)]
  const isLast = index >= questions.length - 1
  const answer = (question && answers[question.id]) ?? emptyAnswer
  const answered = isAnswered(answer)
  const isSubmitting = phase === "submitting"
  const canSend =
    questions.some((item) => isAnswered(answers[item.id])) &&
    questions.every((item) => !item.required || isAnswered(answers[item.id]))

  // Sending removes the focused button. Hold focus on the card so it does not
  // fall back to the top of the page.
  React.useEffect(() => {
    if (phase === "submitted") rootRef.current?.focus()
  }, [phase])

  if (!question) return null

  const updateAnswer = (next: ApprovalAnswer) => {
    setAnswers((current) => ({ ...current, [question.id]: next }))
    if (phase === "error") setPhase("editing")
  }

  const submit = async () => {
    const result: ApprovalAnswers = {}
    for (const item of questions) {
      const given = answers[item.id] ?? emptyAnswer
      result[item.id] = { selected: given.selected, custom: given.custom.trim() }
    }

    setPhase("submitting")
    try {
      await onSubmit(result)
      setPhase("submitted")
    } catch {
      setPhase("error")
    }
  }

  const cardFrame =
    "w-full min-w-0 rounded-[var(--hui-radius-4)] border-[0.5px] border-[var(--hui-color-border-base-primary)] bg-[var(--hui-color-background-base-primary)] text-[var(--hui-color-foreground-base-primary)] shadow-[var(--hui-shadow-soft)] outline-none focus-visible:[outline:var(--hui-focus-ring)]"

  if (phase === "submitted") {
    return (
      <div
        ref={rootRef}
        tabIndex={-1}
        data-slot="approval-card"
        data-phase={phase}
        className={cn(cardFrame, "p-[var(--hui-space-5)]", bodyText, className)}
        {...props}
      >
        <p role="status" className="m-0 flex items-center gap-[var(--hui-space-3)]">
          <CircleCheckIcon
            aria-hidden="true"
            className="size-4 shrink-0 text-[var(--hui-color-foreground-success-primary)]"
          />
          {labels.submitted}
        </p>
      </div>
    )
  }

  return (
    <div
      ref={rootRef}
      tabIndex={-1}
      data-slot="approval-card"
      data-phase={phase}
      className={cn(cardFrame, bodyText, className)}
      {...props}
    >
      <div className="flex flex-col gap-[var(--hui-space-4)] p-[var(--hui-space-5)]">
        <div className="flex items-start gap-[var(--hui-space-3)]">
          <div className="flex min-w-0 flex-1 flex-col gap-[var(--hui-space-2)]">
            {questions.length > 1 && (
              <p
                aria-live="polite"
                className="m-0 text-[var(--hui-color-foreground-base-secondary)] [font-size:var(--hui-font-size-mini)] [line-height:var(--hui-line-height-mini)]"
              >
                {labels.step(index + 1, questions.length)}
              </p>
            )}
            <p
              id={promptId}
              className="m-0 break-words [font-size:var(--hui-font-size-regular)] [font-weight:var(--hui-font-weight-medium)] [letter-spacing:var(--hui-letter-spacing-regular)] [line-height:var(--hui-line-height-regular)]"
            >
              {question.prompt}
            </p>
          </div>
          {onDismiss && (
            <Button
              variant="link"
              size="icon-sm"
              aria-label={labels.dismiss}
              onClick={onDismiss}
              className="-me-[var(--hui-space-2)] -mt-[var(--hui-space-2)]"
            >
              <CloseIcon aria-hidden="true" />
            </Button>
          )}
        </div>

        {question.type === "single" ? (
          <RadioGroup
            key={`${question.id}-options`}
            aria-labelledby={promptId}
            disabled={isSubmitting}
            value={answer.selected[0] ?? ""}
            onValueChange={(value) => updateAnswer({ selected: [String(value)], custom: "" })}
            className="gap-[var(--hui-space-1)]"
          >
            {question.options.map((option) => (
              <OptionRow key={option.value} label={option.label}>
                {(labelId) => <Radio value={option.value} aria-labelledby={labelId} />}
              </OptionRow>
            ))}
          </RadioGroup>
        ) : (
          <div
            key={`${question.id}-options`}
            role="group"
            aria-labelledby={promptId}
            className="flex flex-col gap-[var(--hui-space-1)]"
          >
            {question.options.map((option) => (
              <OptionRow key={option.value} label={option.label}>
                {(labelId) => (
                  <Checkbox
                    size="small"
                    aria-labelledby={labelId}
                    disabled={isSubmitting}
                    checked={answer.selected.includes(option.value)}
                    onCheckedChange={(checked) =>
                      updateAnswer({
                        ...answer,
                        selected: checked
                          ? [...answer.selected, option.value]
                          : answer.selected.filter((value) => value !== option.value),
                      })
                    }
                  />
                )}
              </OptionRow>
            ))}
          </div>
        )}

        {question.allowCustom && (
          <Input
            key={`${question.id}-custom`}
            size="sm"
            aria-label={labels.customAnswer}
            placeholder={labels.customPlaceholder}
            disabled={isSubmitting}
            value={answer.custom}
            onChange={(event) =>
              updateAnswer({
                // A single-choice question holds one answer: typed or picked, not both.
                selected: question.type === "single" ? [] : answer.selected,
                custom: event.target.value,
              })
            }
          />
        )}

        {phase === "error" && (
          <p role="alert" className="m-0 flex items-start gap-[var(--hui-space-3)]">
            <CircleAlertIcon strokeWidth={1.5} strokeLinecap="round"
              aria-hidden="true"
              className="mt-px size-3.5 shrink-0 text-[var(--hui-color-foreground-danger-primary)]"
            />
            <span className="min-w-0 break-words">{labels.error}</span>
          </p>
        )}
      </div>

      <div className="flex flex-wrap items-center justify-end gap-[var(--hui-space-3)] border-t border-[var(--hui-color-border-base-primary)] px-[var(--hui-space-5)] py-[var(--hui-space-3)]">
        {isLast && !canSend && (
          <p className="m-0 me-auto min-w-0 text-[var(--hui-color-foreground-base-secondary)]">
            {labels.incomplete}
          </p>
        )}
        {index > 0 && (
          <Button
            variant="link"
            size="sm"
            disabled={isSubmitting}
            onClick={() => setIndex(index - 1)}
          >
            {labels.back}
          </Button>
        )}
        {isLast ? (
          <Button size="sm" disabled={!canSend || isSubmitting} onClick={() => void submit()}>
            {isSubmitting && (
              <LoaderCircleIcon aria-hidden="true" className="size-3.5 motion-safe:animate-spin" />
            )}
            {isSubmitting ? labels.sending : labels.send}
          </Button>
        ) : answered || question.required ? (
          <Button size="sm" disabled={!answered} onClick={() => setIndex(index + 1)}>
            {labels.continue}
          </Button>
        ) : (
          <Button variant="secondary" size="sm" onClick={() => setIndex(index + 1)}>
            {labels.skip}
          </Button>
        )}
      </div>
    </div>
  )
}

function OptionRow({
  label,
  children,
}: {
  label: string
  /** Renders the control. It is named by `labelId`, because the label alone does not name it. */
  children: (labelId: string) => React.ReactNode
}) {
  const labelId = React.useId()

  return (
    <label className="-mx-[var(--hui-space-2)] flex min-w-0 cursor-pointer items-center gap-[var(--hui-space-3)] rounded-[var(--hui-radius-2)] px-[var(--hui-space-2)] py-[var(--hui-space-2)] motion-safe:[transition:var(--hui-transition-interactive)] hover:bg-[var(--hui-color-background-base-primary-hover)] has-disabled:cursor-not-allowed has-data-disabled:cursor-not-allowed">
      {children(labelId)}
      <span id={labelId} className="min-w-0 break-words">
        {label}
      </span>
    </label>
  )
}

export { ApprovalCard }

```

      
        Update the import paths to match your project setup.
      
    
  


## Usage [#usage]

Render the card when your agent pauses for input, and resume the run from `onSubmit`:

```tsx
import { ApprovalCard, type ApprovalQuestion } from "@/components/ai/approval-card"

const questions: ApprovalQuestion[] = [
  {
    id: "scope",
    prompt: "Which invoices should the export include?",
    type: "single",
    required: true,
    options: [
      { value: "paid", label: "Paid only" },
      { value: "all", label: "Paid and open" },
    ],
  },
]

<ApprovalCard
  questions={questions}
  onSubmit={(answers) => agent.resume(run.id, answers)}
  onDismiss={() => agent.cancel(run.id)}
/>
```

Return the promise from your request, as above, so the card can show progress and a failure.

Write each prompt so it states the consequence. "The migration drops the legacy\_region column. Run it on staging?" lets the person decide. "Proceed?" does not.

## Examples [#examples]

### A send that fails [#a-send-that-fails]

This preview rejects the first send. The answer stays selected and Send is offered again. The second send succeeds.

```tsx
"use client"

import * as React from "react"

import { ApprovalCard, type ApprovalQuestion } from "@/components/ai/approval-card"

// Sample question.
const sampleQuestions: ApprovalQuestion[] = [
  {
    id: "deploy",
    prompt: "The migration drops the legacy_region column. Run it on staging?",
    type: "single",
    required: true,
    options: [
      { value: "run", label: "Run it on staging" },
      { value: "hold", label: "Hold until I review the SQL" },
    ],
  },
]

function wait(milliseconds: number) {
  return new Promise((resolve) => window.setTimeout(resolve, milliseconds))
}

/**
 * There is no server behind this preview. The first send is rejected on
 * purpose to show the failure state; the next send resolves.
 */
export function ApprovalCardFailureExample() {
  const attempts = React.useRef(0)

  return (
    <div className="w-full max-w-sm min-w-0">
      <ApprovalCard
        questions={sampleQuestions}
        labels={{ submitted: "Answer sent. The agent will continue." }}
        onSubmit={async () => {
          attempts.current += 1
          await wait(900)
          if (attempts.current === 1) {
            throw new Error("This preview rejects the first send.")
          }
        }}
      />
    </div>
  )
}

```

## API reference [#api-reference]

### ApprovalCard [#approvalcard]

Accepts native div props beyond those listed.

| Prop        | Type                                                  | Default  | Description                                                              |
| ----------- | ----------------------------------------------------- | -------- | ------------------------------------------------------------------------ |
| `questions` | `ApprovalQuestion[]`                                  | Required | The questions, in order.                                                 |
| `onSubmit`  | `(answers: ApprovalAnswers) => void \| Promise<void>` | Required | Receives every answer when the person presses Send.                      |
| `onDismiss` | `() => void`                                          | None     | Runs when the person closes the card. Passing it shows the close button. |
| `labels`    | `Partial<ApprovalCardLabels>`                         | English  | Button names, the step line, the hint, and the outcome messages.         |

### Types [#types]

| Export               | Shape                                                                                                                                     |
| -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| `ApprovalQuestion`   | `id`, `prompt`, `type` of `"single"` or `"multiple"`, `options`, and optional `allowCustom`, `required`                                   |
| `ApprovalOption`     | `value`, `label`                                                                                                                          |
| `ApprovalAnswer`     | `selected` (the chosen option values) and `custom` (the typed answer)                                                                     |
| `ApprovalAnswers`    | An `ApprovalAnswer` for each question, keyed by question `id`                                                                             |
| `ApprovalCardLabels` | `back`, `skip`, `continue`, `send`, `sending`, `dismiss`, `customAnswer`, `customPlaceholder`, `step`, `incomplete`, `submitted`, `error` |
