Elements

Tool fallback

The default runtime renderer for tool calls that do not have dedicated UI.

fig. 01

Installation

Install manually
npx shadcn@latest add "@assistant-ui/tool-fallback"
First time? Set up a runtime

Runtime components read their state from an assistant-ui runtime. Add one to an existing project:

npx assistant-ui@latest init

Then wrap your app in a runtime provider:

import { AssistantRuntimeProvider } from "@assistant-ui/react";
import { useChatRuntime, AssistantChatTransport } from "@assistant-ui/ai-sdk";

export default function App() {
  const runtime = useChatRuntime({
    transport: new AssistantChatTransport({ api: "/api/chat" }),
  });

  return (
    <AssistantRuntimeProvider runtime={runtime}>
      {/* your components */}
    </AssistantRuntimeProvider>
  );
}

The installation guide covers new projects, templates, and API routes.

Tool fallback renders any tool call that has no dedicated UI of its own: a collapsed one-line summary that opens into the arguments, the result, and (when the tool is waiting on a decision) the buttons to answer it. With a runtime it reads the live tool-call part and calls back into that same run; there is no standalone form, since a tool call and the run it belongs to are the same thing.

Getting started

Thread already renders this for every tool call that doesn't have its own UI.

It's already your default

Inside Thread's message renderer, a tool-call part falls back to ToolFallback whenever nothing more specific (a per-tool renderer, or generative UI) claims it first.

components/assistant-ui/elements/thread.aui.tsx
case "tool-call":
  return part.toolUI ?? <ToolFallbackComponent {...part} />;

Replace it for every unregistered tool

Pass components.ToolFallback to Thread to swap the renderer used whenever a tool call has no dedicated UI. A tool with its own registered renderer still takes precedence over this override, the same way it takes precedence over the built-in ToolFallback.

<Thread components={{ ToolFallback: MyToolCard }} />

MyToolCard receives the same props as ToolFallback itself. See the API reference below.

Anatomy

<div> {/* ToolFallbackRoot, a Collapsible */}
  <button aria-expanded={/* open */}>
    {/* status icon: spinner / check / X-circle / alert */}
    <span>{/* "Running tool: <name>", "Waiting on tool: <name>", "Failed tool: <name>", "Cancelled tool: <name>", or "Used tool: <name>" */}</span>
    <span>{/* elapsed duration, while known */}</span>
    {/* chevron, rotates open */}
  </button>
  <div> {/* ToolFallbackContent */}
    {/* error, only while status is "incomplete" and carries one */}
    {/* args, only when argsText is non-empty */}
    {/* approval controls while requires-action, then a receipt of the decision */}
    {/* result, only when result is defined */}
  </div>
</div>

The row opens by default whenever the tool call's status is "requires-action", and stays as the user left it otherwise. Duration only shows once the call has a recorded start; it keeps counting up once per second while the call is running, and freezes at the recorded total once it completes.

Examples

Approval without declared options

With no approval and no interrupt on the part, a plain Allow / Deny pair calls addResult directly with a fixed approved or denied string. With interrupt set, the same pair instead calls resume({ approved }) to unpause the paused frontend tool execution.

<ToolFallback {...part} />

Approval with declared options

When part.approval.options is set, one button renders per option instead of a plain pair: allow-kind options first (the first one styled as primary), then any custom kind, then reject-kind options. An option can opt into a confirmation step before it resolves.

approval: {
  id: "call-1",
  options: [
    { id: "once", kind: "allow-once", label: "Allow" },
    { id: "always", kind: "allow-always", label: "Always allow", confirm: true },
    { id: "deny", kind: "reject-once", label: "Deny" },
  ],
}

Choosing "Always allow" here shows a confirm/back step before it calls respondToApproval({ optionId: "always" }); the other two resolve immediately. In a thread that cannot answer tool calls, where s.thread.capabilities.answerToolCall is false as in a sub-agent transcript, the block shows only the prompt.

On the web and React Native, changing approval.id starts a fresh request in both the default renderer and the composed approval component, clearing the previous request's input, confirmation, error, and submission state.

Receipt of a settled request

Once approval.approved or approval.resolution is set, the controls give way to a receipt, so scrolling back never shows a live button for a decision already made. The receipt keeps the prompt and states what happened in the past tense: "Allowed" or "Denied" for a decision, "Answered" or "Dismissed" for a question, and "Cancelled before a decision" or "Expired before a decision" for a request that closed unanswered. The chosen option's label follows the verb, a typed text and a host reason follow on their own lines, and isAutomatic appends "automatically" when a policy decided instead of the user.

On the web, when the controls are replaced while they hold keyboard focus, focus moves to the receipt without scrolling. A decision that settles after focus moves elsewhere leaves that focus in place; historical receipts do not take focus when they mount.

