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:
- Streams tokens — Pushes
tokenevents as the LLM generates output - Detects tool calls — Emits
tool-callevents when the LLM requests external capabilities - Applies results — Executes tools server-side, then continues the agent's turn
- Broadcasts state changes — Sends
project-patchevents 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 retryAGENT_TIMEOUT— Skip current agent, continue with nextINVALID_STATE— Discussion corrupted, restart required
Summary
createSSEResponseinlib/pbl/v2/api/sse.tsconstructs properly headered SSE responses from anyReadableStreamPBLSSEventunion type provides seven discriminated events:token,tool-call,project-patch,sim-phase,reset-draft,error, anddonerunDiscussioninlib/pbl/v2/runtime/discussion.tsorchestrates sequential agent turns, yielding events for token streaming and tool executioncreateDiscussionenforces DSL constraints: one discussion per scene, always terminal- The API route transforms the async generator into SSE-formatted chunks with
data:prefixes - Clients use
EventSourcefor 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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →