← Back to DevBytes

How to Stream Agent Intermediate Steps to the Frontend

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:

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:

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:

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

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.

— Ad —

Google AdSense will appear here after approval

← Back to all articles