Reasoning UI with the OpenAI Agents SDK

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

With the OpenAI Agents SDK, set modelSettings: { reasoning: { effort: "medium", summary: "auto" } } on the agent to get reasoning summaries back. When you stream the run, each one arrives as a reasoning_item_created run item event. Forward those to the browser as steps of a thinking line with a live timer, and mark it done when the first output_text_delta arrives, so it folds to "Thought for 9s" above the answer.

How the Agents SDK exposes reasoning#

The SDK's Models guide lists reasoning in ModelSettings: an effort, and a summary of auto, concise or detailed that controls how much reasoning summary the model returns. The Streaming guide lists reasoning_item_created among the run item events, and shows output_text_delta as the raw model event that carries answer text.

So the order on the wire is the order the UI needs: reasoning items while the model thinks, then text deltas once it answers. The reasoning item carries the summary text, not the model's raw thinking; show it as short steps, not a transcript.

From events to the thinking line#

Moment Agents SDK event Thinking state props
Request sent Before the first event label, startedAt
A summary arrives reasoning_item_created, text in item.rawItem.content A new step, active; the previous one done
The answer starts raw_model_stream_event with data.type === "output_text_delta" done; the timer freezes, the line folds
The model returns no summary No reasoning items label and timer only
Answer text streams More output_text_delta StreamingText with the text so far

The code#

The route asks for summaries and forwards three kinds of line: a reasoning step, the answer starting, and answer text.

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

export type ThinkEvent = { type: "reasoning"; text: string } | { type: "answer"; delta: string } | { type: "end" };

const agent = new Agent({
  name: "Analyst",
  instructions: "Answer questions about the revenue data.",
  model: "gpt-5.5",
  modelSettings: { reasoning: { effort: "medium", summary: "auto" } },
});

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

  const body = new ReadableStream({
    async start(controller) {
      const send = (event: ThinkEvent) => controller.enqueue(encoder.encode(`${JSON.stringify(event)}\n`));
      for await (const event of result) {
        if (event.type === "run_item_stream_event" && event.name === "reasoning_item_created" && event.item.rawItem.type === "reasoning") {
          const text = event.item.rawItem.content.map((part) => part.text).join(" ").trim();
          if (text) send({ type: "reasoning", text });
        }
        if (event.type === "raw_model_stream_event" && event.data.type === "output_text_delta") send({ type: "answer", delta: event.data.delta });
      }
      await result.completed;
      send({ type: "end" });
      controller.close();
    },
  });

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

The client grows the steps, folds the line on the first answer text, and streams the answer under it.

components/analyst-answer.tsx
"use client";

import { useState } from "react";
import { StreamingText } from "@/components/pixilui/streaming-text";
import { ThinkingState, type ThinkingStateStep } from "@/components/pixilui/thinking-state";
import type { ThinkEvent } from "../app/api/think/route";

export function AnalystAnswer() {
  const [steps, setSteps] = useState<ThinkingStateStep[]>([]);
  const [answer, setAnswer] = useState("");
  const [streaming, setStreaming] = useState(false);
  const [startedAt, setStartedAt] = useState<number>();

  const apply = (event: ThinkEvent) => {
    if (event.type === "reasoning")
      setSteps((prev) => [...prev.map((s) => ({ ...s, status: "done" as const })), { id: String(prev.length), text: event.text.split(/(?<=[.!?])\s/)[0]!.slice(0, 90), status: "active" }]);
    if (event.type === "answer") setAnswer((so) => so + event.delta);
    if (event.type === "end") setStreaming(false);
  };

  async function ask() {
    setSteps([]);
    setAnswer("");
    setStreaming(true);
    setStartedAt(Date.now());
    const response = await fetch("/api/think", { method: "POST", body: JSON.stringify({ question: "Why did net revenue drop 6% in October?" }) });
    const reader = response.body!.pipeThrough(new TextDecoderStream()).getReader();
    let buffer = "";
    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 ThinkEvent));
    }
    setStreaming(false);
  }

  return (
    <section aria-live="polite">
      <button type="button" onClick={ask} aria-disabled={streaming}>
        Explain the October drop
      </button>
      {startedAt ? <ThinkingState label="Looking into October revenue" steps={steps} startedAt={startedAt} done={answer.length > 0} /> : null}
      {answer ? <StreamingText text={answer} streaming={streaming} /> : null}
    </section>
  );
}

Install both with npx shadcn@latest add @pixilui/thinking-state @pixilui/streaming-text.

What the thinking line adds#

  • A labeled wait from the first moment, before any summary arrives.
  • Steps that check off. Each new summary becomes the active step; the one before it draws its check.
  • A timer that freezes and folds. The first answer text ends the thinking; the line becomes "Thought for 9s".
  • Reduced motion. No moving light and no rise; steps change at once.

When the framework already ships a UI#

The TypeScript Agents SDK's guides list no React components; its streaming examples print to the terminal. ChatKit, OpenAI's chat UI with React bindings, lists chain-of-thought visualizations among its features and can sit in front of an agent you host; if ChatKit renders your chat, use its reasoning display. The thinking line is for answers shown in your own screens.

When not to use this#

  • Models without reasoning. If you set no reasoning or the model does not reason, show the label and timer only, or nothing for fast replies.
  • Showing the summary in full. If people need to read the whole summary, render it as text under a disclosure, not as clipped steps.
  • Tool-heavy runs. When the agent calls tools between thoughts, use a run timeline for the steps.

Questions#

How do I get reasoning summaries from the OpenAI Agents SDK?

Set modelSettings.reasoning.summary on the agent to auto, concise or detailed, with a reasoning model. Summaries then arrive as reasoning items; in a streamed run, as reasoning_item_created events.

How do I know when the model stopped thinking?

The first raw_model_stream_event whose data.type is output_text_delta is the first token of the answer. Mark the thinking line done there; the time since you sent the request is the thinking time.

Can I show the model's raw chain of thought?

The reasoning item in this flow carries summary text, which is what summary asks for. Show that, and say it is a summary if you show it in full.

Sources#