Skip to content

Conversation

@javiercn
Copy link

@javiercn javiercn commented Oct 29, 2025

Summary

This PR implements Phase 1 of AG-UI protocol support in the .NET Agent Framework, enabling text streaming capabilities for both consuming AG-UI-compliant servers and exposing .NET agents via the AG-UI protocol.

Motivation and goals

The AG-UI (Agent-User Interaction) protocol is an open, lightweight, event-based protocol that standardizes how AI agents connect to user-facing applications. It addresses key challenges in agentic applications:

  • Streaming support: Agents are long-running and stream intermediate work across multi-turn sessions
  • Event-driven architecture: Supports nondeterministic agent behavior with real-time UI updates
  • Protocol interoperability: Complements MCP (tool/context) and A2A (agent-to-agent) protocols in the AI ecosystem

Without AG-UI support, .NET agents cannot interoperate with the growing ecosystem of AG-UI-compatible frontends and agent frameworks (LangGraph, CrewAI, Pydantic AI, etc.).

This implementation enables:

  • .NET developers to consume AG-UI servers from any framework
  • .NET agents to be accessible from any AG-UI-compatible client (web, mobile, CLI)
  • Standardized streaming communication patterns for agentic applications

In scope

  1. Client-side AG-UI consumption (Microsoft.Agents.AI.AGUI package)

    • AGUIAgent class for connecting to remote AG-UI servers
    • AGUIAgentThread for managing conversation threads
    • HTTP/SSE streaming support
    • Event-to-framework type conversion
  2. Server-side AG-UI hosting (Microsoft.Agents.AI.Hosting.AGUI.AspNetCore package)

    • MapAGUIAgent extension method for ASP.NET Core
    • Server-Sent Events (SSE) response formatting using SseFormatter
    • Framework-to-event type conversion
    • Agent factory pattern for per-request agent instantiation
  3. Text streaming events (Phase 1)

    • Lifecycle events: RunStarted, RunFinished, RunError
    • Text message events: TextMessageStart, TextMessageContent, TextMessageEnd
    • Bidirectional event/update conversion
    • Thread and run ID management via ConversationId and ResponseId properties
  4. Testing and samples

    • Unit tests for the core protocol
    • Integration tests for end-to-end scenarios
    • AGUIClientServer sample demonstrating both client and server usage

Out of scope

The following AG-UI features are intentionally deferred to future PRs:

  • Tool call events (ToolCallStart, ToolCallArgs, ToolCallEnd, ToolCallResult)
  • State management events (StateSnapshot, StateDelta, MessagesSnapshot)
  • Step events (StepStarted, StepFinished)

Examples

Server: Exposing a .NET agent via AG-UI

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

// Map an AG-UI endpoint at /agent
app.MapAGUIAgent("/", (messages) =>
{
    var chatClient = new AzureOpenAIClient(endpoint, credential)
        .GetChatClient(deploymentName);
    return chatClient.CreateAIAgent(name: "AGUIAssistant");
});

app.Run();

Client: Consuming an AG-UI server

using var httpClient = new HttpClient();
var agent = new AGUIAgent(
    id: "agui-client",
    description: "AG-UI Client Agent",
    httpClient: httpClient,
    endpoint: "http://localhost:5100");

var thread = agent.GetNewThread();
var messages = new List<ChatMessage>
{
    new(ChatRole.System, "You are a helpful assistant."),
    new(ChatRole.User, "What is the capital of France?")
};

// Stream responses in real-time
await foreach (var update in agent.RunStreamingAsync(messages, thread))
{
    ChatResponseUpdate chatUpdate = update.AsChatResponseUpdate();
    
    // Access thread and run IDs from first update
    if (chatUpdate.ConversationId != null)
    {
        Console.WriteLine($"Thread: {chatUpdate.ConversationId}, Run: {chatUpdate.ResponseId}");
    }
    
    foreach (var content in update.Contents)
    {
        if (content is TextContent text)
        {
            Console.Write(text.Text); // Display streaming text
        }
        else if (content is ErrorContent error)
        {
            Console.WriteLine($"Error: {error.Message}");
        }
    }
}

SSE Event Stream Format

When a client sends a request:

