Super-AI-Components

Escalation Handoff

Agent hands the conversation to a person

Handing this to a person

I'm not confident enough about this one to act on it. Passing it to a person.

What they'll see

Colleague asking whether a vendor NDA can be countersigned without legal review.

Vendor
stated
Contract type
inferred
Region
retrieved

Already tried

  • Searched the contract playbook
  • Checked the standing delegation matrix

Confirm whether the under-$50k exception applies here.

What it is

The card an agent shows when it is handing the conversation to a person. Its subject is the packet, not the button: the summary the receiving human reads first, the resolved frame of slot values, what the agent already tried, and the specific unblock being asked for. It moves through four states — the packet in preview, queued, accepted, or unavailable — and the user sees the packet before any of it is sent.

Why it matters

Handoff is the strongest result in the agent slice of the reference board: five unrelated products converged on it, and they converged on the same thing. Sierra auto-generates the handoff summary; Decagon sends history, attempted actions, customer data and an issue summary; Intercom Fin, Voiceflow and Rasa's pattern_human_handoff all ship an equivalent. What none of them converged on was the button. The escalation button is the easy half and the part users notice least — the packet is what decides whether the person on the other end can actually help, or has to start the conversation over.

Observed in: Sierra · Decagon · Intercom Fin · Voiceflow · Rasa

Anatomy

  1. 1escalation-handoffRoot card. Carries data-trigger and data-state, so you can style or assert on either.
  2. 2escalation-handoff-reasonThe one-line reason, chosen by trigger. Four fixed strings; not overridable by design.
  3. 3escalation-handoff-waitQueue copy, in the queued state only. Prints an honest fallback when wait is absent.
  4. 4escalation-handoff-availabilityThe closed-door message, in the unavailable state only. availability appends the window people are back.
  5. 5escalation-handoff-packetThe packet section — summary, the resolved frame, what was already tried. Rendered in every state.
  6. 6escalation-handoff-requestThe specific ask. Optional in the API, but an escalation without one is just a forward.

How to use it

Reach for it at the moment the agent stops and a person starts: the user asked for someone, the repair loop has run out of road, confidence is too low for a high-stakes action, or policy requires a human review. Pick the `trigger` that is actually true — the four reasons produce four different messages, and "you asked for a person" and "policy requires a person" are not interchangeable claims about the conversation. Keep `state` in your own state and advance it as the handoff progresses; the component renders what you give it and never moves itself. Show `preview` first so the user can read and edit the packet before it travels, because it is their conversation being forwarded.

Do

  • Put the specific unblock in request — name what the person is being asked to do, not just what went wrong.

    Handing this to a person

    This needs a person to review before anything happens.

    What they'll see

    Customer wants a refund on order #48120, one day outside the refund window.

    Order
    #48120
    Placed
    3 days ago
    Refund window
    Closed 1 day ago

    Already tried

    • Checked the refund window
    • Offered store credit — declined

    Please approve a one-day exception, or tell them no in your own words.

  • Wire onEditPacket in the preview state. The user is the one who knows the summary is wrong, and it is the only privacy control on a forward of their conversation.

    Handing this to a person

    You asked to speak to someone.

    What they'll see

    Customer asked to speak to someone about a duplicate charge.

    Charged
    £29.00 ×2
    Card
    •••• 6411

    Confirm the duplicate and reverse one of the two charges.

  • Use unavailable honestly when nobody is on, with a real window in availability and an asynchronous path in onLeaveMessage.

    Handing this to a person

    I'm not confident enough about this one to act on it. Passing it to a person.

    No one is available right now. People are back at 9am on Monday.

    What they'll see

    Customer's invoice does not match the plan they think they are on.

    Plan
    Team, annual

    Work out which plan they were actually billed for and correct it.

Don't

  • Don't queue a handoff nobody will pick up. A fake "connecting you" at 3 a.m. is worse than a closed door, because the user waits in it.

    Handing this to a person

    We've gone back and forth on this a few times, so I'm bringing in a person.

    You're in the queue. Someone will be with you in about 2 minutes.

    What they'll see

    Customer cannot upload a file over 2GB.

    Raise their upload limit, or say why it cannot be raised.

  • Don't restate the reason inside summary — the trigger copy already says it, and the packet should read as a briefing for the human, not a second explanation for the user.

    Handing this to a person

    I'm not confident enough about this one to act on it. Passing it to a person.

    What they'll see

    I'm not confident enough about this one to act on it, so I'm passing it to a person.

    Take a look.

  • Don't let the thread die at handoff. Decide what accepted leads to before you ship it: the conversation resumes with state intact, or it is explicitly closed.

    Someone's with you

    You asked to speak to someone.

    What they'll see

    Customer asked to speak to someone about a duplicate charge.

    Confirm the duplicate and reverse one of the two charges.

