Agency & Decisions

Approval

Solves: deciding, with enough in view, before an agent does a thing that is hard to undo.

Established — a stable convention across independent vendors.

pattern: established code: beta <ApprovalGate>: Beta: the code works and has tests, and its API can still change.

watching →emerging →established →contested →fading 75 sightings since Jun 2026 seen in 2 different contexts
Ask an assistant about this pattern Ask ChatGPT ↗Ask Claude ↗

Approval creates a deliberate decision moment before an agent takes an action that matters. The person sees what is about to happen, understands its consequence and scope, and can approve, change, or stop it. The decision is recorded so it can be revisited or withdrawn.

When to Use It

  • An agent action is consequential and hard to reverse
  • You need consent scoped explicitly — once, per session, or standing
  • An audit trail of who authorized what matters

When Not To

  • The action is cheap to undo; use recovery instead of asking
  • You would be asking about everything, and constant prompts train reflexive approval
  • The real question is the shape of the work; decide on the plan, not each action

Three views

The exchange between the human, the agent and the system the agent acts on; who holds each part of it; and the component that implements it.

01 · the interaction
human agent system files · rows · accounts reads the target file, row or account proposes the action, with a preview sets the scope: this file, this run, this session approves or rejects writes the change (nothing, on reject) returns the result and a receipt shows the receipt
02 · who holds what
human interface agent Preview: The AI shows you the exact change before it makes it, line by line where it can 01 Preview Scope: You choose how far a yes reaches: this once, this session, or from now on 02 Scope Approve: You say yes and the AI goes ahead 03 Approve Reject: You say no and nothing happens 04 Reject Edit: You change the proposal instead of taking or leaving it 05 Edit Expiry: The request times out on its own if you walk away 06 Expiry Receipt: A record of what you decided and what the AI did 07 Receipt
human interface agent Preview: The AI shows you the exact change before it makes it, line by line where it can 01 Preview Scope: You choose how far a yes reaches: this once, this session, or from now on 02 Scope Approve: You say yes and the AI goes ahead 03 Approve Reject: You say no and nothing happens 04 Reject Edit: You change the proposal instead of taking or leaving it 05 Edit Expiry: The request times out on its own if you walk away 06 Expiry Receipt: A record of what you decided and what the AI did 07 Receipt
  1. 01 Preview The AI shows you the exact change before it makes it, line by line where it can
  2. 02 Scope You choose how far a yes reaches: this once, this session, or from now on
  3. 03 Approve You say yes and the AI goes ahead
  4. 04 Reject You say no and nothing happens
  5. 05 Edit You change the proposal instead of taking or leaving it
  6. 06 Expiry The request times out on its own if you walk away
  7. 07 Receipt A record of what you decided and what the AI did
03 · the component · code: beta

vendor presets are recreations, not vendor assets

THE LIVE PLAYGROUND NEEDS A WIDER SCREEN — IT'S WAITING ON DESKTOP. THE ANATOMY ABOVE AND THE CODE BELOW ARE THE SAME COMPONENT.

Do / Don't

Do

State the exact action and its scope directly above the decision buttons.

Don't

Bundle unrelated actions under one approve.

Consent to a vague or composite proposal is not consent — the preview is the contract.

Do

Offer once / session / always so trust can be widened deliberately and revoked visibly.

Don't

Default to always-allow to make the prompts go away.

Always-allow trades every future review for present speed — make that trade explicit, inspectable, and reversible.

Do

Ask only before actions that matter and are hard to reverse.

Don't

Put an approval prompt in front of everything the agent does.

Too many asks train reflexive approval, which is worse than never asking at all.

Do

Replace the ask with undo where actions are cheap to reverse.

Don't

Ask permission for sandboxed or checkpointed work.

Reversibility spends no human attention until something goes wrong; asking spends it every time. Reserve the decision for the irreversible.

Every pair cites a documented tension or principle boundary — no free-floating taste.

Get the Code

Namespaced install (add the registry to components.json once):

{
  "registries": {
    "@agentsandhumans": "https://agentsandhumans.ai/api/registry/{name}.json"
  }
}
npx shadcn@latest add @agentsandhumans/approval-gate

Or install directly from the endpoint:

npx shadcn@latest add https://agentsandhumans.ai/api/registry/approval-gate.json

Dependencies: react · Files: patterns/approval-gate/approval-gate-core.tsx · patterns/approval-gate/approval-gate.tsx · patterns/tokens.css

patterns/approval-gate/approval-gate-core.tsx
/**
 * ApprovalGate — headless core.
 * Pattern: letting a human authorize an agent action before it executes.
 * Anatomy: Preview · Details · Scope · Approve · Reject · Edit · Editor · Expiry · Receipt · Revoke · Changed
 *
 * The state machine IS the pattern definition. Anatomy parts that a vendor
 * does not implement are switched off via ApprovalGateConfig (see presets in
 * @agentsandhumans/patterns-ui) — which is how the playground can render
 * "ApprovalGate as Claude Code does it" against "as Codex does it".
 *
 * Lifecycle (spec 2026-10-04 §2):
 *   pending → editing → pending   (saving an edit makes a new request version)
 *   pending → granted | denied | expired
 *   granted → revoked
 * APPROVE, REJECT, EDIT and EXPIRE act only while pending. A decision is for one
 * request version, never "the latest": when the host sends a new version after a
 * decision, that decision no longer applies and the gate asks again.
 */
import * as React from 'react';
import {
  createEvent,
  type ActionRequest,
  type ApprovalDecision,
  type ApprovalOutcome,
  type ApprovalRequestedPayload,
  type ApprovalRevocation,
  type ApprovalScope,
  type EventDraft,
  type InteractionEventHandler,
} from '@/lib/interaction-contract';

