LangGraph agents aren’t black boxes. Every graph is composed of named nodes
that execute in sequence or in parallel: classify, research, analyze,
synthesize. Graph execution cards make this pipeline visible by rendering a card
for each node, showing its status, streaming its content in real time, and
tracking completion across the entire workflow. Users see exactly what the agent
is doing, which step it’s on, and what each step produced.
How graph nodes map to UI cards
A LangGraph graph defines a series of nodes, each responsible for a specific
task. For example, a research pipeline might have:
- Classify—categorize the user’s query
- Research—gather relevant information
- Analyze—draw conclusions from the research
- Synthesize—produce a final, polished response
Each node writes its output to a specific key in the graph’s state. By mapping
these node names and state keys to UI components, you can create a visual
representation of the entire pipeline.
const PIPELINE_NODES = [
{ name: "classify", stateKey: "classification", label: "Classify" },
{ name: "do_research", stateKey: "research", label: "Research" },
{ name: "analyze", stateKey: "analysis", label: "Analyze" },
{ name: "synthesize", stateKey: "synthesis", label: "Synthesize" },
];
const PIPELINE_NODE_NAMES = new Set(PIPELINE_NODES.map((n) => n.name));
Setting up useStream
Wire up useStream as usual. The key properties you’ll use are messages
(for streaming content routing), values (for completed node outputs), and
getMessagesMetadata (for identifying which node produced each token).
Define a TypeScript interface matching your agent’s state schema and pass it as a type parameter to useStream for type-safe access to state values, including custom state keys for each pipeline node. In the examples below, replace typeof myAgent with your interface name:
import type { BaseMessage } from "@langchain/core/messages";
interface AgentState {
messages: BaseMessage[];
classification: string;
research: string;
analysis: string;
synthesis: string;
}
import { useStream } from "@langchain/react";
const AGENT_URL = "http://localhost:2024";
export function PipelineChat() {
const stream = useStream<typeof myAgent>({
apiUrl: AGENT_URL,
assistantId: "graph_execution_cards",
});
return (
<div>
<PipelineProgress nodes={PIPELINE_NODES} values={stream.values} />
<NodeCardList
nodes={PIPELINE_NODES}
messages={stream.messages}
values={stream.values}
getMetadata={stream.getMessagesMetadata}
/>
</div>
);
}
Routing streaming tokens to nodes
As the agent streams, each message is annotated with metadata that identifies
which graph node produced it. Use getMessagesMetadata to extract the
langgraph_node value and route tokens to the correct card:
function getStreamingContent(
messages: BaseMessage[],
getMetadata: (msg: BaseMessage) => MessageMetadata | undefined
): Record<string, string> {
const content: Record<string, string> = {};
for (const message of messages) {
if (message.type !== "ai") continue;
const metadata = getMetadata(message);
const node = metadata?.streamMetadata?.langgraph_node;
if (node && PIPELINE_NODE_NAMES.has(node)) {
content[node] = typeof message.content === "string"
? message.content
: "";
}
}
return content;
}
This gives you a map from node name to its current streaming content. As tokens
arrive, the corresponding card updates in real time.
The streamMetadata.langgraph_node field is set automatically by LangGraph.
You don’t need any special configuration on the backend—just stream messages
as usual, and the metadata is included.
Determining node status
Each node can be in one of four states: not started, streaming, complete, or
idle. You derive the status from two sources: the streaming content map (for
active nodes) and stream.values (for completed nodes):
type NodeStatus = "idle" | "streaming" | "complete";
function getNodeStatus(
node: { name: string; stateKey: string },
streamingContent: Record<string, string>,
values: Record<string, unknown>
): NodeStatus {
if (values?.[node.stateKey]) return "complete";
if (streamingContent[node.name]) return "streaming";
return "idle";
}
Building the pipeline progress bar
A horizontal progress bar at the top gives users a bird’s-eye view of the
entire pipeline. Each step is a labeled segment that fills in as nodes complete:
function PipelineProgress({
nodes,
values,
streamingContent,
}: {
nodes: typeof PIPELINE_NODES;
values: Record<string, unknown>;
streamingContent: Record<string, string>;
}) {
return (
<div className="flex items-center gap-1">
{nodes.map((node, i) => {
const status = getNodeStatus(node, streamingContent, values);
const colors = {
idle: "bg-gray-200 text-gray-500",
streaming: "bg-blue-400 text-white animate-pulse",
complete: "bg-green-500 text-white",
};
return (
<div key={node.name} className="flex items-center">
<div
className={`rounded-full px-3 py-1 text-xs font-medium ${colors[status]}`}
>
{node.label}
</div>
{i < nodes.length - 1 && (
<div
className={`mx-1 h-0.5 w-6 ${
status === "complete" ? "bg-green-500" : "bg-gray-200"
}`}
/>
)}
</div>
);
})}
</div>
);
}
Building collapsible NodeCard components
Each node gets its own card that shows the status badge, content (streaming or
final), and a collapsible body for long outputs:
function NodeCard({
node,
status,
streamingContent,
completedContent,
}: {
node: { name: string; stateKey: string; label: string };
status: NodeStatus;
streamingContent: string | undefined;
completedContent: unknown;
}) {
const [collapsed, setCollapsed] = useState(false);
const displayContent =
status === "complete"
? formatContent(completedContent)
: streamingContent ?? "";
const statusBadge = {
idle: { text: "Waiting", className: "bg-gray-100 text-gray-600" },
streaming: {
text: "Running",
className: "bg-blue-100 text-blue-700 animate-pulse",
},
complete: { text: "Done", className: "bg-green-100 text-green-700" },
};
const badge = statusBadge[status];
return (
<div className="rounded-lg border bg-white shadow-sm">
<button
onClick={() => setCollapsed(!collapsed)}
className="flex w-full items-center justify-between p-4"
>
<div className="flex items-center gap-3">
<h3 className="font-semibold">{node.label}</h3>
<span
className={`rounded-full px-2 py-0.5 text-xs font-medium ${badge.className}`}
>
{badge.text}
</span>
</div>
<ChevronIcon direction={collapsed ? "down" : "up"} />
</button>
{!collapsed && displayContent && (
<div className="border-t px-4 py-3">
<div className="prose prose-sm max-w-none">
{displayContent}
{status === "streaming" && (
<span className="inline-block h-4 w-1 animate-pulse bg-blue-500" />
)}
</div>
</div>
)}
</div>
);
}
function formatContent(value: unknown): string {
if (typeof value === "string") return value;
if (value == null) return "";
return JSON.stringify(value, null, 2);
}
Streaming vs. completed content
There are two sources of content for each node, and picking the right one
matters for a smooth UX:
| Source | When to use |
|---|
streamingContent[node.name] | While the node is actively streaming—shows tokens as they arrive |
stream.values[node.stateKey] | After the node completes—the final, committed output |
The pattern is: show streaming content for live updates, fall back to the
committed state value once the node is done.
for (const node of PIPELINE_NODES) {
const status = getNodeStatus(node, streamingContent, stream.values);
const content =
status === "streaming"
? streamingContent[node.name]
: stream.values?.[node.stateKey];
}
Streaming content may include partial tokens or markdown that hasn’t been
fully formed yet. If you render markdown, make sure your renderer handles
incomplete syntax gracefully (e.g., an unclosed bold marker **).
Putting it all together
Here’s the full card list that combines routing, status detection, and card
rendering:
function NodeCardList({
nodes,
messages,
values,
getMetadata,
}: {
nodes: typeof PIPELINE_NODES;
messages: BaseMessage[];
values: Record<string, unknown>;
getMetadata: (msg: BaseMessage) => MessageMetadata | undefined;
}) {
const streamingContent = getStreamingContent(messages, getMetadata);
return (
<div className="space-y-3">
{nodes.map((node) => {
const status = getNodeStatus(node, streamingContent, values);
return (
<NodeCard
key={node.name}
node={node}
status={status}
streamingContent={streamingContent[node.name]}
completedContent={values?.[node.stateKey]}
/>
);
})}
</div>
);
}
Use cases
Graph execution cards work well for any multi-step pipeline where visibility
matters:
- Research pipelines—classify → gather sources → analyze → synthesize a
report
- Content generation—outline → draft → fact-check → edit → publish
- Data processing—ingest → validate → transform → aggregate → export
- Code generation—understand requirements → plan architecture → write
code → review → test
- Decision workflows—gather context → evaluate options → score
alternatives → recommend
Handling dynamic pipelines
Not all graphs have a fixed set of nodes. Some pipelines add or skip nodes
based on the input. Handle this by checking which state keys actually have
values:
const activeNodes = PIPELINE_NODES.filter(
(node) =>
streamingContent[node.name] ||
values?.[node.stateKey] ||
node.name === currentNode
);
This ensures your UI only shows cards for nodes that are relevant to the
current execution, avoiding empty placeholder cards.
If your graph has conditional branching (e.g., skip “Research” for simple
factual queries), the skipped nodes will never appear in the streaming content
or state values. Your pipeline progress bar should reflect this by dimming or
hiding skipped steps.
Best practices
- Define nodes declaratively—keep your
PIPELINE_NODES array as a single
source of truth that maps node names, state keys, and display labels.
- Prefer streaming content for active nodes—it gives users immediate
feedback. Only fall back to committed state values after the node completes.
- Auto-collapse completed nodes—in long pipelines, auto-collapse finished
cards so users can focus on the currently active step.
- Show estimated timing—if you have historical data on how long each node
takes, display a time estimate to set user expectations.
- Add a global progress indicator—complement per-node cards with an
overall progress bar (e.g., “Step 2 of 4”) at the top of the pipeline view.
- Handle errors per node—if a node fails, show the error in its card
without collapsing the entire pipeline. Other nodes may still complete
successfully.