Introduction
In our previous articles, we built an AI-powered task management agent using Microsoft Agent Framework, secured it with Azure Content Safety, added production-grade observability, and separated the frontend with Next.js. However, all those implementations used a traditional request-response pattern—the user waits for the complete response before seeing anything.
In this article, we'll transform our agent into a real-time streaming experience, similar to ChatGPT. Users will see text appearing progressively as the AI generates it, creating a more engaging and responsive user experience.
What you'll learn
How Server-Sent Events (SSE) enable real-time streaming from backend to frontend
Implementing a custom AG-UI endpoint with state persistence
Building progressive UI rendering in React (ChatGPT-like experience)
Managing conversation continuity with serialized state
Handling content filter events gracefully in the streaming flow
The Problem: Waiting for Complete Responses
In v2.0.0 of TaskAgent, the chat flow looked like this:
User sends message → Backend processes (3-5 seconds) → User sees complete responseDuring those seconds of processing, the user stares at a loading spinner. For complex tasks that involve multiple function calls (create task, update task, get summary), this wait time compounds. Users have no visibility into what's happening.
The solution? Stream the response as it's generated.
Understanding Server-Sent Events (SSE)
Server-Sent Events provide a standardized way for servers to push updates to clients over a single HTTP connection. Unlike WebSockets, SSE is:
Unidirectional: Server → Client only (perfect for AI streaming)
Built on HTTP: Works with existing infrastructure, proxies, and load balancers
Auto-reconnect: Browser handles reconnection automatically
Simple protocol: Plain text format, easy to debug
The protocol is remarkably simple:
HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
data: {"type":"TEXT_MESSAGE_CONTENT","delta":"Hello"}
data: {"type":"TEXT_MESSAGE_CONTENT","delta":" world!"}
data: [DONE]Each line starting with data: is an event. The frontend parses these incrementally.

Browser DevTools Network tab showing SSE connection with streaming events
Architecture Overview
Our streaming implementation consists of three main components:

Components
| Layer | Component | Responsibility |
|---|---|---|
| Frontend | useChat Hook | Manages state, sends requests, processes callbacks |
| Frontend | MessagesList | Renders messages with progressive updates |
| Backend | AgentController | Entry point, configures SSE headers |
| Backend | SseStreamingService | Orchestrates streaming to HTTP response |
| Backend | AIAgent | Microsoft Agent Framework with function tools |
| Persistence | ChatMessageStore | Factory pattern for PostgreSQL persistence |
Backend Implementation
Event Types
First, we define the SSE event types our agent will emit:
public static class AgentConstants
{
// SSE protocol headers
public const string SSE_CONTENT_TYPE = "text/event-stream";
public const string SSE_CACHE_CONTROL = "no-cache";
public const string SSE_CONNECTION = "keep-alive";
// Event types - AG-UI compatible naming
public const string EVENT_TEXT_MESSAGE_CONTENT = "TEXT_MESSAGE_CONTENT";
public const string EVENT_TOOL_CALL_START = "TOOL_CALL_START";
public const string EVENT_TOOL_CALL_RESULT = "TOOL_CALL_RESULT";
public const string EVENT_THREAD_STATE = "THREAD_STATE";
public const string EVENT_CONTENT_FILTER = "CONTENT_FILTER";
public const string EVENT_RUN_ERROR = "RUN_ERROR";
// AG-UI Lifecycle events (for real-time status updates)
public const string EVENT_STEP_STARTED = "STEP_STARTED";
public const string EVENT_STATUS_UPDATE = "STATUS_UPDATE";
public const string EVENT_STEP_FINISHED = "STEP_FINISHED";
}These event types follow the AG-UI protocol naming convention, making our implementation compatible with CopilotKit and other AG-UI clients. The STEP_* events provide real-time visibility into function execution.
The Agent Controller
The AgentController is the entry point for streaming requests:
[HttpPost("chat")]
public async Task ChatAsync(
[FromBody] AgentRequest request,
CancellationToken cancellationToken)
{
// Configure SSE headers BEFORE any response is written
ConfigureSseResponse();
string? serializedState = request.SerializedState;
try
{
List<ChatMessage> messages = request.Messages?
.Select(MapToChatMessage)
.ToList() ?? [];
await _streamingService.StreamToResponseAsync(
Response,
messages,
serializedState,
cancellationToken);
}
catch (ClientResultException ex) when (IsContentFilterError(ex))
{
// Content filter - send event but maintain conversation continuity
await HandleContentFilterAsync(serializedState, cancellationToken);
}
}Key insight: We configure SSE headers before writing any content. Once we call ConfigureSseResponse(), we commit to the SSE protocol—no turning back to JSON error responses.
SSE Streaming Service
The SseStreamingService orchestrates the streaming flow:
public async Task StreamToResponseAsync(
HttpResponse response,
IEnumerable<ChatMessage> messages,
string? serializedState,
CancellationToken cancellationToken)
{
ConfigureSseResponse(response);
// Stream agent responses
await foreach (var update in _agentStreamingService.StreamResponseAsync(
messages, serializedState, cancellationToken))
{
string eventJson = SerializeEvent(update);
await WriteEventAsync(response, eventJson, cancellationToken);
}
// Always send thread state for conversation continuity
var thread = _agentStreamingService.GetCurrentThread();
if (thread != null)
{
await SendThreadStateEventAsync(response, thread, cancellationToken);
}
await WriteDoneEventAsync(response, cancellationToken);
}Event Mapping
The AgentEventMapper translates Microsoft Agent Framework updates to our SSE events:
public static string GetEventType(AgentRunResponseUpdate update)
{
if (update.Contents.Any(c => c is TextContent))
return AgentConstants.EVENT_TEXT_MESSAGE_CONTENT;
if (update.Contents.Any(c => c is FunctionCallContent))
return AgentConstants.EVENT_TOOL_CALL_START;
if (update.Contents.Any(c => c is FunctionResultContent))
return AgentConstants.EVENT_TOOL_CALL_RESULT;
return AgentConstants.EVENT_RUN_UPDATE;
}This separation keeps the streaming service focused on HTTP concerns while the mapper handles the Agent Framework specifics.
Dynamic Status Messages with FunctionDescriptionProvider
A key challenge was providing meaningful status updates without hardcoding messages for each function. We solved this by extracting [Description] attributes via reflection:
public class FunctionDescriptionProvider
{
private readonly ConcurrentDictionary<string, string> _statusMessages = new();
public void RegisterFunctionType(Type functionType)
{
foreach (var method in functionType.GetMethods())
{
var descAttr = method.GetCustomAttribute<DescriptionAttribute>();
if (descAttr != null)
{
// Convert "Creates a new task" → "Creating a new task..."
string status = ConvertToGerund(descAttr.Description);
_statusMessages.TryAdd(method.Name, status);
}
}
}
public string GetStatusMessage(string functionName)
=> _statusMessages.GetValueOrDefault(functionName, $"Processing {functionName}...");
}Why this matters for multi-agent systems:
No hardcoded status mappings per agent
New functions automatically get status messages from their
[Description]Single source of truth: the attribute serves both AI understanding AND user feedback
State Persistence with ChatMessageStore
One challenge with streaming is maintaining conversation continuity. Microsoft Agent Framework provides the ChatMessageStore pattern for this.
The PostgresChatMessageStore
We implemented a custom store that persists messages to PostgreSQL:
public class PostgresChatMessageStore : ChatMessageStore
{
public string? ThreadDbKey { get; private set; }
public override async Task AddMessagesAsync(
IEnumerable<ChatMessage> messages,
CancellationToken cancellationToken = default)
{
// Generate ThreadDbKey on first message
ThreadDbKey ??= Guid.NewGuid().ToString("N");
foreach (var message in messages)
{
var entity = ConversationMessage.Create(
messageId: message.MessageId ?? Guid.NewGuid().ToString("N"),
threadId: ThreadDbKey,
role: message.Role.ToString(),
content: SerializeContents(message.Contents));
await _context.Messages.AddAsync(entity, cancellationToken);
}
await _context.SaveChangesAsync(cancellationToken);
}
}Factory Pattern for Scoped Persistence
The agent is a singleton, but DbContext must be scoped. We use a factory pattern:
ChatMessageStore MessageStoreFactory(
ChatClientAgentOptions.ChatMessageStoreFactoryContext ctx)
{
IServiceScope scope = serviceProvider.CreateScope();
var dbContext = scope.ServiceProvider
.GetRequiredService<ConversationDbContext>();
return new PostgresChatMessageStore(
dbContext,
ctx.SerializedState,
ctx.JsonSerializerOptions);
}This factory creates a fresh DbContext for each conversation thread, avoiding the "DbContext already disposed" issues common with singleton services.