In the web renderer, an explicit approval record keeps the component mounted across run-status updates, including when an external-store adapter updates the run status before the approval. Controls appear only while the request requires action, or while a submitted decision is still running and awaiting its receipt; submitted controls stay locked. If the run ends without settling the approval, the controls disappear. An unanswered approval in a running or finished message does not expose new actions.

approval: {
  id: "call-1",
  approved: true,
  optionId: "always",
  options: [{ id: "always", kind: "allow-always" }],
}
// renders: Allowed · Always allow

Question that accepts a dismissal

A request with display: "select" or "text" is a question, so no Deny renders for it: the kit never fabricates a refusal the host did not ask for. A host that records a dismissal declares dismissible: true, and the question gains a Dismiss control that calls respondToApproval({ approved: false }) with no answer attached, beside the options of a select or beside Send on a text field.

approval: {
  id: "call-2",
  prompt: "Custom summarization instructions",
  display: "text",
  dismissible: true,
}

Send submits the text as typed, an empty answer included; a host that cannot record one rejects the response, and the controls come back with its error.

Several questions at once

A display: "questions" request renders each of its questions with its options as toggle buttons (several stay pressed when the question allows multiple) and a text field where the question accepts a typed answer. One Send stays disabled until every question has an answer, then calls respondToApproval({ answers }) keyed by question id; Dismiss appears only when the request is dismissible. Once settled, the receipt lists each question beside its answer.

approval: {
  id: "call-3",
  display: "questions",
  questions: [
    {
      id: "scope",
      header: "Scope",
      prompt: "Which folders should change?",
      options: [
        { id: "src", label: "src" },
        { id: "tests", label: "tests" },
      ],
      multiple: true,
    },
    { id: "note", prompt: "Anything I should avoid?" },
  ],
}

Restyle a part

Every piece is a named export you can recompose, and each carries its own data-slot for targeted styling.

import { ToolFallbackTrigger, ToolFallbackContent } from "@/components/assistant-ui/elements/tool-fallback.aui";

<ToolFallbackTrigger toolName={part.toolName} status={part.status} className="text-sm" />

API reference

Parts

PartRendersNotes
ToolFallbackfull compositionThe composed renderer; a complete ToolCallMessagePartComponent.
ToolFallbackRootdivCollapsible container. Takes open, onOpenChange, and defaultOpen; ToolFallback opens it on "requires-action".
ToolFallbackTriggerbuttonTakes toolName and status.
ToolFallbackContentdivThe collapsible panel.
ToolFallbackArgspreTakes argsText; renders null when empty.
ToolFallbackResultpreTakes result; renders null when undefined. Stringifies non-string results.
ToolFallbackErrortextTakes status; renders null unless status.type === "incomplete" and it carries an error.
ToolFallbackApprovalbuttons, then a receiptTakes approval, interrupt, status, addResult, resume, respondToApproval. Renders the receipt once the approval is settled.

Tool-call part fields

ToolFallback (and each part above) receives the current tool call as ToolCallMessagePartProps, the full ToolCallMessagePart plus three callbacks:

FieldTypeDescription
toolCallIdstringStable id for this invocation.
toolNamestringName of the tool the model called.
argsTextstringRaw, possibly partial, JSON the model has streamed so far.
resultTResult | undefinedThe tool's result, once it has one; an interim value while isPreliminary is set.
isErrorboolean | undefinedWhether the result represents a failure.
isPreliminaryboolean | undefinedWhether result is interim output from a tool that has not finished; the part then follows the message status instead of reading "complete".
statusToolCallMessagePartStatus"running", "complete", "incomplete" (with a reason, e.g. "cancelled"), or "requires-action" (reason: "tool-calls" | "interrupt").
timingToolCallTiming | undefinedWall-clock start/completion, when tracked.
interrupt{ type: "human"; payload: unknown } | undefinedA paused human-input request.
approvalobject | undefinedServer-side approval gate: id, prompt?, display?, allowFreeform?, dismissible?, options?, questions?, approved?, optionId?, text?, answers?, resolution?.

Callbacks

CallbackTypeDescription
addResult(result) => voidSets this part's result from the renderer instead of a tool's execute.
resume(payload: unknown) => voidResumes a paused frontend tool execution with the requested payload.
respondToApproval(response: ToolApprovalResponse) => Promise<void>Answers an approval gate: { approved }, { optionId }, both, { text } when the request takes a free-form answer, or { answers } for a display: "questions" request, a ToolApprovalAnswer (optionIds and/or text) per question id.

Composition

PartTypeDescription
components.ToolFallbackToolCallMessagePartComponentThread prop that replaces this renderer for every tool call without a dedicated UI.
useToolCallElapsed()number | undefinedElapsed milliseconds for the current tool-call part; undefined off a tool-call scope or with no recorded timing.