Implementing Streamable HTTP with SSE for Real‑Time Chat in FastAPI

This article walks through adding a POST /v1/chat/stream endpoint that uses Server‑Sent Events (SSE) to stream structured events—including status, tool calls, token deltas, and completion—into a static web page, enabling a multi‑turn, typewriter‑style chat UI while preserving the original synchronous /v1/chat API for backward compatibility.

Xike
Xike
Xike
Implementing Streamable HTTP with SSE for Real‑Time Chat in FastAPI

Goal and Acceptance Criteria

The objective is to expose a POST /v1/chat/stream endpoint that returns text/event-stream frames, while keeping the existing POST /v1/chat JSON API unchanged. The front‑end must display a multi‑turn conversation with a typewriter effect, show tool‑execution status, persist session_id in localStorage, and remain functional when the page is refreshed.

Architecture Overview

The system consists of three layers:

Orchestration layer : TurnEventEmitter emits structured events (run_start, status, tool_start, tool_end, token, done, error) without being tied to SSE.

Service layer : chat_stream() creates a session, yields a run_start frame, runs the ReAct loop, and drains events from the emitter, finally yielding a done frame.

Transport layer : FastAPI route /v1/chat/stream returns a StreamingResponse with media_type="text/event-stream" and headers Cache-Control: no-cache, Connection: keep-alive, and X-Accel-Buffering: no to prevent Nginx buffering.

Server‑Side Implementation

Event framing utilities (api/stream.py) :

def sse_frame(event: str, data: dict) -> str:
    payload = json.dumps(data, ensure_ascii=False)
    return f"event: {event}
data: {payload}

"

def sse_comment(text: str = "keep-alive") -> str:
    return f": {text}

"

FastAPI route (api/routes.py) :

@router.post("/v1/chat/stream", tags=["chat"])
async def chat_stream(body: ChatRequest, service: AgentService = Depends(...)):
    session_id = body.session_id or uuid.uuid4().hex[:12]
    async def event_generator():
        try:
            async for frame in service.chat_stream(body.message.strip(), session_id=session_id, user_id=body.user_id or service.settings.user_id):
                yield frame
        except AgentError as exc:
            yield sse_frame("error", {"message": str(exc)})
    return StreamingResponse(event_generator(), media_type="text/event-stream", headers={
        "Cache-Control": "no-cache",
        "Connection": "keep-alive",
        "X-Accel-Buffering": "no",
    })

Service logic (api/service.py) demonstrates the event flow:

def chat_stream(self, message, *, session_id, user_id):
    record = self.session_store.get_or_create(session_id, user_id)
    yield sse_frame("run_start", {"session_id": session_id, "trace_id": app.pending_trace_id, "agent_version": self.settings.agent_version})
    emitter = TurnEventEmitter()
    result = app.turn_stream(message, emitter=emitter)
    for ev in emitter.drain():
        yield sse_frame(ev.type, ev.data)
    yield sse_frame("done", {"reply": result.reply, "trace_id": result.trace_id, "usage": result.usage, "steps": result.steps, "execution_mode": result.execution_mode})
    self._persist_session(app, record)

Static Front‑End

The web/ directory contains a plain HTML page, CSS, and a single JavaScript bundle. No build tools are required.

index.html provides the chat container, a status bar, and a composer textarea.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>mini‑agent</title>
  <link rel="stylesheet" href="/css/app.css">
</head>
<body>
  <div class="app">
    <header class="app-header"><h1>mini‑agent</h1><span id="session-label" class="muted"></span></header>
    <main id="messages" class="messages" aria-live="polite"></main>
    <footer class="composer">
      <textarea id="input" rows="2" placeholder="Enter message, press Enter…"></textarea>
      <button id="send" type="button">Send</button>
    </footer>
  </div>
  <script src="/js/app.js"></script>
</body>
</html>

app.js implements the fetch‑SSE loop, parses SSE blocks, updates the UI, and drives the typewriter queue.

const SESSION_KEY = "mini_agent_session_id";
let sessionId = localStorage.getItem(SESSION_KEY) || null;
let typingQueue = [];
let typingTimer = null;
let abortController = null;

async function sendMessage(text) {
  appendUserBubble(text);
  setComposerEnabled(false);
  const agentEl = appendAgentBubble("");
  showStatus("Connecting…");
  abortController = new AbortController();
  const headers = {"Content-Type": "application/json"};
  const apiKey = localStorage.getItem("mini_agent_api_key");
  if (apiKey) headers["X-API-Key"] = apiKey;
  const res = await fetch("/v1/chat/stream", {
    method: "POST",
    headers,
    body: JSON.stringify({message: text, session_id: sessionId}),
    signal: abortController.signal,
  });
  if (!res.ok) { showError(await res.text()); setComposerEnabled(true); return; }
  const reader = res.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});
    const parts = buffer.split("

");
    buffer = parts.pop() || "";
    for (const block of parts) {
      const ev = parseSseBlock(block);
      if (!ev) continue;
      handleEvent(ev, agentEl, delta => { fullReply += delta; enqueueTyping(agentEl, delta); });
    }
  }
  hideStatus();
  setComposerEnabled(true);
}

