Agent run timeline with the Vercel AI SDK

· checked against the Vercel AI SDK docs · 5 min read

In the AI SDK, send each step of a run as a custom data part, data-step, from createUIMessageStream. Write the same part again with the same id when the step moves on, and the client replaces it in place; that is the SDK's data part reconciliation. On the client, collect the message's data-step parts and pass them to a run timeline: one step each, with status, title, detail and times.

How the AI SDK streams progress#

The Streaming Custom Data page describes three things a route can send next to the model's text: data parts, which land in message.parts as data-<name>; sources; and transient parts, which only reach the onData callback. A data part written with an id is updated, not appended, when the same id is written again. The page's example is a weather card going from loading to success; a run step going from active to done is the same move.

You type the parts once, as the second type argument of UIMessage, and both writer.write on the server and message.parts on the client know the shape.

Steps written as data parts are part of the message history, so a reloaded thread shows the finished run. Use a transient part for anything you do not want to keep, like a "connecting" line.

From data part to step#

What the route writes Run timeline step
{ type: "data-step", id, data: { status: "active", startedAt } } Active, live timer from startedAt
The same id with status: "done", endedAt, detail Done, duration, evidence line
The same id with status: "error", detail Failed, with what went wrong
A step written as pending up front Listed as up next, so the person sees the plan
A tool part in approval-requested A waiting step holding an approval card

The code#

One type for the message, shared by the route and the component.

lib/run-message.ts
import type { UIMessage } from "ai";

export type StepData = {
  title: string;
  status: "pending" | "active" | "done" | "error";
  detail?: string;
  startedAt?: number;
  endedAt?: number;
};

export type RunMessage = UIMessage<never, { step: StepData }>;

The route announces the plan, then updates each step by its id as the work happens, and streams the model's summary last.

app/api/dunning/route.ts
import { convertToModelMessages, createUIMessageStream, createUIMessageStreamResponse, streamText, toUIMessageStream } from "ai";
import type { RunMessage, StepData } from "../../../lib/run-message";

declare function listFailedPayments(): Promise<{ id: string; customer: string; amountCents: number }[]>;
declare function draftReminders(count: number): Promise<number>;

export async function POST(req: Request) {
  const { messages }: { messages: RunMessage[] } = await req.json();

  const stream = createUIMessageStream<RunMessage>({
    execute: async ({ writer }) => {
      const step = (id: string, data: StepData) => writer.write({ type: "data-step", id, data });
      writer.write({ type: "start" });
      step("read", { title: "Reading failed payments", status: "active", startedAt: Date.now() });
      step("draft", { title: "Draft reminders", status: "pending" });

      const failed = await listFailedPayments();
      step("read", { title: `Read ${failed.length} failed payments`, status: "done", detail: "From Stripe, last 7 days", endedAt: Date.now() });

      step("draft", { title: "Drafting reminders", status: "active", startedAt: Date.now() });
      const drafted = await draftReminders(failed.length);
      step("draft", { title: `Drafted ${drafted} reminders`, status: "done", endedAt: Date.now() });

      const result = streamText({ model: "openai/gpt-5.5", system: "Summarize the run in two sentences.", messages: await convertToModelMessages(messages) });
      writer.merge(toUIMessageStream({ stream: result.stream, sendStart: false }));
    },
  });

  return createUIMessageStreamResponse({ stream });
}

The client reads the step parts of the last assistant message.

components/dunning-run.tsx
"use client";

import { useChat } from "@ai-sdk/react";
import { RunTimeline, type RunTimelineStep } from "@/components/pixilui/run-timeline";
import type { RunMessage } from "../lib/run-message";

export function DunningRun() {
  const { messages, sendMessage, status } = useChat<RunMessage>();
  const run = messages.findLast((message) => message.role === "assistant");

  const steps: RunTimelineStep[] = (run?.parts ?? []).flatMap((part) =>
    part.type === "data-step" && part.id ? [{ id: part.id, ...part.data }] : [],
  );

  return (
    <section aria-label="Dunning run">
      <button type="button" disabled={status === "streaming"} onClick={() => sendMessage({ text: "Chase this week's failed payments" })}>
        Chase failed payments
      </button>
      {steps.length ? <RunTimeline steps={steps} /> : null}
    </section>
  );
}

Install the timeline with npx shadcn@latest add @pixilui/run-timeline.

What the timeline adds to the parts#

  • Timers that count. startedAt on an active step runs a live timer; endedAt settles it into a duration.
  • Glyphs that change in place. A step's mark swaps from up next to active to a drawn check, and the connector below fills as it finishes.
  • Evidence under each step. detail holds the count or source a step produced, in past tense.
  • A waiting step. Put an approval card in a step's children and it stays in place after the decision, as a receipt.

When the framework already ships a UI#

The AI SDK has no timeline component. AI Elements, Vercel's component set for the AI SDK, has Task, Plan and Chain of Thought; its site says it is moving into shadcn/ui and will be deprecated at the end of November 2026. The AI Elements migration doc maps those to the run timeline. For durable runs that outlive a request, the AI SDK's WorkflowAgent runs the same agent loop inside a durable workflow; the steps you show stay the same.

When not to use this#

  • The model decides every step. If you only run a tool loop, the tool parts are the steps; render them as tool call rows instead of inventing steps.
  • Sub-second runs. Three steps that finish in 400 ms flash by; a thinking line is enough.
  • Progress you cannot measure. A step with no real start and end is decoration. Only write steps your code can mark done.

Questions#

How do I show agent progress with the Vercel AI SDK?

Write each step as a data part (data-step) with an id from createUIMessageStream, and write it again with the same id as it changes. On the client, the parts are in message.parts; map them to timeline steps.

What is data part reconciliation in the AI SDK?

When the server writes a data part with an id that the message already has, the client replaces that part instead of adding a new one. The AI SDK docs use it for loading states that become results.

Do data parts stay in the chat history?

Yes, unless you mark them transient: true. Transient parts only reach the onData callback and are not added to the message.

Sources#