Skip to content
+

Chat - Tool Approval

Add human-in-the-loop checkpoints to review and approve or deny agent tool calls before they execute.

Tool approval lets you pause the agent when it requests a potentially dangerous action, present the user with an approve/deny interface, and resume or cancel the tool execution based on the user's decision.

Approval workflow

The approval lifecycle extends the standard tool invocation states with two phases:

State Description
input-streaming Tool input JSON is being streamed
input-available Tool input is fully available
approval-requested Stream pauses; user approval is needed
approval-responded User has responded, stream continues
output-available Tool output is ready (if approved)
output-denied User denied the tool call

The stream pauses at approval-requested until your UI calls addToolApprovalResponse().

Approving a tool call in action

Send the suggested prompt; the stream pauses at approval-requested until you approve or deny the call.

'use client';
import * as React from 'react';
import { ChatBox } from '@mui/x-chat';
import type {
  ChatAdapter,
  ChatMessage,
  ChatRealtimeEvent,
} from '@mui/x-chat/headless';
import { demoUsers } from 'docs/data/chat/core/examples/shared/demoData';
import { createChunkStream } from 'docs/data/chat/core/examples/shared/demoUtils';

const CONVERSATION_ID = 'tool-approval';

function createToolApprovalAdapter(): ChatAdapter {
  let onEventRef: ((event: ChatRealtimeEvent) => void) | null = null;
  let pendingMessage: ChatMessage | null = null;

  return {
    async sendMessage() {
      const messageId = 'assistant-approval';
      const textId = 'assistant-approval-text';
      const toolCallId = 'cleanup-1';

      pendingMessage = {
        id: messageId,
        conversationId: CONVERSATION_ID,
        role: 'assistant',
        author: demoUsers.agent,
        status: 'sent',
        parts: [
          {
            type: 'text',
            text: 'I need approval before deleting these files.',
          },
          {
            type: 'dynamic-tool',
            toolInvocation: {
              toolCallId,
              toolName: 'delete_files',
              state: 'approval-requested',
              input: { path: '/tmp/cache', recursive: true },
              approvalId: 'approval-1',
            },
          },
        ],
      };

      return createChunkStream(
        [
          { type: 'start', messageId, author: demoUsers.agent },
          { type: 'text-start', id: textId },
          {
            type: 'text-delta',
            id: textId,
            delta: 'I need approval before deleting these files.',
          },
          { type: 'text-end', id: textId },
          {
            type: 'tool-approval-request',
            toolCallId,
            toolName: 'delete_files',
            input: { path: '/tmp/cache', recursive: true },
            // The optional approval id is round-tripped: the built-in widget
            // responds with it, and the controller matches the invocation by it.
            approvalId: 'approval-1',
            dynamic: true,
          },
          { type: 'finish', messageId, finishReason: 'tool-call' },
        ],
        { delayMs: 240 },
      );
    },
    subscribe({ onEvent }) {
      onEventRef = onEvent;
      return () => {
        onEventRef = null;
      };
    },
    async addToolApprovalResponse({ approved, reason }) {
      if (!pendingMessage) {
        return;
      }

      // 1. Move the tool invocation to its terminal state.
      onEventRef?.({
        type: 'message-updated',
        message: {
          ...pendingMessage,
          parts: [
            pendingMessage.parts[0],
            {
              type: 'dynamic-tool',
              toolInvocation: {
                toolCallId: 'cleanup-1',
                toolName: 'delete_files',
                state: approved ? 'output-available' : 'output-denied',
                input: { path: '/tmp/cache', recursive: true },
                approvalId: 'approval-1',
                approval: { approved, reason },
                output: approved ? { deleted: 3 } : undefined,
              },
            },
          ],
        },
      });

      // 2. Emit a follow-up assistant message after a short delay.
      setTimeout(() => {
        onEventRef?.({
          type: 'message-added',
          message: {
            id: 'assistant-followup',
            conversationId: CONVERSATION_ID,
            role: 'assistant',
            author: demoUsers.agent,
            status: 'sent',
            parts: [
              {
                type: 'text',
                text: approved
                  ? 'Deleted 3 files.'
                  : "Okay, I won't delete anything.",
              },
            ],
          },
        });
      }, 400);
    },
  };
}

export default function ToolApprovalFlow() {
  const adapter = React.useMemo(() => createToolApprovalAdapter(), []);

  return (
    <ChatBox
      adapter={adapter}
      initialActiveConversationId={CONVERSATION_ID}
      suggestions={['Clean up /tmp/cache']}
      suggestionsAutoSubmit
      sx={{
        height: 500,
        border: '1px solid',
        borderColor: 'divider',
        borderRadius: 1,
      }}
    />
  );
}

Triggering an approval request

When the backend determines a tool call needs user approval, it sends a tool-approval-request chunk (see Building an adapter for the server side):

controller.enqueue({
  type: 'tool-approval-request',
  toolCallId: 'call-1',
  toolName: 'delete_files',
  input: { path: '/tmp/data', recursive: true },
  approvalId: 'approval-1', // optional, defaults to toolCallId
});
Field Type Description
toolCallId string The tool invocation being gated
toolName string Name of the tool requesting approval
input ChatToolInput<TToolName> The tool's input (typed per tool; unknown for dynamic tools), shown to the user
approvalId string | undefined Optional distinct approval ID. Stored on the invocation as toolInvocation.approvalId; respond with it when present, otherwise with toolCallId.
dynamic true | undefined Set to true when gating a dynamic (unregistered) tool; required on dynamic-tool approval chunks.

When this chunk arrives, the tool invocation moves to state: 'approval-requested'.

Implementing approval responses

Implement addToolApprovalResponse on your adapter to send the user's decision to the backend:

