How to Stream Agent Intermediate Steps to the Frontend
When you build an AI agent that performs multi-step reasoning, tool calls, and research, the user experience can quickly degrade into a frustrating "spinning cursor" problem. The agent might take 30 seconds or more to produce a final answer, and during that time the user has no idea what is happening behind the scenes. Streaming intermediate steps — the thoughts, tool calls, observations, and partial outputs the agent produces along the way — solves this problem by giving users real-time visibility into the agent's reasoning process.
This tutorial walks through what intermediate step streaming is, why it matters, how to implement it end-to-end, and the best practices that will keep your implementation robust in production.
What Are Agent Intermediate Steps?
An intermediate step is any discrete action or output the agent produces while working toward a final answer. In a typical ReAct-style agent loop, these steps include:
- Thoughts — the agent's internal reasoning about what to do next
- Actions — tool calls the agent decides to invoke, including the tool name and arguments
- Observations — the results returned by those tools
- Partial answers — incremental text the agent generates before finalizing
- Plan updates — changes to the agent's task list or strategy
Without streaming, the backend runs the entire agent loop and returns only the final result. With streaming, each of these steps is emitted to the frontend as it happens, creating a live, transparent experience.
Why Streaming Intermediate Steps Matters
There are three primary reasons to stream intermediate steps rather than waiting for a final response:
- Perceived performance — Users see activity immediately, which dramatically reduces the perceived wait time even when total latency is unchanged.
- Trust and debuggability — When users can see which tools the agent called and what it observed, they can verify the agent is on the right track and intervene if it is not.
- Interactivity — Streaming enables features like "stop generation," "edit tool call before execution," and "approve sensitive actions" that are impossible with batch responses.
For production agents that call external APIs, browse the web, or execute code, this transparency is not a nice-to-have — it is essential for user confidence.
Architecture Overview
The standard architecture for streaming intermediate steps uses Server-Sent Events (SSE) as the transport layer. The flow is:
- The frontend opens an SSE connection (or a fetch streaming connection) to a backend endpoint.
- The backend runs the agent loop, emitting structured events as each step completes.
- The frontend parses each event and updates the UI incrementally.
We will use a typed event schema so the frontend can distinguish between thoughts, tool calls, observations, and final answers. This is more maintainable than raw text streaming.
Defining the Event Schema
The first step is to define a clear, discriminated event type that both the backend and frontend understand. Here is a TypeScript schema that covers the common cases:
// shared/events.ts
export type AgentEvent =
| { type: "step_start"; stepId: string; timestamp: number }
| { type: "thought"; stepId: string; content: string }
| { type: "tool_call"; stepId: string; tool: string; args: Record<string, unknown> }
| { type: "tool_result"; stepId: string; tool: string; result: unknown; error?: string }
| { type: "token"; content: string }
| { type: "final"; answer: string }
| { type: "error"; message: string };
Each event carries a stepId so the frontend can group related events together — for example, linking a tool_call to its corresponding tool_result. The type field acts as the discriminator.
Implementing the Backend SSE Endpoint
Here is a Node.js / Express endpoint that runs a simple agent loop and streams events using SSE. The agent logic is abstracted into a generator function, which is the cleanest pattern for emitting steps incrementally.
// server/agent.ts
import { AgentEvent } from "../shared/events";
// A generator that yields events as the agent works.
export async function* runAgent(
userMessage: string
): AsyncGenerator<AgentEvent> {
let stepCount = 0;
yield { type: "step_start", stepId: "0", timestamp: Date.now() };
// Step 1: Agent thinks about the problem
yield {
type: "thought",
stepId: "0",
content: "The user is asking about the weather. I should call the weather tool.",
};
// Step 2: Agent calls a tool
stepCount++;
const stepId = String(stepCount);
yield { type: "step_start", stepId, timestamp: Date.now() };
const toolArgs = { location: "San Francisco", unit: "celsius" };
yield { type: "tool_call", stepId, tool: "get_weather", args: toolArgs };
// Execute the tool (simulated)
try {
const result = await getWeather(toolArgs.location, toolArgs.unit);
yield { type: "tool_result", stepId, tool: "get_weather", result };
} catch (err) {
yield {
type: "tool_result",
stepId,
tool: "get_weather",
result: null,
error: (err as Error).message,
};
}
// Step 3: Agent produces the final answer, token by token
const finalAnswer = `The weather in San Francisco is 18°C and partly cloudy.`;
const tokens = finalAnswer.split(" ");
for (const token of tokens) {
yield { type: "token", content: token + " " };
await new Promise((r) => setTimeout(r, 50)); // simulate latency
}
yield { type: "final", answer: finalAnswer };
}
async function getWeather(location: string, unit: string) {
// In production, call a real weather API here.
return { location, unit, temp: 18, condition: "partly cloudy" };
}
Now wire the generator into an Express route that formats each event as an SSE message:
// server/index.ts
import express from "express";
import { runAgent } from "./agent";
const app = express();
app.use(express.json());
app.post("/api/agent/stream", async (req, res) => {
const { message } = req.body;
// SSE headers
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
res.flushHeaders();
try {
for await (const event of runAgent(message)) {
res.write(`data: ${JSON.stringify(event)}\n\n`);
}
} catch (err) {
const errorEvent = { type: "error", message: (err as Error).message };
res.write(`data: ${JSON.stringify(errorEvent)}\n\n`);
} finally {
res.end();
}
});
app.listen(3000, () => console.log("Server on http://localhost:3000"));
Each SSE message is prefixed with data: and terminated with a double newline. This is the SSE protocol the browser expects.
Consuming the Stream on the Frontend
The browser's EventSource API is the simplest way to consume SSE, but it only supports GET requests. Since we are sending a POST body, we will use the Fetch API with a streaming response reader instead.
// client/useAgent.ts
import { AgentEvent } from "../shared/events";
export async function streamAgent(
message: string,
onEvent: (event: AgentEvent) => void
): Promise<void> {
const response = await fetch("/api/agent/stream", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message }),
});
if (!response.ok || !response.body) {
throw new Error(`Stream failed: ${response.status}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// SSE messages are separated by double newlines
const messages = buffer.split("\n\n");
buffer = messages.pop() ?? "";
for (const msg of messages) {
const line = msg.trim();
if (!line.startsWith("data:")) continue;
const json = line.slice(5).trim();
try {
const event = JSON.parse(json) as AgentEvent;
onEvent(event);
} catch {
// Ignore malformed chunks
}
}
}
}
The key detail is buffering: network chunks do not always align with SSE message boundaries, so we accumulate bytes and split on \n\n, keeping any incomplete trailing fragment in the buffer for the next iteration.
Rendering Steps in React
Now we can build a React component that renders each event type appropriately. We maintain a list of steps and a running final-answer string, updating both as events arrive.
// client/AgentChat.tsx
import { useState } from "react";
import { AgentEvent } from "../shared/events";
import { streamAgent } from "./useAgent";
interface Step {
stepId: string;
thought?: string;
tool?: string;
args?: Record<string, unknown>;
result?: unknown;
error?: string;
}
export function AgentChat() {
const [steps, setSteps] = useState<Step[]>([]);
const [answer, setAnswer] = useState("");
const [running, setRunning] = useState(false);
async function handleSubmit(message: string) {
setSteps([]);
setAnswer("");
setRunning(true);
await streamAgent(message, (event) => {
switch (event.type) {
case "thought":
setSteps((prev) => [
...prev,
{ stepId: event.stepId, thought: event.content },
]);
break;
case "tool_call":
setSteps((prev) => [
...prev,
{ stepId: event.stepId, tool: event.tool, args: event.args },
]);
break;
case "tool_result":
setSteps((prev) =>
prev.map((s) =>
s.stepId === event.stepId
? { ...s, result: event.result, error: event.error }
: s
)
);
break;
case "token":
setAnswer((prev) => prev + event.content);
break;
case "final":
setAnswer(event.answer);
setRunning(false);
break;
case "error":
console.error(event.message);
setRunning(false);
break;
}
});
}
return (
<div>
{steps.map((step) => (
<div key={step.stepId} className="step">
{step.thought && <p>💭 {step.thought}</p>}
{step.tool && (
<p>🔧 Called {step.tool} with {JSON.stringify(step.args)}</p>
)}
{step.result !== undefined && (
<p>✅ Result: {JSON.stringify(step.result)}</p>
)}
{step.error && <p style={{ color: "red" }}>❌ {step.error}</p>}
</div>
))}
{answer && <div className="answer">{answer}</div>}
<button disabled={running} onClick={() => handleSubmit("What's the weather?")}>
Run
</button>
</div>
);
}
This component renders thoughts, tool calls, and results as they arrive, and streams the final answer token by token. The user sees a live trace of the agent's work.
Integrating with LangChain or Similar Frameworks
If you are using LangChain, the framework exposes intermediate steps through callbacks. You can wire these callbacks into the same SSE event stream. Here is a simplified example using LangChain's AgentExecutor:
// server/langchain-agent.ts
import { AgentExecutor } from "langchain/agents";
import { CallbackHandler } from "./sse-callbacks";
import { AgentEvent } from "../shared/events";
export async function* runLangChainAgent(
executor: AgentExecutor,
input: string
): AsyncGenerator<AgentEvent> {
const queue: AgentEvent[] = [];
let done = false;
const handler: CallbackHandler = {
onAgentAction(action) {
queue.push({
type: "tool_call",
stepId: crypto.randomUUID(),
tool: action.tool,
args: action.toolInput as Record<string, unknown>,
});
},
onToolEnd(output, runId) {
queue.push({
type: "tool_result",
stepId: runId,
tool: "unknown",
result: output,
});
},
onLLMNewToken(token) {
queue.push({ type: "token", content: token });
},
};
// Run the executor in the background, pushing events to the queue
executor
.invoke({ input }, { callbacks: [handler] })
.then((res) => {
queue.push({ type: "final", answer: res.output });
})
.catch((err) => {
queue.push({ type: "error", message: err.message });
})
.finally(() => {
done = true;
});
// Yield events as they arrive
while (!done || queue.length > 0) {
if (queue.length > 0) {
yield queue.shift()!;
} else {
await new Promise((r) => setTimeout(r, 10));
}
}
}
The queue-based pattern bridges LangChain's callback-driven model with the generator-based SSE endpoint, so the same Express route works without modification.
Best Practices
- Use a discriminated event schema — A typed
typefield on every event lets the frontend switch on event kind cleanly and avoids fragile string parsing. - Include step IDs — Grouping related events (a tool call and its result) by a shared ID makes the UI much easier to render correctly.
- Handle reconnection — SSE supports automatic reconnection via the
Last-Event-IDheader. If your agent loop is long, persist step state on the server so a dropped connection can resume rather than restart. - Throttle token events — If your model emits hundreds of tokens per second, batch them into 50ms windows before writing to the SSE stream to avoid overwhelming the browser's main thread.
- Sanitize tool outputs — Tool results may contain large payloads, secrets, or binary data. Truncate and redact before emitting them to the frontend.
- Always send a terminal event — Emit a
finalorerrorevent and close the connection so the frontend knows the stream is complete and can clean up. - Set explicit timeouts — Long-running agents can hold connections open indefinitely. Configure a server-side timeout and emit an error event if the agent exceeds it.
- Consider WebSocket for bidirectional needs — If you want the user to approve tool calls mid-stream or send corrections, SSE is one-directional. WebSockets or a second SSE channel for user input may be more appropriate.
Conclusion
Streaming agent intermediate steps transforms a black-box wait into a transparent, engaging experience. By defining a clear event schema, emitting structured events from a generator-based backend, consuming them with the Fetch streaming API, and rendering each event type appropriately in the UI, you give users real-time insight into what your agent is thinking and doing. The patterns shown here — discriminated events, step IDs, buffered SSE parsing, and callback-to-generator bridging — scale from simple prototypes to production agents built on frameworks like LangChain. Start with the minimal event set, add richer event types as your agent grows more capable, and always prioritize a clean terminal event so the frontend never hangs waiting for a response that never comes.