Expose any AIAgent over the AG-UI protocol and consume it from a remote client over server-sent events. AG-UI standardizes the wire format for messages → tool calls → text deltas → state updates → errors so frontends, mobile apps, and CLI clients can interop with any compliant agent server.
This SDK ships two pieces:
Microsoft.Agents.AI.Hosting.AGUI.AspNetCore—app.MapAGUI(endpoint, agent)extension that turns anAIAgentinto an AG-UI server endpoint backed by SSE.Microsoft.Agents.AI.AGUI—AGUIChatClientthat connects to an AG-UI endpoint and surfaces the stream asAgentResponseUpdates through the normalAIAgentAPI.
Architecture
┌──────────── AG-UI Client ─────────────┐ ┌──────────── AG-UI Server ─────────────┐
│ │ │ │
│ AGUIChatClient(httpClient, endpoint) │ HTTP │ builder.Services.AddAGUI() │
│ ↓ .AsAIAgent(...) │ POST │ app.MapAGUI("/agentName", agent) │
│ AIAgent ──────────────────────────── │ ──────► │ AIAgent (ChatClientAgent / │
│ agent.RunStreamingAsync( │ │ DelegatingAIAgent / custom) │
│ messages, session) │ SSE │ ↓ │
│ ↓ async foreach │ ◄────── │ AgentResponseUpdate stream: │
│ AgentResponseUpdate │ │ TextContent / FunctionCallContent │
│ .Contents │ │ FunctionResultContent / │
│ TextContent │ │ DataContent / ErrorContent │
│ FunctionCallContent (frontend) │ │ │
│ FunctionResultContent │ │ .WithInMemorySessionStore() │
│ DataContent (state snapshot) │ │ .WithSessionStore<TStore>() │
│ ErrorContent │ │ │
│ .ConversationId / .ResponseId │ │ │
└────────────────────────────────────────┘ └────────────────────────────────────────┘
Two highlights worth understanding before writing code:
- Frontend tools. Tools registered on the client (via
AIFunctionFactory.Create) are exposed to the server's model as callable functions. The server emitsFunctionCallContent; the client executes the .NET delegate locally and returns the result. This is how AG-UI does generative UI / browser-side actions. - Backend tools registered on the server run server-side as usual. The server emits no client-side dispatch; the client just sees text deltas.
⚠ Frontend tools + GitHub Copilot CLI backbone. When the server-side
AIAgentis built fromCopilotClient.AsAIAgent(...)(i.e. the model is the local GitHub Copilot CLI rather than anIChatClientsuch as Azure OpenAI), the CLI's tool list is frozen atSessionConfigtime. Frontend tools advertised by the AG-UI client are not merged into the running Copilot session, so the model is likely to hallucinate the tool call ("notification sent") rather than dispatchFunctionCallContentback to the client. For deterministic, machine-checkable evidence that a client tool fired, expose a parallel non-AG-UI HTTP route (e.g.app.MapPost("/scripted-reservation", ...)) and have the client invoke its local delegate after a direct call to that route. See GitHub Copilot CLI as the backbone below.
Installation
# Server (Azure OpenAI backbone)
dotnet add package Microsoft.Agents.AI.Hosting.AGUI.AspNetCore --prerelease
dotnet add package Microsoft.Agents.AI.OpenAI --prerelease
dotnet add package Azure.AI.OpenAI
dotnet add package Azure.Identity
# Server (GitHub Copilot CLI backbone — see section below)
dotnet add package Microsoft.Agents.AI.Hosting.AGUI.AspNetCore --prerelease
dotnet add package Microsoft.Agents.AI.GitHub.Copilot --prerelease # GitHub.Copilot.SDK flows in transitively — do NOT add it directly
dotnet add package Microsoft.Agents.AI.Hosting --prerelease
# Client
dotnet add package Microsoft.Agents.AI --prerelease
dotnet add package Microsoft.Agents.AI.AGUI --prerelease
Adding
GitHub.Copilot.SDKas a direct<PackageReference>breaks restore withCopilotCliVersion is not set—Microsoft.Agents.AI.GitHub.Copilotships the MSBuild props that set that property and pulls the SDK in for you. Stick to the framework package.
The server project's csproj uses Microsoft.NET.Sdk.Web:
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
Prerequisites
- .NET 10 SDK or later
- Azure OpenAI deployment (or any
IChatClient-compatible model) az login(server usesDefaultAzureCredentialby default)- Network reachability from client to server (default:
http://localhost:5100)
Environment Variables
# Server
export AZURE_OPENAI_ENDPOINT="https://<resource>.openai.azure.com/"
export AZURE_OPENAI_DEPLOYMENT_NAME="gpt-5.4-mini"
# Client
export AGUI_SERVER_URL="http://localhost:5100"
Authentication & Lifecycle
🔑 Two rules apply to every code sample below:
- Prefer
DefaultAzureCredential/AzureCliCredential. The server side authenticates to Azure OpenAI; the AGUI HTTP channel itself is plain HTTP/SSE (add your own auth middleware in production).- Dispose
HttpClientandAGUIChatClientconsumers correctly.AGUIChatClienttakes ownership of the underlying transport via theHttpClientyou supply; wrap it inusing(or register it viaIHttpClientFactory).
using Azure.Identity;
// Server-side credential for Azure OpenAI
var credential = new DefaultAzureCredential();
// Production: prefer a specific identity to avoid latency from probing
// var credential = new ManagedIdentityCredential();
Core Workflow
Minimal Server (MapAGUI)
Mirrors AGUIServer/Program.cs.
using System.ComponentModel;
using AGUIServer;
using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI.Hosting;
using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore;
using Microsoft.Extensions.AI;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpClient().AddLogging();
builder.Services.ConfigureHttpJsonOptions(options =>
options.SerializerOptions.TypeInfoResolverChain.Add(AGUIServerSerializerContext.Default));
// ⬇ The single line that wires AG-UI into the DI container.
builder.Services.AddAGUI();
string endpoint = builder.Configuration["AZURE_OPENAI_ENDPOINT"]
?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
string deploymentName = builder.Configuration["AZURE_OPENAI_DEPLOYMENT_NAME"]
?? throw new InvalidOperationException("AZURE_OPENAI_DEPLOYMENT_NAME is not set.");
const string AgentName = "AGUIAssistant";
var agent = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential())
.GetChatClient(deploymentName)
.AsAIAgent(
name: AgentName,
tools: [
AIFunctionFactory.Create(
() => DateTimeOffset.UtcNow,
name: "get_current_time",
description: "Get the current UTC time."),
AIFunctionFactory.Create(
([Description("The weather forecast request")] ServerWeatherForecastRequest request) =>
new ServerWeatherForecastResponse
{
Summary = "Sunny",
TemperatureC = 25,
Date = request.Date,
},
name: "get_server_weather_forecast",
description: "Gets the forecast for a specific location and date",
AGUIServerSerializerContext.Default.Options),
]);
builder
.AddAIAgent(AgentName, (_, _) => agent)
.WithInMemorySessionStore(); // session storage (swap for persistent store in prod)
WebApplication app = builder.Build();
app.MapAGUI(AgentName, "/");
await app.RunAsync();
Run:
dotnet run --urls "http://localhost:5100"
Minimal Client (AGUIChatClient)
Mirrors AGUIClient/Program.cs.
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.AGUI;
using Microsoft.Extensions.AI;
string serverUrl = Environment.GetEnvironmentVariable("AGUI_SERVER_URL") ?? "http://localhost:5100";
using HttpClient httpClient = new() { Timeout = TimeSpan.FromSeconds(60) };
var chatClient = new AGUIChatClient(
httpClient,
serverUrl,
jsonSerializerOptions: AGUIClientSerializerContext.Default.Options);
AIAgent agent = chatClient.AsAIAgent(
name: "agui-client",
description: "AG-UI Client Agent",
tools: []); // frontend tools go here
AgentSession session = await agent.CreateSessionAsync();
List<ChatMessage> messages = [new(ChatRole.System, "You are a helpful assistant.")];
while (true)
{
Console.Write("\nUser (:q to exit): ");
string? line = Console.ReadLine();
if (string.IsNullOrWhiteSpace(line) || line is ":q" or "quit") { break; }
messages.Add(new(ChatRole.User, line));
await foreach (AgentResponseUpdate update in agent.RunStreamingAsync(messages, session))
{
foreach (AIContent content in update.Contents)
{
switch (content)
{
case TextContent text:
Console.Write(text.Text);
break;
case ErrorContent err:
Console.Error.WriteLine($"\n[Error] {err.Message}");
break;
}
}
}
messages.Clear();
}
Frontend Tools (Tools Defined on the Client)
The client registers AIFunctions; the server's model sees them in the tool list and can call them. The .NET delegate executes on the client and the result is sent back.
var changeBackground = AIFunctionFactory.Create(
() =>
{
Console.ForegroundColor = ConsoleColor.DarkBlue;
Console.WriteLine("Changing color to blue");
},
name: "change_background_color",
description: "Change the console background color to dark blue.");
var readClientClimateSensors = AIFunctionFactory.Create(
([Description("The sensors measurements to include in the response")] SensorRequest request) =>
new SensorResponse { Temperature = 22.5, Humidity = 45.0, AirQualityIndex = 75 },
name: "read_client_climate_sensors",
description: "Reads the climate sensor data from the client device.",
serializerOptions: AGUIClientSerializerContext.Default.Options);
AIAgent agent = chatClient.AsAIAgent(
name: "agui-client",
tools: [changeBackground, readClientClimateSensors]);
Observe each tool round-trip in the stream:
await foreach (AgentResponseUpdate update in agent.RunStreamingAsync(messages, session))
{
foreach (AIContent content in update.Contents)
{
switch (content)
{
case TextContent text:
Console.Write(text.Text);
break;
case FunctionCallContent call:
Console.WriteLine($"\n[Function Call] {call.Name}({JsonSerializer.Serialize(call.Arguments)})");
break;
case FunctionResultContent result when result.Exception is not null:
Console.WriteLine($"\n[Function Error] {result.Exception}");
break;
case FunctionResultContent result:
Console.WriteLine($"\n[Function Result] {result.Result}");
break;
case ErrorContent err:
string code = err.AdditionalProperties?["Code"] as string ?? "Unknown";
Console.WriteLine($"\n[Error {code}] {err.Message}");
break;
}
}
}
Source-Generated Serializer Context (Required for AOT)
AG-UI uses JsonSerializerContext for trim-safe payload handling. Declare every type that crosses the wire.
using System.Text.Json.Serialization;
[JsonSerializable(typeof(SensorRequest))]
[JsonSerializable(typeof(SensorResponse))]
internal sealed partial class AGUIClientSerializerContext : JsonSerializerContext;
On the server:
[JsonSerializable(typeof(ServerWeatherForecastRequest))]
[JsonSerializable(typeof(ServerWeatherForecastResponse))]
internal sealed partial class AGUIServerSerializerContext : JsonSerializerContext;
builder.Services.ConfigureHttpJsonOptions(options =>
options.SerializerOptions.TypeInfoResolverChain.Add(AGUIServerSerializerContext.Default));
Pass xxxSerializerContext.Default.Options to AGUIChatClient(... jsonSerializerOptions: ...) and to every AIFunctionFactory.Create(... serializerOptions: ...) for typed tool payloads.
Multiple Endpoints on One Server
Mirror AGUIDojoServer/Program.cs — register several agents on different paths from a single server:
builder.Services.AddAGUI();
ChatClientAgentFactory.Initialize(app.Configuration);
app.MapAGUI("/agentic_chat", ChatClientAgentFactory.CreateAgenticChat());
app.MapAGUI("/backend_tool_rendering", ChatClientAgentFactory.CreateBackendToolRendering());
app.MapAGUI("/human_in_the_loop", ChatClientAgentFactory.CreateHumanInTheLoop());
app.MapAGUI("/tool_based_generative_ui",ChatClientAgentFactory.CreateToolBasedGenerativeUI());
var jsonOptions = app.Services
.GetRequiredService<IOptions<Microsoft.AspNetCore.Http.Json.JsonOptions>>();
app.MapAGUI("/agentic_generative_ui", ChatClientAgentFactory.CreateAgenticUI(jsonOptions.Value.SerializerOptions));
app.MapAGUI("/shared_state", ChatClientAgentFactory.CreateSharedState(jsonOptions.Value.SerializerOptions));
app.MapAGUI("/predictive_state_updates",ChatClientAgentFactory.CreatePredictiveStateUpdates(jsonOptions.Value.SerializerOptions));
REST Test (.http File)
The server accepts a plain AG-UI POST. Use this for smoke tests before plugging in the client:
@host = http://localhost:5100
### Send a message to the AG-UI agent
POST {{host}}/
Content-Type: application/json
{
"threadId": "thread_123",
"runId": "run_456",
"messages": [
{ "role": "user", "content": "What is the capital of France?" }
],
"context": {}
}
The response is an SSE stream — open it in the REST Client or curl --no-buffer to watch events arrive.
GitHub Copilot CLI as the backbone (CopilotClient.AsAIAgent)
When the model behind the AG-UI mount is the local GitHub Copilot CLI (rather than an IChatClient), build the agent through SessionConfig so the CLI receives the system prompt, the model name, the tool list, and a permission handler in one shot. The bare AsAIAgent(ownsClient, name, instructions, tools) overload throws ArgumentException "An OnPermissionRequest handler is required" at the first tool call.
using GitHub.Copilot.SDK;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.GitHub.Copilot;
using Microsoft.Agents.AI.Hosting;
using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore;
using Microsoft.Extensions.AI;
const string AgentName = "retail_orchestrator";
const string Instructions = "You are ZavaShop's retail orchestrator. ...";
string cliPath = Environment.GetEnvironmentVariable("COPILOT_CLI_PATH")
?? Environment.GetEnvironmentVariable("GITHUB_COPILOT_CLI_PATH")
?? "copilot";
string model = Environment.GetEnvironmentVariable("GITHUB_COPILOT_MODEL") ?? "gpt-5.5";
CopilotClient copilotClient = new(new CopilotClientOptions { CliPath = cliPath });
await copilotClient.StartAsync(); // boot the CLI subprocess
SessionConfig sessionConfig = new()
{
OnPermissionRequest = PermissionHandler.ApproveAll, // REQUIRED — see Best Practices
Model = model,
SystemMessage = new SystemMessageConfig { Mode = SystemMessageMode.Append, Content = Instructions },
Tools =
[
AIFunctionFactory.Create(ServerTools.SearchProducts, name: "search_products", description: "Search the catalog."),
AIFunctionFactory.Create(ServerTools.GetWarehouseStock, name: "get_warehouse_stock", description: "Get stock for a SKU at a warehouse."),
],
};
AIAgent retailAgent = copilotClient.AsAIAgent(
sessionConfig,
ownsClient: true, // agent disposes the CLI subprocess on shutdown
id: "retail-orchestrator",
name: AgentName,
description: Instructions);
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpClient().AddLogging();
builder.Services.AddAGUI();
builder.AddAIAgent(AgentName, (_, _) => retailAgent).WithInMemorySessionStore();
WebApplication app = builder.Build();
app.UseMiddleware<ApiKeyMiddleware>(); // see API-key middleware below
app.MapAGUI(AgentName, "/retail"); // (agentName, pattern)
app.MapPost("/scripted-reservation", async (ReservationRequest r) =>
Results.Ok(new { order = await ServerTools.RunRetailWorkflow(r.CustomerId, r.Sku, r.Quantity, r.PreferredWarehouse) }));
await app.RunAsync("http://127.0.0.1:5100");
Key rules:
- Always pass an explicit
name:toAIFunctionFactory.Create(...). Without it the SDK uses the .NET method name, and the system prompt'ssnake_casereferences won't match the function name the model sees. - Never define your own
MapAGUI(this WebApplication, ...)extension. It will out-rank the realIEndpointRouteBuilderextension during overload resolution and turn the SSE stream into a one-shot JSON blob. - Mount AG-UI at
/<path>and a non-AG-UI bypass route at/<other-path>on the sameWebApplication— the bypass is how you get reliable client-tool evidence with the Copilot CLI backbone (see warning above).
API-key middleware (X-API-Key)
AddAGUI ships no authentication. For the workshop and most production setups, gate every request with a header check:
public sealed class ApiKeyMiddleware(RequestDelegate next, ILogger<ApiKeyMiddleware> logger)
{
private static int _warned;
public async Task InvokeAsync(HttpContext context)
{
string? expected = Environment.GetEnvironmentVariable("AG_UI_API_KEY");
if (string.IsNullOrEmpty(expected))
{
if (Interlocked.Exchange(ref _warned, 1) == 0)
logger.LogWarning("AG_UI_API_KEY is not set — running in dev mode without auth.");
await next(context);
return;
}
if (!context.Request.Headers.TryGetValue("X-API-Key", out var got) || got != expected)
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
await context.Response.WriteAsync("bad api key");
return;
}
await next(context);
}
}
// Program.cs: app.UseMiddleware<ApiKeyMiddleware>(); BEFORE app.MapAGUI(...)
Customizing Behavior with DelegatingAIAgent
Wrap an inner AIAgent to inject AG-UI-specific behavior — shared state, predictive state updates, structured snapshots, multi-turn orchestration. The Dojo server uses this pattern for every advanced scenario.
internal sealed class SharedStateAgent(AIAgent innerAgent, JsonSerializerOptions options)
: DelegatingAIAgent(innerAgent)
{
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentSession? session = null,
AgentRunOptions? options = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
// 1. Read client-supplied state from ChatOptions.AdditionalProperties["ag_ui_state"]
// 2. Inject the state into a system message for the inner agent
// 3. Run the inner agent and capture structured output
// 4. Emit a DataContent("application/json") snapshot to the client
// 5. Optionally do a follow-up summarizing turn
// ... full implementation in references/server.md
await foreach (var u in InnerAgent.RunStreamingAsync(messages, session, options, cancellationToken))
{
yield return u;
}
}
}
See references/server.md for the full predictive-state-updates and shared-state patterns.
Core Types Quick Reference
| Type | Namespace | Purpose |
|---|---|---|
AGUIChatClient |
Microsoft.Agents.AI.AGUI |
Client transport. Wraps an HttpClient and speaks AG-UI to a remote endpoint. |
AGUIChatClient.AsAIAgent(name, description, tools) |
Microsoft.Agents.AI |
Promotes the chat client to an AIAgent with frontend tools. |
app.MapAGUI(name, agent) / app.MapAGUI(path, agent) |
Microsoft.Agents.AI.Hosting.AGUI.AspNetCore |
Maps an AIAgent to an AG-UI HTTP endpoint. |
builder.Services.AddAGUI() |
Microsoft.Agents.AI.Hosting.AGUI.AspNetCore |
Registers AG-UI services in DI. |
.AddAIAgent(name, factory).WithInMemorySessionStore() |
Microsoft.Agents.AI.Hosting |
Registers the agent + in-memory session storage. |
AgentSession / agent.CreateSessionAsync() |
Microsoft.Agents.AI |
Conversation handle that the AG-UI server uses as threadId. |
AgentResponseUpdate |
Microsoft.Agents.AI |
Streaming update returned by RunStreamingAsync. |
update.AsChatResponseUpdate() |
Microsoft.Agents.AI |
Cast to ChatResponseUpdate for ConversationId, ResponseId, etc. |
DelegatingAIAgent |
Microsoft.Agents.AI |
Base class for wrapping an inner agent — override RunCoreStreamingAsync. |
Streaming Content Types Quick Reference
Content (in update.Contents) |
When it appears | Notes |
|---|---|---|
TextContent |
Streaming text deltas from the model. | Concatenate to build the full message. |
FunctionCallContent |
Server-issued frontend tool call. | The client's tool executes locally; SDK auto-sends the result. |
FunctionResultContent |
Result of a tool call (frontend or backend). | Inspect .Exception for failures. |
DataContent (application/json) |
Structured state snapshot (shared state, predictive state updates). | Payload is JSON-serialized state. |
ErrorContent |
Server-reported error. | Read .Message and .AdditionalProperties["Code"]. |
Complete Example
End-to-end pair that demonstrates streaming text + frontend tool + structured serializer context.
Server
using System.ComponentModel;
using AGUIServer;
using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI.Hosting;
using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore;
using Microsoft.Extensions.AI;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpClient().AddLogging();
builder.Services.ConfigureHttpJsonOptions(o =>
o.SerializerOptions.TypeInfoResolverChain.Add(AGUIServerSerializerContext.Default));
builder.Services.AddAGUI();
string endpoint = builder.Configuration["AZURE_OPENAI_ENDPOINT"]!;
string deployment = builder.Configuration["AZURE_OPENAI_DEPLOYMENT_NAME"]!;
const string AgentName = "AGUIAssistant";
var agent = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential())
.GetChatClient(deployment)
.AsAIAgent(
name: AgentName,
tools: [
AIFunctionFactory.Create(
() => DateTimeOffset.UtcNow,
name: "get_current_time",
description: "Get the current UTC time."),
]);
builder.AddAIAgent(AgentName, (_, _) => agent).WithInMemorySessionStore();
WebApplication app = builder.Build();
app.MapAGUI(AgentName, "/");
await app.RunAsync();
Client
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.AGUI;
using Microsoft.Extensions.AI;
string url = Environment.GetEnvironmentVariable("AGUI_SERVER_URL") ?? "http://localhost:5100";
using HttpClient http = new() { Timeout = TimeSpan.FromSeconds(60) };
var chatClient = new AGUIChatClient(http, url, jsonSerializerOptions: null);
var changeBackground = AIFunctionFactory.Create(
() => Console.WriteLine("[client] changing background"),
name: "change_background_color",
description: "Change the console background color.");
AIAgent agent = chatClient.AsAIAgent(
name: "agui-client",
description: "AG-UI Client Agent",
tools: [changeBackground]);
AgentSession session = await agent.CreateSessionAsync();
List<ChatMessage> messages = [new(ChatRole.System, "You are helpful.")];
while (true)
{
Console.Write("\n> ");
string? line = Console.ReadLine();
if (string.IsNullOrWhiteSpace(line) || line is ":q") { break; }
messages.Add(new(ChatRole.User, line));
string? threadId = null, runId = null;
bool isFirst = true;
await foreach (AgentResponseUpdate update in agent.RunStreamingAsync(messages, session))
{
ChatResponseUpdate chat = update.AsChatResponseUpdate();
threadId ??= chat.ConversationId;
runId = update.ResponseId;
if (isFirst && threadId is not null && runId is not null)
{
Console.WriteLine($"[Run Started - Thread: {threadId}, Run: {runId}]");
isFirst = false;
}
foreach (AIContent c in update.Contents)
{
switch (c)
{
case TextContent t: Console.Write(t.Text); break;
case FunctionCallContent fc: Console.WriteLine($"\n[Call] {fc.Name}"); break;
case FunctionResultContent fr: Console.WriteLine($"\n[Result] {fr.Result}"); break;
case ErrorContent err: Console.Error.WriteLine($"\n[Error] {err.Message}"); break;
}
}
}
Console.WriteLine($"\n[Run Finished - Thread: {threadId}, Run: {runId}]");
messages.Clear();
}
Conventions
- One agent per route.
MapAGUI(path, agent)binds oneAIAgentto one route. For multiple personas / scenarios, register multiple endpoints on the same server (the Dojo sample). - Always register a session store. Without
.WithInMemorySessionStore()(or a persistent equivalent), the AG-UI server has no conversation context to associate withthreadId. - Source-generate serializer contexts. Every type that crosses the wire — tool arguments, tool results, state snapshots — must be reachable from a
[JsonSerializable]partial context, and that context must be added toConfigureHttpJsonOptionson the server and toAGUIChatClient/AIFunctionFactory.Createon the client. - Use
update.AsChatResponseUpdate()to accessConversationIdandResponseId. The rawAgentResponseUpdateonly exposesResponseIddirectly. - Frontend tools come from the client constructor, backend tools come from the server
AsAIAgent(... tools: ...)call. Don't duplicate the same tool on both sides. DelegatingAIAgentfor AG-UI customization. When you need to inject state, emit structured snapshots, or rewrite messages, derive fromDelegatingAIAgentand overrideRunCoreStreamingAsync. Don't try to hook the AG-UI middleware itself.
Best Practices
- Production session storage.
WithInMemorySessionStore()loses everything on restart. Implement a session store backed by Redis, Cosmos DB, or your existing chat history store, and call.WithSessionStore<TStore>(). - Auth at the HTTP layer. AG-UI is plain HTTP/SSE — add ASP.NET Core auth middleware (
AddAuthentication+RequireAuthorization()) in front ofMapAGUI, or use a simpleApiKeyMiddlewarelike the one above. Don't rely on the protocol for security. - Bound client timeouts. AG-UI runs can stream for a long time. Set
HttpClient.Timeoutgenerously (e.g. 60 s+) and passCancellationTokenthroughRunStreamingAsync(..., cancellationToken)so users can abort. - Surface frontend tool errors. Wrap your
AIFunctionFactory.Createdelegate body in try/catch and return a structured error object — otherwise the model receives an opaque exception string and can't recover. - Don't block on
DataContentsnapshots. State snapshots can arrive interleaved with text. Render them incrementally — patch your UI on every snapshot rather than waiting for the run to end. - Production credential. Replace
DefaultAzureCredentialwith a specific credential (e.g.ManagedIdentityCredential) in deployed environments to avoid credential-chain probing latency and accidental fallbacks. - HTTP logging in dev only. The Dojo server enables full request/response body logging (
HttpLoggingFields.RequestBody | ResponseBody). Keep that onDevelopmentprofile only — SSE bodies are large. - GitHub Copilot CLI backbone: always go through
SessionConfig.CopilotClient.AsAIAgent(sessionConfig, ownsClient, id, name, description)is the only overload that lets you installOnPermissionRequest = PermissionHandler.ApproveAll. The bare(ownsClient, name, instructions, tools)overload throwsArgumentException "An OnPermissionRequest handler is required"the first time the model tries to call a tool — the CLI raises aCUSTOM_TOOLpermission per function call regardless of[Description]/AIFunctionFactoryconfiguration. TheMicrosoft.Agents.AI.GitHub.Copilotpackage bringsGitHub.Copilot.SDKin transitively — do not add a direct<PackageReference>to the SDK. - GitHub Copilot CLI backbone: never trust frontend tools to round-trip. The CLI's tool list is fixed at
SessionConfigconstruction time; tools advertised byAGUIChatClient.AsAIAgent(tools: ...)are not merged into the running session. Expose a parallel non-AG-UI HTTP endpoint (e.g.app.MapPost("/scripted-reservation", ...)) that performs the same work, and have the client invoke its local delegate after a direct call to that endpoint. This is the only deterministic way to assert that the frontend tool actually fired locally in a verify harness. - Always name your tools explicitly.
AIFunctionFactory.Create(method, name: "snake_case", description: "...")— the system prompt almost always refers to the function by its snake_case name, and silently falling back to the .NET method name (PascalCase) leaves the model unable to dispatch. - Verify harnesses: walk to repo root. When the verify binary lives in
bin/Debug/net10.0/, relative project paths like"../../RetailServer"resolve incorrectly. Walk parent directories fromAppContext.BaseDirectoryuntil a known sentinel file (e.g.data/zava_warehouses.json) is found, then build absolute project paths from there. Pre-build all projects and spawn subprocesses withdotnet run --project "<abs>" --no-buildto keep stdout deterministic. - Foundry backbone + AG-UI client tools → fresh
AgentSessionper turn (today). When the AG-UI mount is backed by a Foundry agent (AIProjectClient.AsAIAgent(new ChatClientAgentOptions { ChatOptions = { ModelId, Instructions, Tools = [...] } })) and the client registers any tools throughchatClient.AsAIAgent(name, description, tools: [...]), reusing oneAgentSessionacross turns wedges withBadRequestError: 400 ... No tool output found for function call call_…. Parameter: inputon the second turn. The AG-UI ASP.NET Core bridge executes the client tool locally and ships its result back over SSE, but does not persist the matchingfunction_call_outputinto the Foundry-side conversation thread. Workaround until that gap is closed upstream: open a freshAgentSession session = await agent.CreateSessionAsync();per turn on the client (smoke clients, page-transition-style UIs). This only applies to Foundry-backed agents —AzureOpenAIClient.GetChatClient(...).AsAIAgent(...)andCopilotClient.AsAIAgent(...)mounts don't exhibit this because they don't carry server-side thread state across requests. Server-only tools (no client tools at all) are also unaffected. - Linking upstream LAB source instead of
<ProjectReference>. When the upstream lab keeps its typesinternal(so a class library refactor is intrusive) and its CLIMainwould collide with your host'sMain, prefer<Compile Include="..\..\OtherLab\Program.cs" Link="OtherLabSrc.cs" />combined with a preprocessor constant (<DefineConstants>$(DefineConstants);HOST_NAME</DefineConstants>) that the upstream file wraps only its CLI block with:#if !HOST_NAME/internal static class Program { … }/#endif. The upstream.csprojstill builds standalone; the consumer assembly compiles every workflow type, executor, record, and factory directly — no NuGet packaging, nointernal→publicsurgery. This is how LAB 5 reuses LAB 4'sWorkflowFactory.Build/ShippedVoucheretc. from inside the same assembly as the AG-UI host. - Running a workflow from inside an
AIFunctiondelegate. A server-side tool can drive a fullMicrosoft.Agents.AI.Workflowsworkflow:var (workflow, checkpoints, _) = WorkflowFactory.Build(Path.Combine(Path.GetTempPath(), $"<lab>-{Guid.NewGuid():N}")); await using StreamingRun run = await InProcessExecution.RunStreamingAsync(workflow, input, checkpoints, sessionId: $"<lab>-{input}-{DateTime.UtcNow:yyyyMMddHHmmssfff}"); await foreach (WorkflowEvent evt in run.WatchStreamAsync()) { switch on WorkflowOutputEvent.Data is ShippedVoucher / ExecutorFailedEvent / WorkflowErrorEvent }. Give each invocation its own checkpoint directory so concurrent tool calls don't collide, and clean it up best-effort in afinally. Do not try to drive the workflow'sRequestPort(HITL gate) from inside the tool — the Foundry response is suspended while your delegate is awaiting, and there's no inbound channel to pumpRequestInfoEventresponses back. Instead, check the HITL precondition (e.g.total_usd >= threshold) inside the tool and return anApprovalDialog-shaped record so the client drives the gate; retry the tool withsupervisorApproval=trueonce approved. - Tool payloads as generative-UI / tool-based-UI sources. The .NET AG-UI bridge transports
AIFunctionreturn values straight throughMicrosoft.Extensions.AIserialization; clients read them asFunctionResultContentinupdate.Contents. To deliver "tool-based UI" or "generative UI", design typed records with a discriminator:internal sealed record ExceptionsResult(string Component, IReadOnlyList<ExceptionRow> Rows, int HighSeverity)withComponent = "ExceptionsList". Frontend toggles render based on theComponentvalue. There is noIAGUIContext,ToolResult,ctx.UpdateStateAsync,ctx.InvokeClientToolAsync, orPredictStateConfigAPI in this layer — those are imaginary surfaces from older draft READMEs. Shared-state and predictive-state-updates patterns belong inDelegatingAIAgentoverrides emittingDataContent("application/json")snapshots (see references/server.md), not in tool return values.
Reference Files
- references/server.md:
MapAGUI,AddAGUI, session storage, multiple endpoints,DelegatingAIAgentpatterns (shared state, predictive state updates, agentic UI). - references/client.md:
AGUIChatClient,AsAIAgent, frontend tools,AgentResponseUpdatecontent switching, session management, error handling. - references/protocol.md: AG-UI request shape, SSE event stream,
threadId/runIdsemantics, content type reference. - references/advanced.md: Production hardening — auth, persistent session stores, AOT/source-generated serializer contexts, observability, structured state snapshots, multi-tenancy.