Skip to content
Star on GitHub
Agent statusError and recovery
Style
Accent
Sound

Built by DesignMe, free to use under the MIT licence.

Expert design and dev support for startups and enterprises.

Book a call

Error 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.

$npx designme add error-recovery
Sync the pipeline to SheetsStopped at step 2
How should the agent continue?
01

Install

Add it to your project with the CLI.

HTML, CSS and JavaScript

$npx designme add error-recovery

React

$npx designme add error-recovery --react

The 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.

02

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.
03

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>
  );
}
04

Props

Everything ErrorRecovery takes.

PropTypeWhat it does
titlerequiredstringWhat the agent was doing, like "Sync the pipeline to Sheets"
statusstringWhere the run stands, like "Stopped at step 2". Crossfades when it changes
stepsrequiredRecoveryStep[]
questionstringThe question above the choices
optionsrequiredRecoveryOption[]The ways the agent can carry on
valuestringThe chosen option. Leave it out to let the choices hold their own selection
defaultValuestringDefaults to the first option
onValueChange(value: string) => void
onContinue(value: string) => voidCalled with the chosen option when Continue is pressed
continueLabelstring
openbooleanWhether the choices show. Leave it out and they fold away once Continue is pressed
defaultOpenboolean
onOpenChange(open: boolean) => void
barebooleanSits flat in a chat thread (the default, as on designme.dev). Set false to give it a card of its own
classNamestring
05

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');
    }
  });
});
06

Questions

What people ask about it.

What happens after the person presses Continue?
The choices fold away and onContinue gets 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. options is 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 state to 'hand' and use note to say who has it and what they got, for example the error and the logs.
07

More in Agent status

Show what the agent is doing, how far along it is, and what it is working with.

ThinkingThe live trace, written as the agent works, then folded away.
Reasoning trailThe agent's plan, notes, sources and edits, after it thinks.
Agent lanesSeveral agents working in parallel, each with its own lane and hand-offs.
Agent treeAn orchestrator and its sub-agents, with cost rolled up per branch.
Agent inboxEvery run that needs you, is running or is done, in one list.
Trace viewOne agent run as a waterfall of spans, with latency and tokens.
Browser sessionThe agent browsing for you, step by step, with Pause and Take over.
Run summaryWhat the agent did, what changed and what needs you.
All 18 in Agent status →