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.
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.
"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.
Related#
- Showing a multi-step agent run as a timeline: what a run timeline needs, framework aside.
- Tool call UI with the OpenAI Agents SDK: the same events as rows.
- Agent run timeline with LangGraph: the same timeline from graph nodes.
- Agent run timeline with the Vercel AI SDK: the same timeline from data parts.
Sources#
- OpenAI Agents SDK (TypeScript): Streaming, read 4 November 2026
- OpenAI Agents SDK (TypeScript): Agent Orchestration, read 4 November 2026
- OpenAI Agents SDK (TypeScript): Human-in-the-loop, read 4 November 2026
- OpenAI: ChatKit, read 4 November 2026