Agency & Decisions

Recovery

Solves: putting things back, or taking over, after an agent makes a mistake.

Emerging — seen across multiple products; the shape is still settling.

pattern: emerging code: experimental <Recovery>: Experimental: a first core to try out, and it will change.

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

Recovery is what a person can do after an agent gets it wrong. Undo the last thing, return to a saved good state, or take the work back. Cheap reversal changes how much humans are willing to delegate. If the agent cannot fix a failure, it hands the failure to the person instead of guessing.

When to Use It

  • Agent actions can be checkpointed and rolled back
  • Cheap reversal lets you grant more autonomy with fewer asks
  • Mistakes should be recoverable by the person, not only reportable

When Not To

  • Actions are irreversible by nature (sent messages, purchases); ask before those instead
  • Undo would only hide the change, not reverse its effects
  • Checkpoint cost exceeds the value of what it protects

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 checkpoints · sent mail · rows writes a checkpoint before it acts acts: edits, sends, runs spots the problem undo or roll back restores the checkpoint; reverts what it can reports what cannot be reverted: a sent email, a deleted row confirms the restored state and what is unrecoverable
02 · who holds what
human interface agent Undo: You reverse the last thing the AI did 01 Undo Checkpoint: A saved good state you can return to 02 Checkpoint Rollback: You put things back to a checkpoint 03 Rollback Escalation: The AI hands a failure to you instead of guessing 04 Escalation
human interface agent Undo: You reverse the last thing the AI did 01 Undo Checkpoint: A saved good state you can return to 02 Checkpoint Rollback: You put things back to a checkpoint 03 Rollback Escalation: The AI hands a failure to you instead of guessing 04 Escalation
  1. 01 Undo You reverse the last thing the AI did
  2. 02 Checkpoint A saved good state you can return to
  3. 03 Rollback You put things back to a checkpoint
  4. 04 Escalation The AI hands a failure to you instead of guessing
03 · the component · code: experimental

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

Write a checkpoint before the first action, and restore it in one move.

Don't

Ask permission before each step of work you could roll back.

Reversibility spends no attention until something goes wrong; asking spends it on each step.

Do

Name what a rollback cannot pull back, such as a sent email, before and after it runs.

Don't

Call a rollback complete while an email it could not recall is still out.

Reversibility ends at the first action that left the building. Past that point the person has to act, so tell them where it is.

Do

Offer retry only when running the step again cannot repeat a side effect, and cap the attempts.

Don't

Retry a failed send in a loop until it goes through.

A retry that repeats a send turns one failure into two. Speed is worth having only where a second run is harmless.

Do

Hand a failure the agent cannot fix to a named person, and keep the agent off it until they hand it back.

Don't

Let the agent guess its way past the failure, or take the work back on a timer.

The person who takes over has to know the agent will not act behind them. The hand-back is their act.

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/recovery

Or install directly from the endpoint:

npx shadcn@latest add https://agentsandhumans.ai/api/registry/recovery.json

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

patterns/recovery/recovery-core.tsx
/**
 * Recovery — headless core.
 * Pattern: a person undoes, rolls back, or takes over when an agent's work goes wrong.
 * Anatomy: Checkpoint · Undo · Rollback · Retry · Escalation
 *
 * Three ways back, each one a request the host carries out and confirms:
 *   - Checkpoint restore: revert every action since the checkpoint that can be
 *     reverted. Actions that cannot (a sent email) are named, and stay the
 *     person's to handle. Undo is the one-step version and stops at the first
 *     action it cannot pull back.
 *   - Safe retry: run the failed step again, only when the host marks it safe
 *     (running it twice cannot repeat a side effect), and at most maxRetries times.
 *   - Takeover: the person takes the work, marks what they handled, and hands it
 *     back on purpose. The agent does not act on it in between.
 * The host confirms what it actually reverted (onRevert → confirm) and whether a
 * retry worked (onRetry → settle); the component reports what the host confirmed.
 *
 * Lifecycle:
 *   acted | escalated → undoing → (where it was)       (UNDO, then CONFIRM)
 *   acted | escalated → restoring → restored            (RESTORE, then CONFIRM)
 *   escalated | restored → retrying → acted | escalated (RETRY, then RETRY_RESULT)
 *   acted → escalated                                   (FAIL: the agent hit a failure it cannot fix)
 *   escalated | restored → taken-over → handed-back     (TAKE_OVER, HANDLE…, HAND_BACK)
 * Contract events (spec 2026-10-04 §1): execution.failed; execution.progress with
 * recovery "undo" or "restore" and phase "requested" then "confirmed"; execution.retried;
 * execution.paused on takeover (the agent holds) and execution.resumed on hand-back.
 */
import * as React from 'react';
import type { Actor, InteractionEvent, InteractionEventType } from '@/lib/interaction-contract';

export type { Actor, InteractionEvent, InteractionEventType } from '@/lib/interaction-contract';

export interface AgentAction {
  id: string;
  /** What the agent did: an edit, a send, a run. */
  label: string;
  /** False for things that cannot be pulled back, such as a sent email. */
  reversible: boolean;
  /** Clock time of the action. */
  at?: string;
}

export interface Checkpoint {
  id: string;
  /** The saved good state, in words: what it covers and when it was written. */
  label: string;
  at?: string;
}

export interface Failure {
  message: string;
  /** The action that failed, if it is one of the actions. */
  actionId?: string;
  /** "safe" when running it again cannot repeat a side effect (an idempotent call, a read). */
  retry: 'safe' | 'unsafe';
}

export type RecoveryState = 'acted' | 'undoing' | 'restoring' | 'restored' | 'retrying' | 'escalated' | 'taken-over' | 'handed-back';
export type RevertKind = 'undo' | 'restore';

export interface RevertRequest {
  runId: string;
  kind: RevertKind;
  checkpointId: string;
  /** The actions to revert, newest first. */
  actionIds: string[];
}

export interface RevertResult {
  /** Actions the host reverted. */
  reverted: string[];
  /** Actions it tried and could not revert. Anything asked for and not listed counts here. */
  failed?: string[];
}

export interface RetryRequest {
  runId: string;
  actionId: string | null;
  attempt: number;
}

export interface RetryResult {
  ok: boolean;
  /** What went wrong this time, when it failed again. */
  message?: string;
}

/** Something left for the person: the failure, or an action that could not be reverted. */
export interface OpenItem {
  id: string;
  label: string;
}

export type Event =
  | { type: 'UNDO'; at?: string }
  | { type: 'RESTORE'; at?: string }
  /** The host reports what it reverted. */
  | { type: 'CONFIRM'; result: RevertResult; at?: string }
  | { type: 'FAIL'; failure: Failure; at?: string }
  | { type: 'RETRY'; at?: string }
  | { type: 'RETRY_RESULT'; result: RetryResult; at?: string }
  | { type: 'TAKE_OVER'; at?: string }
  | { type: 'HANDLE'; id: string; handled: boolean }
  | { type: 'HAND_BACK'; at?: string };

export interface MachineState {
  state: RecoveryState;
  runId: string;
  checkpoint: Checkpoint;
  /** Actions since the checkpoint, oldest first. */
  actions: AgentAction[];
  failure: Failure | null;
  /** Confirmed reverted by the host. */
  reverted: string[];
  /** The host tried and could not revert these. */
  revertFailed: string[];
  /** The checkpoint was restored at least once. */
  restored: boolean;
  /** The revert the host is carrying out, and the state to return to after an undo. */
  pending: { kind: RevertKind; actionIds: string[]; back: RecoveryState } | null;
  attempts: number;
  maxRetries: number;
  handled: string[];
  events: InteractionEvent[];
}

export interface InitialOptions {
  runId: string;
  checkpoint: Checkpoint;
  actions: AgentAction[];
  failure?: Failure | null;
  maxRetries?: number;
}

export function initialState({ runId, checkpoint, actions, failure = null, maxRetries = 2 }: InitialOptions): MachineState {
  return {
    state: failure ? 'escalated' : 'acted',
    runId,
    checkpoint,
    actions,
    failure,
    reverted: [],
    revertFailed: [],
    restored: false,
    pending: null,
    attempts: 0,
    maxRetries,
    handled: [],
    events: [],
  };
}

const now = () => new Date().toISOString();

function emit<P>(s: MachineState, type: InteractionEventType, actor: Actor, at: string, payload: P, correlationId?: string): MachineState {
  const e: InteractionEvent<P> = {
    id: `${s.runId}:recovery:${s.events.length + 1}`,
    runId: s.runId,
    actor,
    type,
    at,
    payload,
    ...(correlationId ? { correlationId } : {}),
  };
  return { ...s, events: [...s.events, e] };
}

/** Actions still in effect, oldest first. */
export const liveActions = (s: MachineState) => s.actions.filter((a) => !s.reverted.includes(a.id));
export const lastLive = (s: MachineState): AgentAction | undefined => liveActions(s).at(-1);
export const canRevert = (s: MachineState, a: AgentAction) => a.reversible && !s.reverted.includes(a.id) && !s.revertFailed.includes(a.id);
/** Actions a restore cannot pull back: not reversible, or the host could not revert them. */
export const stuckActions = (s: MachineState) => s.actions.filter((a) => !s.reverted.includes(a.id) && (!a.reversible || s.revertFailed.includes(a.id)));

/** What the person handles before a hand-back: the failure, then whatever a revert left behind. */
export function openItems(s: MachineState): OpenItem[] {
  const items: OpenItem[] = s.failure ? [{ id: 'failure', label: s.failure.message }] : [];
  const left = s.restored ? stuckActions(s) : s.actions.filter((a) => s.revertFailed.includes(a.id) && !s.reverted.includes(a.id));
  return [...items, ...left.map((a) => ({ id: a.id, label: a.label }))];
}

export const canRetry = (s: MachineState) =>
  (s.state === 'escalated' || s.state === 'restored') && !!s.failure && s.failure.retry === 'safe' && s.attempts < s.maxRetries;

/** The escalation shows while there is something to hand over, and stays after the hand-back as the record. */
export const escalationVisible = (s: MachineState) =>
  (s.state === 'escalated' || s.state === 'restored' || s.state === 'retrying' || s.state === 'taken-over' || s.state === 'handed-back') && openItems(s).length > 0;

export const canTakeOver = (s: MachineState) => (s.state === 'escalated' || s.state === 'restored') && openItems(s).length > 0;

const lastRequest = (s: MachineState, type: InteractionEventType) =>
  [...s.events].reverse().find((ev) => ev.type === type && (ev.payload as { phase?: string }).phase === 'requested')?.id;

function requestRevert(s: MachineState, kind: RevertKind, actionIds: string[], at: string): MachineState {
  const payload = { recovery: kind, phase: 'requested', checkpointId: s.checkpoint.id, actionIds };
  const next = emit({ ...s, state: kind === 'undo' ? 'undoing' : 'restoring', pending: { kind, actionIds, back: s.state } }, 'execution.progress', 'human', at, payload);
  // Nothing to revert: the restore is done as soon as it is asked for.
  return actionIds.length === 0 ? reducer(next, { type: 'CONFIRM', result: { reverted: [] }, at }) : next;
}

export function reducer(s: MachineState, e: Event): MachineState {
  switch (e.type) {
    case 'UNDO': {
      if (s.state !== 'acted' && s.state !== 'escalated') return s;
      const last = lastLive(s);
      if (!last || !canRevert(s, last)) return s;
      return requestRevert(s, 'undo', [last.id], e.at ?? now());
    }

    case 'RESTORE': {
      if (s.state !== 'acted' && s.state !== 'escalated') return s;
      const ids = s.actions.filter((a) => canRevert(s, a)).map((a) => a.id).reverse();
      return requestRevert(s, 'restore', ids, e.at ?? now());
    }

    case 'CONFIRM': {
      if ((s.state !== 'undoing' && s.state !== 'restoring') || !s.pending) return s;
      const asked = s.pending.actionIds;
      const reverted = asked.filter((id) => e.result.reverted.includes(id));
      const failed = asked.filter((id) => !reverted.includes(id));
      const restore = s.pending.kind === 'restore';
      const next: MachineState = {
        ...s,
        state: restore ? 'restored' : s.pending.back,
        reverted: [...s.reverted, ...reverted],
        revertFailed: [...s.revertFailed, ...failed],
        restored: s.restored || restore,
        pending: null,
      };
      return emit(next, 'execution.progress', 'system', e.at ?? now(), { recovery: s.pending.kind, phase: 'confirmed', checkpointId: s.checkpoint.id, reverted, failed }, lastRequest(s, 'execution.progress'));
    }

    case 'FAIL': {
      if (s.state !== 'acted') return s;
      const next: MachineState = { ...s, state: 'escalated', failure: e.failure, attempts: 0, handled: [] };
      return emit(next, 'execution.failed', 'agent', e.at ?? now(), { actionId: e.failure.actionId ?? null, message: e.failure.message, retry: e.failure.retry });
    }

    case 'RETRY': {
      if (!canRetry(s)) return s;
      const attempt = s.attempts + 1;
      const next: MachineState = { ...s, state: 'retrying', attempts: attempt };
      return emit(next, 'execution.retried', 'human', e.at ?? now(), { phase: 'requested', actionId: s.failure!.actionId ?? null, attempt });
    }

    case 'RETRY_RESULT': {
      if (s.state !== 'retrying' || !s.failure) return s;
      const at = e.at ?? now();
      const correlation = lastRequest(s, 'execution.retried');
      if (e.result.ok) {
        const next: MachineState = { ...s, state: 'acted', failure: null, handled: [] };
        return emit(next, 'execution.retried', 'system', at, { phase: 'confirmed', actionId: s.failure.actionId ?? null, attempt: s.attempts, ok: true }, correlation);
      }
      const failure = { ...s.failure, message: e.result.message ?? s.failure.message };
      const next: MachineState = { ...s, state: 'escalated', failure };
      return emit(next, 'execution.failed', 'agent', at, { actionId: failure.actionId ?? null, message: failure.message, retry: failure.retry, attempt: s.attempts }, correlation);
    }

    case 'TAKE_OVER': {
      if (!canTakeOver(s)) return s;
      const open = openItems(s).map((o) => o.id);
      return emit({ ...s, state: 'taken-over', handled: [] }, 'execution.paused', 'human', e.at ?? now(), { takeover: true, open });
    }

    case 'HANDLE': {
      if (s.state !== 'taken-over' || !openItems(s).some((o) => o.id === e.id)) return s;
      const has = s.handled.includes(e.id);
      if (has === e.handled) return s;
      return { ...s, handled: e.handled ? [...s.handled, e.id] : s.handled.filter((h) => h !== e.id) };
    }

    case 'HAND_BACK': {
      if (s.state !== 'taken-over') return s;
      const open = openItems(s);
      if (!open.every((o) => s.handled.includes(o.id))) return s;
      const next: MachineState = { ...s, state: 'handed-back' };
      return emit(next, 'execution.resumed', 'human', e.at ?? now(), {
        handBack: true,
        fromCheckpointId: s.restored ? s.checkpoint.id : null,
        handled: open.map((o) => o.id),
      });
    }
  }
}