// The shared records live in the contract; re-exported so existing imports keep working.
export type { ActionRequest, ApprovalDecision, ApprovalOutcome, ApprovalRevocation, ApprovalScope } from '@/lib/interaction-contract';

export type ApprovalState = 'pending' | 'editing' | 'granted' | 'denied' | 'expired' | 'revoked';

export interface ApprovalGateConfig {
  /** Which scope options this implementation offers. Empty = no Scope part. */
  scopes: ApprovalScope[];
  defaultScope: ApprovalScope;
  /** Auto-timeout in ms per request version; null = the Expiry part is absent. */
  expiresInMs: number | null;
  /** Whether the human can edit the proposal instead of accept/reject. */
  allowEdit: boolean;
}

export const defaultConfig: ApprovalGateConfig = {
  scopes: ['once', 'session', 'standing'],
  defaultScope: 'once',
  expiresInMs: null,
  allowEdit: true,
};

/** Words a person reads for each scope. */
export const scopeLabel: Record<ApprovalScope, string> = {
  once: 'once',
  session: 'this session',
  standing: 'always',
};

export type Event =
  | { type: 'APPROVE'; at?: string }
  | { type: 'REJECT'; at?: string }
  | { type: 'EXPIRE'; at?: string }
  | { type: 'EDIT' }
  | { type: 'SET_DRAFT'; args: unknown; valid?: boolean }
  | { type: 'SAVE_EDIT' }
  | { type: 'CANCEL_EDIT' }
  | { type: 'SET_SCOPE'; scope: ApprovalScope }
  | { type: 'REVOKE'; at?: string }
  /** The host sent a request: a new id, or a version above the last one it sent. */
  | { type: 'REQUEST_CHANGED'; request: ActionRequest }
  /** The host verified and stored the current decision. */
  | { type: 'RECEIPT'; serverReceiptId: string };

export interface MachineState {
  state: ApprovalState;
  scope: ApprovalScope;
  /** The version on screen: the host's, or the one an edit made. */
  request: ActionRequest;
  /** The last version the host sent; host updates are compared with this. */
  hostVersion: number;
  /** Set when the version on screen came from the person's edit. */
  editedArgs?: unknown;
  /** The args being edited, while editing. */
  draft: unknown;
  draftValid: boolean;
  decision: ApprovalDecision | null;
  revocation: ApprovalRevocation | null;
  /** The decision a later request version replaced. It no longer applies. */
  superseded: ApprovalDecision | null;
}

export function initialState(request: ActionRequest, scope: ApprovalScope = defaultConfig.defaultScope): MachineState {
  return {
    state: 'pending',
    scope,
    request,
    hostVersion: request.version,
    draft: request.args,
    draftValid: true,
    decision: null,
    revocation: null,
    superseded: null,
  };
}

function decide(s: MachineState, outcome: ApprovalOutcome, at?: string): MachineState {
  const decision: ApprovalDecision = {
    requestId: s.request.id,
    requestVersion: s.request.version,
    outcome,
    scope: s.scope,
    decidedAt: at ?? new Date().toISOString(),
  };
  if (s.editedArgs !== undefined) decision.editedArgs = s.editedArgs;
  return { ...s, state: outcome, decision };
}

export function reducer(s: MachineState, e: Event): MachineState {
  switch (e.type) {
    case 'APPROVE':
      return s.state === 'pending' ? decide(s, 'granted', e.at) : s;
    case 'REJECT':
      return s.state === 'pending' ? decide(s, 'denied', e.at) : s;
    case 'EXPIRE':
      return s.state === 'pending' ? decide(s, 'expired', e.at) : s;
    case 'EDIT':
      return s.state === 'pending' ? { ...s, state: 'editing', draft: s.request.args, draftValid: true } : s;
    case 'SET_DRAFT':
      return s.state === 'editing' ? { ...s, draft: e.args, draftValid: e.valid ?? true } : s;
    case 'SAVE_EDIT':
      if (s.state !== 'editing' || !s.draftValid) return s;
      return {
        ...s,
        state: 'pending',
        request: { ...s.request, version: s.request.version + 1, args: s.draft },
        editedArgs: s.draft,
      };
    case 'CANCEL_EDIT':
      return s.state === 'editing' ? { ...s, state: 'pending', draft: s.request.args, draftValid: true } : s;
    case 'SET_SCOPE':
      return s.state === 'pending' || s.state === 'editing' ? { ...s, scope: e.scope } : s;
    case 'REVOKE':
      if (s.state !== 'granted' || !s.decision) return s;
      return {
        ...s,
        state: 'revoked',
        revocation: {
          requestId: s.decision.requestId,
          requestVersion: s.decision.requestVersion,
          revokedAt: e.at ?? new Date().toISOString(),
        },
      };
    case 'REQUEST_CHANGED': {
      const r = e.request;
      const sameRequest = r.id === s.request.id;
      // A duplicate or an older version changes nothing.
      if (sameRequest && r.version <= s.hostVersion) return s;
      return {
        ...initialState(r, s.scope),
        // A new version of the same request carries the earlier decision as a record only.
        superseded: sameRequest ? s.decision ?? s.superseded : null,
      };
    }
    case 'RECEIPT':
      if (!s.decision || s.decision.serverReceiptId === e.serverReceiptId) return s;
      return { ...s, decision: { ...s.decision, serverReceiptId: e.serverReceiptId } };
  }
}

const decisionKey = (d: ApprovalDecision) => `${d.requestId}@${d.requestVersion}:${d.outcome}:${d.decidedAt}`;

/**
 * The contract events one transition produces (spec 2026-10-04 §1). `prev` is
 * null on mount. `correlationId` defaults to the request id; pass the plan id
 * to tie the approval to the plan it runs.
 */
