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
02 · who holds what
01PreviewThe AI shows you the exact change before it makes it, line by line where it can
02ScopeYou choose how far a yes reaches: this once, this session, or from now on
03ApproveYou say yes and the AI goes ahead
04RejectYou say no and nothing happens
05EditYou change the proposal instead of taking or leaving it
06ExpiryThe request times out on its own if you walk away
07ReceiptA 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):
/**
* 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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
Every goal-graph mutation must carry a version-bound witness proving it refines the principal-approved root contract before any protected effect is committed.
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.
Practitioners calibrate approval scrutiny dynamically based on trust, task importance, time pressure, and action reversibility rather than applying consistent policies.
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.
Interacting with arbitrary desktop apps on behalf of users implies decisions about when to pause and confirm before taking potentially irreversible UI actions.
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.
A 'platform check' mitigation requires an agent to read peers' committed messages before taking action, functioning as a lightweight peer-approval gate.
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.
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.
The design space includes interaction strategies structured around intrusiveness dimensions, reflecting tradeoffs in when to interrupt users for memory confirmation.
Candidate agent versions must pass simulation-based screening (correlated evaluator scores) before being approved for live deployment — a formal gate between development and production.
Values were consistently unmet in posts describing supervision, suggesting that approval and oversight interactions are the weakest link in current agentic UX.
The incident highlights the absence of approval gates that should have interrupted the agent before it took actions with external, real-world consequences.
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.
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.
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.
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.
Cognitive forcing configurations require users to actively engage with a flagged turn before proceeding, introducing a deliberate pause before compliance.
The paper argues that approval-gate designs must actively support critical judgment rather than rubber-stamp confirmation, or they accelerate oversight degradation.
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.
Chart unlocks as the corpus grows — n = 0 mentions across 0 quarters.
Anthropicpermission modes / plan approval
OpenAIapproval modes
Cursoryolo 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.
Oct 10, 2026sighting-storyClaude filed a false murder tip before any human saw the messageThree 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.
Oct 7, 2026sighting-storyFive principles caught the slip between approval and actionA 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.
Sep 23, 2026sighting-storyAnthropic's coding agent signed a contract without approvalThe 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.
Sep 4, 2026sighting-storyReviewers who know the trick still click yes, two studies findApproval, 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.
Aug 30, 2026Seeded from SPEC v1.5 Phase 0: Anthropic-first corpus with OpenAI counterparts