// ---------------------------------------------------------------- words

const plural = (n: number, one: string, many: string) => (n === 1 ? one : many);

export function statusText(s: MachineState, agent = 'the agent'): string {
  const label = (id: string) => s.actions.find((a) => a.id === id)?.label ?? id;
  const n = s.actions.length;
  const stuck = stuckActions(s).length;
  switch (s.state) {
    case 'undoing':
      return `Undoing "${label(s.pending!.actionIds[0])}".`;
    case 'restoring':
      return `Restoring the checkpoint. Reverting ${s.pending!.actionIds.length} ${plural(s.pending!.actionIds.length, 'action', 'actions')}.`;
    case 'restored': {
      const reverted = s.reverted.length;
      const need = stuck > 0 ? ` ${stuck} cannot be reverted and ${plural(stuck, 'needs', 'need')} you.` : '';
      return `Restored the checkpoint. ${reverted} of ${n} actions reverted.${need}`;
    }
    case 'retrying':
      return `Retrying, attempt ${s.attempts} of ${s.maxRetries}.`;
    case 'escalated':
      return canRetry(s)
        ? 'I am waiting for you. I will not touch this again until you retry or take over.'
        : 'I am waiting for you. I will not touch this again until you take over.';
    case 'taken-over':
      return `You took over from ${agent}. It will not act on this again until you hand it back.`;
    case 'handed-back':
      return s.restored
        ? `You handed the work back to ${agent}. It starts from the restored checkpoint.`
        : `You handed the work back to ${agent}. It carries on from where you left it.`;
    case 'acted': {
      const last = s.events.at(-1);
      const p = last?.payload as { recovery?: RevertKind; reverted?: string[]; failed?: string[]; ok?: boolean } | undefined;
      if (last?.type === 'execution.progress' && p?.recovery === 'undo') {
        if (p.failed?.length) return `Could not undo "${label(p.failed[0])}". It stays as it is, and it is yours to handle.`;
        return `Undid "${label(p.reverted![0])}". Everything before it stays as it was.`;
      }
      if (last?.type === 'execution.retried' && p?.ok) return `The retry worked. ${agent} carries on from here.`;
      const undoable = s.actions.filter((a) => a.reversible).length;
      return `${n} actions since the checkpoint. ${undoable} can be undone, ${n - undoable} cannot.`;
    }
  }
}

export function retryText(s: MachineState): string {
  if (!s.failure) return '';
  if (s.failure.retry === 'unsafe') return 'A retry could repeat what already happened, so retry is off. Take over instead.';
  if (s.attempts >= s.maxRetries) return `Retried ${s.attempts} ${plural(s.attempts, 'time', 'times')}. Take over instead.`;
  return 'Retry is safe: running it again cannot repeat what already happened.';
}

// ---------------------------------------------------------------- React

interface RecoveryContext extends MachineState {
  agent: string;
  send: (e: Event) => void;
}

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

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

export interface RootProps {
  runId: string;
  agent?: string;
  checkpoint: Checkpoint;
  /** Actions since the checkpoint, oldest first. */
  actions: AgentAction[];
  /** A failure the agent could not fix. Pass it when it happens; the component escalates. */
  failure?: Failure | null;
  /** How many retries a safe failure gets before only a takeover is left. Defaults to 2. */
  maxRetries?: number;
  /** Revert these actions, then call confirm with what you reverted and what you could not. */
  onRevert?: (request: RevertRequest, confirm: (result: RevertResult) => void) => void;
  /** Run the failed step again, then call settle with whether it worked. */
  onRetry?: (request: RetryRequest, settle: (result: RetryResult) => void) => void;
  /** Every contract event, once each. */
  onEvent?: (e: InteractionEvent) => void;
  children: React.ReactNode;
}

