Recovery Panel
When the agent can't finish what it proposed, it says what happened, what still worked, and offers concrete ways to continue.
≈ 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.
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 andavailableAtcome from tool results. Never estimate counts or invent alternatives.- Set
nothingChangedonly 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
canceloption when stopping is reasonable. reconnectnever collects credentials. The host opens its own sign-in.- Act only on the returned option: use
valuesforfix-input,choiceIdforalternativeandmessageforrequest-access. Oncancel, stop the flow. - Lifecycle: set
expiresAtwhen the alternatives are held offers (a table, a seat, a price), andfetchedAt+staleAfterMswhen they're live availability that wasn't held. When a retry fails again, supersede the old panel with the new one.
How it works
| Option | Selecting it shows | Primary button | Result |
|---|---|---|---|
retry | "Available in 1:52" until availableAt (detail is read to screen readers) | "Try again", disabled until availableAt (checked after mount) | { type: "retry" } |
reconnect | A note that the service sign-in opens next | The option's label, e.g. "Reconnect Google Drive" | { type: "reconnect" } |
request-access | An editable message to the person in to | "Send request" | { type: "request-access", message } |
fix-input | A field per value, prefilled with the value that failed and its error underneath | "Save and retry" | { type: "fix-input", values } |
alternative | A radio group of choices | verb + the chosen label: "Book 7:30 PM". A single choice is preselected | { type: "alternative", choiceId } |
skip | consequence, always visible | The option's label | { type: "skip" } |
contact | Nothing extra | The option's label. With a safe http(s) url, it opens the page in a new tab | { type: "contact" } |
cancel | Not 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-describedbyand setaria-invalid.
Installation
npx shadcn@latest add https://your-site.com/r/recovery-panel.json
Usage
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,
}),
}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
| Prop | Type | Notes |
|---|---|---|
title | string | What didn't happen: "2 invoices weren't sent" |
kind | access | input | unavailable | partial | limit | conflict | Optional. Read to screen readers after the title ("Partly done") |
explanation | string | One 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 |
nothingChanged | boolean? | Shows "Nothing was changed." after the explanation |
options | RecoveryOption[] | 1–5, best first |
RecoveryOption (every option has id, type and label; all but cancel take actionLabel?)
type | Extra fields |
|---|---|
retry | detail?, availableAt? (ISO, data-bound) |
reconnect | service |
request-access | to? (data-bound), messagePreview? (≤ 500 characters) |
fix-input | fields: { id, label, type?, value?, error?, required? }[] (1–8; value and error are data-bound) |
alternative | choices: { id, label, detail? }[] (1–8, data-bound), verb? |
skip | consequence? |
contact | url? (http(s) only) |
cancel |
Host props: status (streaming, ready, submitting, chosen, cancelled), onResult, result and className.