Recovery Panel

When the agent can't finish what it proposed, it says what happened, what still worked, and offers concrete ways to continue.

Pattern

≈ 1,969 tokens per use (tool 1,417 · guidance 366 · typical payload 186)How token costs are estimated

Example: Recovery Panel

Book the 7 pm table at Tasca Velha for four.

The 7:00 PM table is taken. Not available

It went while I was confirming. Nothing was changed.

How do you want to continue?
Pick another time tonight

When to use

  • The agent couldn't finish what it proposed and there are concrete ways to continue: missing access, rejected input, an option that's gone (sold out, slot taken, price changed), a partial failure, a limit (quota, rate limit) or a conflict.
  • Part of the work succeeded and the user needs to see what did and didn't happen before deciding, e.g. "8 of 10 invoices sent".

When not to use

  • A tool call errored and all the agent can offer is trying again. The Renderer shows its own failure card for that; see Lifecycle States.
  • Nothing went wrong and the agent only needs a choice. Use the Entity Picker.
  • The agent needs new information, not a fix to rejected input. Use the Generated Form.
  • A transient error the agent can safely retry on its own. Retry first, then report.

How the agent should use it

  • Explain what happened in one or two plain sentences, without blaming the user or the service.
  • completed, failed, field errors, alternatives and availableAt come from tool results. Never estimate counts or invent alternatives.
  • Set nothingChanged only when you're certain nothing was written, sent or charged.
  • Offer 1–5 options, best first. The first is selected and announced as recommended. Add a cancel option when stopping is reasonable.
  • reconnect never collects credentials. The host opens its own sign-in.
  • Act only on the returned option: use values for fix-input, choiceId for alternative and message for request-access. On cancel, stop the flow.
  • Lifecycle: set expiresAt when the alternatives are held offers (a table, a seat, a price), and fetchedAt + staleAfterMs when they're live availability that wasn't held. When a retry fails again, supersede the old panel with the new one.

How it works

OptionSelecting it showsPrimary buttonResult
retry"Available in 1:52" until availableAt (detail is read to screen readers)"Try again", disabled until availableAt (checked after mount){ type: "retry" }
reconnectA note that the service sign-in opens nextThe option's label, e.g. "Reconnect Google Drive"{ type: "reconnect" }
request-accessAn editable message to the person in to"Send request"{ type: "request-access", message }
fix-inputA field per value, prefilled with the value that failed and its error underneath"Save and retry"{ type: "fix-input", values }
alternativeA radio group of choicesverb + the chosen label: "Book 7:30 PM". A single choice is preselected{ type: "alternative", choiceId }
skipconsequence, always visibleThe option's label{ type: "skip" }
contactNothing extraThe option's label. With a safe http(s) url, it opens the page in a new tab{ type: "contact" }
cancelNot a card: a secondary button in the footer{ action: "cancel" }

Any option except cancel can set actionLabel to override its button label, e.g. "Continue without them".

A fix-input field stays invalid while it still holds the value that failed, so the same bad address can't be resent. Other checks follow the field's type (email, number, date, text) and required. On submit, focus moves to the first invalid field and the footer says how many need attention.

For a partial failure, "8 sent · 2 failed" sits above a two-part bar. The text carries the numbers; the bar is decorative. Every failed item is listed with its reason, since that's what the user fixes. Completed items (up to 5, plus "and 3 more" when completed.count is higher) are read to screen readers but not listed, to keep the panel on what to do next.

The header is the title and one quiet line: the explanation, then "Nothing was changed." when set. The kind is read to screen readers after the title.

The pure helpers (field validation, button labels, the countdown, partial counts) live in recovery-panel-utils.ts, so the host can reuse them.

Accessibility

  • The panel is a region labelled by its title. It doesn't use role="alert": the user asked for this, so it isn't urgent.
  • Options are native radio buttons with visible focus. Selecting one announces it and what it reveals, e.g. "Fix the 2 addresses and resend selected. 2 fields to fix below."
  • Field errors are linked to their inputs with aria-describedby and set aria-invalid.

Installation

npx shadcn@latest add https://your-site.com/r/recovery-panel.json

Usage

app/api/chat/route.ts
import { tool } from "ai"
import { recoveryPanelTool } from "@/components/genui/recovery-panel.schema"

const tools = {
  // No execute(): the call is completed by the user's choice in the UI.
  [recoveryPanelTool.name]: tool({
    description: recoveryPanelTool.description,
    inputSchema: recoveryPanelTool.inputSchema,
  }),
}
components/message.tsx
import { RecoveryPanel } from "@/components/genui/recovery-panel"

function ToolPart({ part, addToolResult }) {
  if (part.type !== "tool-offer_recovery") return null

  return (
    <RecoveryPanel
      {...part.input}
      status={part.state === "input-streaming" ? "streaming" : undefined}
      result={part.state === "output-available" ? part.output : undefined}
      onResult={(result) =>
        addToolResult({
          tool: "offer_recovery",
          toolCallId: part.toolCallId,
          output: result,
        })
      }
    />
  )
}

When the user picks reconnect, the host opens its own sign-in, then tells the agent to try again. The agent receives one of:

{ action: "recover", optionId: "fix", type: "fix-input", values: { "inv-1046": "ap@brightline.example" } }
{ action: "recover", optionId: "tonight", type: "alternative", choiceId: "1930" }
{ action: "recover", optionId: "ask", type: "request-access", message: "Hi Dana, could you…" }
{ action: "recover", optionId: "later", type: "retry" }
{ action: "cancel" }

With LangGraph

Push it from a graph node with ui.push({ name: "offer_recovery", props: { toolCallId, ...args } }, { message }), and render the thread's UI messages with the Renderer.

Restoring from history

Pass the stored result back as result. The panel shows a compact record ("Asked Dana Levi for access", "Chose 7:30 PM", the corrected values) and never calls onResult again.

<RecoveryPanel
  {...part.input}
  result={part.state === "output-available" ? part.output : undefined}
/>

Schema

PropTypeNotes
titlestringWhat didn't happen: "2 invoices weren't sent"
kindaccess | input | unavailable | partial | limit | conflictOptional. Read to screen readers after the title ("Partly done")
explanationstringOne or two plain sentences, up to 280 characters
completed{ label, count?, verb?, items? }?Data-bound. What worked. items ≤ 5, read to screen readers; verb fills "8 sent · 2 failed"
failed{ label, count?, items?: { id, label, reason }[] }?Data-bound. items ≤ 20
nothingChangedboolean?Shows "Nothing was changed." after the explanation
optionsRecoveryOption[]1–5, best first

RecoveryOption (every option has id, type and label; all but cancel take actionLabel?)

typeExtra fields
retrydetail?, availableAt? (ISO, data-bound)
reconnectservice
request-accessto? (data-bound), messagePreview? (≤ 500 characters)
fix-inputfields: { id, label, type?, value?, error?, required? }[] (1–8; value and error are data-bound)
alternativechoices: { id, label, detail? }[] (1–8, data-bound), verb?
skipconsequence?
contacturl? (http(s) only)
cancel

Host props: status (streaming, ready, submitting, chosen, cancelled), onResult, result and className.

Source

On this page