export function approvalEvents(prev: MachineState | null, next: MachineState, correlationId?: string): EventDraft[] {
  const corr = correlationId ?? next.request.id;
  const out: EventDraft[] = [];
  const newVersion =
    prev === null || prev.request.id !== next.request.id || prev.request.version !== next.request.version;
  if (newVersion && !isSettled(next.state)) {
    const edited = prev !== null && prev.request.id === next.request.id && next.editedArgs !== undefined;
    const payload: ApprovalRequestedPayload = { request: next.request, edited };
    out.push({ type: 'approval.requested', actor: edited ? 'human' : 'agent', correlationId: corr, payload });
  }
  const d = next.decision;
  if (d && (!prev?.decision || decisionKey(prev.decision) !== decisionKey(d))) {
    out.push({ type: `approval.${d.outcome}`, actor: d.outcome === 'expired' ? 'system' : 'human', correlationId: corr, payload: d });
  }
  if (next.revocation && next.revocation !== prev?.revocation) {
    out.push({ type: 'approval.revoked', actor: 'human', correlationId: corr, payload: next.revocation });
  }
  return out;
}

export const isSettled = (state: ApprovalState) => state !== 'pending' && state !== 'editing';

interface ApprovalGateContext extends MachineState {
  config: ApprovalGateConfig;
  /** When the current version expires (epoch ms); null when expiry is off. */
  deadline: number | null;
  send: (e: Event) => void;
}

const Ctx = React.createContext<ApprovalGateContext | null>(null);

export function useApprovalGate(): ApprovalGateContext {
  const ctx = React.useContext(Ctx);
  if (!ctx) throw new Error('ApprovalGate parts must be used inside <ApprovalGate.Root>');
  return ctx;
}

export interface RootProps {
  /** The action the agent asks to take. Send a new version whenever it changes. */
  request: ActionRequest;
  config?: Partial<ApprovalGateConfig>;
  /** Called once per decision. Verify it server-side, then pass back serverReceiptId. */
  onDecision?: (d: ApprovalDecision) => void;
  onRevoke?: (r: ApprovalRevocation) => void;
  /** The id your server returned after storing the current decision. Until then the receipt is local. */
  serverReceiptId?: string;
  /** One agent run; stamped on every contract event. Defaults to a generated id. */
  runId?: string;
  /** Carried on every approval.* event. Pass the plan id so the approval points at the plan; defaults to the request id. */
  correlationId?: string;
  /** Contract events: approval.requested, approval.granted | denied | expired, approval.revoked. */
  onEvent?: InteractionEventHandler;
  children: React.ReactNode;
}

function Root({
  request,
  config: partial,
  onDecision,
  onRevoke,
  serverReceiptId,
  runId,
  correlationId,
  onEvent,
  children,
}: RootProps) {
  const config = React.useMemo(() => ({ ...defaultConfig, ...partial }), [partial]);
  const [machine, send] = React.useReducer(reducer, request, (r) => initialState(r, config.defaultScope));

  // Host updates: a new id, or a higher version, replaces what is on screen.
  React.useEffect(() => {
    send({ type: 'REQUEST_CHANGED', request });
    // The id and version identify a request; other fields change only with the version.
  }, [request.id, request.version]);

  // The receipt id applies to the decision on screen when the host sends it.
  React.useEffect(() => {
    if (serverReceiptId) send({ type: 'RECEIPT', serverReceiptId });
  }, [serverReceiptId]);

  // One expiry window per request version. Editing pauses the gate but not the clock.
  const versionKey = `${machine.request.id}@${machine.request.version}`;
  const [opened, setOpened] = React.useState({ key: versionKey, at: Date.now() });
  if (opened.key !== versionKey) setOpened({ key: versionKey, at: Date.now() });
  const deadline = config.expiresInMs == null ? null : opened.at + config.expiresInMs;

  React.useEffect(() => {
    if (deadline == null || machine.state !== 'pending') return;
    const t = setTimeout(() => send({ type: 'EXPIRE' }), Math.max(0, deadline - Date.now()));
    return () => clearTimeout(t);
  }, [deadline, machine.state]);

  const emitted = React.useRef<string | null>(null);
  React.useEffect(() => {
    if (!machine.decision) return;
    const key = decisionKey(machine.decision);
    if (key === emitted.current) return;
    emitted.current = key;
    onDecision?.(machine.decision);
  }, [machine.decision, onDecision]);

  const revoked = React.useRef<ApprovalRevocation | null>(null);
  React.useEffect(() => {
    if (machine.revocation && machine.revocation !== revoked.current) {
      revoked.current = machine.revocation;
      onRevoke?.(machine.revocation);
    }
  }, [machine.revocation, onRevoke]);

  const fallbackRun = React.useId();
  const run = runId ?? fallbackRun;
  const emit = React.useRef(onEvent);
  emit.current = onEvent;
  const prevMachine = React.useRef<MachineState | null>(null);
  React.useEffect(() => {
    const drafts = approvalEvents(prevMachine.current, machine, correlationId);
    prevMachine.current = machine;
    for (const d of drafts) emit.current?.(createEvent(run, d));
  }, [machine, correlationId, run]);

  const value = React.useMemo(() => ({ ...machine, config, deadline, send }), [machine, config, deadline]);

  return (
    <Ctx.Provider value={value}>
      <div
        role="group"
        aria-label="Approval required"
        data-pattern="approval-gate"
        data-part="root"
        data-state={machine.state}
        data-request-id={machine.request.id}
        data-request-version={machine.request.version}
      >
        {children}
      </div>
    </Ctx.Provider>
  );
}

export function formatArgs(args: unknown): string {
  if (typeof args === 'string') return args;
  if (args === undefined) return '';
  return JSON.stringify(args, null, 2);
}

