Tool call UI with LangGraph

· checked against the LangGraph docs · 5 min read

With LangGraph and LangChain agents, useStream from @langchain/react gathers every tool call into one array, stream.toolCalls. Each entry has the tool's name, its input, its output once it returns, and a status of running, finished or error. Render each as a tool call row, match calls to the AI message that made them by callId, and group one message's calls under a line that counts them.

How LangGraph exposes tool calls#

The frontend Tool calling page explains the merge: the agent emits tool calls inside an AI message, the runtime runs each tool, the result comes back as a ToolMessage, and useStream unifies call and result into one AssembledToolCall. The docs list its fields: name, callId (also id), namespace, input (also args), output (null until the call succeeds), status and error.

To put calls under the right message, the docs filter stream.toolCalls by the IDs in that message's tool_calls. The docs then render each call as a loading card, an error card or a tool-specific card. A row is the quieter default: one line per call, with the tool-specific card below it when a result deserves one.

From AssembledToolCall to row#

AssembledToolCall Tool call row
name name, shown in mono
status: "running" status="running", live timer, present-tense summary
status: "finished" status="done", duration, past-tense summary with the count
status: "error" status="error", error in place, Retry
input input, folded JSON with Copy
output output, folded JSON with Copy
namespace Which subgraph ran it; group by it if you show subagents

The code#

A LangChain agent with one tool. useStream reads it from a LangGraph server.

agent/support.ts
import { MemorySaver } from "@langchain/langgraph";
import { createAgent, tool } from "langchain";
import { z } from "zod";

const searchOrders = tool(async ({ email }) => JSON.stringify({ email, orders: 3, lastOrder: "NW-20418" }), {
  name: "search_orders",
  description: "Find a customer's orders by email",
  schema: z.object({ email: z.string() }),
});

export const agent = createAgent({ model: "openai:gpt-5.5", tools: [searchOrders], checkpointer: new MemorySaver() });

The client renders the calls of one AI message as rows.

components/message-tool-calls.tsx
"use client";

import { useRef } from "react";
import type { AssembledToolCall } from "@langchain/react";
import { ToolCallRow, ToolCallRowGroup, type ToolCallRowStatus, type ToolCallRowValue } from "@/components/pixilui/tool-call-row";

const json = (value: unknown) => (value == null ? undefined : (JSON.parse(JSON.stringify(value)) as ToolCallRowValue));

const statusOf = (call: AssembledToolCall): ToolCallRowStatus => (call.status === "finished" ? "done" : call.status);

// Present tense while it runs, past tense with the evidence once it is done.
function summary(call: AssembledToolCall) {
  if (call.name === "search_orders") {
    const found = call.status === "finished" ? (JSON.parse(String(call.output)) as { orders: number }).orders : undefined;
    return found === undefined ? "Searching orders" : `Found ${found} orders`;
  }
  return call.status === "finished" ? `Ran ${call.name}` : `Running ${call.name}`;
}

export function MessageToolCalls({ callIds, toolCalls }: { callIds: string[]; toolCalls: AssembledToolCall[] }) {
  // The calls carry no timing: note when each one first shows up and when it settles.
  const times = useRef(new Map<string, { start: number; end?: number }>());
  const calls = toolCalls.filter((call) => callIds.includes(call.callId));
  if (!calls.length) return null;

  return (
    <ToolCallRowGroup>
      {calls.map((call) => {
        const time = times.current.get(call.callId) ?? { start: Date.now() };
        if (call.status !== "running" && time.end === undefined) time.end = Date.now();
        times.current.set(call.callId, time);
        return (
          <ToolCallRow
            key={call.callId}
            name={call.name}
            status={statusOf(call)}
            summary={summary(call)}
            startedAt={time.start}
            durationMs={time.end !== undefined ? time.end - time.start : undefined}
            input={json(call.input)}
            output={json(call.output)}
            error={call.error}
          />
        );
      })}
    </ToolCallRowGroup>
  );
}

Use it under each AI message: <MessageToolCalls callIds={message.tool_calls?.map((t) => t.id ?? "") ?? []} toolCalls={stream.toolCalls} />, where stream is useStream<typeof agent>(...). Install the row with npx shadcn@latest add @pixilui/tool-call-row.

What the row adds to the tool call#

  • One quiet line per call. The docs' loading and error cards become states of one row, so a message with four calls stays four lines.
  • A summary in words. "Found 3 orders" for the person; search_orders in mono for the developer.
  • Time. A live timer while running, the duration once finished.
  • The raw payload, folded. Input and output as formatted JSON with Copy, closed by default.

When the framework already ships a UI#

LangGraph's docs list no tool call component; they build the cards in plain markup and name AI Elements, assistant-ui, CopilotKit and OpenUI as libraries that can render on top of useStream. If one of those already renders your chat, use its tool UI. LangSmith shows every tool call in a trace for developers; the row is for the people using your product.

When not to use this#

  • A tool whose result is the answer. A weather card or chart should render as itself; keep the row only if the call needs a record.
  • Hidden plumbing. Retrieval and routing calls can be noise; show the calls that changed the answer.
  • Headless tools on the client. Tools that run in the browser (the docs call them headless tools) may need their own UI, such as a permission prompt.

Questions#

How do I show tool calls from a LangGraph agent in React?

Use useStream from @langchain/react and read stream.toolCalls. Each entry joins the call and its result, with name, input, output, status and error. Map the status to a row's running, done or error state.

How do I show only one message's tool calls?

Filter stream.toolCalls by the IDs in that AI message's tool_calls. The LangChain frontend docs match on callId.

Do LangGraph tool calls include timing?

The AssembledToolCall fields the docs list carry no timestamps. Note when a call first appears and when its status leaves running, or return the duration from the tool itself.

Sources#