POST /agent
{
  "threadId": "thread_123",
  "runId": "run_456",
  "messages": [{"role": "user", "content": "Hello"}]
}

The server streams back SSE events:

data: {"type":"run_started","threadId":"thread_123","runId":"run_456"}

data: {"type":"text_message_start","messageId":"msg_1","role":"assistant"}

data: {"type":"text_message_content","messageId":"msg_1","delta":"Hello"}

data: {"type":"text_message_content","messageId":"msg_1","delta":" there!"}

data: {"type":"text_message_end","messageId":"msg_1"}

data: {"type":"run_finished","threadId":"thread_123","runId":"run_456"}

Detailed design

Architecture

Package Structure

Microsoft.Agents.AI.AGUI/
├── AGUIAgent.cs                          // Public: Client agent implementation
├── AGUIAgentThread.cs                    // Public: Thread management
├── AGUIHttpService.cs                    // Internal: HTTP/SSE communication
└── Shared/                               // Internal: Protocol types
    ├── BaseEvent.cs
    ├── RunStartedEvent.cs
    ├── TextMessageStartEvent.cs
    ├── AGUIMessage.cs
    ├── RunAgentInput.cs
    └── [conversion extensions]

Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/
├── AGUIEndpointRouteBuilderExtensions.cs // Public: MapAGUIAgent
├── AGUIServerSentEventsResult.cs         // Internal: SSE formatting
└── Shared/                               // Internal: Protocol types & converters

Key Design Decisions

1. Event Models as Internal Types

  • AG-UI event types (e.g., RunStartedEvent, TextMessageContentEvent) are internal
  • Conversion happens at boundaries via extension methods
  • Public API uses framework-native types (AgentRunResponseUpdate, ChatMessage)
  • Rationale: Protects consumers from protocol changes; maintains framework abstractions

2. No Custom Content Types

  • Run lifecycle communicated through ChatResponseUpdate.ConversationId and ResponseId properties
  • Errors use standard ErrorContent type
  • First update in a stream contains ConversationId and ResponseId indicating run started
  • Last update with FinishReason indicates run finished
  • Rationale: Avoids introducing non-standard content types; uses existing framework abstractions

3. Agent Factory Pattern in MapAGUIAgent

  • Factory function creates agent per request: (messages) => AIAgent
  • Allows request-specific agent configuration (e.g., different models per user)
  • Rationale: Supports multi-tenancy and request customization without global state

4. Bidirectional Conversion Architecture

  • Server: AgentRunResponseUpdate → AG-UI events
  • Client: AG-UI events → AgentRunResponseUpdate
  • Extension methods in Shared/ namespace (compiled in both packages)
  • Rationale: Symmetric conversion logic; shared code between client and server

5. Thread Management

  • AGUIAgentThread stores only ThreadId (string)
  • Thread ID communicated via ConversationId property on updates
  • No automatic thread persistence; applications manage storage
  • Rationale: AG-UI spec defines threads by ID only; persistence is application-specific

6. SSE Formatting

  • Uses ASP.NET Core's SseFormatter for Server-Sent Events
  • Standard SSE format with data: prefix and double newline delimiter
  • Rationale: Leverages framework infrastructure for SSE compliance

Event Conversion Logic

Text Message Streaming (Server → Client):

// Server side: Framework updates → AG-UI events
IAsyncEnumerable<AgentRunResponseUpdate> updates = agent.RunStreamingAsync(...);
IAsyncEnumerable<BaseEvent> events = updates.AsAGUIEventStreamAsync(threadId, runId);

// Emits: RunStarted
//        TextMessageStart (on role/messageId change)
//        TextMessageContent (per text chunk)
//        TextMessageEnd (on message completion)
//        RunFinished

Text Message Streaming (Client → Framework):

// Client side: AG-UI events → Framework updates
IAsyncEnumerable<BaseEvent> events = httpService.PostRunAsync(...);
IAsyncEnumerable<AgentRunResponseUpdate> updates = events.AsAgentRunResponseUpdatesAsync();

// Converts: RunStarted → Update with ConversationId/ResponseId
//           TextMessageStart → (tracks current message)
//           TextMessageContent → ChatResponseUpdate with text delta
//           TextMessageEnd → (closes message)
//           RunFinished → Update with FinishReason
//           RunError → Update with ErrorContent

JSON Serialization

  • Uses System.Text.Json.Serialization.JsonConverter for polymorphic events
  • BaseEventJsonConverter dispatches based on "type" field
  • Source-generated serialization context (AGUIJsonSerializerContext) for AOT compatibility
  • Rationale: Built-in STJ polymorphism requires discriminator as first property, which AG-UI protocol doesn't guarantee

Error Handling

  • HTTP errors (4xx, 5xx) throw HttpRequestException
  • AG-UI RunError events convert to ErrorContent in update stream
  • SSE parsing errors throw JsonException or InvalidOperationException
  • Connection failures propagate as OperationCanceledException when cancelled
  • Rationale: Standard .NET exception patterns; errors surface naturally through async streams

Drawbacks

  1. Custom JSON Converter: Required custom polymorphic deserialization instead of built-in STJ support

    • Mitigation: Internal implementation detail; doesn't affect public API
  2. Shared Code via Preprocessor Directives: Files in Shared/ use #if ASPNETCORE to compile into both packages

    • Mitigation: Necessary to avoid duplicating event definitions; contained to internal types

Considered alternatives

Alternative 1: Expose AG-UI Event Types Publicly

Rejected: We already have common abstractions for AI concepts. We can break AG-UI specific payloads into a separate assembly in the future and take a dependency on that if needed.

Alternative 2: Implement All Event Types Upfront

Rejected: Keeping the PR manageable and focused on text streaming. Tool calls and state management can be added incrementally.

Alternative 3: Custom AIContent Types for Lifecycle

Rejected after review: Initially proposed RunStartedContent, RunFinishedContent, and RunErrorContent. Feedback indicated these should use existing framework properties (ConversationId, ResponseId, ErrorContent) instead.

#Fixes #1775

Copilot AI review requested due to automatic review settings October 29, 2025 12:41
@markwallace-microsoft markwallace-microsoft added documentation Improvements or additions to documentation .NET labels Oct 29, 2025
@javiercn javiercn requested a review from DeagleGross October 29, 2025 12:41
@github-actions github-actions bot changed the title AG-UI support for .NET .NET: AG-UI support for .NET Oct 29, 2025
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull Request Overview

This PR introduces AG-UI (Agent-User Interaction) protocol support for the Microsoft Agent Framework, enabling standardized client-server communication for AI agents. The implementation includes both server-side ASP.NET Core hosting capabilities and client-side consumption functionality.

Key changes:

  • Added AG-UI protocol implementation with server-sent events (SSE) streaming
  • Created ASP.NET Core hosting extensions for exposing agents via AG-UI endpoints
  • Implemented AGUIAgent client for consuming remote AG-UI services
  • Added comprehensive unit and integration tests covering protocol behavior

Reviewed Changes

Copilot reviewed 47 out of 47 changed files in this pull request and generated 29 comments.

Show a summary per file
File Description
Microsoft.Agents.AI.AGUI/AGUIAgent.cs Core client implementation for connecting to AG-UI servers
Microsoft.Agents.AI.AGUI/AGUIHttpService.cs HTTP service for SSE-based communication with AG-UI endpoints
Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIEndpointRouteBuilderExtensions.cs Extension methods for mapping AG-UI agents to ASP.NET endpoints
Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIServerSentEventsResult.cs IResult implementation for streaming AG-UI events via SSE
Shared/*.cs Protocol event types, serialization contexts, and message conversion utilities
Test files Comprehensive unit and integration tests for AG-UI functionality
samples/AGUIClientServer/* Sample client and server demonstrating AG-UI protocol usage

ThreadId = typedThread.ThreadId,
RunId = runId,
Messages = messages.AsAGUIMessages(),
Context = new Dictionary<string, string>(StringComparer.Ordinal)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is Context? It needs to be set to a new dictionary even if it's never used?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've taken this out from the current PR. Context is data that the client can pass to the agent. I'll deal with it in a separate PR so that we can better discuss how to map it.

Comment on lines 23 to 31
/// <summary>
/// Gets the ID of the conversation thread.
/// </summary>
public string ThreadId { get; }

/// <summary>
/// Gets the ID of the agent run.
/// </summary>
public string RunId { get; }
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this actually need an AIContent-derived type to communicate this, or could it be modeled as ChatResponse{Update}.ConversationId and ResponseId?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I got rid of this in favor of an empty update

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This info is captured in ConversationId, and ResponseId

/// <summary>
/// Gets optional result data from the run.
/// </summary>
public string? Result { get; }
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can't just be TextContent?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

https://docs.ag-ui.com/sdk/js/core/events#runfinishedevent

Result can be any structured data. I think it is fair to map it to a separate message + some TextContent, but what's the best way to represent this when the data is structured? Ideally we would want some JsonContent or similar, wouldn't we? For now I'll just map it to a string

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ideally we would want some JsonContent or similar, wouldn't we? For now I'll just map it to a string

Mapping to a string sounds fine for now.

But you could also map it to a DataContent with a MediaType of "application/json".

/// Gets the ID of the agent run.
/// </summary>
public string RunId { get; }
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm surprised you're not running into serialization issues with these AIContent-derived types, e.g. I'd expect doing serialization on a ChatMessage containing these will fail, because they're not connected to the base type's polymorphism infrastructure. Whatever JsonSerializerOptions is used would likely need to use the AddAIContentType method to augment the resolver in that options. It's doable, but it also adds complication, and is one of the reason it'd be nice to avoid needing custom AIContent types, if possible.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got rid of all the custom content types, things are mapped to response updates. I think this is fine for now, but I want to align later on in what the best ways are for us representing some of this information

/// Custom JSON converter for polymorphic deserialization of BaseEvent and its derived types.
/// Uses the "type" property as a discriminator to determine the concrete type to deserialize.
/// </summary>
internal sealed class BaseEventJsonConverter : JsonConverter<BaseEvent>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It can't use the built-in polymorphism support in STJ?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The built-in polymorphism support requires the discriminator to be the first element in the payload, which we can't guarantee for clients that we don't own.

I started with that approach and switched to this approach because the copilotkit typescript client was not working that way.

namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.Shared;
#else
namespace Microsoft.Agents.AI.AGUI.Shared;
#endif
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need to change this internal namespace based on what project it's being built into?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is because the test projects have IVT to different assemblies, and otherwise it creates conflicts when you import the namespace.

@markwallace-microsoft
Copy link
Member

@javiercn can you copy the information in the PR description to an ADR and add to this folder: https://github.com/microsoft/agent-framework/tree/main/docs/decisions

It will be much easier to find the information later and it will be with all of the other design decisions.

Copy link
Author

@javiercn javiercn left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed feedback:

  • I've gotten rid of the custom contents and mapped the info from the events as ResponseUpdate.
  • I switched to use the SseFormatter instead of the manual SSE Event serialization.
  • I've gotten rid of Context/Tools/ForwardedProps for now and will bring them in a separate PR.
  • I've mapped additional properties to response updates.
  • I removed some buffering when dealing with messages.
  • I've fixed the build dependencies and cleaned up some copilot leftover.

switch (evt)
{
case RunStartedEvent runStarted:
yield return new AgentRunResponseUpdate(new ChatResponseUpdate(
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've populated the response ID and conversation ID as well as the message ID for the text updates. For the RunStarted/Finished events there isn't a message ID.

return result ?? [];
}

public static ChatRole MapChatRole(string role)
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we care about the string being in the "canonical" form? https://docs.ag-ui.com/sdk/js/core/types#role

@javiercn
Copy link
Author

@javiercn can you copy the information in the PR description to an ADR and add to this folder: https://github.com/microsoft/agent-framework/tree/main/docs/decisions

It will be much easier to find the information later and it will be with all of the other design decisions.

Yep, I saw the ADRs, and was planning to do that, but wanted to check with you first. I'll include the ADRs as a last step as we work out through the details

@javiercn javiercn force-pushed the javiercn/ag-ui-support-for-net branch from b3a1d95 to a1f8977 Compare October 31, 2025 11:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation .NET

Projects

None yet

Development

Successfully merging this pull request may close these issues.

.NET: [AG-UI] Setup infrastructure and enable text streaming

4 participants