Agent statusError and recovery
Built by DesignMe, free to use under the MIT licence.
Expert design and dev support for startups and enterprises.
Book a callError and recovery
A failed step with retry, skip or hand over to a person. Free and MIT licensed, as HTML or a typed React component, in two styles with light and dark mode.
Install
Add it to your project with the CLI.
HTML, CSS and JavaScript
npx designme add error-recoveryReact
npx designme add error-recovery --reactThe CLI copies the source into your project, so you own it and can change anything. The HTML version lands in ./designme with the shared core and a demo.html. The React version lands in src/components/designme and needs React 19; it works in Next.js, Vite and Astro.
About
What it is for and what it does.
Error recovery shows an agent run that stopped on a failed step, with the steps before and after it and the error underneath. Below the steps, the person chooses how the agent should carry on, such as retry, skip the step or hand it to a colleague, and presses Continue.
- When an agent hits an error it can't fix by itself, like a lost permission or a missing file, and needs a decision.
- When you want the person to see what already worked before choosing what to do about what failed.
- When a failure can go to a teammate instead of stopping the whole run.
- Each step has a
state: done, fail (with a red alert and note), run (spinner), wait (not started), skip (struck through) or hand (passed to a person). - A step's
notesits under it, like "Done in 8s" or the error message, and crossfades when it changes. The glyph plays a short entrance each time the state changes. - The header shows the run's
titleand astatusline, like "Stopped at step 2", that crossfades as you update it. optionsare shown as single-choice rows underquestion. The first one is picked by default, or you control the choice withvalueandonValueChange.- Continue calls
onContinuewith the chosen value and folds the choices away.continueLabelrenames the button, andopenlets you keep the choices in view.
- The choices are a
role="radiogroup"labelled by the question, and each option is arole="radio"witharia-checked. - Once folded away, the choices are
inertandaria-hidden.
Usage
Import it and drive it from your own state.
Import
import { ErrorRecovery } from '@/components/designme/error-recovery/ErrorRecovery';Example
The demo above as a React component, 42 lines you can copy.
'use client';
// The animated preview from designme.dev, rebuilt from the React component: an example of driving it from your own state.
import { useState } from 'react';
import { ErrorRecovery, type RecoveryStep } from './ErrorRecovery';
import { useScript } from '../lib/useScript';
/* ---------- Error and recovery: step 2 failed; pick how to carry on and the run plays it out ---------- */
const OPTIONS = [
{ value: 'retry', label: 'Ask for edit access, then retry' },
{ value: 'skip', label: 'Skip it and post the summary' },
{ value: 'hand', label: 'Hand it over to Maya' }
];
export default function ErrorRecoveryDemo() {
const [steps, setSteps] = useState<RecoveryStep[]>([
{ label: 'Pull 1,240 deals from the CRM', state: 'done', note: 'Done in 8s' },
{ label: 'Write to the Pipeline 2026 sheet', state: 'fail', note: 'Error 403: the agent lost edit access to this sheet' },
{ label: 'Post the summary in #sales', state: 'wait' }
]);
const [status, setStatus] = useState('Stopped at step 2');
const [choice, setChoice] = useState<string | null>(null);
const set = (i: number, state: RecoveryStep['state'], note?: string) => setSteps(s => s.map((x, k) => k === i ? { ...x, state, note } : x));
// plays out the chosen recovery once Continue is pressed
useScript(async ({ wait }) => {
if (choice === 'retry') {
set(1, 'run', 'Asking for edit access'); setStatus('Recovering'); await wait(1200);
set(1, 'run', 'Access granted, retrying'); await wait(1000);
set(1, 'done', 'Wrote 1,240 rows'); set(2, 'run'); await wait(1000);
set(2, 'done', 'Posted to 14 people'); setStatus('Recovered');
} else if (choice === 'skip') {
set(1, 'skip', 'Skipped, nothing was written'); set(2, 'run'); await wait(1000);
set(2, 'done', 'Posted, with a note about the sheet'); setStatus('2 of 3 done');
} else if (choice === 'hand') {
set(1, 'hand', 'Maya has the error, the logs and the last good state'); setStatus('Waiting on Maya');
}
}, [choice]);
return (
<div style={{ width: '100%' }}>
<ErrorRecovery title="Sync the pipeline to Sheets" status={status} steps={steps} options={OPTIONS} onContinue={setChoice} />
</div>
);
}
Props
Everything ErrorRecovery takes.
| Prop | Type | What it does |
|---|---|---|
titlerequired | string | What the agent was doing, like "Sync the pipeline to Sheets" |
status | string | Where the run stands, like "Stopped at step 2". Crossfades when it changes |
stepsrequired | RecoveryStep[] | |
question | string | The question above the choices |
optionsrequired | RecoveryOption[] | The ways the agent can carry on |
value | string | The chosen option. Leave it out to let the choices hold their own selection |
defaultValue | string | Defaults to the first option |
onValueChange | (value: string) => void | |
onContinue | (value: string) => void | Called with the chosen option when Continue is pressed |
continueLabel | string | |
open | boolean | Whether the choices show. Leave it out and they fold away once Continue is pressed |
defaultOpen | boolean | |
onOpenChange | (open: boolean) => void | |
bare | boolean | Sits flat in a chat thread (the default, as on designme.dev). Set false to give it a card of its own |
className | string |
Code
The full source, as the CLI installs it.
ErrorRecovery.tsxReact · 96 lines
'use client';
import { cx } from '../lib/cx';
import { useControllable } from '../lib/hooks';
import { Icon } from '../lib/icons';
import { Dot, Fold, Spin, Swap } from '../lib/motion';
import '../designme.css';
import './error-recovery.css';
export type RecoveryStepState = 'done' | 'fail' | 'run' | 'wait' | 'skip' | 'hand';
export interface RecoveryStep {
label: string;
/** done, fail (red note), run (spinner), wait (not started), skip (struck through) or hand (passed to a person) */
state: RecoveryStepState;
/** The quiet line under the step, like "Done in 8s" or the error. Crossfades when it changes */
note?: string;
}
export interface RecoveryOption {
value: string;
label: string;
}
export interface ErrorRecoveryProps {
/** What the agent was doing, like "Sync the pipeline to Sheets" */
title: string;
/** Where the run stands, like "Stopped at step 2". Crossfades when it changes */
status?: string;
steps: RecoveryStep[];
/** The question above the choices */
question?: string;
/** The ways the agent can carry on */
options: RecoveryOption[];
/** The chosen option. Leave it out to let the choices hold their own selection */
value?: string;
/** Defaults to the first option */
defaultValue?: string;
onValueChange?: (value: string) => void;
/** Called with the chosen option when Continue is pressed */
onContinue?: (value: string) => void;
continueLabel?: string;
/** Whether the choices show. Leave it out and they fold away once Continue is pressed */
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
/** Sits flat in a chat thread (the default, as on designme.dev). Set false to give it a card of its own */
bare?: boolean;
className?: string;
}
function Glyph({ state }: { state: RecoveryStepState }) {
if (state === 'done') return <span style={{ color: 'var(--dm-ok)' }}><Icon name="check" size="xs" /></span>;
if (state === 'fail') return <span style={{ color: 'var(--dm-bad)' }}><Icon name="alert" size="xs" /></span>;
if (state === 'run') return <Spin />;
if (state === 'skip') return <span className="dm-muted"><Icon name="chev-r" size="xs" /></span>;
if (state === 'hand') return <span style={{ color: 'var(--dm-pl)' }}><Icon name="user" size="xs" /></span>;
return <Dot color="var(--dm-faint)" />;
}
/** A run that hit an error, with the steps around it and a choice of how the agent should carry on. */
export function ErrorRecovery({ title, status, steps, question = 'How should the agent continue?', options, value, defaultValue, onValueChange, onContinue, continueLabel = 'Continue', open, defaultOpen = true, onOpenChange, bare = true, className }: ErrorRecoveryProps) {
const [pick, setPick] = useControllable(value, defaultValue ?? options[0]?.value ?? '', onValueChange);
const [isOpen, setOpen] = useControllable(open, defaultOpen, onOpenChange);
return (
<div data-dm="" className={cx('dm-erx', !bare && 'dm-panel', className)} style={bare ? { paddingInline: 0 } : undefined}>
<div className="dm-erx-h">
<span className="dm-erx-t">{title}</span>
{status !== undefined && <Swap className="dm-erx-s dm-muted">{status}</Swap>}
</div>
<div className="dm-erx-list">
{steps.map((s, i) => (
<div key={i} className={cx('dm-erx-row', 'dm-' + s.state)}>
{/* keyed by state and note, so a new state plays its entrance like the site does */}
<span className="dm-g"><span key={s.state + '\u0000' + (s.note ?? '')} className="dm-enter" style={{ display: 'grid' }}><Glyph state={s.state} /></span></span>
<span className="dm-tx">{s.label}<Swap className="dm-d">{s.note ?? ''}</Swap></span>
</div>
))}
</div>
<Fold open={isOpen} className="dm-erx-box">
<div className="dm-erx-in">
<div className="dm-erx-q">{question}</div>
<div className="dm-erx-opts" role="radiogroup" aria-label={question}>
{options.map(o => (
<button type="button" key={o.value} className={cx('dm-erx-o', o.value === pick && 'dm-on')} role="radio" aria-checked={o.value === pick} onClick={() => setPick(o.value)}>
<i /><span>{o.label}</span>
</button>
))}
</div>
<button type="button" className="dm-pill dm-primary dm-sm dm-erx-go" onClick={() => { setOpen(false); onContinue?.(pick); }}>{continueLabel}</button>
</div>
</Fold>
</div>
);
}
error-recovery.cssReact · 23 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- Error and recovery ---------- */
.dm-erx{padding:var(--dm-s-2)}
.dm-erx-h{display:flex;align-items:center;justify-content:space-between;gap:var(--dm-s-3);padding:var(--dm-s-2) 10px 6px}
.dm-erx-t{font-weight:500}
.dm-erx-s{font-size:12.5px;white-space:nowrap}
.dm-erx-list{padding:2px 10px 6px}
.dm-erx-row{position:relative;display:flex;gap:10px;padding:6px 0 6px var(--dm-s-6);font-size:13.5px;color:var(--dm-fg)}
.dm-erx-row .dm-g{position:absolute;left:0;top:7px;width:16px;height:16px;display:grid;place-items:center}
.dm-erx-row:not(:last-child)::before{content:"";position:absolute;left:7.5px;top:25px;bottom:-6px;width:1px;background:var(--dm-rail)}
.dm-erx-row .dm-d{display:block;font-size:12.5px;color:var(--dm-muted);transition:color .3s}
.dm-erx-row.dm-wait{color:var(--dm-muted)}
.dm-erx-row.dm-fail .dm-d{color:var(--dm-bad)}
.dm-erx-row.dm-skip .dm-tx{text-decoration:line-through;color:var(--dm-muted)}
.dm-erx-in{margin:var(--dm-s-1) 0 0;padding:var(--dm-s-2) 10px 2px}
.dm-erx-q{font-size:13px;font-weight:500;margin-bottom:var(--dm-s-2)}
.dm-erx-opts{display:flex;flex-direction:column;gap:2px;margin:0 -10px var(--dm-s-3)}
.dm-erx-o{display:flex;align-items:center;gap:10px;padding:var(--dm-s-2) 10px;border-radius:10px;font-size:13px;text-align:left;color:var(--dm-fg2);transition:background .25s,color .25s}
@media (hover:hover) and (pointer:fine){.dm-erx-o:hover{background:var(--dm-row);color:var(--dm-fg)}}
.dm-erx-o.dm-on{background:var(--dm-sel);color:var(--dm-fg)}
.dm-erx-o i{width:15px;height:15px;border-radius:50%;box-shadow:inset 0 0 0 1.5px color-mix(in srgb,var(--dm-muted) 80%,var(--dm-surface));flex:none;transition:box-shadow .35s var(--dm-ease)}
.dm-erx-o.dm-on i{box-shadow:inset 0 0 0 4.5px var(--dm-accent-mark,var(--dm-accent))}
error-recovery.htmlHTML · 18 lines
<div class="card bare" data-c="error-recovery">
<div class="stage-in">
<div class="erx panel">
<div class="erx-h"><span class="erx-t">Sync the pipeline to Sheets</span><span class="erx-s muted">Stopped at step 2</span></div>
<div class="erx-list"></div>
<div class="fold erx-box"><div><div class="erx-in">
<div class="erx-q">How should the agent continue?</div>
<div class="erx-opts" role="radiogroup">
<button class="erx-o on" data-o="retry" role="radio" aria-checked="true"><i></i><span>Ask for edit access, then retry</span></button>
<button class="erx-o" data-o="skip" role="radio" aria-checked="false"><i></i><span>Skip it and post the summary</span></button>
<button class="erx-o" data-o="hand" role="radio" aria-checked="false"><i></i><span>Hand it over to Maya</span></button>
</div>
<button class="pill primary sm erx-go">Continue</button>
</div></div></div>
</div>
</div>
</div>
error-recovery.cssHTML · 22 lines
/* ---------- Error and recovery ---------- */
.erx{padding:var(--s-2)}
.erx-h{display:flex;align-items:center;justify-content:space-between;gap:var(--s-3);padding:var(--s-2) 10px 6px}
.erx-t{font-weight:500}
.erx-s{font-size:12.5px;white-space:nowrap}
.erx-list{padding:2px 10px 6px}
.erx-row{position:relative;display:flex;gap:10px;padding:6px 0 6px var(--s-6);font-size:13.5px;color:var(--fg)}
.erx-row .g{position:absolute;left:0;top:7px;width:16px;height:16px;display:grid;place-items:center}
.erx-row:not(:last-child)::before{content:"";position:absolute;left:7.5px;top:25px;bottom:-6px;width:1px;background:var(--rail)}
.erx-row .d{display:block;font-size:12.5px;color:var(--muted);transition:color .3s}
.erx-row.wait{color:var(--muted)}
.erx-row.fail .d{color:var(--bad)}
.erx-row.skip .tx{text-decoration:line-through;color:var(--muted)}
.erx-in{margin:var(--s-1) 0 0;padding:var(--s-2) 10px 2px}
.erx-q{font-size:13px;font-weight:500;margin-bottom:var(--s-2)}
.erx-opts{display:flex;flex-direction:column;gap:2px;margin:0 -10px var(--s-3)}
.erx-o{display:flex;align-items:center;gap:10px;padding:var(--s-2) 10px;border-radius:10px;font-size:13px;text-align:left;color:var(--fg2);transition:background .25s,color .25s}
@media (hover:hover) and (pointer:fine){.erx-o:hover{background:var(--row);color:var(--fg)}}
.erx-o.on{background:var(--sel);color:var(--fg)}
.erx-o i{width:15px;height:15px;border-radius:50%;box-shadow:inset 0 0 0 1.5px color-mix(in srgb,var(--muted) 80%,var(--surface));flex:none;transition:box-shadow .35s var(--ease)}
.erx-o.on i{box-shadow:inset 0 0 0 4.5px var(--accent-mark,var(--accent))}
error-recovery.jsHTML · 26 lines
DM.def('error-recovery', (r, c) => {
const list = $('.erx-list', r), box = $('.erx-box', r), go = $('.erx-go', r), sum = $('.erx-s', r), opts = $$('.erx-o', r);
const steps = [['Pull 1,240 deals from the CRM'],['Write to the Pipeline 2026 sheet'],['Post the summary in #sales']];
list.innerHTML = steps.map(s => `<div class="erx-row"><span class="g"></span><span class="tx">${s[0]}<span class="d"></span></span></div>`).join('');
const rows = $$('.erx-row', r);
const G = { done:`<span style="color:var(--ok)">${I('check','xs')}</span>`, fail:`<span style="color:var(--bad)">${I('alert','xs')}</span>`, run:'<span class="spin"></span>', wait:'<span class="dot" style="color:var(--faint)"></span>', skip:`<span class="muted">${I('chev-r','xs')}</span>`, hand:`<span style="color:var(--pl)">${I('user','xs')}</span>` };
const set = (i, st, note) => { const rw = rows[i]; rw.className = 'erx-row ' + st; const g = $('.g', rw); g.innerHTML = G[st]; enter(g); swap($('.d', rw), note || ''); };
set(0, 'done', 'Done in 8s'); set(1, 'fail', 'Error 403: the agent lost edit access to this sheet'); set(2, 'wait');
let pick = 'retry';
opts.forEach(o => o.onclick = () => { pick = o.dataset.o; opts.forEach(x => { x.classList.toggle('on', x === o); x.setAttribute('aria-checked', x === o); }); });
go.onclick = () => c.run(async () => {
box.classList.add('shut');
if (pick === 'retry'){
set(1, 'run', 'Asking for edit access'); swap(sum, 'Recovering'); await c.wait(1200);
set(1, 'run', 'Access granted, retrying'); await c.wait(1000);
set(1, 'done', 'Wrote 1,240 rows'); set(2, 'run'); await c.wait(1000);
set(2, 'done', 'Posted to 14 people'); swap(sum, 'Recovered');
} else if (pick === 'skip'){
set(1, 'skip', 'Skipped, nothing was written'); set(2, 'run'); await c.wait(1000);
set(2, 'done', 'Posted, with a note about the sheet'); swap(sum, '2 of 3 done');
} else {
set(1, 'hand', 'Maya has the error, the logs and the last good state'); swap(sum, 'Waiting on Maya');
}
});
});
Questions
What people ask about it.
- What happens after the person presses Continue?
- The choices fold away and
onContinuegets the chosen option's value. Your code carries on the run and updates the steps, for example setting the failed step to 'run', then 'done', or to 'skip' or 'hand'. - Can I offer my own recovery options?
- Yes.
optionsis a list of{ value, label }, so you can offer any choices that fit the error, like asking for access first and then retrying. - How do I show that a step went to a person?
- Set its
stateto 'hand' and usenoteto say who has it and what they got, for example the error and the logs.
More in Agent status
Show what the agent is doing, how far along it is, and what it is working with.