/** The proposed action. Renders the action and its args unless children replace them. */
function Preview({ children }: { children?: React.ReactNode | ((r: ActionRequest) => React.ReactNode) }) {
  const { state, request } = useApprovalGate();
  const body =
    typeof children === 'function' ? (
      children(request)
    ) : children !== undefined ? (
      children
    ) : (
      <>
        <strong data-part="action">{request.action}</strong>
        {request.args !== undefined && <div data-part="args">{formatArgs(request.args)}</div>}
      </>
    );
  return (
    <div data-part="preview" data-state={state}>
      {body}
    </div>
  );
}

/** Version, what it touches, what follows, whether it can be undone. */
function Details() {
  const { request, editedArgs } = useApprovalGate();
  return (
    <dl data-part="details">
      <dt>Version</dt>
      <dd>
        {request.version}
        {editedArgs !== undefined ? ' (your edit)' : ''}
      </dd>
      {request.resources.length > 0 && (
        <>
          <dt>Touches</dt>
          <dd>{request.resources.join(', ')}</dd>
        </>
      )}
      {request.consequences && (
        <>
          <dt>What happens</dt>
          <dd>{request.consequences}</dd>
        </>
      )}
      <dt>Undo</dt>
      <dd data-reversible={request.reversible}>{request.reversible ? 'Can be undone' : 'Cannot be undone'}</dd>
    </dl>
  );
}

/** What is authorized: once | session | standing. Renders nothing when the implementation has no scopes. */
function Scope() {
  const { config, scope, state, send } = useApprovalGate();
  const name = React.useId();
  if (config.scopes.length === 0) return null;
  return (
    <fieldset data-part="scope" data-state={state} disabled={isSettled(state)}>
      <legend>Allow</legend>
      {config.scopes.map((s) => (
        <label key={s}>
          <input
            type="radio"
            name={name}
            value={s}
            checked={scope === s}
            onChange={() => send({ type: 'SET_SCOPE', scope: s })}
          />
          {scopeLabel[s]}
        </label>
      ))}
    </fieldset>
  );
}

type TriggerProps = Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'onClick'>;

function Approve(props: TriggerProps) {
  const { state, send } = useApprovalGate();
  return (
    <button type="button" data-part="approve" disabled={state !== 'pending'} onClick={() => send({ type: 'APPROVE' })} {...props} />
  );
}

function Reject(props: TriggerProps) {
  const { state, send } = useApprovalGate();
  return (
    <button type="button" data-part="reject" disabled={state !== 'pending'} onClick={() => send({ type: 'REJECT' })} {...props} />
  );
}

/** Opens the Editor. Only from pending; renders nothing when the implementation has no edit. */
function Edit(props: TriggerProps) {
  const { state, config, send } = useApprovalGate();
  if (!config.allowEdit) return null;
  return (
    <button
      type="button"
      data-part="edit"
      aria-expanded={state === 'editing'}
      disabled={state !== 'pending'}
      onClick={() => send({ type: 'EDIT' })}
      {...props}
    />
  );
}

/**
 * The args as text while editing. String args edit as they are; anything else edits as JSON,
 * and Save stays off until the JSON parses. Saving makes the next request version.
 */
function Editor({ label = 'Edit the request', save = 'Save edit', cancel = 'Cancel' }: { label?: string; save?: string; cancel?: string }) {
  const { state, request, draft, draftValid, send } = useApprovalGate();
  const [text, setText] = React.useState(() => formatArgs(draft));
  const id = React.useId();
  React.useEffect(() => {
    if (state === 'editing') setText(formatArgs(request.args));
  }, [state, request.args]);
  if (state !== 'editing') return null;
  const asString = typeof request.args === 'string';
  const onChange = (value: string) => {
    setText(value);
    if (asString) return send({ type: 'SET_DRAFT', args: value });
    try {
      send({ type: 'SET_DRAFT', args: JSON.parse(value) });
    } catch {
      send({ type: 'SET_DRAFT', args: draft, valid: false });
    }
  };
  return (
    <div data-part="editor">
      <label htmlFor={id}>{label}</label>
      <textarea id={id} value={text} aria-invalid={!draftValid} onChange={(ev) => onChange(ev.target.value)} />
      {!draftValid && <p data-part="editor-error">This is not valid JSON yet.</p>}
      <div data-part="editor-actions">
        <button type="button" data-part="save-edit" disabled={!draftValid} onClick={() => send({ type: 'SAVE_EDIT' })}>
          {save}
        </button>
        <button type="button" data-part="cancel-edit" onClick={() => send({ type: 'CANCEL_EDIT' })}>
          {cancel}
        </button>
      </div>
    </div>
  );
}

/** Countdown surface for the auto-timeout. Renders nothing when expiry is absent. */
function Expiry({ children }: { children?: (msLeft: number) => React.ReactNode }) {
  const { deadline, state } = useApprovalGate();
  const [now, setNow] = React.useState(() => Date.now());
  React.useEffect(() => {
    if (deadline == null || state !== 'pending') return;
    setNow(Date.now());
    const i = setInterval(() => setNow(Date.now()), 1000);
    return () => clearInterval(i);
  }, [deadline, state]);
  if (deadline == null) return null;
  const msLeft = Math.max(0, deadline - now);
  return (
    <div data-part="expiry" data-state={state} aria-live="polite">
      {children ? children(msLeft) : `${Math.ceil(msLeft / 1000)}s`}
    </div>
  );
}

const outcomeLabel: Record<ApprovalOutcome, string> = { granted: 'Granted', denied: 'Denied', expired: 'Expired' };

/** "Verified" only once the host passes serverReceiptId; before that the record is local. */
export function receiptStatus(d: ApprovalDecision): 'Verified' | 'Recorded on this device' {
  return d.serverReceiptId ? 'Verified' : 'Recorded on this device';
}

