How the Multi-Agent Discussion API with SSE Streaming Works in OpenMAIC

The OpenMAIC multi-agent discussion API streams real-time conversation events through a Server-Sent Events (SSE) endpoint, enabling live display of LLM tokens, tool calls, and state patches across multiple autonomous agents.

This article explains the complete implementation of the multi-agent discussion API with SSE streaming in THU-MAIC/OpenMAIC. We'll trace the flow from the SSE response constructor through the discussion engine to the client-side consumption, referencing exact source files and function signatures from the codebase.


SSE Response Construction

At the heart of the streaming system is createSSEResponse in lib/pbl/v2/api/sse.ts. This utility wraps any ReadableStream into a properly configured HTTP response with SSE headers.

export function createSSEResponse(body: ReadableStream<Uint8Array>) {
  return new Response(body, {
    headers: {
      "Content-Type": "text/event-stream; charset=utf-8",
      "Cache-Control": "no-cache, no-transform",
      "Connection": "keep-alive",
    },
  });
}

The headers ensure:

  • No caching: Clients receive every event immediately
  • Keep-alive connection: The TCP connection persists for the discussion duration
  • UTF-8 encoding: Supports international characters in agent responses

Event Type System

All SSE events in the discussion API share a discriminator field type. The union type PBLSSEEvent encompasses every possible event, defined alongside specialized interfaces in lib/pbl/v2/api/sse.ts.

Core Event Types

Event Type Interface Purpose
token SSETokenEvent LLM token streaming for "typing" effect
tool-call SSEToolCallEvent Agent requests external tool execution
project-patch SSEProjectPatchEvent Incremental workspace state updates
sim-phase SSESimPhaseEvent Simulation phase transitions
reset-draft SSEResetDraftEvent Clear draft content on restart
error SSEErrorEvent Fatal or recoverable error reporting
done SSEDoneEvent Signal stream completion

Example payload structure for a token event:

interface SSETokenEvent {
  type: "token";
  token: string;  // Individual LLM output token
}

The project-patch event deserves special attention. It carries PBLProjectPatch objects that the client applies atomically to update slides, canvas elements, or other workspace state without full re-renders.


Multi-Agent Discussion Engine

The discussion orchestration resides in lib/pbl/v2/runtime/discussion.ts. The runDiscussion function implements the core loop that coordinates multiple agents through sequential LLM invocations.

Agent Iteration Pattern

For each configured agent, the engine:

  1. Streams tokens — Pushes token events as the LLM generates output
  2. Detects tool calls — Emits tool-call events when the LLM requests external capabilities
  3. Applies results — Executes tools server-side, then continues the agent's turn
  4. Broadcasts state changes — Sends project-patch events for any workspace modifications
// Conceptual flow from discussion.ts implementation
async function* runDiscussion(config: DiscussionConfig) {
  for (const agent of config.agents) {
    yield { type: "sim-phase", phase: `${agent.id}-thinking` };
    
    const stream = await invokeLLM(agent, context);
    for await (const token of stream) {
      yield { type: "token", token };
    }
    
    if (detectToolCall(stream)) {
      yield { type: "tool-call", tool, args };
      const result = await executeTool(tool, args);
      // Continue with tool result injected into context
    }
    
    // Any workspace mutations become patches
    for (const mutation of collectMutations()) {
      yield { type: "project-patch", patch: mutation };
    }
  }
  
  yield { type: "done" };
}

DSL Validation and Scene Integration

Before runtime execution, the discussion action undergoes static validation in packages/@openmaic/dsl/src/action.ts. The createDiscussion function enforces two critical constraints:

  • Uniqueness: Only one discussion action per scene
  • Terminal position: Must be the final action in the scene sequence

These rules prevent invalid multi-agent configurations that could deadlock or produce undefined ordering.


API Route and Stream Instantiation

The public endpoint in pages/api/pbl/v2/discussion.ts bridges HTTP requests to the SSE stream:

import { createSSEResponse } from "@/lib/pbl/v2/api/sse";
import { runDiscussion } from "@/lib/pbl/v2/runtime/discussion";

