An agent that thinks for eight seconds and then dumps a wall of text feels broken, even when it’s working perfectly. The reason ChatGPT feels alive is streaming — words appear as they’re generated. If you’re building an agent-backed web UI in .NET, streaming isn’t a nice-to-have; it’s the difference between “is this thing frozen?” and a responsive product. This article wires a .NET agent’s streamed output to a browser using Server-Sent Events (SSE).
This builds on the agent from our tool-using agent tutorial and the API host from the Azure deployment guide.
Streaming at the agent level
The Microsoft Agent Framework exposes a streaming variant that yields partial updates as the model produces them, instead of returning one final string. Conceptually:
await foreach (var update in agent.RunStreamingAsync("Explain what a service mesh is."))
{
Console.Write(update); // each chunk is a small piece of the answer
}
Each update is a fragment — often a few tokens. Your job is to relay those fragments to the browser as they arrive rather than buffering them into one response.
Why Server-Sent Events
For agent output you have three transport choices:
- Server-Sent Events (SSE) — a one-way stream from server to browser over plain HTTP. Perfect for “server pushes tokens to client,” trivial to consume with the browser’s built-in
EventSource, and it survives proxies well. - WebSockets — bidirectional and heavier; overkill unless the client also needs to stream to the server mid-response.
- SignalR — great if you’re already using it, but more moving parts than a simple token stream needs.
For streaming an answer to the page, SSE is the least-effort, most-robust option. Here’s the endpoint.
The SSE endpoint
In ASP.NET Core, set the text/event-stream content type and write each chunk in SSE’s data: format, flushing after every write so nothing buffers:
app.MapPost("/ask/stream", async (HttpContext ctx, AskRequest req, MyAgent agent) =>
{
ctx.Response.Headers.ContentType = "text/event-stream";
ctx.Response.Headers.CacheControl = "no-cache";
await foreach (var chunk in agent.RunStreamingAsync(req.Message, ctx.RequestAborted))
{
// SSE frame: "data: <payload>\n\n"
await ctx.Response.WriteAsync($"data: {JsonSerializer.Serialize(chunk.ToString())}\n\n");
await ctx.Response.Body.FlushAsync();
}
await ctx.Response.WriteAsync("event: done\ndata: end\n\n");
await ctx.Response.Body.FlushAsync();
});
record AskRequest(string Message);
Two details that matter in production:
- Pass
ctx.RequestAbortedinto the streaming call. If the user closes the tab, the token stops early — you don’t want to keep paying for a response nobody’s reading. - Flush after every chunk. Without
FlushAsync, the server may buffer and defeat the whole point.
JSON-encoding each chunk avoids trouble when a fragment contains newlines, which would otherwise break the SSE frame.
Consuming it in the browser
The client side is refreshingly small. Because SSE is a POST here, use fetch with a streaming reader:
const res = await fetch('/ask/stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: input.value }),
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
for (const line of buffer.split('\n\n')) {
if (line.startsWith('data: ')) {
output.textContent += JSON.parse(line.slice(6));
}
}
buffer = buffer.endsWith('\n\n') ? '' : buffer.split('\n\n').pop();
}
Now the answer materializes word-by-word in output, exactly the experience users expect.
Streaming with tool calls
There’s a wrinkle: when an agent calls a tool mid-response, there’s a pause while the tool runs, and the stream may emit structured events (tool-call-started, tool-result) rather than text. A good UI surfaces these — a subtle “Looking up your order…” indicator while a tool runs keeps the pause from feeling like a hang. Inspect the update type in your await foreach and emit a distinct SSE event: for tool activity so the client can show the right state.
Note: the streaming method name (
RunStreamingAsync) and the shape of each update are still settling across Agent Framework releases. The pattern — async-enumerate updates, relay each over SSE, flush, honor cancellation — is the durable part; confirm the exact API against the current docs.
Takeaway
Streaming turns a .NET agent from something that feels frozen into something that feels fast. Server-Sent Events give you that with almost no infrastructure: an await foreach on the agent’s streamed updates, each chunk written as an SSE frame and flushed, and a small fetch reader on the client. Add cancellation and a tool-activity indicator, and you have the responsive experience users now expect by default.
Have a correction or a topic you want covered? Email mani.bc72@gmail.com.