interface ChatAddToolApproveResponseInput {
  id: string; // the chunk's approvalId when provided, otherwise the toolCallId
  approved: boolean; // true = approved, false = denied
  reason?: string; // optional reason surfaced to the model when denied
}
async addToolApprovalResponse({ id, approved, reason }) {
  await fetch('/api/tool-approval', {
    method: 'POST',
    body: JSON.stringify({ id, approved, reason }),
  });
},

Two behaviors are worth calling out:

  • reason is stored on the invocation as toolInvocation.approval.reason, and the built-in tool widget displays it when the call ends in output-denied; otherwise it is only sent to your backend. The built-in Deny button sends no reason — provide a custom renderer (see Registering custom renderers for tool approval) to collect one.
  • If your adapter's addToolApprovalResponse rejects, the built-in widget re-enables its buttons and the error is reported through the chat error channel (onError); the invocation stays in approval-requested so the user can retry.

Responding to approval requests in the UI

Use useChat() to call addToolApprovalResponse from your component:

const { addToolApprovalResponse } = useChat();

// Approve
await addToolApprovalResponse({
  id: toolCall.approvalId ?? toolCall.toolCallId,
  approved: true,
});

// Deny with reason
await addToolApprovalResponse({
  id: toolCall.approvalId ?? toolCall.toolCallId,
  approved: false,
  reason: 'User declined the operation',
});

After responding, the tool invocation moves to state: 'approval-responded', and the stream continues. If approved, the tool proceeds to execution and eventually reaches output-available. If denied, the tool moves to output-denied.

Prompting the user for confirmation

ChatConfirmation renders a prominent warning card with a message and two action buttons for human-in-the-loop checkpoints:

import { ChatConfirmation } from '@mui/x-chat';

<ChatConfirmation
  message="Are you sure you want to delete all files?"
  onConfirm={() => agent.confirm()}
  onCancel={() => agent.cancel()}
/>;

Interactive playground

Preview the ChatConfirmation UI in different states:

ChatConfirmation
A confirmation card for gating a tool call before it runs.

The assistant wants to delete 3 files. Approve this action?

props
messagestring
confirmLabelstring
cancelLabelstring

Custom labels

Use confirmLabel and cancelLabel to tailor the button text to the action:

<ChatConfirmation
  message="Send this email on your behalf?"
  confirmLabel="Send email"
  cancelLabel="Cancel"
  onConfirm={handleConfirm}
  onCancel={handleCancel}
/>

Connecting to the adapter

Hold the card visibility in React.useState. Show the card when the agent triggers a confirmation step, and hide it once the user responds:

const [pendingConfirmation, setPendingConfirmation] = React.useState(false);

const setConfirmRef = React.useRef(setPendingConfirmation);
setConfirmRef.current = setPendingConfirmation;

const adapter = React.useMemo(
  () => ({
    async sendMessage({ message }) {
      // ... stream agent response ...
      // Signal that a confirmation is needed:
      setConfirmRef.current(true);
      return responseStream;
    },
  }),
  [],
);

// In your JSX:
{
  pendingConfirmation && (
    <ChatConfirmation
      message="Proceed with this action?"
      onConfirm={() => {
        setPendingConfirmation(false);
        /* approve the tool */
      }}
      onCancel={() => setPendingConfirmation(false)}
    />
  );
}

Relationship to tool-call approval

The built-in tool part approval-requested state handles the narrow case of approving a specific tool call: it renders inside the collapsible tool widget. ChatConfirmation is a broader, more prominent pattern for any "human-in-the-loop" checkpoint that does not require a structured tool invocation.

Use the stream-based tool-approval-request when:

  • The approval is tied to a specific tool invocation
  • You want the approval UI inline within the message
  • The backend needs to resume the stream after approval

Use ChatConfirmation when:

  • You need a prominent, page-level confirmation dialog
  • The confirmation is not tied to a specific tool call
  • You want full control over the confirmation UI and flow

Registering custom renderers for tool approval

Register a renderer that handles the approval-requested state inside tool parts:

const renderers: ChatPartRendererMap = {
  tool: ({ part, message, index }) => {
    const { toolInvocation } = part;

    if (toolInvocation.state === 'approval-requested') {
      return (
        <ApprovalCard
          toolName={toolInvocation.toolName}
          input={toolInvocation.input}
          onApprove={() =>
            addToolApprovalResponse({
              id: toolInvocation.approvalId ?? toolInvocation.toolCallId,
              approved: true,
            })
          }
          onDeny={(reason) =>
            addToolApprovalResponse({
              id: toolInvocation.approvalId ?? toolInvocation.toolCallId,
              approved: false,
              reason,
            })
          }
        />
      );
    }

    return <ToolCard invocation={toolInvocation} />;
  },
};

<ChatProvider adapter={adapter} partRenderers={renderers}>
  <MyChat />
</ChatProvider>;

Accessibility

  • ChatConfirmation is an inline, non-modal group labeled by its message. It does not steal focus or trap it, so it sits naturally in the message flow.
  • It is deliberately not an alertdialog: that role promises modal focus management the card neither provides nor needs. If you require blocking confirmation, render it inside your own modal (for example a MUI Dialog) and manage focus there.
  • The built-in inline tool widget's Approve and Deny controls are native button elements, disabled while a response is pending.

For the full keyboard model and ARIA structure of the chat UI, see the Accessibility page.

See also

  • Tool calling for details on the full tool invocation lifecycle and chunk protocol.
  • Adapter for details on the approval response method.
  • Streaming for details on the tool-approval-request chunk type.
  • Reasoning for details on displaying LLM thinking alongside tool calls.

API

See the documentation below for a complete reference to all of the props and classes available to the components mentioned here.