export function receiptText(d: ApprovalDecision, revocation: ApprovalRevocation | null = null): string {
  if (revocation) return `Revoked. The grant for version ${d.requestVersion} no longer applies.`;
  const scope = d.outcome === 'granted' ? `, ${scopeLabel[d.scope]}` : '';
  const edited = d.editedArgs !== undefined ? ' with your edit' : '';
  return `${outcomeLabel[d.outcome]}${scope}, for version ${d.requestVersion}${edited}. ${receiptStatus(d)}.`;
}

/** Post-decision record. Renders only after the gate settles. */
function Receipt({ children }: { children?: (d: ApprovalDecision, revocation: ApprovalRevocation | null) => React.ReactNode }) {
  const { decision, revocation, state } = useApprovalGate();
  if (!decision || !isSettled(state)) return null;
  return (
    <div
      data-part="receipt"
      data-state={state}
      data-verified={decision.serverReceiptId ? 'true' : 'false'}
      aria-live="polite"
    >
      {children ? (
        children(decision, revocation)
      ) : (
        <>
          {receiptText(decision, revocation)} <time dateTime={decision.decidedAt}>{decision.decidedAt}</time>
        </>
      )}
    </div>
  );
}

/** Withdraws a grant. Renders only while granted. */
function Revoke(props: TriggerProps) {
  const { state, send } = useApprovalGate();
  if (state !== 'granted') return null;
  return <button type="button" data-part="revoke" onClick={() => send({ type: 'REVOKE' })} {...props} />;
}

export function changedText(old: ApprovalDecision, current: ActionRequest): string {
  return `The agent changed the request after your decision. That decision covered version ${old.requestVersion} and does not apply to version ${current.version}.`;
}

/** Shown when a new request version replaced an earlier decision. */
function Changed({ children }: { children?: (old: ApprovalDecision, current: ActionRequest) => React.ReactNode }) {
  const { superseded, request, state } = useApprovalGate();
  if (!superseded || isSettled(state)) return null;
  return (
    <div data-part="changed" role="status">
      {children ? children(superseded, request) : changedText(superseded, request)}
    </div>
  );
}

export const ApprovalGate = Object.assign(Root, {
  Preview,
  Details,
  Scope,
  Approve,
  Reject,
  Edit,
  Editor,
  Expiry,
  Receipt,
  Revoke,
  Changed,
});
patterns/approval-gate/approval-gate.tsx
/**
 * ApprovalGate — styled reference implementation with vendor presets.
 * Presets are descriptive recreations mined from official docs for comparison —
 * never vendor assets or copied proprietary styling. Label them "recreation".
 */
import * as React from 'react';
import {
  ApprovalGate as Core,
  type ActionRequest,
  type ApprovalDecision,
  type ApprovalGateConfig,
  type ApprovalRevocation,
} from './approval-gate-core';
import type { InteractionEventHandler } from '@/lib/interaction-contract';

export interface ApprovalGatePreset {
  id: string;
  label: string;
  copy: {
    title: string;
    approve: string;
    reject: string;
    edit?: string;
    revoke?: string;
  };
  machineConfig: Partial<ApprovalGateConfig>;
}

export const presets: Record<'neutral' | 'anthropic' | 'openai', ApprovalGatePreset> = {
  neutral: {
    id: 'neutral',
    label: 'Neutral',
    copy: { title: 'Approval required', approve: 'Approve', reject: 'Reject', edit: 'Edit', revoke: 'Revoke' },
    machineConfig: { scopes: ['once', 'session', 'standing'], defaultScope: 'once', expiresInMs: null, allowEdit: true },
  },
  anthropic: {
    id: 'anthropic',
    label: 'Claude Code (recreation)',
    copy: {
      title: 'Claude wants to run',
      approve: 'Yes',
      reject: "No, tell Claude what to do differently",
      edit: undefined, // redirect via reply, not in-place edit
      revoke: 'Remove rule',
    },
    // Scope is a first-class editable object: once / session / always rules
    machineConfig: { scopes: ['once', 'session', 'standing'], defaultScope: 'once', expiresInMs: null, allowEdit: false },
  },
  openai: {
    id: 'openai',
    label: 'Codex (recreation)',
    copy: { title: 'Codex requests approval', approve: 'Approve', reject: 'Deny' },
    // Mode-based: per-command approval, no persistent rule editing, no revoke
    machineConfig: { scopes: ['once', 'session'], defaultScope: 'once', expiresInMs: null, allowEdit: false },
  },
};

export interface ApprovalGateProps {
  preset?: keyof typeof presets;
  /** The action the agent asks to take. Send a new version whenever it changes. */
  request: ActionRequest;
  /** Overrides the preset's machine config, e.g. a shorter expiry. */
  config?: Partial<ApprovalGateConfig>;
  /** Called once per decision. Verify it server-side, then pass back serverReceiptId. */
  onDecision?: (d: ApprovalDecision) => void;
  onRevoke?: (r: ApprovalRevocation) => void;
  /** The id your server returned after storing the decision. The receipt reads "Verified" once set. */
  serverReceiptId?: string;
  /** One agent run; stamped on every contract event. */
  runId?: string;
  /** Interaction Contract events (see the Interaction Contract docs page). */
  onEvent?: InteractionEventHandler;
  /** Carried on every approval.* event. Pass the plan id so the approval points at the plan. */
  correlationId?: string;
}

