# Agent run timeline with the OpenAI Agents SDK

> Show an OpenAI Agents SDK run as a timeline: map handoffs, tool calls, approvals and the final message from the event stream to steps with live timers.

Published 2026-11-04. Facts checked 2026-11-04. Source: https://pixilui.com/guides/agent-run-timeline-openai-agents-sdk

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](https://openai.github.io/openai-agents-js/guides/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](https://openai.github.io/openai-agents-js/guides/multi-agent/) 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](/guides/human-approval-openai-agents-sdk) 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.

```ts
// 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`.

```tsx
// 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](/guides/human-approval-openai-agents-sdk) 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](/guides/tool-call-ui-openai-agents-sdk) 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.

## Related

- [Showing a multi-step agent run as a timeline](/guides/multi-step-agent-run-timeline): what a run timeline needs, framework aside.
- [Tool call UI with the OpenAI Agents SDK](/guides/tool-call-ui-openai-agents-sdk): the same events as rows.
- [Agent run timeline with LangGraph](/guides/agent-run-timeline-langgraph): the same timeline from graph nodes.
- [Agent run timeline with the Vercel AI SDK](/guides/agent-run-timeline-vercel-ai-sdk): the same timeline from data parts.

## Sources

- [OpenAI Agents SDK (TypeScript): Streaming](https://openai.github.io/openai-agents-js/guides/streaming/), read 4 November 2026
- [OpenAI Agents SDK (TypeScript): Agent Orchestration](https://openai.github.io/openai-agents-js/guides/multi-agent/), read 4 November 2026
- [OpenAI Agents SDK (TypeScript): Human-in-the-loop](https://openai.github.io/openai-agents-js/guides/human-in-the-loop/), read 4 November 2026
- [OpenAI: ChatKit](https://developers.openai.com/api/docs/guides/chatkit), read 4 November 2026
