Streaming
Real-time response streaming with SSE support.
Overview
Chat UI supports Server-Sent Events (SSE) for real-time streaming of AI responses. This provides a better user experience as users see the response as it's generated.
Enable Streaming
Streaming is enabled by default:
<Chat
baseUrl="/api/agents"
agentId="assistant"
streaming={true} // Default
/>To disable streaming:
<Chat
baseUrl="/api/agents"
agentId="assistant"
streaming={false}
/>Stream Events
The chat handles these SSE events:
Text Streaming
<Chat
baseUrl="/api/agents"
agentId="assistant"
onStreamText={(chunk, fullText) => {
console.log("Chunk:", chunk);
console.log("Full text so far:", fullText);
}}
/>Tool Calls
<Chat
baseUrl="/api/agents"
agentId="assistant"
onToolCall={(toolName, args) => {
console.log("Tool called:", toolName);
console.log("Arguments:", args);
}}
onToolResult={(toolName, result) => {
console.log("Tool result:", toolName, result);
}}
/>Stream UI Behavior
While streaming:
- Typing indicator - Shows while waiting for response
- Incremental text - Text appears as it's received
- Tool calls - Displayed inline as they occur
- Stop button - Stops the turn (on agent-server 0.3.0+ the run itself, see Running turns)
A stream is not the run
On @cognipeer/agent-server 0.3.0 or later a streamed request is only a subscriber of the run that executes the turn. If the connection drops, the page does not report a failure: the turn goes on without it, so the page looks at the conversation again and follows what is still running (GET /conversations/:id/events/stream) or shows what finished. Every stream.* frame carries seq, the position in the conversation's event log, and stream.done carries stopReason (end_turn, cancelled, limit, budget_reached, ...), runId and - for a conversation with a usage limit - budget and spentUsd. A stopped answer gets a notice, see Running turns.
Stop Streaming
Users can stop the stream using the stop button, or programmatically. With a transport that can cancel (AgentServerClient) stop() stops the run on the server, closes the stream and keeps the text received so far as an answer marked stopped; otherwise it only closes the connection:
import { useChat } from "@cognipeer/chat-ui";
function CustomChat() {
const { isLoading, stop, sendMessage } = useChat({
baseUrl: "/api/agents",
agentId: "assistant",
});
return (
<div>
{isLoading && (
<button onClick={stop}>
Stop Generation
</button>
)}
</div>
);
}Streaming State
Track streaming state with hooks:
import { useChat } from "@cognipeer/chat-ui";
function CustomChat() {
const {
isLoading, // True while streaming
streamingText, // Current accumulated text
activeToolCalls,// Map of active tool calls
} = useChat({
baseUrl: "/api/agents",
agentId: "assistant",
});
return (
<div>
{isLoading && <div>Generating...</div>}
{streamingText && <div>Current: {streamingText}</div>}
</div>
);
}Error Handling
Handle stream errors:
<Chat
baseUrl="/api/agents"
agentId="assistant"
onError={(error) => {
console.error("Stream error:", error);
// Show error notification
}}
/>Retry Failed Requests
import { useChat } from "@cognipeer/chat-ui";
function CustomChat() {
const { error, retry } = useChat({
baseUrl: "/api/agents",
agentId: "assistant",
});
return (
<div>
{error && (
<div>
<p>Error: {error.message}</p>
<button onClick={retry}>Retry</button>
</div>
)}
</div>
);
}Stream events
The streaming methods read Server-Sent Events whose data is a JSON StreamEvent. Every frame may carry seq, its position in the conversation's event log (agent-server 0.3.0+).
| Event | useChat shows | StreamCallbacks |
|---|---|---|
stream.start | Starts the live turn | onStart |
stream.text | Appends to the streaming text | onText(chunk, fullText) |
stream.thinking | Treated as text | onText |
stream.tool_call | A tool row appears | onToolCall |
stream.tool_result | The row finishes | onToolResult |
stream.progress | The progress line | onProgress |
stream.action_required | Approval and question cards | onActionRequired |
stream.action_resolved | The card settles (also when answered from another app) | onActionResolved |
stream.error | An error banner; its code selects a localized message | onError (the error carries code) |
stream.done | The final message, with citations, files, title, usage, status, pendingActions, stopReason, runId, budget, spentUsd, warnings | onDone |
Custom Stream Handler
For custom implementations, use the API client directly. Events arrive through callbacks:
import { AgentServerClient } from "@cognipeer/chat-ui";
const client = new AgentServerClient({ baseUrl: "/api/agents" });
async function streamMessage(conversationId: string, message: string, signal?: AbortSignal) {
await client.sendMessageStream(
conversationId,
{ message },
{
onStart: () => console.log("Stream started"),
onText: (chunk, fullText) => console.log("Text so far:", fullText),
onToolCall: (event) => console.log("Tool:", event.type === "stream.tool_call" && event.toolName),
onError: (error) => console.error(error.message, (error as { code?: string }).code),
onDone: (event) => console.log("Done:", event.type === "stream.done" && event.stopReason),
},
signal
);
}With streaming={false} (or sendMessage instead of sendMessageStream) the server answers with one JSON response: { message, response, status?, pendingActions?, stopReason?, budget?, spentUsd?, warnings? }.
Performance and proxies
- The built-in
ChatMessageListalways draws the live turn below the stored messages, so a continuation (for example Continue after a usage limit) never replaces the answer it carries on. - A proxy or CDN that buffers
text/event-streammakes the text arrive in one piece at the end: turn response buffering off for the route (for nginx,proxy_buffering off). - A long history is sent as its newest page (100 messages by default on agent-server);
loadEarlierMessagesfetches older ones on request. See Running turns. - For very long lists in a custom layout, render
ChatMessageinside your own virtualized list.