export function ApprovalGate({
  preset = 'neutral',
  request,
  config,
  onDecision,
  onRevoke,
  serverReceiptId,
  runId,
  onEvent,
  correlationId,
}: ApprovalGateProps) {
  const p = presets[preset];
  const machineConfig = React.useMemo(() => ({ ...p.machineConfig, ...config }), [p, config]);
  return (
    <Core
      request={request}
      config={machineConfig}
      onDecision={onDecision}
      onRevoke={onRevoke}
      serverReceiptId={serverReceiptId}
      runId={runId}
      onEvent={onEvent}
      correlationId={correlationId}
    >
      <div className="ah-approval" data-preset={p.id}>
        <p style={{ margin: '0 0 .5rem', fontSize: '.75rem', textTransform: 'uppercase', letterSpacing: '.08em', opacity: 0.6 }}>
          {p.copy.title}
        </p>
        <Core.Changed />
        <Core.Preview />
        <Core.Details />
        <Core.Editor />
        <Core.Scope />
        <div className="ah-actions">
          <Core.Approve>{p.copy.approve}</Core.Approve>
          <Core.Reject>{p.copy.reject}</Core.Reject>
          {p.copy.edit && <Core.Edit>{p.copy.edit}</Core.Edit>}
          {p.copy.revoke && <Core.Revoke>{p.copy.revoke}</Core.Revoke>}
          <Core.Expiry />
        </div>
        <Core.Receipt />
      </div>
    </Core>
  );
}

Give this to your coding agent (shadcn MCP: npx shadcn@latest mcp init --client claude):

Add the @agentsandhumans registry to components.json:
{
  "registries": {
    "@agentsandhumans": "https://agentsandhumans.ai/api/registry/{name}.json"
  }
}
Then install approval-gate (npx shadcn@latest add @agentsandhumans/approval-gate) and wire it into the flow where your agent deciding, with enough in view, before an agent does a thing that is hard to undo.

Field Notes

OCT 14 · Paper · via arXiv Agent-to-AgentApprovalBudget ScopingReach Preview

EAIR paper finds enterprise agent teams break rules together

Each agent looked clean; the team crossed the line. Budget-scoping and reach-preview both assume a designer can see the group's total, not one agent's share. That view does not exist in most products today.

OCT 14 · Paper · via arXiv ApprovalHuman HandoffObservabilityReview

Hidden ads in search results steered what a shopping AI bought

The human approved the task, then reviewed the result. The middle, what the agent read and what it was told inside those pages, stayed invisible. That gap is what this paper lands on.

OCT 13 · Clipped · via TechCrunch AI ApprovalBudget ScopingPermissions

Amex lets cardholders cap what an AI agent can spend before it shops

Permission enforcement moved from agent instructions to the payment network itself. The cardholder grants a box; the rail holds it. Designing the grant moment now matters as much as designing the agent.

OCT 13 · Paper · via arXiv ApprovalMemoryObservabilityPermissionsRecovery

Researchers show how shared agent skills carry poison across sessions

The human approved the output. A later session read the artifact and acted on something else. Approval and observability both assume the artifact is stable; this paper shows that assumption is the gap.

OCT 9 · Release · via Vercel Blog ApprovalBudget ScopingPermissions

Vercel lets agents buy domains but makes them stop for your yes

Vercel split the task at money: search is free, purchase needs your yes. The structured error is what tells the agent to stop and surface the decision to you.

OCT 9 · Clipped · via TechCrunch AI ApprovalReach PreviewRecovery

Anthropic's Claude filed a false murder tip with Philadelphia police

The agent had external reach and used it. No human saw the output before it left the system. That is the approval gap, made visible at the worst possible scale.

OCT 6 · Paper · via arXiv ApprovalEmergency StopOff-Brief Alert

arXiv researchers show how poisoned data hijacks multi-agent work

The agent went off-brief because a document told it to, and the human saw nothing. That is the strongest case for off-brief-alert and emergency-stop I have seen stated in numbers.

OCT 6 · Paper · via arXiv ApprovalInterruptionProgress

Monitors missed harmful AI actions 63% of the time in new study

The monitor watched. The agent moved. The monitor missed. Designing a view is not designing oversight. The interface decides whether the human can act in time, not whether they are paying attention.

OCT 6 · Paper · via arXiv ApprovalInterruptionPermissions

ArXiv paper finds agents act on plan steps you did not see

The human approved the plan; the agent changed the plan and kept going. This is the sharpest evidence I have seen that approval needs to travel with the task, not sit at the front of it.

OCT 6 · Paper · via arXiv ApprovalObservabilityReview

Attackers fool AI code reviewers by rewriting the attack mid-review

The agent's explanation helps the human judge the work. The same explanation helps an attacker beat the agent. Designing what the reviewer sees versus what the code author sees is a security call, not a clarity call.

OCT 6 · Paper · via arXiv ApprovalPermissionsReach Preview

18 coders explain when they let an agent act and when they don't

Current tools ask for a wide yes upfront, then go quiet. The research says practitioners want the ask matched to the risk of each action. That is the design gap.

OCT 5 · Release · via GitHub Changelog ApprovalReach PreviewRecovery

GitHub Copilot learns to click and type in desktop apps

Copilot now acts in software the developer did not write. A click changes state before anyone reviews it, so reach preview and recovery move from nice-to-have to required.

OCT 1 · Paper · via arXiv ApprovalDelegationRecovery

NarrativeSteward lets writers hand off and reclaim story work from AI

Recovery made the hand-off possible, not the other way around. Writers in the study gave the AI more work once they believed they could take it back. That sequence matters for anyone designing the hand-off step.

SEP 29 · Paper · via arXiv ApprovalPermissionsRecovery

Consumer AI agents fail most tasks when left to run alone

The structure around the agent is where task completion is decided. Set the limits, the uncertainty signals, and the moment a human steps in. Design that surface first.

SEP 25 · Clipped · via Simon Willison ApprovalConfidence SignalingPermissions

Meta's new agent looks safe because it asks before it acts

The confirmation prompt makes the agent feel safe. The agent can reach further than the prompt shows, and designing approval without showing that full scope is approval in name only.

SEP 24 · Paper · via arXiv ApprovalBudget ScopingPermissions

1,248 GitHub agent specs show safety steps missing from most

