
write
Tool called:{
{
"path": "src/App.tsx",
"contents": "export const App = () => <div>Hello</div>;\n"
}Stream, track, and render LLM tool calls through their full lifecycle of input, execution, and output.
Tool calling lets an AI assistant invoke external functions during a conversation. The runtime tracks the full tool lifecycle through the streaming chunk protocol — streaming tool input, exposing the parsed input, and surfacing the tool's output and errors — so you can render each stage. Executing the tool itself is your backend's (or adapter's) responsibility.
The demo below streams a get_weather tool call through input-streaming → input-available → output-available and renders each stage with a custom tool card:
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;
}
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.
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, dynamic? |
Tool input is fully available |
tool-input-error |
toolCallId, errorText |
Tool input parsing failed |
tool-approval-request |
approvalId?, toolCallId, toolName, input, dynamic? |
Request user approval before execution |
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 |
For the approval request/response flow, see Tool approval.
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();
},
});
},
};
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 that live outside the chat state — logging, analytics, and external API calls.
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.
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 }.
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,
});
// ...stream the input deltas, then mark the input as available —
// the `dynamic: true` flag must be repeated here for the same call.
controller.enqueue({
type: 'tool-input-available',
toolCallId: 'call-2',
toolName: 'user_defined_tool',
input: { query: 'anything' },
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;
providerExecuted?: boolean;
title?: string;
callProviderMetadata?: Record<string, unknown>;
preliminary?: boolean;
}
ChatDynamicToolInvocation has the same shape as ChatToolInvocation — the only difference is that input and output are typed as unknown.
Register custom renderers for tool parts through the partRenderers prop on ChatProvider:
function ToolCard({ invocation }: { invocation: ChatToolInvocation }) {
switch (invocation.state) {
case 'input-streaming':
return <Skeleton>Calling {invocation.toolName}…</Skeleton>;
case 'output-available':
return <pre>{JSON.stringify(invocation.output, null, 2)}</pre>;
case 'output-error':
return <Alert severity="error">{invocation.errorText}</Alert>;
default:
return <pre>{JSON.stringify(invocation.input, null, 2)}</pre>;
}
}
const renderers: ChatPartRendererMap = {
tool: ({ part }) => <ToolCard invocation={part.toolInvocation} />,
};
<ChatProvider adapter={adapter} partRenderers={renderers}>
<MyChat />
</ChatProvider>;
See the demo at the top of this page for a complete ToolCard implementation.
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 });
}
// Render your own fallback here — parts without a registered renderer
// are otherwise dropped. Built-in components like <ChatMessageContent />
// only consult this hook first and fall back to their default part rendering.
return null;
}
When building custom tool cards, announce state transitions to assistive technology — for example with a polite live region when output arrives — and keep interactive elements reachable through the message list's keyboard navigation. See the message list accessibility model for details.
The built-in tool card rendered by ChatMessageContent is a collapsible disclosure.
By default it opens while the tool gathers input or awaits approval and never collapses on its own.
Use partProps.tool.defaultExpanded to control that per tool — for example to keep a noisy tool collapsed, or to expand a tool while it runs and collapse it once it finishes.
defaultExpanded is a map keyed by toolName, with a '*' fallback for every other tool.
Each value is either a static boolean (applied to the card and its input/output sections) or a resolver (ownerState) => boolean | undefined:
<ChatBox
adapter={adapter}
slotProps={{
messageContent: {
partProps: {
tool: {
defaultExpanded: {
// Expand `write` while it runs, collapse it when the tool ends;
// leave its input/output sections at their default.
write: (ownerState) =>
ownerState.section
? undefined
: ownerState.state === 'input-streaming' ||
ownerState.state === 'input-available',
search: true, // always expanded
'*': undefined, // built-in default for everything else
},
},
},
},
}}
/>
A resolver's returned boolean is applied on every state transition, so returning false collapses a card when its tool ends. Returning undefined — or omitting the tool — keeps the built-in behavior. The ownerState exposes:
toolName, state, and role of the invocation;isMessageStreaming — whether the whole message is still streaming (message.status === 'streaming');section — 'input' or 'output' for a section, and undefined for the card root, so a single resolver can scope its decision.defaultExpanded controls only whether a disclosure is open; section visibility still follows the tool state (the output section appears once output is available). When a policy collapses a card that holds keyboard focus, focus moves to its summary so it is never dropped.
Map keys are matched by exact toolName; only the literal '*' key is special, as the fallback for tools without their own entry.
There is no glob matching on keys — a key such as 'write*' matches a tool literally named write*, not a prefix.
To target a group of tools — for example every tool whose name starts with write — read ownerState.toolName inside the '*' resolver and return undefined for the rest so they keep the built-in behavior:
defaultExpanded={{
'*': (ownerState) => {
if (ownerState.section || !ownerState.toolName.startsWith('write')) {
return undefined; // sections and non-write tools: built-in default
}
// every write* tool: expand while running, collapse when done
return (
ownerState.state === 'input-streaming' || ownerState.state === 'input-available'
);
},
}}
An exact key takes precedence over '*': when defaultExpanded[toolName] exists, that entry is used and '*' is not consulted, even if the exact entry's resolver returns undefined. This lets you combine one-off overrides with a group rule:
defaultExpanded={{
search: true, // one specific tool, always expanded
'*': (ownerState) =>
ownerState.toolName.startsWith('write') && !ownerState.section ? true : undefined,
}}
The playground below maps a few common policies to presets — pick one, then step the tool state and toggle message streaming to watch the card respond:

{
"path": "src/App.tsx",
"contents": "export const App = () => <div>Hello</div>;\n"
}defaultExpanded={{
write: (ownerState) =>
ownerState.section
? undefined
: ownerState.state === 'input-streaming' ||
ownerState.state === 'input-available',
}}See the documentation below for a complete reference to all of the props and classes available to the components mentioned here.