The OpenAI Agents SDK for TypeScript reports tool calls as run item events when you run with { stream: true }: tool_called when the model calls a tool and tool_output when it returns, both carrying the call's callId. On your server, forward those two events to the browser as JSON lines. On the client, keep one row per callId: running on tool_called, done on tool_output, with the arguments and output folded under it.
How the Agents SDK reports tool calls#
The SDK's Streaming guide lists three event types. raw_model_stream_event carries the model's own deltas, agent_updated_stream_event fires on a handoff, and run_item_stream_event wraps the items the SDK creates, named by what happened: message_output_created, tool_called, tool_output, reasoning_item_created, tool_approval_requested, handoffs and tool search. toTextStream() gives only the text; tool calls, handoffs and approvals are in the full event stream.
The SDK runs on the server and has no browser hook, so the shape on the wire is yours. JSON lines keep it simple: one small object per change, and a reader that never has to parse a half-finished event.
From run item event to row#
| Run item event | Tool call row |
|---|---|
tool_called, item.rawItem.callId |
A new row, status="running", startedAt |
item.rawItem.name |
name, in mono |
item.rawItem.arguments (a JSON string) |
input, parsed, folded with Copy |
tool_output with the same callId |
status="done", durationMs, output |
tool_approval_requested |
An approval card instead of a row |
| The stream throws | Rows still running become status="error" |
The code#
The route runs the agent, keeps only the events the rows need, and writes each as a line.
import { Agent, run, tool } from "@openai/agents";
import { z } from "zod";
export type ToolEvent = { id: string; name: string; status: "running" | "done" | "error"; input?: string; output?: string; at: number };
const lookUpInvoice = tool({
name: "look_up_invoice",
description: "Find a Stripe invoice by number",
parameters: z.object({ number: z.string() }),
execute: async ({ number }) => JSON.stringify({ number, status: "open", amountDue: 41300 }),
});
const agent = new Agent({ name: "Billing agent", instructions: "Answer billing questions.", tools: [lookUpInvoice] });
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 = (event: ToolEvent) => controller.enqueue(encoder.encode(`${JSON.stringify(event)}\n`));
for await (const event of result) {
if (event.type !== "run_item_stream_event") continue;
const raw = event.item.rawItem;
if (event.name === "tool_called" && raw.type === "function_call") send({ id: raw.callId, name: raw.name, status: "running", input: raw.arguments, at: Date.now() });
if (event.name === "tool_output" && raw.type === "function_call_result" && event.item.type === "tool_call_output_item")
send({ id: raw.callId, name: raw.name, status: "done", output: String(event.item.output), at: Date.now() });
}
await result.completed;
controller.close();
},
});
return new Response(body, { headers: { "Content-Type": "application/x-ndjson" } });
}The client reads the lines and keeps one row per call.
"use client";
import { useState } from "react";
import { ToolCallRow, ToolCallRowGroup, type ToolCallRowValue } from "@/components/pixilui/tool-call-row";
import type { ToolEvent } from "../app/api/agent/route";
type Row = { name: string; status: ToolEvent["status"]; input?: string; output?: string; startedAt: number; endedAt?: number };
const parse = (text?: string): ToolCallRowValue | undefined => {
if (text === undefined) return undefined;
try {
return JSON.parse(text) as ToolCallRowValue;
} catch {
return text; // plain text output stays text
}
};
export function AgentTools() {
const [rows, setRows] = useState<Map<string, Row>>(new Map());
async function ask(message: string) {
setRows(new Map());
const response = await fetch("/api/agent", { method: "POST", body: JSON.stringify({ message }) });
const reader = response.body!.pipeThrough(new TextDecoderStream()).getReader();
let buffer = "";
try {
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buffer += value;
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines.filter(Boolean)) {
const event = JSON.parse(line) as ToolEvent;
setRows((prev) => {
const next = new Map(prev);
const row = prev.get(event.id);
next.set(event.id, row ? { ...row, status: event.status, output: event.output, endedAt: event.at } : { name: event.name, status: event.status, input: event.input, startedAt: event.at });
return next;
});
}
}
} catch {
setRows((prev) => new Map([...prev].map(([id, row]) => [id, row.status === "running" ? { ...row, status: "error" } : row])));
}
}
return (
<section>
<button type="button" onClick={() => ask("Is invoice NW-1042 still open?")}>
Check invoice NW-1042
</button>
{rows.size ? (
<ToolCallRowGroup>
{[...rows].map(([id, row]) => (
<ToolCallRow
key={id}
name={row.name}
status={row.status}
summary={row.status === "done" ? "Looked up the invoice" : "Looking up the invoice"}
startedAt={row.startedAt}
durationMs={row.endedAt ? row.endedAt - row.startedAt : undefined}
input={parse(row.input)}
output={parse(row.output)}
error={row.status === "error" ? "The run stopped before this call returned." : undefined}
/>
))}
</ToolCallRowGroup>
) : null}
</section>
);
}Install the row with npx shadcn@latest add @pixilui/tool-call-row. The server stamps the times, so the duration is the tool's, not the network's.
What the row adds to the events#
- One row per call ID. Two events become one row that changes state, not two lines.
- Arguments you can read.
argumentsis a JSON string; parsed, it folds out as formatted JSON with Copy. - Timing from the server.
startedAtruns a live timer; thetool_outputtime settles it. - A group line. "Used 3 tools" above the rows, so a long run stays short.
When the framework already ships a UI#
The TypeScript Agents SDK runs on the server, and its guides list no React components; its streaming examples print to the terminal. OpenAI's ChatKit is a chat UI with React bindings and tool-invocation support that can sit in front of an agent you host; if ChatKit renders your chat, use its tool display. The OpenAI dashboard's traces show every tool call for developers. The row is for an agent you run with the SDK and show in your own screens.
When not to use this#
- Hosted tools. Web search and file search run on OpenAI's side and arrive as different item types; decide whether they need a row at all.
- Text-only agents. If the agent never calls a tool,
toTextStream()is all you need. - Debugging. For spans, tokens and timings per call, use tracing.
Questions#
How do I stream tool calls from the OpenAI Agents SDK to React?
Run the agent with { stream: true }, loop over the result on your server, and forward run_item_stream_event events named tool_called and tool_output as JSON lines. The client keeps one row per callId.
What is the difference between tool_called and tool_output?
tool_called fires when the model's tool call item is created, with the tool's name and arguments. tool_output fires when the tool's result item is created. Both carry the same callId.
How do I get only the text from an Agents SDK stream?
Call toTextStream() on the streamed result. It emits assistant text only; tool calls, handoffs and approvals are in the full event stream.
Related#
- How to show tool calls and their results in an AI chat: what a tool row needs, framework aside.
- Human approval with the OpenAI Agents SDK: when a call needs a person first.
- Agent run timeline with the OpenAI Agents SDK: the same events as steps.
- Tool call UI with the Vercel AI SDK: the same row from AI SDK tool parts.
Sources#
- OpenAI Agents SDK (TypeScript): Streaming, read 4 November 2026
- OpenAI Agents SDK (TypeScript): Tools, read 4 November 2026
- OpenAI: ChatKit, read 4 November 2026
- OpenAI Agents SDK (TypeScript): Tracing, read 4 November 2026