Database view showing conversation_messages table with thread_id groupings
Frontend Implementation
The useChat Hook
The heart of our frontend streaming is the useChat hook with streaming callbacks:
export function useChat(options: UseChatOptions = {}): UseChatReturn {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [isStreaming, setIsStreaming] = useState(false);
const [serializedState, setSerializedState] = useState<string | null>(null);
const streamingMessageIdRef = useRef<string | null>(null);
const sendMessageInternal = useCallback(async (message: string) => {
// 1. Add user message immediately (optimistic update)
const userMessage: ChatMessage = {
id: `temp-${Date.now()}`,
role: "user",
content: message,
createdAt: new Date().toISOString(),
};
setMessages(prev => [...prev, userMessage]);
// 2. Create placeholder for streaming assistant message
const streamingMsgId = `streaming-${Date.now()}`;
streamingMessageIdRef.current = streamingMsgId;
setMessages(prev => [...prev, {
id: streamingMsgId,
role: "assistant",
content: "", // Will be updated progressively
createdAt: new Date().toISOString(),
}]);
setIsStreaming(true);
// 3. Define streaming callbacks
const callbacks: StreamingCallbacks = {
onTextChunk: (_delta, fullText) => {
// Progressive UI update - the magic!
setMessages(prev => prev.map(msg =>
msg.id === streamingMessageIdRef.current
? { ...msg, content: fullText }
: msg
));
},
onComplete: (newSerializedState) => {
setIsStreaming(false);
if (newSerializedState) {
setSerializedState(newSerializedState);
}
},
};
// 4. Send message with streaming
await sendMessageWithStreaming(
{ message, serializedState: serializedState || undefined },
callbacks
);
}, [serializedState]);
// ... rest of hook implementation
}Key insight: We create a placeholder message with empty content, then update its content progressively as chunks arrive. React's state updates batch efficiently, creating smooth text animation.
Parsing the SSE Stream
The chat service parses the SSE stream:





Join the conversation! Your thoughts help the community grow.