Getting started with AG-UI .NET — streaming chat
Goal: install the packages, stand up an AG-UI endpoint, connect a client, and stream a multi-turn conversation. You are done when you run the client and it prints a streamed reply.
The two agent types (read this first)
How you keep a conversation going depends on who owns the history:
- Stateless agent (the default). The server keeps nothing between turns — it is a pure function of the request. The client owns the history and resends the full message list every turn. This is where you start.
- Hosted / conversation-holding agent. A server (or runtime) persists the conversation in a session keyed by the thread id, so it can recall history. The client sends the new turn under a stable thread id. Covered below.
AGUIChatClient is a Microsoft.Extensions.AI.IChatClient, so you consume it with the ordinary GetStreamingResponseAsync(messages, options, ct) API.
Thread id. Every conversation has one — it is the stable identifier for the whole exchange. Keep it stable across turns by reusing the same ChatOptions instance: AGUIChatClient pins the resolved thread id onto it after the first turn. (The SDK never surfaces it as ConversationId — update.ConversationId is always null by design, issue #4869 — so don't use ConversationId to track a conversation.)
Install the packages
Client app:
dotnet add package AGUI.Client
Server app:
dotnet add package AGUI.Server # RunAgentInput adaptation + event-stream conversion
dotnet add package AGUI.Formatting # SseEventStreamFormatter (writes the SSE wire format)
dotnet add package installs the latest stable version. To discover versions or pin one:
dotnet package search AGUI.Client --exact-match # show the latest version on nuget.org
dotnet add package AGUI.Client --prerelease # install latest, including prereleases
dotnet add package AGUI.Client --version <x.y.z> # pin a specific version
AGUI.Abstractions (protocol types such as RunAgentInput and RunStartedEvent) comes in transitively with both. On the server you also use Microsoft.Extensions.AI (AddChatClient, IChatClient) plus a chat-client provider of your choice (OpenAI, Azure OpenAI, Ollama, …).
Stateless: the client
Construct it from an AGUIChatClientOptions (here via the (HttpClient, endpoint) options constructor):
using AGUI.Client;
using Microsoft.Extensions.AI;
using var httpClient = new HttpClient();
IChatClient client = new AGUIChatClient(new(httpClient, "http://localhost:5001"));
await foreach (var update in client.GetStreamingResponseAsync(
new List<ChatMessage> { new(ChatRole.User, "Hello") }))
{
Console.Write(update.Text); // text deltas stream in as they arrive
}
You own the HttpClient. The transport defaults to Server-Sent Events.
Stateless: the server
Host the endpoint yourself: receive RunAgentInput, adapt it to Microsoft.Extensions.AI, stream from any IChatClient, convert to AG-UI events, and write them as SSE.
using AGUI.Abstractions;
using AGUI.Formatting;
using AGUI.Server;
using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Options;
using JsonOptions = Microsoft.AspNetCore.Http.Json.JsonOptions;
var builder = WebApplication.CreateBuilder(args);
// Register your chat client. Any Microsoft.Extensions.AI provider works
// (OpenAI, Ollama, a local model, …); Azure OpenAI is shown here.
builder.Services.AddChatClient(
new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential())
.GetChatClient(deploymentName)
.AsIChatClient());
// SseEventStreamFormatter is stateless — register it once as a singleton.
builder.Services.AddSingleton<IAGUIEventStreamFormatter, SseEventStreamFormatter>();
var app = builder.Build();
app.MapPost("/", async (
[FromBody] RunAgentInput input,
IChatClient chatClient,
IAGUIEventStreamFormatter formatter,
IOptions<JsonOptions> jsonOptions,
HttpContext http,
CancellationToken ct) =>
{
var jso = jsonOptions.Value.SerializerOptions;
// 1) RunAgentInput -> messages + ChatOptions
var ctx = input.ToChatRequestContext(jso);
// 2) Stream from the model
var updates = chatClient.GetStreamingResponseAsync(ctx.Messages, ctx.ChatOptions, ct);
// 3) ChatResponseUpdate stream -> AG-UI event stream
var events = updates.AsAGUIEventStreamAsync(ctx, ct);
// 4) Write the events using the injected (singleton) formatter
http.Response.ContentType = formatter.MediaType;
http.Response.Headers.CacheControl = "no-cache";
await formatter.WriteAsync(events, http.Response.Body, ct);
});
app.Run();
That is the whole server contract. If you later expose server-executed tools, add .UseFunctionInvocation() to the chat-client registration — not needed for plain chat.
Stateless: multi-turn (carry the history)
A stateless server has no memory, so the client keeps the running List<ChatMessage>: append the assistant reply, then add the next user turn. Reuse the same ChatOptions so the thread id stays stable across the conversation.
var messages = new List<ChatMessage> { new(ChatRole.User, "Hello") };
var options = new ChatOptions();
await SendAsync(messages, options);
messages.Add(new ChatMessage(ChatRole.User, "How are you?"));
await SendAsync(messages, options);
async Task SendAsync(List<ChatMessage> history, ChatOptions options)
{
var updates = new List<ChatResponseUpdate>();
await foreach (var update in client.GetStreamingResponseAsync(history, options))
{
Console.Write(update.Text); // stream the reply as it arrives
updates.Add(update);
}
Console.WriteLine();
history.AddMessages(updates.ToChatResponse()); // carry the reply into the next turn
}
AGUIChatClient sends the full message list every turn by design — that is how a stateless server sees prior context.
Run it (definition of done)
- Start the server:
dotnet runin the server project (listening on, e.g.,:5001). - Run the client against it. It works when the client prints the reply incrementally (multiple non-empty
update.Textchunks, not one blob at the end). - Smoke-test the server directly:
curl -N -X POST http://localhost:5001/ -H "Content-Type: application/json" \ -d '{"threadId":"t1","runId":"r1","messages":[{"id":"m1","role":"user","content":"Hello"}]}'
You should see data: {...} SSE frames: RUN_STARTED … TEXT_MESSAGE_CONTENT … RUN_FINISHED.
4. Multi-turn keeps context: the second answer reflects the first exchange.
Hosted agents
Use this when the server holds the conversation (a session store keyed by thread id). The server recalls history from the thread id, so the client just needs to keep that thread id stable — reusing the same ChatOptions (above) is usually enough. Two extra tools for when you need them:
Pin a known thread id (resuming a session created earlier): set it as
ConversationId—AGUIChatClientmaps it inward to the AG-UI thread id.var options = new ChatOptions { ConversationId = threadId };Read the thread id / run id for a turn — they ride on the
RUN_STARTEDevent, exposed as the update'sRawRepresentation:using AGUI.Abstractions; string? threadId = null, runId = null; await foreach (var u in client.GetStreamingResponseAsync(messages, options)) { if (u.RawRepresentation is RunStartedEvent started) { threadId = started.ThreadId; runId = started.RunId; } Console.Write(u.Text); }Advanced — set wire-level fields with no
ChatOptionsequivalent. For exampleParentRunId, to chain one run onto the previous. Supply it viaRawRepresentationFactory; messages still flow through the normal call argument, and to send only the new turn you simply pass a shorter list:var options = new ChatOptions { RawRepresentationFactory = _ => new RunAgentInput { ParentRunId = runId }, }; await foreach (var update in client.GetStreamingResponseAsync(newMessages, options)) { Console.Write(update.Text); }
Send only the new messages this way only when the server truly owns history; otherwise prefer the full-history stateless pattern, which works against any AG-UI server.
Anti-patterns
- Reusing one
ChatOptionsacross different conversations. The thread id pins onto the options instance, so a second, unrelated conversation inherits the first one's thread id — and against a hosted server the two get merged into one session. Use a freshChatOptionsper conversation; reuse it only across turns of the same one. - Calling
GetResponseAsyncwhen you want streaming. It returns only the final aggregated response — no incremental text, and content-less updates (lifecycle and state events) do not survive the aggregation. UseGetStreamingResponseAsync.
Source: ag-ui-protocol/ag-ui → sdks/dotnet/plugins/ag-ui-dotnet/skills/agui-dotnet-streaming-chat/SKILL.md