Skip to main content

AGUIChatClient

AGUIChatClient is an IChatClient implementation for AG-UI. It converts ChatMessage values and ChatOptions into a RunAgentInput, sends that input to an AG-UI endpoint, and converts the returned event stream into ChatResponseUpdate values.
using AGUI.Client;
using Microsoft.Extensions.AI;
The .NET client does not define an AbstractAgent equivalent. The integration point is Microsoft.Extensions.AI.IChatClient.

Construction

Create a client from an AGUIChatClientOptions. The simplest form builds the built-in HTTP transport from an HttpClient and the AG-UI endpoint URL:
using AGUI.Client;
using Microsoft.Extensions.AI;

HttpClient httpClient = new();
IChatClient client = new AGUIChatClient(new(httpClient, "https://api.example.com/agent"));
AGUIChatClient has a single constructor that takes AGUIChatClientOptions:
new AGUIChatClient(AGUIChatClientOptions options);
AGUIChatClientOptions carries the transport and optional serializer settings:
public sealed class AGUIChatClientOptions
{
    public AGUIChatClientOptions();
    public AGUIChatClientOptions(HttpClient httpClient, string endpoint); // builds the HTTP transport
    public required IAGUITransport Transport { get; init; }
    public JsonSerializerOptions? JsonSerializerOptions { get; init; }
}
Set Transport directly when you need a custom transport for tests or an alternative wire protocol. See Transport.

Streaming responses

GetStreamingResponseAsync streams AG-UI output as MEAI ChatResponseUpdate objects:
using AGUI.Client;
using Microsoft.Extensions.AI;

using HttpClient httpClient = new();
IChatClient client = new AGUIChatClient(new(httpClient, "https://api.example.com/agent"));

List<ChatMessage> messages =
[
    new(ChatRole.User, "Explain AG-UI in one paragraph"),
];

await foreach (ChatResponseUpdate update in client.GetStreamingResponseAsync(
    messages,
    options: null,
    cancellationToken: CancellationToken.None))
{
    if (!string.IsNullOrEmpty(update.Text))
    {
        Console.Write(update.Text);
    }
}
Internally, the client converts the AG-UI event stream back into ChatResponseUpdate objects. Text events become text deltas, tool call events become MEAI tool call content, and lifecycle events remain available through RawRepresentation.

Non-streaming responses

GetResponseAsync is also implemented. It consumes the streaming response and returns a final ChatResponse:
ChatResponse response = await client.GetResponseAsync(
    messages,
    cancellationToken: CancellationToken.None);

Console.WriteLine(response.Text);

Statelessness and ConversationId

AGUIChatClient is stateless. It sends the full message history on every turn and never surfaces a ConversationId on returned updates. This is intentional. In Microsoft.Extensions.AI, a non-null ConversationId signals a service-managed conversation. Agent wrappers may then send only the new message deltas on the next turn. That would truncate history when talking to a stateless AG-UI server. Use these identifiers instead:
  • ChatResponseUpdate.ResponseId is the AG-UI run id.
  • update.RawRepresentation as RunStartedEvent exposes the AG-UI ThreadId and RunId from the RUN_STARTED event.
  • update.AdditionalProperties["agui_thread_id"] also contains the resolved AG-UI thread id on the RUN_STARTED update.
using AGUI.Abstractions;
using Microsoft.Extensions.AI;

static (string? ThreadId, string? RunId) GetAGUIIds(
    IEnumerable<ChatResponseUpdate> updates)
{
    RunStartedEvent? started = updates
        .Select(update => update.RawRepresentation)
        .OfType<RunStartedEvent>()
        .FirstOrDefault();

    return (started?.ThreadId, started?.RunId);
}
Do not use ConversationId as AG-UI conversation state. Pass full message history each turn and use AG-UI thread/run ids for wire-level correlation.

Thread continuity

To keep a stable AG-UI thread, reuse the same ChatOptions instance across turns. The client pins the resolved thread id onto that options instance without setting ConversationId. For explicit continuation or branching, set RunAgentInput.ThreadId and RunAgentInput.ParentRunId through ChatOptions.RawRepresentationFactory. This is the AG-UI-native way to control wire-level fields:
using AGUI.Abstractions;
using Microsoft.Extensions.AI;

List<ChatResponseUpdate> firstTurn = [];
await foreach (ChatResponseUpdate update in client.GetStreamingResponseAsync(
    [new ChatMessage(ChatRole.User, "Hello, tell me about serialization")],
    cancellationToken: CancellationToken.None))
{
    firstTurn.Add(update);
    Console.Write(update.Text);
}

RunStartedEvent? runStarted = firstTurn
    .Select(update => update.RawRepresentation)
    .OfType<RunStartedEvent>()
    .FirstOrDefault();

ChatOptions followUpOptions = new()
{
    RawRepresentationFactory = _ => new RunAgentInput
    {
        ThreadId = runStarted?.ThreadId ?? string.Empty,
        ParentRunId = runStarted?.RunId,
    },
};

await foreach (ChatResponseUpdate update in client.GetStreamingResponseAsync(
    [new ChatMessage(ChatRole.User, "Tell me more about event compaction")],
    followUpOptions,
    CancellationToken.None))
{
    Console.Write(update.Text);
}
Use ParentRunId when you want the next request to branch from a previous run. Omit it when you only need to continue on the same thread.

Interrupts and approvals

When an AG-UI server finishes a run with an interrupt outcome, the client surfaces the pause as MEAI content:
  • Tool-call approvals become ToolApprovalRequestContent.
  • Other interrupts become InterruptRequestContent.
The caller appends a response message and sends the next request. The client extracts ToolApprovalResponseContent and InterruptResponseContent from the latest message and sends them as RunAgentInput.Resume.
using AGUI.Abstractions;
using Microsoft.Extensions.AI;

List<ChatMessage> messages =
[
    new(ChatRole.User, "Delete the generated files"),
];

ChatResponse firstResponse = await client.GetResponseAsync(
    messages,
    cancellationToken: CancellationToken.None);

ToolApprovalRequestContent? approval = firstResponse.Messages
    .SelectMany(message => message.Contents)
    .OfType<ToolApprovalRequestContent>()
    .FirstOrDefault();

if (approval?.ToolCall is FunctionCallContent toolCall)
{
    messages.AddRange(firstResponse.Messages);
    messages.Add(new ChatMessage(ChatRole.User,
    [
        new ToolApprovalResponseContent(approval.RequestId, approved: true, toolCall),
    ]));

    ChatResponse resumed = await client.GetResponseAsync(
        messages,
        cancellationToken: CancellationToken.None);

    Console.WriteLine(resumed.Text);
}
For non-tool interrupts, respond with InterruptResponseContent:
using System.Text.Json;
using AGUI.Abstractions;
using Microsoft.Extensions.AI;

static ChatMessage CreateInterruptResponse(InterruptRequestContent request)
{
    using JsonDocument payload = JsonDocument.Parse("""{"approved":true}""");

    return new ChatMessage(ChatRole.User,
    [
        new InterruptResponseContent(request.RequestId)
        {
            Payload = payload.RootElement.Clone(),
        },
    ]);
}