function Root({ runId, agent = 'the agent', checkpoint, actions, failure, maxRetries, onRevert, onRetry, onEvent, children }: RootProps) {
  const [machine, send] = React.useReducer(reducer, { runId, checkpoint, actions, failure, maxRetries }, initialState);
  const confirm = React.useCallback((result: RevertResult) => send({ type: 'CONFIRM', result }), []);
  const settle = React.useCallback((result: RetryResult) => send({ type: 'RETRY_RESULT', result }), []);

  // A failure that arrives after mount escalates; the one passed at mount is already in the state.
  const failureKey = failure ? `${failure.actionId ?? ''}:${failure.message}` : '';
  const seenFailure = React.useRef(failureKey);
  React.useEffect(() => {
    if (!failure || failureKey === seenFailure.current) return;
    seenFailure.current = failureKey;
    send({ type: 'FAIL', failure });
  }, [failureKey]);

  const sent = React.useRef(0);
  React.useEffect(() => {
    const fresh = machine.events.slice(sent.current);
    sent.current = machine.events.length;
    for (const e of fresh) {
      onEvent?.(e);
      const p = e.payload as { phase?: string; recovery?: RevertKind; checkpointId?: string; actionIds?: string[]; actionId?: string | null; attempt?: number };
      if (p.phase !== 'requested') continue;
      if (e.type === 'execution.progress' && p.recovery && p.actionIds?.length) {
        onRevert?.({ runId: e.runId, kind: p.recovery, checkpointId: p.checkpointId!, actionIds: p.actionIds }, confirm);
      }
      if (e.type === 'execution.retried') onRetry?.({ runId: e.runId, actionId: p.actionId ?? null, attempt: p.attempt! }, settle);
    }
  }, [machine.events, onEvent, onRevert, onRetry, confirm, settle]);

  const value = React.useMemo(() => ({ ...machine, agent, send }), [machine, agent]);
  return (
    <Ctx.Provider value={value}>
      <div role="group" aria-label={`Recover ${agent}'s work`} data-pattern="recovery" data-part="root" data-state={machine.state}>
        {children}
      </div>
    </Ctx.Provider>
  );
}

/** The saved good state, and whether it has been restored. */
function CheckpointPart() {
  const { checkpoint, restored } = useRecovery();
  return (
    <div data-part="checkpoint">
      <span data-part="pin" aria-hidden="true" />
      <span data-part="checkpoint-label">{checkpoint.label}</span>
      <span data-part="chip" data-tone={restored ? 'reverted' : 'saved'}>
        {restored ? 'Restored' : 'Saved'}
      </span>
    </div>
  );
}

export type ActionTone = 'done' | 'stuck' | 'reverted' | 'reverting';

export const actionChip: Record<ActionTone, string> = {
  done: 'Done, can undo',
  stuck: 'Done, cannot be undone',
  reverted: 'Reverted',
  reverting: 'Reverting',
};

export function actionTone(s: MachineState, a: AgentAction): ActionTone {
  if (s.reverted.includes(a.id)) return 'reverted';
  if (s.pending?.actionIds.includes(a.id)) return 'reverting';
  return canRevert(s, a) ? 'done' : 'stuck';
}

/** The actions since the checkpoint, and Undo for the newest one still in effect. */
function Actions({ label = 'Actions since the checkpoint', undo = 'Undo the last action' }: { label?: string; undo?: string }) {
  const machine = useRecovery();
  const last = lastLive(machine);
  const open = machine.state === 'acted' || machine.state === 'escalated';
  return (
    <div data-part="undo">
      <ul data-part="actions" aria-label={label}>
        {machine.actions.map((a) => {
          const tone = actionTone(machine, a);
          return (
            <li key={a.id} data-part="action" data-tone={tone} data-last={a.id === last?.id || undefined}>
              <span data-part="action-at">{a.at ?? ''}</span>
              <span data-part="action-label">{a.label}</span>
              <span data-part="chip" data-tone={tone}>
                {actionChip[tone]}
              </span>
            </li>
          );
        })}
      </ul>
      <button type="button" data-part="undo-button" disabled={!open || !last || !canRevert(machine, last)} onClick={() => machine.send({ type: 'UNDO' })}>
        {undo}
      </button>
      {open && last && !canRevert(machine, last) && (
        <p data-part="callout">The last action, "{last.label}", cannot be undone. Undo stops here. Roll back to revert the rest.</p>
      )}
      {open && !last && <p data-part="hint">Nothing left to undo. You are back at the checkpoint.</p>}
    </div>
  );
}

/** Restores the checkpoint: what it reverts and what stays, before and after. */
function Rollback({ children = 'Roll back to the checkpoint' }: { children?: React.ReactNode }) {
  const machine = useRecovery();
  const revertable = machine.actions.filter((a) => canRevert(machine, a));
  const stuck = stuckActions(machine);
  const open = machine.state === 'acted' || machine.state === 'escalated';
  return (
    <div data-part="rollback">
      {machine.restored ? (
        <p data-part="preview">
          <span data-part="chip" data-tone="reverted">
            Rolled back
          </span>{' '}
          {machine.reverted.length} reverted, {stuck.length} left as {plural(stuck.length, 'it was', 'they were')}.
        </p>
      ) : (
        <p data-part="preview">
          Reverts {revertable.length} {plural(revertable.length, 'action', 'actions')}.
          {stuck.length > 0 && ` ${stuck.length} ${plural(stuck.length, 'stays', 'stay')} as ${plural(stuck.length, 'it is', 'they are')}: ${stuck.map((a) => a.label).join('; ')}.`}
        </p>
      )}
      <button type="button" data-part="rollback-button" disabled={!open} onClick={() => machine.send({ type: 'RESTORE' })}>
        {children}
      </button>
    </div>
  );
}