export async function POST(request: Request) {
  const body = await request.json();
  const { sceneId, discussionId } = body;
  
  // Create readable stream from async generator
  const stream = new ReadableStream({
    async start(controller) {
      const encoder = new TextEncoder();
      
      for await (const event of runDiscussion({ sceneId, discussionId })) {
        const sseChunk = `data: ${JSON.stringify(event)}\n\n`;
        controller.enqueue(encoder.encode(sseChunk));
      }
      
      controller.close();
    }
  });
  
  return createSSEResponse(stream);
}

Note the data: prefix and double newline — mandatory SSE protocol formatting.


Client-Side Consumption

The front-end consumes the multi-agent discussion API using the browser's EventSource interface or a fetch-based polyfill for advanced control.

React Hook Implementation

import { useEffect, useRef } from "react";
import type { PBLSSEvent } from "@/lib/pbl/v2/api/sse";

export function useDiscussionSSE(url: string, handlers: EventHandlers) {
  const handlersRef = useRef(handlers);
  handlersRef.current = handlers;

  useEffect(() => {
    const es = new EventSource(url);
    
    es.onmessage = (e) => {
      const data: PBLSSEvent = JSON.parse(e.data);
      
      switch (data.type) {
        case "token":
          handlersRef.current.onToken?.(data.token);
          break;
        case "tool-call":
          handlersRef.current.onToolCall?.(data.tool, data.args);
          break;
        case "project-patch":
          handlersRef.current.onPatch?.(data.patch);
          break;
        case "error":
          handlersRef.current.onError?.(data.code, data.message);
          break;
        case "done":
          es.close();
          handlersRef.current.onComplete?.();
          break;
      }
    };
    
    es.onerror = (err) => {
      console.error("SSE connection failed", err);
      // Auto-retry or degrade based on error type
    };
    
    return () => es.close();
  }, [url]);
}

Unidirectional Design Consideration

Because SSE is server-to-client only, user interactions (join, skip, inject message) require separate HTTP POST requests or WebSocket messages. The discussion controller correlates these actions with the active SSE stream through session-scoped state management.


Error Handling and Resilience

The error event type supports two recovery modes:

  • Recoverable errors: Client displays toast, stream continues
  • Fatal errors: Client closes connection, user must restart discussion

Error codes (defined in SSEErrorEvent) enable programmatic handling:

  • RATE_LIMITED — Back off and retry
  • AGENT_TIMEOUT — Skip current agent, continue with next
  • INVALID_STATE — Discussion corrupted, restart required

Summary

  • createSSEResponse in lib/pbl/v2/api/sse.ts constructs properly headered SSE responses from any ReadableStream
  • PBLSSEvent union type provides seven discriminated events: token, tool-call, project-patch, sim-phase, reset-draft, error, and done
  • runDiscussion in lib/pbl/v2/runtime/discussion.ts orchestrates sequential agent turns, yielding events for token streaming and tool execution
  • createDiscussion enforces DSL constraints: one discussion per scene, always terminal
  • The API route transforms the async generator into SSE-formatted chunks with data: prefixes
  • Clients use EventSource for real-time UI updates, with separate channels for user-initiated actions

Frequently Asked Questions

What is the advantage of SSE over WebSockets for multi-agent discussions?

SSE provides automatic reconnection, simpler one-directional semantics, and native browser support without additional libraries. Since OpenMAIC's discussion flow is primarily server-to-client (agents generate content, client displays it), SSE's unidirectional design matches the architecture perfectly. WebSockets would add unnecessary complexity for bidirectional needs that HTTP POST requests already satisfy.

How does the client handle dropped SSE connections mid-discussion?

The EventSource interface automatically attempts reconnection with exponential backoff. On reconnect, the client receives a reset-draft event if the server cleared state, or resumes from the last checkpoint. The client can also detect sequence gaps through event ordering and request a full state refresh via separate API call.

Can tool calls block other agents from proceeding?

No, the discussion engine processes tool calls within a single agent's turn without blocking the overall stream. The tool-call event notifies the client of pending work, the server executes asynchronously, and results feed back into the same agent's context. Other agents wait only for the current agent's complete turn, not individual tool latencies.

Where are the agent personalities and discussion configurations defined?

Agent configurations reside in the scene definition processed by createDiscussion in packages/@openmaic/dsl/src/action.ts. Each agent specifies a system prompt, available tools, and turn-taking rules. The runtime in lib/pbl/v2/runtime/discussion.ts instantiates these configurations into executable agent loops.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →