Agent run timeline with the OpenAI Agents SDK

· checked against the OpenAI Agents SDK docs · 6 min read

An OpenAI Agents SDK run streamed with { stream: true } tells you each thing it does as a run item event: a handoff to another agent, a tool call and its output, a tool waiting for approval, the final message. Turn each into a timeline step on your server and send it as a JSON line with an id. The client keeps the steps in order, updates each by id, and passes them to a run timeline.

How the Agents SDK reports a run#

The SDK's Streaming guide names the events a run emits. agent_updated_stream_event fires when a handoff moves the run to another agent. run_item_stream_event wraps each item the SDK creates, with names like handoff_occurred, tool_called, tool_output, tool_approval_requested and message_output_created. After a pause for approval, stream.completed resolves and stream.interruptions holds what waits; you approve or reject and run again from stream.state.

The Agent Orchestration guide names the two patterns that come up most: agents as tools, where a manager agent calls specialists through agent.asTool(), and handoffs, where another agent takes over. A handoff changes the current agent and shows up as agent_updated_stream_event; an agent used as a tool shows up as a tool call. Either way the timeline can say which agent did what.

From event to step#

Agents SDK event Run timeline step
tool_called A step, active, titled from the tool, startedAt
tool_output, same callId That step, done, endedAt, the evidence in detail
agent_updated_stream_event A done step, "Handed off to Refund agent"
tool_approval_requested A waiting step with an approval card as children
message_output_created A last done step, "Wrote the answer"
The stream throws The active step becomes error, with what to do next

The code#

The route maps events to steps. Titles live here, where you know what each tool does.

app/api/run/route.ts
import { Agent, run, tool } from "@openai/agents";
import { z } from "zod";

export type StepEvent = { id: string; title: string; status: "active" | "done" | "waiting" | "error"; detail?: string; at: number };

const titles: Record<string, [string, string]> = {
  list_failed_payments: ["Reading failed payments", "Read the failed payments"],
  draft_reminder: ["Drafting a reminder", "Drafted a reminder"],
};

const listFailedPayments = tool({
  name: "list_failed_payments",
  description: "List failed Stripe payments from the last 7 days",
  parameters: z.object({}),
  execute: async () => JSON.stringify({ count: 9 }),
});

const agent = new Agent({ name: "Dunning agent", instructions: "Chase failed payments.", tools: [listFailedPayments] });

export async function POST(req: Request) {
  const { message }: { message: string } = await req.json();
  const result = await run(agent, message, { stream: true });
  const encoder = new TextEncoder();

  const body = new ReadableStream({
    async start(controller) {
      const send = (step: Omit<StepEvent, "at">) => controller.enqueue(encoder.encode(`${JSON.stringify({ ...step, at: Date.now() })}\n`));
      for await (const event of result) {
        if (event.type === "agent_updated_stream_event") {
          send({ id: `agent-${event.agent.name}`, title: `Handed off to ${event.agent.name}`, status: "done" });
          continue;
        }
        if (event.type !== "run_item_stream_event") continue;
        const raw = event.item.rawItem;
        const [doing, did] = "name" in raw && typeof raw.name === "string" ? (titles[raw.name] ?? [`Running ${raw.name}`, `Ran ${raw.name}`]) : ["Working", "Done"];
        if (event.name === "tool_called" && raw.type === "function_call") send({ id: raw.callId, title: doing, status: "active" });
        if (event.name === "tool_output" && raw.type === "function_call_result") send({ id: raw.callId, title: did, status: "done" });
        if (event.name === "tool_approval_requested") send({ id: `approval-${"callId" in raw ? raw.callId : "run"}`, title: "Waiting for your approval", status: "waiting" });
        if (event.name === "message_output_created") send({ id: "answer", title: "Wrote the answer", status: "done" });
      }
      await result.completed;
      controller.close();
    },
  });

  return new Response(body, { headers: { "Content-Type": "application/x-ndjson" } });
}

The client keeps steps in arrival order and updates them by id.

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

import { useState } from "react";
import { RunTimeline, type RunTimelineStep } from "@/components/pixilui/run-timeline";
import type { StepEvent } from "../app/api/run/route";

export function DunningTimeline() {
  const [steps, setSteps] = useState<RunTimelineStep[]>([]);

  const apply = (event: StepEvent) =>
    setSteps((prev) => {
      const at = prev.findIndex((step) => step.id === event.id);
      const next: RunTimelineStep = {
        id: event.id,
        title: event.title,
        detail: event.detail,
        status: event.status,
        startedAt: at < 0 ? event.at : prev[at]!.startedAt,
        endedAt: event.status === "done" ? event.at : undefined,
      };
      return at < 0 ? [...prev, next] : prev.map((step, i) => (i === at ? next : step));
    });

  async function start() {
    setSteps([]);
    const response = await fetch("/api/run", { method: "POST", body: JSON.stringify({ message: "Chase this week's failed payments" }) });
    const reader = response.body!.pipeThrough(new TextDecoderStream()).getReader();
    let buffer = "";
    try {
      for (;;) {
        const { value, done } = await reader.read();
        if (done) break;
        const lines = (buffer + value).split("\n");
        buffer = lines.pop() ?? "";
        lines.filter(Boolean).forEach((line) => apply(JSON.parse(line) as StepEvent));
      }
    } catch {
      setSteps((prev) => prev.map((step) => (step.status === "active" ? { ...step, status: "error", detail: "The run stopped here. Start it again to retry." } : step)));
    }
  }

  return (
    <section aria-label="Dunning run">
      <button type="button" onClick={start}>
        Chase failed payments
      </button>
      {steps.length ? <RunTimeline steps={steps} /> : null}
    </section>
  );
}

Install the timeline with npx shadcn@latest add @pixilui/run-timeline. For the waiting step, render the approval card from the approval guide as that step's children.

What the timeline adds to the events#

  • One step per thing done. Call and output become one step that changes state, with a live timer in between.
  • Handoffs in words. "Handed off to Refund agent" tells the person why the voice changed.
  • A waiting step. The run's pause for approval becomes a step that holds the card, in place.
  • Errors in place. A failed run marks the step it stopped on and says what to do.

When the framework already ships a UI#

The TypeScript Agents SDK's guides list no React components; its streaming examples print to the terminal. Traces in the OpenAI dashboard show every run step for developers. ChatKit renders a chat with its own widgets in front of an agent you host. The timeline is for showing your users a run in your own screens.

When not to use this#

  • A single tool call. One call reads better as a tool call row in the message.
  • Runs you do not stream. Without { stream: true } you only get the final result; show a thinking line while it runs.
  • Developer debugging. Use traces for spans, tokens and timings.

Questions#

How do I show progress for an OpenAI Agents SDK run?

Run with { stream: true } and loop over the result on your server. Turn tool_called, tool_output, tool_approval_requested, message_output_created and agent updates into steps, send them as JSON lines, and update each step on the client by its ID.

How do I show handoffs between agents?

Listen for agent_updated_stream_event; it carries the new agent. Add a done step that names it, so the person sees which agent took over.

What happens to the stream when a tool needs approval?

The run pauses: stream.completed resolves and stream.interruptions lists the pending approvals. Approve or reject each with stream.state, then run again with { stream: true } to keep streaming.

Sources#