/** Runs the failed step again, only when that is safe. Renders while there is a failure. */
function Retry({ children = 'Retry' }: { children?: React.ReactNode }) {
  const machine = useRecovery();
  if (!machine.failure && machine.state !== 'retrying') return null;
  return (
    <div data-part="retry" data-safe={machine.failure?.retry === 'safe'}>
      <button type="button" data-part="retry-button" disabled={!canRetry(machine)} onClick={() => machine.send({ type: 'RETRY' })}>
        {children}
      </button>
      <p data-part="hint">{retryText(machine)}</p>
    </div>
  );
}

/** The failure and what is left, with take over and hand back. Renders when there is something to hand over. */
function Escalation() {
  const machine = useRecovery();
  const { state, failure, handled, agent, send } = machine;
  const open = openItems(machine);
  if (!escalationVisible(machine)) return null;
  const leftCount = open.filter((o) => !handled.includes(o.id)).length;
  const holding = state === 'taken-over' || state === 'handed-back';
  return (
    <div data-part="escalation">
      <p data-part="say">{failure ? `${failure.message} I stopped instead of guessing.` : 'I cannot fix these. They are yours to handle:'}</p>
      <fieldset data-part="handle" disabled={state !== 'taken-over'}>
        <legend>{state === 'taken-over' ? 'Mark what you handled' : state === 'handed-back' ? 'Handled by you' : 'Take over to handle these'}</legend>
        {open.map((o) => (
          <label key={o.id} data-done={handled.includes(o.id) || undefined}>
            <input type="checkbox" checked={handled.includes(o.id)} aria-label={`Handled: ${o.label}`} onChange={(ev) => send({ type: 'HANDLE', id: o.id, handled: ev.target.checked })} />
            <span>{o.label}</span>
          </label>
        ))}
      </fieldset>
      {holding ? (
        <button type="button" data-part="hand-back" disabled={state === 'handed-back' || leftCount > 0} onClick={() => send({ type: 'HAND_BACK' })}>
          Hand back to {agent}
        </button>
      ) : (
        <button type="button" data-part="take-over" disabled={!canTakeOver(machine)} onClick={() => send({ type: 'TAKE_OVER' })}>
          Take over from {agent}
        </button>
      )}
      {state === 'taken-over' && leftCount > 0 && <p data-part="hint">{leftCount} left to handle before you hand it back.</p>}
    </div>
  );
}

/** One polite line: where the recovery stands. */
function Status({ children }: { children?: (s: MachineState) => React.ReactNode }) {
  const machine = useRecovery();
  return (
    <p data-part="status" role="status" aria-live="polite">
      {children ? children(machine) : statusText(machine, machine.agent)}
    </p>
  );
}

export const Recovery = Object.assign(Root, { Checkpoint: CheckpointPart, Actions, Rollback, Retry, Escalation, Status });
patterns/recovery/recovery.tsx
/**
 * Recovery — styled reference implementation of the headless core.
 * The checkpoint, the actions since it with undo, rollback, retry when the
 * failure allows it, and the escalation with take over and hand back.
 * Styling lives in tokens.css (.ah-ctl, .ah-recovery).
 */
import * as React from 'react';
import {
  Recovery as Core,
  escalationVisible,
  useRecovery,
  type AgentAction,
  type Checkpoint,
  type Failure,
  type RetryRequest,
  type RetryResult,
  type RevertRequest,
  type RevertResult,
  type InteractionEvent,
} from './recovery-core';

export interface RecoveryProps {
  runId: string;
  agent: string;
  /** The saved good state, written before the first action. */
  checkpoint: Checkpoint;
  /** Actions since the checkpoint, oldest first. */
  actions: AgentAction[];
  /** A failure the agent could not fix. Opens with the escalation. */
  failure?: Failure | null;
  maxRetries?: number;
  /** Revert the actions asked for, then call confirm with what you reverted. */
  onRevert?: (request: RevertRequest, confirm: (result: RevertResult) => void) => void;
  /** Run the failed step again, then call settle with whether it worked. */
  onRetry?: (request: RetryRequest, settle: (result: RetryResult) => void) => void;
  onEvent?: (e: InteractionEvent) => void;
}

function Body() {
  const machine = useRecovery();
  const { state, failure } = machine;
  return (
    <section className="ah-ctl ah-recovery" data-state={state}>
      <div className="ah-ctl-part" data-actor="system">
        <span className="ah-ctl-label">Checkpoint</span>
        <Core.Checkpoint />
      </div>

      <div className="ah-ctl-part" data-actor="human">
        <span className="ah-ctl-label">Undo</span>
        <Core.Actions />
      </div>

      <div className="ah-ctl-part" data-actor="human">
        <span className="ah-ctl-label">Rollback</span>
        <Core.Rollback />
      </div>

      {(failure || state === 'retrying') && (
        <div className="ah-ctl-part" data-actor="human">
          <span className="ah-ctl-label">Retry</span>
          <Core.Retry />
        </div>
      )}

      {escalationVisible(machine) && (
        <div className="ah-ctl-part" data-actor="agent">
          <span className="ah-ctl-label">Escalation</span>
          <Core.Escalation />
        </div>
      )}

      <Core.Status />
    </section>
  );
}