Accessibility

Keyboard
  • Tab stops depend entirely on `state`, and two of the four have none. `preview` offers up to three buttons (Send to a person, Edit what's shared, Keep trying here) and `unavailable` up to three (Leave it for them, plus the same two). `queued` and `accepted` render no footer at all, so the card is unreachable by keyboard exactly while the user is waiting in it.
  • The count inside a state is the number of handlers you passed: each footer button renders only when its callback exists, so a `preview` given only `onSend` is one tab stop rather than three.
  • The packet can add stops you did not ask for. A `packet.slots` entry marked `copyable` renders a copy button inside `StatReadout`, so four copyable slots is four more tab stops between the packet and the footer.
  • Everything is a plain button — Space and Enter, nothing else. There is no Escape handler: this is a card in the conversation, not a dialog, so Escape does not cancel the handoff.
Screen reader
  • The card has no heading anywhere. "Handing this to a person" renders through `CardTitle`, which is a `div`, and "What they'll see" comes from `SectionHeader` in its non-collapsible shape, which is a `span` — so neither is reachable by heading navigation and the packet is a block of text with nothing to jump to inside it.
  • The trigger copy is ordinary text, announced in reading order after the title. All four strings are module constants, so what is announced is exactly one of the four and nothing here is assembled from your props.
  • `state` is reflected only as `data-state`. Moving from `preview` to `queued` to `accepted` swaps the card's content with no live region anywhere in the component, so the transition this card exists to model — the moment the handoff is actually sent — is announced as nothing at all. Put a live region on the surface hosting the card.
  • Every copy button in the packet is named "Copy" and nothing more. Four copyable slots means four identically-named buttons, and which is which is carried only by the label sitting beside it.
  • The "Already tried" list is a real `ul`, but the line above it is a loose paragraph rather than anything the list points at, so the list announces with an item count and no name.
  • All the icons here are lucide glyphs with no children, which apply `aria-hidden` to themselves — the person mark in the title and the arrows in the attempted list contribute nothing to any name.
Focus
  • Pressing Send is the sharpest edge in this component. The host moves `state` to `queued`, the entire footer unmounts, and the button that had focus goes with it — focus falls to `<body>` and the next Tab restarts from the top of the page. Move focus to the queue copy, or to a live region you own, inside your `onSend`.
  • The same thing happens on `unavailable` → `queued` through `onLeaveMessage`, and on any host-driven advance to `accepted`.
  • Every footer control is a shadcn `Button` and carries the house `focus-visible` ring; the packet's copy buttons carry their own. Nothing else in the card can take focus at all.

Watch out for

  • The footer only renders in preview and unavailable. In queued and accepted there is no footer at all, so onCancel and onEditPacket are accepted and silently dropped — the escape hatch disappears exactly while the user is waiting. If you need a way out of the queue, it has to live outside this card.
  • In preview and unavailable the footer element renders even when you pass no handlers at all, leaving an empty action bar under the packet. Pass at least one action, or the card ends in a blank strip.
  • wait being absent is not the same as passing an empty string: the component prints "We don't have a wait time yet." on purpose. That fallback is the honest answer when queue depth is unknown — don't pass an invented estimate to avoid it.
  • The four trigger strings are a module constant, not props. You cannot reword them per surface; if none of the four fits, the situation probably is not an escalation.
  • The component models no return path. accepted is where it stops — there is no prop for resuming the conversation or closing it out, so the host owns whatever happens after a person picks it up.
  • Under dir="rtl" the card mirrors correctly, but the "Already tried" bullets use a right-pointing arrow that does not flip. Cosmetic rather than blocking, and tracked as a gap.

Installation

npx shadcn@latest add https://super-ai-components.vercel.app/r/escalation-handoff.json

Requires a shadcn app on Base UI (style base-nova). Radix-based styles such as new-york fail to typecheck: these components use Base UI's render prop and callbacks like onOpenChangeComplete, which Radix primitives do not have.