function parseSseBlock(block) {
  let event = "message";
  let data = "";
  for (const line of block.split("
")) {
    if (line.startsWith("event:")) event = line.slice(6).trim();
    if (line.startsWith("data:")) data += line.slice(5).trim();
  }
  if (!data) return null;
  return {event, data: JSON.parse(data)};
}

function handleEvent({event, data}, agentEl, onToken) {
  switch (event) {
    case "run_start":
      sessionId = data.session_id;
      localStorage.setItem(SESSION_KEY, sessionId);
      updateSessionLabel(sessionId, data.trace_id);
      break;
    case "status":
      showStatus(data.message);
      break;
    case "tool_start":
      showStatus(`Calling tool: ${data.name}…`);
      break;
    case "tool_end":
      showStatus(data.ok ? `Tool ${data.name} completed` : `Tool ${data.name} failed`);
      break;
    case "token":
      onToken(data.delta);
      break;
    case "done":
      hideStatus();
      break;
    case "error":
      showError(data.message);
      break;
  }
}

function enqueueTyping(el, text) {
  for (const ch of text) typingQueue.push(ch);
  if (!typingTimer) typingTimer = setInterval(flushTyping, 28);
}

function flushTyping() {
  const el = document.querySelector(".msg.agent:last-child .content");
  if (!el || typingQueue.length === 0) { clearInterval(typingTimer); typingTimer = null; return; }
  el.textContent += typingQueue.shift();
  scrollToBottom();
}

The CSS defines message bubbles, a status bar, disabled composer state, and a mobile‑first flex layout.

Configuration

config/agent.yaml

adds a stream section with enabled, typing_fallback, pseudo_chunk_chars, and keepalive_seconds.

Environment variable STREAM_ENABLED toggles registration of the streaming route. HTTP_API_KEY (optional) is injected as X-API-Key header when present.

Running the Demo

Clone the repository and activate the virtual environment.

cd mini-agent
source .venv/bin/activate
pip install -r requirements.txt
python main.py serve

Open http://127.0.0.1:8000/ in a browser.

Ask "What time is it?" – see a status flash followed by a typewriter‑style answer.

Ask "Shanghai weather?" – see a tool‑start/status line, then the answer.

Refresh the page and ask "Which city did you just check?" – the conversation continues using the persisted session_id.

Verify the SSE frames with

curl -N -X POST http://127.0.0.1:8000/v1/chat/stream -H "Content-Type: application/json" -d '{"message": "Introduce yourself in one sentence"}'

. The output should contain sequential event: and data: blocks for run_start, status, token, and done.

Run the evaluation script to ensure the original synchronous API still passes all golden tests:

python main.py eval

Common Pitfalls and Fixes

Static page 404 : Ensure WEB_DIR points to the repository root web/ folder and that API routes are mounted before the static StaticFiles mount.

SSE frames buffered by reverse proxy : Add X-Accel-Buffering: no header and configure Nginx with proxy_buffering off or bypass the proxy during development.

Typewriter glitches : Cancel any previous request with abortController.abort() before sending a new one and ensure only a single setInterval timer runs.

CORS errors when opening the HTML file directly : Always access the page via http://127.0.0.1:8000/ so the browser and FastAPI share the same origin.

Tool calls appearing as partial token streams : The brain layer accumulates tool_calls and only yields token events for the final natural‑language answer, matching the design in Module 12.

Next Steps

The upcoming article (Advance 01) will show how to mount an MCP server on the same FastAPI instance, allowing the agent to invoke external tools such as a filesystem service via the MCP protocol.

Conclusion

The new web/ chat page plus the POST /v1/chat/stream endpoint turns the agent from a raw API into an interactive demo that can be opened directly in a browser.

Structured SSE events (status, tool, token, done) implement the module‑12 contract, while the front‑end typewriter queue provides a smooth UX and supports a pseudo‑stream fallback when the LLM SDK cannot stream.

Only the streaming path is new; the original synchronous /v1/chat endpoint remains unchanged, preserving existing CLI and evaluation workflows.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

JavaScriptstreamingapiFastAPIssechattypewriter
Xike
Written by

Xike

Stupid is as stupid does.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.