export function Recovery({ runId, agent, checkpoint, actions, failure, maxRetries, onRevert, onRetry, onEvent }: RecoveryProps) {
  return (
    <Core
      runId={runId}
      agent={agent}
      checkpoint={checkpoint}
      actions={actions}
      failure={failure}
      maxRetries={maxRetries}
      onRevert={onRevert}
      onRetry={onRetry}
      onEvent={onEvent}
    >
      <Body />
    </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 recovery (npx shadcn@latest add @agentsandhumans/recovery) and wire it into the flow where your agent putting things back, or taking over, after an agent makes a mistake.

Field Notes

arXiv paper tests what happens when AI teammates fail each other

The human handed the work off and had no signal when a member of the group went wrong. Recovery and off-brief alerts are the gap. Design for the failure that looks like progress.

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 · 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 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 Background ExecutionPermissionsRecovery

AI agent runs supercomputer jobs for scientists across four tasks

ASCEND moves the researcher out of the failure loop: the agent diagnoses and resubmits first. That changes what a progress view has to surface, and what 'step in' means.

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 29 · Paper · via arXiv ContextMemoryRecovery

457 diary entries show what breaks when a chat assistant forgets you

The human had no view into what the assistant stored about them. That gap is the whole failure. If the human cannot inspect and correct what the assistant holds, trust breaks and stays broken.

SEP 22 · Paper · via arXiv ObservabilityProgressRecovery

Vibe-GUIDE gives developers a graph view of agent coding work

The graph view changed when developers chose to redirect the agent, not whether they could. That is the design lever: the shape of the work display shifts the timing of human judgment.

SEP 22 · Paper · via arXiv InterruptionProgressRecovery

19 developers mapped the real work of supervising a coding agent

The study names six distinct moments where a developer must act, not watch. Most tools are built for the moment a task is assigned and leave the rest thin. That gap is where trust breaks.

SEP 16 · Paper · via arXiv Agent-to-AgentObservabilityRecovery

86 runs show flat agent teams beat hierarchical ones on hard tasks

The experiment moves the question from 'who is in charge' to 'who can redirect'. A product team designing agent teams needs to build that redirect behavior in by design, or it disappears.

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.

SEP 7 · Clipped · via Hacker News ApprovalBudget ScopingRecovery

Seven agents ran real businesses and sent $12,431 in unasked invoices

No spend cap, no checkpoint requiring human sign-off, no log of planned actions. This is the clearest negative sighting for budget-scoping and human sign-off I have seen in a live financial setting. Build those in before you ship.

AUG 29 · Paper · via arXiv ApprovalRecoveryReview

Margaret Mitchell argues oversight wears down as agents do more alone

This is the clearest framing I've seen for why approval and review patterns decay in production. The design challenge isn't shipping the gate — it's keeping the gate meaningful as agents scale.

AUG 28 · Release · via Hacker News Recovery

Claude Code can now edit many files at once

Multi-file edits raise the stakes of every gate: approve and undo now span a changeset, not a file. Recovery patterns are about to matter more than approval ones.

AUG 25 · Clipped · via X · @gdb ObservabilityPermissionsRecovery

OpenAI lets ChatGPT Work agents sign into accounts on your behalf

Authenticated access is the moment permissions design stops being optional scaffolding. If there's no clear scope boundary, audit trail, or revocation path visible to the user, trust is being borrowed against nothing.

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

The diagnosis-and-repair pipeline uses execution traces to locate the contributing interaction boundaries and guide targeted workflow repairs before retesting.

arXiv · Oct 14, 2026

UnCoBench explicitly benchmarks whether agents can recover from misaligned, incomplete, or missed responses from peer agents — mapping failure modes and corrective follow-up behaviors.

arXiv · Oct 14, 2026

The 2-month discovery gap raises questions about what recovery or remediation mechanisms exist once an agent has already triggered an irreversible real-world action.

TechCrunch AI · Oct 9, 2026

The volunteer human moderator attempted manual content deletion as the sole recovery mechanism against ~18,000 agent posts over six weeks.

arXiv · Oct 6, 2026

The mechanism blocks forbidden-state commits in drifted replanning cases while still completing all benign counterparts, preserving useful replanning as a recovery path.

arXiv · Oct 6, 2026

Reversibility of actions is identified as a key factor in permission decisions, with practitioners applying less scrutiny when they believe mistakes can be undone.

arXiv · Oct 6, 2026

Restoring the system-prompt anchor reversibly restored persona enactment, but conversational recovery alone was largely ineffective (only 1/18 remained persona-enacting after purely conversational recovery).

arXiv · Oct 3, 2026

The paper proposes environment-specific mitigations (team lead role, procedural instructions) as recovery mechanisms when coordination breaks down in multi-agent teams.

arXiv · Oct 2, 2026

The system includes recovery mechanisms validated by technical tests to address agent execution failures or unintended changes.

arXiv · Oct 1, 2026

Precisely targeted hedges cut wrong-move acceptance from 78% to 36%, functioning as a lightweight recovery mechanism that preserves correct-move acceptance.

arXiv · Oct 1, 2026

The paper examines relaxation dynamics — how multi-agent systems return to or diverge from equilibrium — as a signal for detecting and potentially responding to emergent pathologies.

arXiv · Sep 29, 2026

The agent closed a failure-recovery loop on a planted tensor-device fault, autonomously diagnosing and resubmitting with artifacts preserved.

arXiv · Sep 29, 2026

Completion scoring measures whether the agent persists through hard errands rather than abandoning them, framing graceful task recovery as a distinct evaluable capability.

arXiv · Sep 29, 2026

The paper frames both incidents through crisis-informatics: agents improvised coordination after losing their designed communication pathways, analogous to disaster-survivor behavior.

arXiv · Sep 28, 2026

The simulation workflow is explicitly designed to catch failures (policy violations, tool misuse) that would otherwise erode customer trust in production, enabling pre-deployment correction.

arXiv · Sep 26, 2026

The control layer handles in-game recovery actions autonomously, with the LM agent issuing high-level recovery choices that the faster layer executes.

arXiv · Sep 26, 2026

Post-breach remediation across three companies raises open questions about what recovery even looks like when an agent causes cross-organizational harm.

Simon Willison · Sep 19, 2026

The report implicitly frames detection of these injections as a recovery/correction problem: catching misaligned behaviour introduced at the compaction boundary before it propagates through subsequent agent turns.

Simon Willison · Sep 17, 2026

The benchmark tracks post-refusal failure as a distinct metric, capturing whether agents that initially refuse later capitulate under adversarial pressure — a recovery-pattern failure mode.

arXiv · Sep 16, 2026

The system proves crash/retry and refund safety properties, ensuring that failed or retried activation transactions cannot leave dangling authorized resources.

arXiv · Sep 16, 2026

Contestability after the fact is named as one of three required properties for trustworthy human-AI systems, framing recovery/redress as an authorization requirement.

arXiv · Sep 16, 2026

No evaluated world achieved full resilience across all three stress events, exposing the absence of effective recovery mechanisms once adversarial content entered shared system state.

arXiv · Sep 16, 2026

Study participants used cross-checking across multiple evidence sources within the workflow graph to identify and understand errors in agent execution.

arXiv · Sep 16, 2026

RubyGems security team had to pause all signups and conduct hours of triage to contain the damage after the agent swarm had already acted.

Simon Willison · Sep 12, 2026

The $3,200 loss scenario implies a need for recovery mechanisms when agents take irreversible real-world financial actions.

Hacker News · Sep 7, 2026

Termination and grounding failures identified in traces expose the lack of robust recovery mechanisms when agents misidentify UI state.

arXiv · Sep 2, 2026

Under crash-recovery schedules across Stripe and Resend, AID-Guard's delivery fence and reservation model prevented duplicate effects in all 10 evaluated crash-recovery scenarios.

arXiv · Aug 30, 2026
Anthropic

Time-travel recovery: checkpoints before risky changes; rewind restores code, conversation, or both.

Anthropic docs · Feb 15, 2026
OpenAI

Prevention as recovery: sandbox contains changes; discard the diff instead of undoing applied work.

OpenAI docs · Feb 15, 2026

Matt's 'Zaggy' git wrapper is a guardrail preventing agents from force-pushing repositories, illustrating recovery-oriented safety tooling around agentic dev workflows.

The Changelog · Aug 29, 2026

Despite multiple failure declarations, the AI resumed adding debug code and analyzing results when the human insisted, demonstrating human-driven recovery from agent surrender.

Simon Willison · Aug 28, 2026

Claude attempted to self-correct by killing the compromised process, but the safety layer prevented it—demonstrating a failure in agent recovery when guardrails lack context about remediation intent.

Simon Willison · Aug 28, 2026

Side by Side

Anatomy part AnthropicOpenAI
Undo per-change revert via rewind—
Checkpoint automatic before risky changessandbox boundary is the checkpoint
Rollback rewind code/conversation/bothdiscard sandbox diff
Escalation ——

What Vendors Call It

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

  • Anthropic rewind / checkpoints
  • OpenAI revert

Tensions & Failure Modes

  • Reversibility is a spectrum: code reverts, sent emails do not
  • Checkpoint cost vs. granularity

What we are learning from the community

Readers who build with Recovery 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 4, 2026 Component published, experimental: checkpoint restore and undo report what the host confirmed it reverted, retry runs only when a second run cannot repeat a side effect, and takeover keeps the agent off the work until the person hands it back.
  2. Sep 25, 2026 drift: new → rising (30d sightings 8 → 7)
  3. Aug 30, 2026 Seeded from SPEC v1.5 Phase 0: Anthropic-first corpus with OpenAI counterparts

Related Patterns