Find and summarize the step tracking docs.
Chat - Step Tracking
Track and display multi-step agent progress with visual delimiters in the message stream.
Agentic AI workflows often involve multiple processing steps: reasoning, tool calls, and a final text answer. Step tracking lets you visually delimit these phases in the message stream so users can follow the agent's progress.
Step boundary chunks
The streaming protocol provides two chunks for step boundaries:
| Chunk type | Description |
|---|---|
start-step |
Begin a new processing step |
finish-step |
End the current step |
interface ChatStartStepChunk {
type: 'start-step';
}
interface ChatFinishStepChunk {
type: 'finish-step';
}
Step start part structure
When a start-step chunk arrives, the runtime creates a ChatStepStartMessagePart on the assistant message:
interface ChatStepStartMessagePart {
type: 'step-start';
}
The finish-step chunk signals the end of the current step but does not create a separate message part—it serves as a boundary marker in the stream.
Streaming example
A typical agentic loop might produce multiple steps, each containing reasoning, tool calls, or text:
const adapter: ChatAdapter = {
async sendMessage({ message }) {
return new ReadableStream({
start(controller) {
controller.enqueue({ type: 'start', messageId: 'msg-1' });
// Step 1: Search for information
controller.enqueue({ type: 'start-step' });
controller.enqueue({
type: 'tool-input-start',
toolCallId: 'call-1',
toolName: 'search',
});
controller.enqueue({
type: 'tool-input-available',
toolCallId: 'call-1',
toolName: 'search',
input: { query: 'MUI X Chat documentation' },
});
controller.enqueue({
type: 'tool-output-available',
toolCallId: 'call-1',
output: { results: ['…'] },
});
controller.enqueue({ type: 'finish-step' });
// Step 2: Analyze results
controller.enqueue({ type: 'start-step' });
controller.enqueue({
type: 'tool-input-start',
toolCallId: 'call-2',
toolName: 'analyze',
});
controller.enqueue({
type: 'tool-input-available',
toolCallId: 'call-2',
toolName: 'analyze',
input: { data: '…' },
});
controller.enqueue({
type: 'tool-output-available',
toolCallId: 'call-2',
output: { summary: '…' },
});
controller.enqueue({ type: 'finish-step' });
// Step 3: Final answer
controller.enqueue({ type: 'start-step' });
controller.enqueue({ type: 'text-start', id: 'text-1' });
controller.enqueue({
type: 'text-delta',
id: 'text-1',
delta: 'Based on my research, here is the answer…',
});
controller.enqueue({ type: 'text-end', id: 'text-1' });
controller.enqueue({ type: 'finish-step' });
controller.enqueue({ type: 'finish', messageId: 'msg-1' });
controller.close();
},
});
},
};
Displaying step progress
The step-start parts act as delimiters in the message's parts array.
Render them as visual separators, progress indicators, or collapsible sections.
Default rendering
By default, step-start parts render as an unstyled <div role="separator" />.
This keeps the accessible structure of the message intact but is visually invisible.
Register a partRenderers['step-start'] entry to make steps visible.
Step delimiter renderer
Register a custom renderer for step-start parts (see Custom parts for the full ChatPartRendererMap API):
const renderers: ChatPartRendererMap = {
'step-start': ({ index, message }) => {
const stepNumber = message.parts
.slice(0, index + 1)
.filter((part) => part.type === 'step-start').length;
return (
<div
role="separator"
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
margin: '8px 0',
color: 'gray',
fontSize: '0.8em',
}}
>
<div style={{ flex: 1, height: 1, background: 'lightgray' }} />
<span>Step {stepNumber}</span>
<div style={{ flex: 1, height: 1, background: 'lightgray' }} />
</div>
);
},
};
<ChatProvider adapter={adapter} partRenderers={renderers}>
<MyChat />
</ChatProvider>;
The partRenderers prop is accepted by both ChatProvider and ChatBox.
Pass it to whichever component you mount.
Step progress with Material UI
For a styled delimiter, use Material UI components:
import Divider from '@mui/material/Divider';
import Typography from '@mui/material/Typography';
const renderers: ChatPartRendererMap = {
'step-start': ({ index, message }) => {
const stepNumber = message.parts
.slice(0, index + 1)
.filter((part) => part.type === 'step-start').length;
return (
<Divider sx={{ my: 1 }} role="separator">
<Typography variant="caption" color="text.secondary">
Step {stepNumber}
</Typography>
</Divider>
);
},
};
For an alternative approach that renders an animated, live-updating task list from a custom tool part instead of step-start delimiters, see the Plan and task example.
Grouping parts by step
After streaming, the message's parts array contains step-start entries interleaved with the content parts for each step:
// Example parts array after streaming
[
{ type: 'step-start' }, // Step 1 delimiter
{
type: 'tool',
toolInvocation: {
/* ... */
},
}, // Step 1 content
{ type: 'step-start' }, // Step 2 delimiter
{
type: 'tool',
toolInvocation: {
/* ... */
},
}, // Step 2 content
{ type: 'step-start' }, // Step 3 delimiter
{ type: 'text', text: 'Final answer…' }, // Step 3 content
];
Group the parts into per-step sections by treating each step-start as a new group boundary:
function groupPartsByStep(parts: ChatMessagePart[]): ChatMessagePart[][] {
const groups: ChatMessagePart[][] = [];
for (const part of parts) {
if (part.type === 'step-start' || groups.length === 0) {
groups.push([]);
}
if (part.type !== 'step-start') {
groups[groups.length - 1].push(part);
}
}
return groups;
}
Each group can then be rendered as its own section, for example inside a Material UI Accordion for collapsible steps.
While the assistant message is still streaming (message.status === 'streaming'), the last step-start part marks the step currently in progress.
Use this to render a spinner or "running" state on the final group.
Steps with reasoning and tool calls
Steps compose naturally with reasoning and tool calling. A single step can contain reasoning, one or more tool invocations, and text:
// Step with reasoning + tool call
controller.enqueue({ type: 'start-step' });
controller.enqueue({ type: 'reasoning-start', id: 'r-1' });
controller.enqueue({
type: 'reasoning-delta',
id: 'r-1',
delta: 'I need to look up the user data first.',
});
controller.enqueue({ type: 'reasoning-end', id: 'r-1' });
controller.enqueue({
type: 'tool-input-start',
toolCallId: 'call-1',
toolName: 'get_user',
});
controller.enqueue({
type: 'tool-input-available',
toolCallId: 'call-1',
toolName: 'get_user',
input: { userId: '123' },
});
controller.enqueue({
type: 'tool-output-available',
toolCallId: 'call-1',
output: { name: 'Alice', email: 'alice@example.com' },
});
controller.enqueue({ type: 'finish-step' });
See also
- See Tool calling for the tool invocation lifecycle within steps.
- See Reasoning for displaying LLM thinking traces.
- See Streaming for the full chunk protocol reference including step boundary chunks.
- See Tool approval for human-in-the-loop checkpoints within agent steps.
- See the Plan and task example for agent progress rendered from a custom tool part, an alternative to
step-startdelimiters.
API
See the documentation below for a complete reference to all of the props and classes available to the components mentioned here.