In this lesson you will learn two essential techniques for building responsive, production-ready AI applications in .NET: streaming (displaying tokens as they arrive) and structured output (getting strongly-typed objects instead of plain text).
Why Streaming Matters
With GetResponseAsync the user stares at a blank screen until the entire response is ready. For long answers this feels sluggish. Streaming lets you display each token the moment it arrives — exactly like ChatGPT or Copilot.
| Regular | Streaming |
|---|
GetResponseAsync | GetStreamingResponseAsync |
Returns ChatResponse | Returns IAsyncEnumerable<ChatResponseUpdate> |
| Wait for full response | Process each token as it arrives |
| Simpler code | Better user experience |
Part 1 — Streaming Responses
Basic Streaming Pattern
Replace GetResponseAsync with GetStreamingResponseAsync and iterate with await foreach:
await foreach (ChatResponseUpdate update in
chatClient.GetStreamingResponseAsync(
"Create a 3-day travel itinerary for someone visiting Kyoto, Japan for the first time."))
{
Console.Write(update.Text);
}
Each ChatResponseUpdate typically contains a single word or token. Writing it immediately gives the user real-time feedback.
Streaming with Conversation History
You can combine streaming with a message history list. Collect all updates, then call AddMessages so the next turn includes the full context:
List<ChatMessage> travelChat = [
new(ChatRole.System,
"You are a seasoned travel guide. Give brief, practical advice.")
];
travelChat.Add(new(ChatRole.User, "What is the best way to get around Istanbul on a budget?"));
List<ChatResponseUpdate> updates = [];
await foreach (var update in chatClient.GetStreamingResponseAsync(travelChat))
{
Console.Write(update.Text);
updates.Add(update);
}
// Merge streamed tokens into history
travelChat.AddMessages(updates);
Converting Updates to a Full Response
Sometimes you need the complete ChatResponse object after streaming. Use ToChatResponse:
List<ChatResponseUpdate> allUpdates = [];
await foreach (var update in chatClient.GetStreamingResponseAsync(
"Name five must-try street foods in Bangkok, Thailand."))
{
Console.Write(update.Text);
allUpdates.Add(update);
}
ChatResponse fullResponse = allUpdates.ToChatResponse();
Console.WriteLine($"Collected {fullResponse.Messages.Count} message(s) from the stream.");
Part 2 — Structured Output
Plain text is flexible, but sometimes your code needs data in a specific shape — enums, records, or lists. Microsoft.Extensions.AI provides GetResponseAsync<T> which instructs the model to return JSON matching your type and deserializes it automatically.
Enum Classification
Step 1 — Define the type:
public enum TripType
{
Beach,
Mountain,
City,
Cultural,
Adventure
}
Step 2 — Request a typed response:
string[] destinations = [
"Bali — famous for its beaches, surf spots and ocean sunsets.",
"Zurich — a walkable European city with museums, trams and lakeside cafés.",
"Machu Picchu — ancient Inca ruins perched high in the Andes mountains.",
"Marrakech — vibrant souks, riads, and centuries of Moroccan heritage.",
"Queenstown — bungee jumping, skydiving and jet-boat rides in New Zealand."
];
foreach (var dest in destinations)
{
var result = await chatClient.GetResponseAsync<TripType>(
$"Classify this destination into a trip type: {dest}");
Console.WriteLine($"{dest.Split('—')[0].Trim()} → {result.Result}");
}
Expected output:
Bali → Beach
Zurich → City
Machu Picchu → Mountain
Marrakech → Cultural
Queenstown → Adventure
Complex Structured Output with Records
For richer data define a record:
public record DestinationProfile(
string DestinationName,
TripType Category,
string BestSeason,
int EstimatedDailyCostUsd,
string[] TopAttractions
);
var profileResult = await chatClient.GetResponseAsync<DestinationProfile>(
"Create a travel profile for Reykjavik, Iceland.");
var profile = profileResult.Result;
Console.WriteLine($"Destination : {profile.DestinationName}");
Console.WriteLine($"Category : {profile.Category}");
Console.WriteLine($"Best Season : {profile.BestSeason}");
Console.WriteLine($"Daily Cost : ${profile.EstimatedDailyCostUsd}");
Console.WriteLine($"Attractions : {string.Join(", ", profile.TopAttractions)}");
Sample output:
Destination : Reykjavik
Category : Adventure
Best Season : Summer (June–August)
Daily Cost : $180
Attractions : Blue Lagoon, Hallgrímskirkja, Golden Circle
Lists of Structured Items
You can request a List<T> just as easily:
public record PackingItem(
string Item,
string Reason,
bool Essential
);
var tripDescription = """
We are going on a week-long winter trek through the Scottish Highlands.
Expect rain, muddy trails, near-freezing temperatures at night,
and limited access to shops once we leave Fort William.
""";
var packingResult = await chatClient.GetResponseAsync<List<PackingItem>>(
$"Generate a packing list for this trip: {tripDescription}");
foreach (var item in packingResult.Result)
{
var tag = item.Essential ? "ESSENTIAL" : "optional";
Console.WriteLine($"[{tag}] {item.Item} — {item.Reason}");
}
Sample output:
[ESSENTIAL] Waterproof jacket — Rain is near-constant in the Highlands
[ESSENTIAL] Insulated sleeping bag — Temperatures drop close to 0 °C
[optional] Trekking poles — Helpful on muddy, uneven terrain
Part 3 — Combining Streaming and Structured Output
For the best of both worlds: stream a conversational response for real-time feedback, then make a second structured-output call to extract machine-readable data.
// 1. Stream the travel recommendation
StringBuilder streamed = new();
await foreach (var update in chatClient.GetStreamingResponseAsync(
"Recommend a 5-day road trip route through Portugal."))
{
Console.Write(update.Text);
streamed.Append(update.Text);
}
// 2. Extract structured stops from the streamed text
var stopsResult = await chatClient.GetResponseAsync<List<DestinationProfile>>(
$"Extract each city/stop as a DestinationProfile: {streamed}");
foreach (var stop in stopsResult.Result)
{
Console.WriteLine($" {stop.DestinationName} ({stop.Category}) — " +
$"{string.Join(", ", stop.TopAttractions)}");
}
ChatOptions — Fine-Tuning Responses
Both streaming and structured output accept a ChatOptions parameter for additional control:
var options = new ChatOptions
{
Temperature = 0.2f, // Lower = more deterministic
MaxOutputTokens = 150
};
await foreach (var update in chatClient.GetStreamingResponseAsync(
"Give three safety tips for solo travelers in South America.", options))
{
Console.Write(update.Text);
}
| Property | Purpose |
|---|
Temperature | Controls randomness (0–2). Lower = more focused. |
MaxOutputTokens | Limits response length. |
TopP | Alternative to temperature for controlling randomness. |
ModelId | Override the default model. |
Summary
| Concept | Key Takeaway |
|---|
| Streaming | Display tokens as they arrive for a responsive UX. |
IAsyncEnumerable | C# pattern for async streams of data. |
ToChatResponse | Convert stream updates into a complete response object. |
| Structured Output | Get strongly-typed objects instead of plain text. |
| Record Types | Ideal for defining output schemas the model must follow. |
Full Example
using System.Text;
using Microsoft.Extensions.AI;
using OpenAI;
namespace MicrosoftAgentFrameworkLesson.ConsoleApp;
// ── Structured-output types ─────────────────────────────
public enum TripType
{
Beach,
Mountain,
City,
Cultural,
Adventure
}
public record DestinationProfile(
string DestinationName,
TripType Category,
string BestSeason,
int EstimatedDailyCostUsd,
string[] TopAttractions
);
public record PackingItem(
string Item,
string Reason,
bool Essential
);
// ── Demo class ──────────────────────────────────────────
public static class StreamingAndStructuredOutputDemo
{
public static async Task RunAsync()
{
// 1. Retrieve API key from environment
var apiKey = Environment.GetEnvironmentVariable("OPEN_AI_KEY");
if (string.IsNullOrWhiteSpace(apiKey))
{
Console.WriteLine("Please set the OPEN_AI_KEY environment variable.");
return;
}
// 2. Create an IChatClient
IChatClient chatClient = new OpenAIClient(apiKey)
.GetChatClient("gpt-4o-mini")
.AsIChatClient();
// ══════════════════════════════════════════════
// PART 1 — Streaming Responses
// ══════════════════════════════════════════════
Console.WriteLine("═══ Part 1: Streaming a Travel Itinerary ═══\n");
await foreach (ChatResponseUpdate update in
chatClient.GetStreamingResponseAsync(
"Create a 3-day travel itinerary for someone visiting Kyoto, Japan for the first time. Be concise."))
{
Console.Write(update.Text);
}
Console.WriteLine("\n");
// Streaming with conversation history
Console.WriteLine("═══ Part 1b: Streaming Chat with History ═══\n");
List<ChatMessage> travelChat =
[
new(ChatRole.System,
"You are a seasoned travel guide. Give brief, practical advice. " +
"Limit answers to three sentences maximum.")
];
string[] travelerQuestions =
[
"What is the best way to get around Istanbul on a budget?",
"Any hidden gems the tourists usually miss there?"
];
foreach (var question in travelerQuestions)
{
Console.WriteLine($"Traveler: {question}");
travelChat.Add(new(ChatRole.User, question));
List<ChatResponseUpdate> updates = [];
Console.Write("Guide: ");
await foreach (var update in chatClient.GetStreamingResponseAsync(travelChat))
{
Console.Write(update.Text);
updates.Add(update);
}
Console.WriteLine("\n");
travelChat.AddMessages(updates);
}
// Converting stream updates to a full ChatResponse
Console.WriteLine("═══ Part 1c: ToChatResponse ═══\n");
List<ChatResponseUpdate> allUpdates = [];
await foreach (var update in chatClient.GetStreamingResponseAsync(
"Name five must-try street foods in Bangkok, Thailand."))
{
Console.Write(update.Text);
allUpdates.Add(update);
}
ChatResponse fullResponse = allUpdates.ToChatResponse();
Console.WriteLine($"\n\n→ Collected {fullResponse.Messages.Count} message(s) from the stream.\n");
// ══════════════════════════════════════════════
// PART 2 — Structured Output
// ══════════════════════════════════════════════
// 2a — Enum classification
Console.WriteLine("═══ Part 2a: Trip Type Classification ═══\n");
string[] destinations =
[
"Bali — famous for its beaches, surf spots and ocean sunsets.",
"Zurich — a walkable European city with museums, trams and lakeside cafés.",
"Machu Picchu — ancient Inca ruins perched high in the Andes mountains.",
"Marrakech — vibrant souks, riads, and centuries of Moroccan heritage.",
"Queenstown — bungee jumping, skydiving and jet-boat rides in New Zealand."
];
foreach (var dest in destinations)
{
var result = await chatClient.GetResponseAsync<TripType>(
$"Classify this destination into a trip type: {dest}");
Console.WriteLine($"{dest.Split('—')[0].Trim(),-16} → {result.Result}");
}
// 2b — Complex record
Console.WriteLine("\n═══ Part 2b: Destination Profile ═══\n");
var profileResult = await chatClient.GetResponseAsync<DestinationProfile>(
"Create a travel profile for Reykjavik, Iceland. " +
"Include destination name, category, best season, estimated daily cost in USD, and top 3 attractions.");
var profile = profileResult.Result;
Console.WriteLine($"Destination : {profile.DestinationName}");
Console.WriteLine($"Category : {profile.Category}");
Console.WriteLine($"Best Season : {profile.BestSeason}");
Console.WriteLine($"Daily Cost : ${profile.EstimatedDailyCostUsd}");
Console.WriteLine($"Attractions : {string.Join(", ", profile.TopAttractions)}");
// 2c — List of structured items
Console.WriteLine("\n═══ Part 2c: Packing List Extraction ═══\n");
var tripDescription = """
We are going on a week-long winter trek through the Scottish Highlands.
Expect rain, muddy trails, near-freezing temperatures at night,
and limited access to shops once we leave Fort William.
""";
var packingResult = await chatClient.GetResponseAsync<List<PackingItem>>(
$"Generate a packing list for this trip: {tripDescription}");
foreach (var item in packingResult.Result)
{
var tag = item.Essential ? "ESSENTIAL" : "optional";
Console.WriteLine($"[{tag,-9}] {item.Item,-28} — {item.Reason}");
}
// ══════════════════════════════════════════════
// PART 3 — Combining Streaming + Structured Output
// ══════════════════════════════════════════════
Console.WriteLine("\n═══ Part 3: Stream then Structure ═══\n");
Console.WriteLine("Streaming a travel recommendation...\n");
StringBuilder streamed = new();
await foreach (var update in chatClient.GetStreamingResponseAsync(
"Recommend a 5-day road trip route through Portugal. Mention cities, driving times and highlights."))
{
Console.Write(update.Text);
streamed.Append(update.Text);
}
Console.WriteLine("\n\nExtracting structured stops...\n");
var stopsResult = await chatClient.GetResponseAsync<List<DestinationProfile>>(
$"Extract each city/stop from this road trip as a DestinationProfile: {streamed}");
foreach (var stop in stopsResult.Result)
{
Console.WriteLine($" {stop.DestinationName} ({stop.Category}) — {string.Join(", ", stop.TopAttractions)}");
}
// ══════════════════════════════════════════════
// ChatOptions — Fine-tuning
// ══════════════════════════════════════════════
Console.WriteLine("\n═══ Bonus: ChatOptions ═══\n");
var options = new ChatOptions
{
Temperature = 0.2f,
MaxOutputTokens = 150
};
await foreach (var update in chatClient.GetStreamingResponseAsync(
"Give three safety tips for solo travelers in South America.", options))
{
Console.Write(update.Text);
}
Console.WriteLine();
}