Home / Articles

Streaming LangGraph Agents to React Without Leaking Tool Internals

How to serialize LangGraph astream_events output for Server-Sent Events, redact sensitive tool-call payloads in FastAPI and show friendly tool badges in React.

Publisher: REACTAPP.TOP5 min read1102 words

This article is published in English.

Sources: Enterprise Agentic RAG: Taming LangGraph State & Securing React Streams

Streaming an agent's progress to the browser makes a RAG application feel alive, but piping LangGraph's raw event stream straight to the client causes two separate failures. The stream crashes on objects JSON cannot encode, and whatever does get through exposes schemas, internal endpoints and reasoning to anyone with the network tab open. This article builds a small sanitization layer in FastAPI that fixes both, then shows how a React client turns the redacted events into readable status badges.

Why raw agent state breaks JSON serialization

During execution, LangGraph state is a rich Python structure, not a JSON document. Sending the output of astream_events to json.dumps() or directly into a FastAPI StreamingResponse typically fails because the events contain:

  • LangChain message classes such as AIMessage, HumanMessage and ToolMessage, which are Pydantic models (v1 or v2, depending on your LangChain version) with their own serialization methods.
  • Nested Pydantic objects holding intermediate agent reasoning.
  • Exception objects and tracebacks from failed tool calls.

When the encoder hits one of these, it raises, the Server-Sent Events connection drops, and the frontend waits forever for a response that never arrives.

Why raw tool calls are a security problem

In a ReAct-style agent, the model emits a tool call before the tool runs, and that payload is often sensitive. A text-to-SQL tool may receive the full schema of a table such as underwriting_policies. A pricing tool may carry the internal URL and parameter layout of a private microservice. The chain-of-thought itself can reveal business rules or system prompt instructions. Streamed unfiltered, this gives an attacker a map of your backend, so the payloads must be removed on the server before anything is sent.

A sanitizing generator in FastAPI

The generator below wraps the event stream. For on_tool_start events it emits a new tool_update event carrying only the tool name, a status and a redaction marker. For message chunks it converts BaseMessage instances to dictionaries with model_dump(). Everything else is encoded with default=str as a fallback, and a serialization failure produces a generic error event rather than a raw exception. The snippet is Python despite its original label.

import json
from typing import AsyncGenerator, Dict, Any
from langchain_core.messages import BaseMessage

async def sanitize_agent_stream(event_stream: AsyncGenerator[Dict[str, Any], None]) -> AsyncGenerator[str, None]:
    """
    Intercepts the LangGraph event stream, serializes objects, and scrubs tool payloads.
    """
    async for event in event_stream:
        # 1. Scrub Sensitive Tool Signatures
        if event.get("event") == "on_tool_start":
            tool_name = event.get("name")
            # We want to tell the UI a tool started, but hide the sensitive input
            scrubbed_event = {
                "event": "tool_update",
                "data": {
                    "tool": tool_name,
                    "status": "running",
                    "payload": "[REDACTED_FOR_SECURITY]" # Masked payload
                }
            }
            yield f"data: {json.dumps(scrubbed_event)}\n\n"
            continue

        # 2. Serialize LangChain Messages safely
        if "data" in event and "chunk" in event["data"]:
            chunk = event["data"]["chunk"]
            if isinstance(chunk, BaseMessage):
                # Safely convert to dict, ensuring content is serialized
                event["data"]["chunk"] = chunk.model_dump()

        # 3. Fallback Serialization for custom objects (simplified for example)
        try:
            # Attempt to serialize the cleaned event
            payload = json.dumps(event, default=str) # Fallback to string representation
            yield f"data: {payload}\n\n"
        except Exception as e:
            # Log error internally, do not stream raw exception to client
            print(f"Serialization Error: {e}")
            yield f"data: {json.dumps({'event': 'error', 'message': 'Internal processing error'})}\n\n"

Two gaps deserve attention before production use. First, only on_tool_start is scrubbed. Events such as on_tool_end, chain events carrying graph state, and model chunks whose tool_call_chunks contain arguments all fall through to the fallback and are streamed unchanged. An allowlist, forwarding only the event types and fields the UI needs, is safer than a blocklist. Second, replace print with structured logging.

The endpoint starts the graph with astream_events using the v2 event schema, pipes it through the sanitizer and returns it as text/event-stream:

from fastapi import APIRouter
from fastapi.responses import StreamingResponse

router = APIRouter()

@router.post("/chat/stream")
async def stream_chat(request: ChatRequest):
    # Retrieve the LangGraph runnable
    graph = get_underwriting_graph()

    # Start the raw stream
    raw_stream = graph.astream_events(
        {"messages": [("user", request.message)]},
        version="v2"
    )

    # Pipe through the sanitization layer
    clean_stream = sanitize_agent_stream(raw_stream)

    return StreamingResponse(clean_stream, media_type="text/event-stream")

Mapping redacted events to UI labels in React

Because the client never sees tool inputs, it works from the tool name alone. A lookup table maps internal names like policy_db_search to user-facing text, with a generic fallback for unknown tools. A tool_update event shows a pulsing badge, and the first on_chat_model_stream chunk clears it, since the agent has started answering.

import React, { useState, useEffect } from 'react';

// Map internal tool names to professional UI labels
const TOOL_UI_MAPPING: Record<string, string> = {
  actuarial_sandbox_tool: "Calculating Premium Factors...",
  policy_db_search: "Querying Policy Guidelines...",
  risk_model_evaluator: "Evaluating Risk Profile...",
};

export const AgentChatInterface = () => {
  const [activeTool, setActiveTool] = useState<string | null>(null);
  const [messages, setMessages] = useState<string[]>([]);

  useEffect(() => {
    const eventSource = new EventSource('/api/chat/stream');

    eventSource.onmessage = (event) => {
      const parsed = JSON.parse(event.data);

      if (parsed.event === 'tool_update') {
        // Display the user-friendly name instead of internal mechanics
        const uiLabel = TOOL_UI_MAPPING[parsed.data.tool] || "Processing Data...";
        setActiveTool(uiLabel);
      } else if (parsed.event === 'on_chat_model_stream') {
        setActiveTool(null); // Clear tool status when agent speaks
        // Append text chunk to messages...
      }
    };

    return () => eventSource.close();
  }, []);

  return (
    <div className="chat-container">
      {/* Active Tool Badge */}
      {activeTool && (
        <div className="tool-badge bg-blue-100 text-blue-800 px-3 py-1 rounded-full text-sm font-semibold animate-pulse">
          {activeTool}
        </div>
      )}

      {/* Chat Messages */}
      <div className="message-list">
        {/* Render messages here */}
      </div>
    </div>
  );
};

One correction applies here: the browser's EventSource only issues GET requests and cannot send a body, while the endpoint is a POST route expecting a message. In practice you would either read the response with fetch and a stream reader, or use a helper library that parses SSE over POST. Also confirm that the /api prefix matches your Vite proxy configuration.

Key takeaways

  • LangGraph events contain objects JSON cannot encode; convert messages explicitly and never let an encoder exception reach the client.
  • Tool arguments, schemas and reasoning are server-side secrets. Redact them before streaming, preferably by allowlisting what the UI needs.
  • A tool-name-to-label map keeps the interface informative without revealing internals.
  • Match your client transport to the endpoint: EventSource suits GET streams, while POST streams need fetch.