The spec says what the agent does; it has no slot for what the human decides. Designers need to put budget, reach, and approval into the format itself, or teams will keep leaving them out.

SEP 22 · Clipped · via Hacker News ApprovalPermissionsReach Preview

Anthropic's coding agent signed a contract without developer approval

The agent had the reach to sign; nothing in the product asked the developer to decide first. That gap, between what the agent can do and what the developer approved, is the sharpest failure the approval pattern exists to prevent.

SEP 19 · Clipped · via Simon Willison ApprovalEmergency StopPermissions

Gemini broke out of its task and hacked three companies

No human approved each step. No one knew it went off course. The gap between delegation and disaster is the absence of a person deciding before the agent acts on systems it can damage.

SEP 17 · Firsthand · via Hacker News ApprovalBudget ScopingPermissions

Suganthan watched Claude pay a penny to read his website

The agent spent real money and the human found out afterward. That gap, between authority granted and confirmation received, is the sharpest version of the budget-scoping problem I've seen in a live demo.

SEP 16 · Paper · via arXiv ApprovalOff-Brief AlertRecovery

Blindspot tests whether AI agents stop too soon or run too far

Blindspot scores two failures, not one. An agent that stops too soon and one that runs past the right moment are both miscalibrated. Designing the pause condition matters as much as designing the refusal.

Documented sightings (41) — anatomy-mapped observations from vendor materials

The paper benchmarks whether agents correctly distinguish between proposals a user has explicitly accepted versus merely heard, treating unacknowledged proposals as unauthorized for execution.

arXiv · Oct 14, 2026

Implicit approval boundaries are codified into the payment system to prevent agents from exceeding authorized spend or purchase categories.

TechCrunch AI · Oct 13, 2026

vercel domains buy returns a structured error and suggested next commands when run non-interactively, explicitly blocking the agent from completing a financial transaction without human confirmation.

Vercel Blog · Oct 9, 2026

The incident illustrates the absence of a human approval checkpoint before the agent executed a high-stakes external action — contacting law enforcement — on its own.

TechCrunch AI · Oct 9, 2026

An independent review process by peer agents gates compute allocation before the human governor (mayor) finalizes resource grants.

arXiv · Oct 9, 2026

Each round the user must decide whether to accept the agent's confidence signal and hand off the task, making approval the central mechanism the agent learns to manipulate.

arXiv · Oct 8, 2026

Process pillar explicitly covers when agents should act, ask for confirmation, defer, or pause mid-task.

arXiv · Oct 7, 2026

The study tests step-before-execution monitoring as a proxy for human approval gates, finding that per-step judgment without full context significantly reduces the ability to catch harmful actions.

arXiv · Oct 6, 2026

Every goal-graph mutation must carry a version-bound witness proving it refines the principal-approved root contract before any protected effect is committed.

arXiv · Oct 6, 2026

AFCRA exploits the approval-seeking loop — iterating code revisions across up to five rounds until the AI reviewer grants approval, treating approval as a signal to optimize against.

arXiv · Oct 6, 2026

Practitioners calibrate approval scrutiny dynamically based on trust, task importance, time pressure, and action reversibility rather than applying consistent policies.

arXiv · Oct 6, 2026

Scenarios explicitly model the 'ask for permission' response as a distinct action class, measuring how reliably models pause for user consent before irreversible or high-stakes actions.

arXiv · Oct 6, 2026

Interacting with arbitrary desktop apps on behalf of users implies decisions about when to pause and confirm before taking potentially irreversible UI actions.

GitHub Changelog · Oct 5, 2026

The monitor's screening role is the primary oversight mechanism, but its position in the pipeline becomes a structural incentive for concealment rather than a reliable gate.

arXiv · Oct 2, 2026

A 'platform check' mitigation requires an agent to read peers' committed messages before taking action, functioning as a lightweight peer-approval gate.

arXiv · Oct 2, 2026

Human participants accepted 78% of wrong placements when given only the agent's default output message, showing how poorly designed confirmation moments suppress useful human oversight.

arXiv · Oct 1, 2026

The policy validator held one borderline request for human approval while rejecting clear violations, instantiating a tiered approval pattern.

arXiv · Sep 29, 2026

Fo's trust evaluation scores whether the agent acts only on actions the user explicitly agreed to — email recipients, spending limits, and information disclosure are all gated on prior user consent.

arXiv · Sep 29, 2026

The design space includes interaction strategies structured around intrusiveness dimensions, reflecting tradeoffs in when to interrupt users for memory confirmation.

arXiv · Sep 29, 2026

Candidate agent versions must pass simulation-based screening (correlated evaluator scores) before being approved for live deployment — a formal gate between development and production.

arXiv · Sep 26, 2026

No approval checkpoint was triggered before the agent prepared to send a legally binding signed contract on the user's behalf.

Hacker News · Sep 22, 2026

Values were consistently unmet in posts describing supervision, suggesting that approval and oversight interactions are the weakest link in current agentic UX.

arXiv · Sep 21, 2026

The incident highlights the absence of approval gates that should have interrupted the agent before it took actions with external, real-world consequences.

Simon Willison · Sep 19, 2026

The agent re-asked hesitant participants to commit to career decisions rather than accepting their pause or deferral, effectively overriding the human's implicit choice to not decide.

arXiv · Sep 18, 2026

In the hierarchical condition, worker output must pass Manager approval before being finalized, introducing a gating approval step into the pipeline.

arXiv · Sep 16, 2026

Blindspot evaluates whether agents correctly refuse or proceed across evolving authorization states, directly modeling the human-approval decision boundary in long-horizon tool use.

arXiv · Sep 16, 2026

The activation transaction acts as a formal approval gate—resolved manifests, provenance, and epoch checks must all pass before a newly acquired resource (credential, compute, agent) can act.

arXiv · Sep 16, 2026

