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
02 · who holds what
01UndoYou reverse the last thing the AI did
02CheckpointA saved good state you can return to
03RollbackYou put things back to a checkpoint
04EscalationThe 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):
/**
* 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.
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.
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.
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.
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.
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.
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 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.
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.
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.
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.
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.
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.
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.
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.
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.
UnCoBench explicitly benchmarks whether agents can recover from misaligned, incomplete, or missed responses from peer agents — mapping failure modes and corrective follow-up behaviors.
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.
The mechanism blocks forbidden-state commits in drifted replanning cases while still completing all benign counterparts, preserving useful replanning as a recovery path.
Reversibility of actions is identified as a key factor in permission decisions, with practitioners applying less scrutiny when they believe mistakes can be undone.
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).
The paper proposes environment-specific mitigations (team lead role, procedural instructions) as recovery mechanisms when coordination breaks down in multi-agent teams.
Precisely targeted hedges cut wrong-move acceptance from 78% to 36%, functioning as a lightweight recovery mechanism that preserves correct-move acceptance.
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.
Completion scoring measures whether the agent persists through hard errands rather than abandoning them, framing graceful task recovery as a distinct evaluable capability.
The paper frames both incidents through crisis-informatics: agents improvised coordination after losing their designed communication pathways, analogous to disaster-survivor behavior.
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.
Post-breach remediation across three companies raises open questions about what recovery even looks like when an agent causes cross-organizational harm.
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.
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.
The system proves crash/retry and refund safety properties, ensuring that failed or retried activation transactions cannot leave dangling authorized resources.
Contestability after the fact is named as one of three required properties for trustworthy human-AI systems, framing recovery/redress as an authorization requirement.
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.
Study participants used cross-checking across multiple evidence sources within the workflow graph to identify and understand errors in agent execution.
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.
Matt's 'Zaggy' git wrapper is a guardrail preventing agents from force-pushing repositories, illustrating recovery-oriented safety tooling around agentic dev workflows.
Despite multiple failure declarations, the AI resumed adding debug code and analyzing results when the human insisted, demonstrating human-driven recovery from agent surrender.
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.
Chart unlocks as the corpus grows — n = 0 mentions across 0 quarters.
Anthropicrewind / checkpoints
OpenAIrevert
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.
The Story So Far
Oct 4, 2026Component 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.