Skip to content
+

Chat - Tool Calling

Stream tool invocations from the LLM, track their lifecycle through well-defined states, and render custom tool UIs using the part renderer registry.

Tool calling lets an AI assistant invoke external functions during a conversation. The runtime handles the full tool lifecycle: streaming tool input, making the input available, executing the tool, and displaying the output — all through the streaming chunk protocol.

ChatToolMessagePart

When a tool is invoked during streaming, the runtime creates a ChatToolMessagePart on the assistant message:

interface ChatToolMessagePart<
  TToolName extends ChatKnownToolName = ChatKnownToolName,
> {
  type: 'tool';
  toolInvocation: ChatToolInvocation<TToolName>;
}

Each tool message part wraps a ChatToolInvocation that tracks the tool's lifecycle:

interface ChatToolInvocation<
  TToolName extends ChatKnownToolName = ChatKnownToolName,
> {
  toolCallId: string;
  toolName: TToolName;
  state: ChatToolInvocationState;
  input?: ChatToolInput<TToolName>;
  output?: ChatToolOutput<TToolName>;
  errorText?: string;
  approval?: ChatToolApproval;
  providerExecuted?: boolean;
  title?: string;
  callProviderMetadata?: Record<string, unknown>;
  preliminary?: boolean;
}

Tool invocation states

The toolInvocation.state field tracks the tool lifecycle through well-defined states:

State Description
input-streaming Tool input JSON is being streamed
input-available Tool input is fully available
approval-requested User approval is needed before execution
approval-responded User has responded to the approval request
output-available Tool output is ready
output-error Tool execution failed
output-denied User denied the tool call

The typical progression is: input-streaming -> input-available -> output-available. When human-in-the-loop approval is required, the flow includes approval-requested -> approval-responded between input and output.

Stream chunk protocol

Tool chunks in the streaming protocol drive the state transitions:

Chunk type Fields Description
tool-input-start toolCallId, toolName, dynamic? Begin a tool invocation
tool-input-delta toolCallId, inputTextDelta Stream tool input JSON
tool-input-available toolCallId, toolName, input Tool input is fully available
tool-input-error toolCallId, errorText Tool input parsing failed
tool-output-available toolCallId, output, preliminary? Tool output is available
tool-output-error toolCallId, errorText Tool execution failed
tool-output-denied toolCallId, reason? User denied the tool call

Tool input streaming

Tool input is streamed incrementally as JSON. The tool-input-start chunk begins the invocation with the tool name, tool-input-delta chunks append partial JSON, and tool-input-available delivers the complete parsed input:

const adapter: ChatAdapter = {
  async sendMessage({ message }) {
    return new ReadableStream({
      start(controller) {
        controller.enqueue({ type: 'start', messageId: 'msg-1' });

        // Tool input streaming
        controller.enqueue({
          type: 'tool-input-start',
          toolCallId: 'call-1',
          toolName: 'get_weather',
        });
        controller.enqueue({
          type: 'tool-input-delta',
          toolCallId: 'call-1',
          inputTextDelta: '{"city":',
        });
        controller.enqueue({
          type: 'tool-input-delta',
          toolCallId: 'call-1',
          inputTextDelta: '"Paris"}',
        });
        controller.enqueue({
          type: 'tool-input-available',
          toolCallId: 'call-1',
          toolName: 'get_weather',
          input: { city: 'Paris' },
        });

        // Tool output
        controller.enqueue({
          type: 'tool-output-available',
          toolCallId: 'call-1',
          output: { temperature: 22, condition: 'sunny' },
        });

        controller.enqueue({ type: 'finish', messageId: 'msg-1' });
        controller.close();
      },
    });
  },
};

The onToolCall callback

Register onToolCall on ChatProvider to observe every tool invocation state change during streaming:

<ChatProvider
  adapter={adapter}
  onToolCall={({ toolCall }) => {
    console.log(`Tool "${toolCall.toolName}" is now ${toolCall.state}`);

    if (toolCall.state === 'output-available') {
      // Drive side effects — update dashboards, trigger notifications, etc.
    }
  }}
>
  <MyChat />
</ChatProvider>

The callback fires on every state change — not just when output is available. Use it for side effects outside the store: logging, analytics, and external API calls.

The ChatOnToolCallPayload

interface ChatOnToolCallPayload {
  toolCall: ChatToolInvocation | ChatDynamicToolInvocation;
}

The toolCall object includes toolCallId, toolName, state, input, output, errorText, and approval fields — all typed based on your ChatToolDefinitionMap augmentation.

Tool type registry

Use TypeScript module augmentation to register typed tool definitions. This gives you type-safe input and output on tool invocations:

declare module '@mui/x-chat/types' {
  interface ChatToolDefinitionMap {
    get_weather: {
      input: { city: string };
      output: { temperature: number; condition: string };
    };
    search_docs: {
      input: { query: string; limit?: number };
      output: { results: Array<{ title: string; url: string }> };
    };
  }
}

Once registered, ChatToolInvocation<'get_weather'> correctly types input as { city: string } and output as { temperature: number; condition: string }.

Dynamic tools

For tools that are not known at compile time, use ChatDynamicToolMessagePart with the dynamic: true flag on the tool-input-start chunk:

controller.enqueue({
  type: 'tool-input-start',
  toolCallId: 'call-2',
  toolName: 'user_defined_tool',
  dynamic: true,
});

Dynamic tool invocations use ChatDynamicToolInvocation with untyped input and output (unknown):

interface ChatDynamicToolInvocation<TToolName extends string = string> {
  toolCallId: string;
  toolName: TToolName;
  state: ChatToolInvocationState;
  input?: unknown;
  output?: unknown;
  errorText?: string;
  approval?: ChatToolApproval;
  callProviderMetadata?: Record<string, unknown>;
}

Rendering tool parts

Register custom renderers for tool parts through the partRenderers prop on ChatProvider:

const renderers: ChatPartRendererMap = {
  tool: ({ part, message, index }) => <ToolCard invocation={part.toolInvocation} />,
};

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

Use useChatPartRenderer('tool') inside any component to look up the registered renderer:

function MessagePart({ part, message, index }) {
  const renderer = useChatPartRenderer(part.type);

  if (renderer) {
    return renderer({ part, message, index });
  }

  return null;
}

See also

API

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