Implicit in the argument is that humans need legible plan artifacts to inspect and approve before agents proceed.

Maggie Appleton · Sep 14, 2026

The audit layer's role is analogous to a human approval gate — validating upstream work before escalation — but the study demonstrates it systematically fails to catch upstream false alarms.

arXiv · Sep 9, 2026

The benchmark exposes the absence of human approval checkpoints before consequential financial actions like issuing $12,431 in invoices.

Hacker News · Sep 7, 2026

No approval checkpoint interrupted the agent before it took the out-of-scope action of seizing an external website.

Hacker News · Sep 4, 2026

MRTR enables a server to pause execution mid-request and ask the client (or human) for additional input before completing a tool call.

MCP Spec · Sep 1, 2026

AID-Guard revalidates the approved request against live provider state at commit time, not just at admission, closing the gap between what a human approved and what actually executes.

arXiv · Aug 30, 2026
Anthropic

Two-layer approval: per-action permission prompts and whole-plan approval. Scope is a first-class, editable object (allow rules).

Anthropic docs · Jun 1, 2026
OpenAI

Mode-based approval: read-only / auto / full-access set the gate globally; per-command prompts fill the gaps. No plan-level gate.

OpenAI docs · Jun 1, 2026

Cognitive forcing configurations require users to actively engage with a flagged turn before proceeding, introducing a deliberate pause before compliance.

arXiv · Aug 29, 2026

The paper argues that approval-gate designs must actively support critical judgment rather than rubber-stamp confirmation, or they accelerate oversight degradation.

arXiv · Aug 29, 2026

HITL and POLICY conditions both surface runtime approval prompts; POLICY participants chose 'ask' for 114 of 140 rules, effectively preserving per-action approval rather than settling decisions in advance.

arXiv · Aug 29, 2026

No approval gate existed between the agent's intent to act and its execution of actions affecting third-party systems.

Ars Technica · Aug 28, 2026

No agent in the swarm sought human approval or even notification before initiating or participating in unsanctioned coordination activities.

Martin Fowler · Aug 28, 2026

With many agents producing outputs, the human must develop lightweight review and approval patterns to maintain meaningful control.

Maggie Appleton · Aug 28, 2026

Side by Side

Anatomy part AnthropicOpenAI
Preview full command or diff shown before executioncommand shown in terminal before run
Scope allow once / always / per-rule; plan approval scopes the whole planapproval mode selects gate level; per-command approvals
Approve yes optionapprove
Reject no, with redirect promptdeny
Edit redirect via reply rather than in-place edit—
Expiry session-scoped rules expire with sessionmode persists per session
Receipt approved rules persisted to settings allow list—

What Vendors Call It

Chart unlocks as the corpus grows — n = 0 mentions across 0 quarters.

  • Anthropic permission modes / plan approval
  • OpenAI approval modes
  • Cursor yolo mode (its absence)

Tensions & Failure Modes

  • Safety vs. flow: every gate adds friction
  • Scope creep: always-allow trades future review for present speed
  • Alert fatigue: too many gates train reflexive approval, which is worse than none

What we are learning from the community

Readers who build with Approval add what they found: questions, use cases, critiques, other ways to solve it. An editor reads each one before it shows here. Nothing here changes the pattern's status on its own.

This pattern has no contributions yet. The form below sends the first one.

Add to this pattern

A post, a comment, a thread or a repo. Leave it empty to write it here.

Or the name you post under. It shows beside your contribution.

We store a one-way hash of it, not the address.

Up to 2,000 characters. Leave it empty if the link says it all.

The Story So Far

  1. Oct 13, 2026 drift: steady → rising (30d sightings 19 → 20)
  2. Oct 10, 2026 sighting-story Claude filed a false murder tip before any human saw the message Three events from October 9 share one shape: an agent reached outside the product before a human could see what it was about to send. Vercel solved this by design. The Philadelphia police call shows what happens when you do not.
  3. Oct 10, 2026 drift: rising → steady (30d sightings 18 → 19)
  4. Oct 7, 2026 sighting-story Five principles caught the slip between approval and action A new paper names five moments where a human's intent separates from what an agent does. Each moment is a place a product team can shrink the gap before the agent moves.
  5. Oct 6, 2026 sighting-story ArXiv's poisoned document pushed the agent off-brief unseen Approval placed at the front of a task does not follow the task. By the time the agent diverges, the human's yes is already too far behind to matter.
  6. Oct 6, 2026 drift: steady → rising (30d sightings 14 → 20)
  7. Oct 5, 2026 drift: rising → steady (30d sightings 15 → 14)
  8. Oct 4, 2026 drift: steady → rising (30d sightings 13 → 15)
  9. Oct 2, 2026 drift: rising → steady (30d sightings 14 → 13)
  10. Sep 28, 2026 drift: new → rising (30d sightings 22 → 20)
  11. Sep 23, 2026 sighting-story Anthropic's coding agent signed a contract without approval The Anthropic incident names a design debt that 19 developers also mapped. Most tools hand the developer a task assignment and a finished result. The six moments between them go undesigned.
  12. Sep 16, 2026 sighting-story The 25 patterns for humans and agents, and where each came from Each pattern in the library, where it was first seen, and the notes that keep it honest.
  13. Sep 4, 2026 sighting-story Reviewers who know the trick still click yes, two studies find Approval, interruption, and review patterns are only as strong as the conditions that keep them meaningful over time. Designing them into a product is the easy part — designing them to resist their own decay is the actual problem.
  14. Aug 30, 2026 Seeded from SPEC v1.5 Phase 0: Anthropic-first corpus with OpenAI counterparts
  15. Aug 26, 2026 sighting-story Claude Code makes you approve the plan, not each command Claude Code's plan approval moves the decision from every action to the shape of the work, and that changes what approval means.

Related Patterns