Skip to content
+

Chat - Step Tracking

Track multi-step agent progress using start-step and finish-step stream chunks that create visual delimiters in the message.

Agentic AI workflows often involve multiple processing steps — reasoning, tool calls, intermediate results, and a final 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';
}

ChatStepStartMessagePart

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. You can render them as visual separators, progress indicators, or collapsible sections.

Step delimiter renderer

Register a custom renderer for step-start parts:

const renderers: ChatPartRendererMap = {
  'step-start': ({ index, message }) => {
    const stepNumber = message.parts
      .slice(0, index + 1)
      .filter((part) => part.type === 'step-start').length;
    return (
      <div
        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>;

Step progress with Material UI

For a more polished UI, use Material UI components to show step progress:

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 }}>
        <Typography variant="caption" color="text.secondary">
          Step {stepNumber}
        </Typography>
      </Divider>
    );
  },
};

Step structure in the parts array

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
];

This structure makes it straightforward to group parts by step when building custom renderers. Iterate through the parts and treat each step-start as a new group boundary.

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

  • Tool Calling for the tool invocation lifecycle within steps.
  • Reasoning for displaying LLM thinking traces.
  • Streaming for the full chunk protocol reference including step boundary chunks.
  • Tool Approval for human-in-the-loop checkpoints within agent steps.

API

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