This blog post demonstrates how to use Microsoft Agent Framework to build an agent that exposes its capabilities via A2A (Agent-to-Agent), OpenAI-Compatible Endpoints (Chat Completions and Response APIs) and AG-UI protocols.
A2A (Agent-to-Agent) is a protocol that standardises communication between agents, enabling seamless interoperability regardless of the underlying framework or technology.
OpenAI-compatible endpoints support Chat Completions API, which provides the familiar stateless request/response format widely adopted across tools and clients, while the Responses API takes things further supporting multi-turn conversations, real-time streaming, and long-running agent processes.
AG-UI is a protocol that standardises integration between AI agents and web clients, enabling advanced capabilities such as real-time streaming, state management, and interactive UI components. Through the Microsoft Agent Framework's native AG-UI integration, agents can connect to web-based frontends seamlessly, without custom plumbing.
The blog post begins with a simple application based on the Aspire Starter App (ASP.NET Core/Blazor) template and progressively incorporates the Microsoft Agent Framework, Ollama, A2A Inspector, A2A TCK, DevUI, and Scalar, demonstrating how to develop an agent and equip it with the tools developers need to inspect, debug, and validate servers across supported transports.
To set the scene, here is a glimpse of Agent Protocol Workbench, the demo application that ties everything together. It is an interactive web app purpose-built for manually exercising and exploring the protocol endpoints exposed by the agent.

Prerequisites
- Docker for Deskop - Docker Desktop enhances your development experience by offering a powerful, user-friendly platform for container management.
- Microsoft Agent Framework - Microsoft Agent Framework (MAF) is an open, multi-language framework for building production-grade AI agents and multi-agent workflows in .NET and Python.
- Aspire - Aspire gives you a unified, code-first toolkit to compose, debug, and deploy distributed apps and agents, all from a single AppHost.
- A2A Inspector - The A2A Inspector is a web-based tool designed to help developers inspect, debug, and validate servers that implement the A2A (Agent2Agent) protocol.
- A2A TCK - A compatibility test suite that validates A2A (Agent-to-Agent) Protocol implementations across three transports: gRPC, JSON-RPC, and HTTP+JSON.
DEMO App
The demo application is based on the Aspire Starter App (ASP.NET Core/Blazor) template, which scaffolds four projects: ApiService, AppHost, ServiceDefaults, and a web application. In addition, an external folder has been added for the A2A Inspector and A2A TCK; the contents and structure of this folder are described in detail in a subsequent section.
The solution structure should look similar to the image given below.

Let's begin
With the initial structure in place, let us start building out the rest beginning with the external dependencies.
External
The external folder contains two sub-folders, each corresponding to a separate Git repository from which the respective source code should be copied.

a2a-inspector-main - https://github.com/a2aproject/a2a-inspector
a2a-tck-main - https://github.com/a2aproject/a2a-tck
NOTE: Ensure all dependencies are in place before working through the codebases,missing dependencies will cause issues further down the line.
AppHost
Aspire's AppHost is the code-first place where you declare your application's services and their relationships and in this demo, it does a lot of heavy lifting. It orchestrates the entire application stack: starting Ollama, loading the chat model, launching the agent, the Web Workbench, and the Inspector, while keeping the TCK runner on an explicit-start for on-demand execution.

Add the CommunityToolkit.Aspire.Hosting.Ollama NuGet package to the AppHost. It provides the extension methods and resource definitions needed to run Ollama containers and download a model on startup.
dotnet add package CommunityToolkit.Aspire.Hosting.Ollama --version 13.4.0Next, update appsettings.Development.json and AppHost.cs as shown below.
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"Telemetry": {
"CaptureSensitiveData": true
}
}apphost.cs
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
var builder = DistributedApplication.CreateBuilder(args);
var captureSensitiveTelemetry = builder.Environment.IsDevelopment()
&& builder.Configuration.GetValue("Telemetry:CaptureSensitiveData", false);
// Ollama LLM & Model
var ollama = builder.AddOllama("ollama")
.WithDataVolume()
.WithUrlForEndpoint("http", url =>
{
url.Url = "/";
url.DisplayText = "Ollama LLM";
});;
var chatModel = ollama.AddModel("model", "llama3.1:latest");
// Agent - Microsoft Agent Framework (MAF)
var agent = builder.AddProject<Projects.agent_protocols_agent>("agent")
.WithReference(chatModel, "ollama")
.WaitFor(chatModel)
.WithHttpHealthCheck("/health")
.WithUrlForEndpoint("http", url =>
{
url.Url = "/devui";
url.DisplayText = "Agent DevUI";
})
.WithUrlForEndpoint("https", url =>
{
url.Url = "/devui";
url.DisplayText = "Agent DevUI";
});
if (captureSensitiveTelemetry)
{
agent
.WithEnvironment("Telemetry__CaptureSensitiveData", "true")
.WithEnvironment("OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT", "true");
}
// A2A Protocol Inspector
var inspector = builder.AddDockerfile("A2A-Protocol-Inspector", "../external/a2a-inspector-main")
.WithImageTag("dev")
.WithHttpEndpoint(port: 8080, targetPort: 8080)
.WaitFor(agent)
.WithUrlForEndpoint("http", url =>
{
url.Url = "/";
url.DisplayText = "A2A Protocol Inspector";
});
// A2A Protocol Technology Compatibility Kit (TCK)
// Explicit-start conformance runner.
// It writes reports under external/a2a-tck-main.
var tckArgs = new List<string>
{
"--system-certs",
"run", "python", "run_tck.py",
"--sut-host", "http://localhost:5501/a2a/tck",
"--transport", "http_json",
"--level", "must"
};
var allowInsecureTckPackageDownloads = builder.Configuration.GetValue(
"A2A:Tck:AllowInsecurePackageDownloads",
false);
if (allowInsecureTckPackageDownloads)
{
tckArgs.InsertRange(1,
[
"--allow-insecure-host", "pypi.org",
"--allow-insecure-host", "files.pythonhosted.org"
]);
}
var tck = builder.AddExecutable(
"A2A-Protocol-TCK",
"uv",
"../external/a2a-tck-main",
tckArgs.ToArray())
.WithEnvironment("UV_SYSTEM_CERTS", "true")
.WaitFor(agent)
.WithExplicitStart();
// Web App
var web = builder.AddProject<Projects.agent_protocols_Web>("web")
.WithReference(agent)
.WaitFor(agent)
.WithUrlForEndpoint("http", url =>
{
url.Url = "/";
url.DisplayText = "Agent Protocols Web App";
})
.WithUrlForEndpoint("https", url =>
{
url.Url = "/";
url.DisplayText = "Agent Protocols Web App";
});
builder.Build().Run();The following diagram illustrates how AppHost composes local resources.

ServiceDefaults
It is responsible for shared health checks, service discovery, resilience, and telemetry setup.

Extensions.cs
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.ServiceDiscovery;
using OpenTelemetry;
using OpenTelemetry.Metrics;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;
namespace Microsoft.Extensions.Hosting;
public static class Extensions
{
private const string HealthEndpointPath = "/health";
private const string AlivenessEndpointPath = "/alive";
public static TBuilder AddServiceDefaults<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
{
builder.ConfigureOpenTelemetry();
builder.AddDefaultHealthChecks();
builder.Services.AddServiceDiscovery();
builder.Services.ConfigureHttpClientDefaults(http =>
{
http.AddStandardResilienceHandler(options =>
{
options.AttemptTimeout.Timeout = TimeSpan.FromMinutes(5);
options.TotalRequestTimeout.Timeout = TimeSpan.FromMinutes(6);
options.CircuitBreaker.SamplingDuration = TimeSpan.FromMinutes(11);
});
http.AddServiceDiscovery();
});
return builder;
}
public static TBuilder ConfigureOpenTelemetry<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
{
builder.Logging.AddOpenTelemetry(logging =>
{
logging.IncludeFormattedMessage = true;
logging.IncludeScopes = true;
});
builder.Services.AddOpenTelemetry()
.ConfigureResource(resource => resource
.AddService(builder.Environment.ApplicationName))
.WithMetrics(metrics =>
{
metrics.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddRuntimeInstrumentation()
.AddMeter(
builder.Environment.ApplicationName,
"Microsoft.Agents.AI",
"Microsoft.Extensions.AI",
"A2A",
"A2A.AspNetCore");
})
.WithTracing(tracing =>
{
tracing.AddSource(
builder.Environment.ApplicationName,
"Microsoft.Agents.AI",
"Microsoft.Extensions.AI",
"A2A",
"A2A.AspNetCore")
.AddAspNetCoreInstrumentation(tracing =>
tracing.Filter = context =>
!context.Request.Path.StartsWithSegments(HealthEndpointPath)
&& !context.Request.Path.StartsWithSegments(AlivenessEndpointPath)
)
.AddHttpClientInstrumentation(http =>
{
http.RecordException = true;
});
});
builder.AddOpenTelemetryExporters();
return builder;
}
private static TBuilder AddOpenTelemetryExporters<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
{
var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]);
if (useOtlpExporter)
{
builder.Services.AddOpenTelemetry().UseOtlpExporter();
}
return builder;
}
public static TBuilder AddDefaultHealthChecks<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
{
builder.Services.AddHealthChecks()
.AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]);
return builder;
}
public static WebApplication MapDefaultEndpoints(this WebApplication app)
{
app.MapHealthChecks(HealthEndpointPath);
app.MapHealthChecks(AlivenessEndpointPath, new HealthCheckOptions
{
Predicate = r => r.Tags.Contains("live")
});
return app;
}
}Agent
The agent service is an ASP.NET Core service built around a clean compositional structure. The entry point pulls everything together wiring up services, middleware, and endpoint modules, while a dedicated factory class handles the creation and configuration of the shared agent. Protocol hosting is managed centrally by a registration class, with each protocol A2A, OpenAI-Compatible, and AG-UI owning its own dedicated endpoint class. A development-only class covers the deterministic A2A TCK surface separately, and all protocol configuration and route constants live in dedicated options and path classes, keeping the service clean and maintainable.

Add the following NuGet packages to the agent and update appsettings.json and the remaining files as follows.
dotnet add package CommunityToolkit.Aspire.OllamaSharp --version 13.1.1
dotnet add package Microsoft.Agents.AI --version 1.13.0
dotnet add package Microsoft.Agents.AI.Hosting --version 1.13.0-preview.260703.1
dotnet add package Microsoft.Agents.AI.Hosting.A2A.AspNetCore --version 1.13.0-preview.260703.1
dotnet add package Microsoft.Agents.AI.Hosting.AGUI.AspNetCore --version 1.12.0-preview.260629.1
dotnet add package Microsoft.Agents.AI.Hosting.OpenAI --version 1.13.0-alpha.260703.1
dotnet add package Microsoft.AspNetCore.OpenApi --version 10.0.8
dotnet add package Microsoft.Extensions.AI --version 10.7.0
dotnet add package Microsoft.OpenApi --version 2.9.0
dotnet add package Microsoft.Agents.AI.DevUI --version 1.13.0-preview.260703.1
dotnet add package Scalar.AspNetCore --version 2.16.10appsettings.json
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"Agent": {
"Name": "agent",
"DisplayName": "Storyteller",
"Description": "A storyteller agent that writes complete micro-stories in 50 words or fewer.",
"Instructions": "You are Storyteller, a creative writing agent. Write vivid, complete micro-stories in one paragraph and no more than 50 words. When a user asks for a story, use the registered CreateResponsePlan tool with request set to the user's request and responseType set to story, then write the story using that plan. Do not print the tool call syntax, title, outline, explanation, or word count in the final answer.",
"MaxIterations": 10,
"MaxOutputTokens": 100,
"Temperature": 0.2,
"AllowMultipleToolCalls": false
},
"OpenAICompatible": {
"Enabled": true,
"DevelopmentOnly": true,
"ChatCompletionsPath": "/agent/v1/chat/completions",
"ResponsesPath": "/agent/v1/responses",
"EnableConversations": true
},
"AgUi": {
"Enabled": true,
"DevelopmentOnly": true,
"EndpointPath": "/ag-ui/agent",
"EnableDojoCompatibility": true,
"EnableStateSynchronization": true,
"EnableFrontendToolCalls": true,
"EnableInterrupts": true,
"EnableGenerativeUi": true,
"DojoAllowedOrigins": [
"https://dojo.ag-ui.com",
"http://localhost:3000",
"http://localhost:5173",
"http://127.0.0.1:3000",
"http://127.0.0.1:5173"
]
},
"AllowedHosts": "*"
}Program.cs
using Microsoft.Agents.AI.DevUI;
using Scalar.AspNetCore;
var builder = WebApplication.CreateBuilder(args);
var captureSensitiveTelemetry = builder.Configuration.GetValue("Telemetry:CaptureSensitiveData", false);
builder.AddServiceDefaults();
builder.Services.AddProblemDetails();
builder.Services.AddOpenApi();
builder.Services.AddDevUI();
builder.Services.AddProtocolHosting(builder.Configuration);
builder.Services.AddAgentOptions(builder.Configuration);
builder.AddAgentChatClient(captureSensitiveTelemetry);
var agent = builder.AddAgent(captureSensitiveTelemetry)
.AddA2AServer();
var app = builder.Build();
app.UseExceptionHandler();
app.MapDefaultEndpoints();
app.UseOpenAICompatiblePathAliases();
app.UseRouting();
app.UseCors();
app.UseA2ATelemetry();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
app.MapDevUI();
app.MapScalarApiReference();
}
app.MapProtocolEndpoints(agent);
app.Run();A2AEndpoints.cs
using System.Diagnostics;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using A2A;
using Microsoft.Agents.AI.Hosting;
using Microsoft.Extensions.Options;
public static class A2AEndpoints
{
public static IApplicationBuilder UseA2ATelemetry(this WebApplication app)
{
app.Use(async (context, next) =>
{
if (!context.Request.Path.StartsWithSegments(ProtocolPaths.A2A))
{
await next(context);
return;
}
var agentOptions = context.RequestServices.GetRequiredService<IOptions<AgentOptions>>().Value;
var activity = Activity.Current;
activity?.SetTag("agent.name", agentOptions.Name);
activity?.SetTag("a2a.protocol", ProtocolBindingNames.HttpJson);
activity?.SetTag("a2a.operation", GetA2AOperation(context.Request.Path));
try
{
using var _ = IterationLimitedChatClient.BeginScope(agentOptions.MaxIterations);
await next(context);
activity?.SetTag("http.response.status_code", context.Response.StatusCode);
}
catch (Exception ex)
{
activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
throw;
}
});
return app;
}
public static void MapA2AEndpoints(this WebApplication app, IHostedAgentBuilder agent)
{
app.MapA2AHttpJson(agent, path: ProtocolPaths.A2A);
app.MapGet(ProtocolPaths.A2AAgentCard, (HttpContext context) =>
TypedResults.Json(CreateAgentCard(context)))
.WithOrder(-1)
.WithName("GetA2AAgentCard");
if (!app.Environment.IsDevelopment())
{
return;
}
app.MapA2AHttpJson(agent, path: ProtocolPaths.InspectorA2A);
app.MapGet(ProtocolPaths.InspectorAgentCard, (HttpContext context) =>
TypedResults.Json(CreateInspectorAgentCardDocument(context)))
.WithName("GetInspectorAgentCard");
}
private static JsonObject CreateInspectorAgentCardDocument(HttpContext context)
{
var agentUrl = CreateAgentUrl(context);
var document = JsonSerializer.SerializeToNode(
CreateAgentCard(context),
new JsonSerializerOptions(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
})?.AsObject()
?? [];
document["url"] = agentUrl;
document["preferredTransport"] = ProtocolBindingNames.HttpJson;
return document;
}
private static AgentCard CreateAgentCard(HttpContext context)
{
var options = context.RequestServices.GetRequiredService<IOptions<AgentOptions>>().Value;
var agentUrl = CreateAgentUrl(context);
return new()
{
Name = options.DisplayName,
Description = options.Description,
Version = "1.0",
SupportedInterfaces =
[
new()
{
Url = agentUrl,
ProtocolBinding = ProtocolBindingNames.HttpJson
}
],
Capabilities = new()
{
Streaming = true,
PushNotifications = false
},
DefaultInputModes = ["text"],
DefaultOutputModes = ["text"],
Skills =
[
new()
{
Id = "short-story-writing",
Name = "Short story writing",
Description = "Writes complete micro-stories in 50 words or fewer.",
Tags = ["writing", "story", "creative"],
Examples = ["Write a mystery story about a missing key."]
}
]
};
}
private static string CreateAgentUrl(HttpContext context) =>
$"{context.Request.Scheme}://{context.Request.Host}{context.Request.PathBase}{ProtocolPaths.A2A}";
private static string GetA2AOperation(PathString path)
{
var value = path.Value;
return value switch
{
not null when value.EndsWith("/message:send", StringComparison.OrdinalIgnoreCase) => "message.send",
not null when value.EndsWith("/message:stream", StringComparison.OrdinalIgnoreCase) => "message.stream",
not null when value.EndsWith("/card", StringComparison.OrdinalIgnoreCase) => "card.get",
not null when value.EndsWith("/v1/card", StringComparison.OrdinalIgnoreCase) => "card.get",
not null when value.Contains("/tasks/", StringComparison.OrdinalIgnoreCase) => "task.get",
not null when value.EndsWith("/tasks", StringComparison.OrdinalIgnoreCase) => "task.list",
_ => "unknown"
};
}
}AgentConfiguration.cs
public static class AgentConfiguration
{
public static IServiceCollection AddAgentOptions(
this IServiceCollection services,
IConfiguration configuration)
{
services.AddOptions<AgentOptions>()
.Bind(configuration.GetSection(AgentOptions.SectionName))
.Validate(options => !string.IsNullOrWhiteSpace(options.Name), "Agent:Name is required.")
.Validate(options => !string.IsNullOrWhiteSpace(options.DisplayName), "Agent:DisplayName is required.")
.Validate(options => !string.IsNullOrWhiteSpace(options.Description), "Agent:Description is required.")
.Validate(options => !string.IsNullOrWhiteSpace(options.Instructions), "Agent:Instructions is required.")
.Validate(options => options.MaxIterations > 0, "Agent:MaxIterations must be greater than zero.")
.Validate(options => options.MaxOutputTokens > 0, "Agent:MaxOutputTokens must be greater than zero.")
.Validate(options => options.Temperature is >= 0 and <= 2, "Agent:Temperature must be between 0 and 2.")
.ValidateOnStart();
services.AddOptions<OpenAICompatibleOptions>()
.Bind(configuration.GetSection(OpenAICompatibleOptions.SectionName))
.Validate(options => options.ResponsesPath.StartsWith('/'), "OpenAICompatible:ResponsesPath must start with '/'.")
.Validate(options => options.ChatCompletionsPath.StartsWith('/'), "OpenAICompatible:ChatCompletionsPath must start with '/'.")
.ValidateOnStart();
services.AddOptions<AgUiOptions>()
.Bind(configuration.GetSection(AgUiOptions.SectionName))
.Validate(options => options.EndpointPath.StartsWith('/'), "AgUi:EndpointPath must start with '/'.")
.Validate(
options => options.DojoAllowedOrigins.All(origin => Uri.TryCreate(origin, UriKind.Absolute, out _)),
"AgUi:DojoAllowedOrigins values must be absolute URIs.")
.ValidateOnStart();
return services;
}
}AgentFactory.cs
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Hosting;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Options;
public static class AgentFactory
{
public static void AddAgentChatClient(
this WebApplicationBuilder builder,
bool captureSensitiveTelemetry)
{
builder.AddOllamaApiClient("ollama")
.AddChatClient(telemetry => telemetry.EnableSensitiveData = captureSensitiveTelemetry)
.UseFunctionInvocation()
.Use((innerClient, serviceProvider) =>
{
var options = serviceProvider.GetRequiredService<IOptions<AgentOptions>>().Value;
return new IterationLimitedChatClient(innerClient, options.MaxIterations);
});
}
public static IHostedAgentBuilder AddAgent(
this WebApplicationBuilder builder,
bool captureSensitiveTelemetry)
{
var agentOptions = builder.Configuration
.GetSection(AgentOptions.SectionName)
.Get<AgentOptions>()
?? new AgentOptions();
return builder.AddAIAgent(agentOptions.Name, (services, name) =>
{
var loggerFactory = services.GetRequiredService<ILoggerFactory>();
var chatClient = services.GetRequiredService<IChatClient>();
var options = services.GetRequiredService<IOptions<AgentOptions>>().Value;
var responsePlanTool = AIFunctionFactory.Create(AgentTools.CreateResponsePlan);
AITool[] tools = [responsePlanTool];
loggerFactory.CreateLogger("AgentTools")
.LogInformation("Registered agent tool {ToolName}.", responsePlanTool.Name);
var agent = chatClient.AsAIAgent(
options: new ChatClientAgentOptions
{
Name = name,
Description = options.Description,
ChatOptions = CreateChatOptions(options, tools, responsePlanTool.Name)
},
loggerFactory: loggerFactory,
services: services);
return agent.AsBuilder()
.UseOpenTelemetry(
builder.Environment.ApplicationName,
telemetry => telemetry.EnableSensitiveData = captureSensitiveTelemetry)
.Build(services);
});
}
private static ChatOptions CreateChatOptions(AgentOptions options, IList<AITool> tools, string requiredToolName) => new()
{
Instructions = options.Instructions,
Temperature = options.Temperature,
MaxOutputTokens = options.MaxOutputTokens,
AllowMultipleToolCalls = options.AllowMultipleToolCalls,
ToolMode = ChatToolMode.RequireSpecific(requiredToolName),
Tools = tools
};
}AgentOptions.cs
public sealed class AgentOptions
{
public const string SectionName = "Agent";
public string Name { get; init; } = "agent";
public string DisplayName { get; init; } = string.Empty;
public string Description { get; init; } = string.Empty;
public string Instructions { get; init; } = string.Empty;
public int MaxIterations { get; init; } = 10;
public int MaxOutputTokens { get; init; } = 100;
public float Temperature { get; init; } = 0.2f;
public bool AllowMultipleToolCalls { get; init; }
}AgentTools.cs
using System.ComponentModel;
using System.Diagnostics;
using Microsoft.Extensions.Logging;
public static class AgentTools
{
private static readonly ActivitySource ActivitySource = new("agent-protocols.agent");
[Description("Creates a concise response plan for the user's request. Use this before producing the final answer.")]
public static string CreateResponsePlan(
[Description("The user's request or task.")]
string request,
[Description("The type of response to produce, such as story, explanation, troubleshooting, summary, or recommendation.")]
string responseType = "general",
IServiceProvider? services = null)
{
using var activity = ActivitySource.StartActivity("AgentTools.CreateResponsePlan", ActivityKind.Internal);
activity?.SetTag("tool.name", nameof(CreateResponsePlan));
activity?.SetTag("response.type", responseType);
var normalizedRequest = string.IsNullOrWhiteSpace(request)
? "Respond to the user's request."
: request.Trim();
services?.GetService<ILoggerFactory>()
?.CreateLogger("AgentTools")
.LogInformation(
"Tool {ToolName} invoked for response type {ResponseType} and request {Request}.",
nameof(CreateResponsePlan),
responseType,
normalizedRequest);
return responseType.Trim().ToLowerInvariant() switch
{
"story" => $"Plan: identify the genre and central conflict in \"{normalizedRequest}\"; choose one vivid protagonist, one turning point, and one emotionally clear ending; keep the final story to 50 words or fewer.",
"explanation" => $"Plan: restate the core question in \"{normalizedRequest}\"; answer directly; include only the context needed to make the answer clear.",
"troubleshooting" => $"Plan: identify the failing behavior in \"{normalizedRequest}\"; list the most likely causes; provide concrete checks in the safest order.",
"summary" => $"Plan: extract the central idea from \"{normalizedRequest}\"; preserve important constraints; omit incidental detail.",
"recommendation" => $"Plan: identify the user's goal in \"{normalizedRequest}\"; compare practical options; recommend one path with the key tradeoff.",
_ => $"Plan: understand the user's goal in \"{normalizedRequest}\"; choose a clear structure; produce a focused, useful answer."
};
}
}AgUiEndpoints.cs
using System.Text.Json;
using Microsoft.Agents.AI.Hosting;
using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore;
using Microsoft.Extensions.Options;
public static class AgUiEndpoints
{
public const string DojoCorsPolicyName = "AgUiDojo";
public static IServiceCollection AddAgUiDojoCompatibility(
this IServiceCollection services,
IConfiguration configuration)
{
var options = configuration.GetSection(AgUiOptions.SectionName).Get<AgUiOptions>() ?? new();
services.AddCors(cors =>
{
cors.AddPolicy(DojoCorsPolicyName, policy =>
{
policy
.WithOrigins(options.DojoAllowedOrigins)
.AllowAnyHeader()
.AllowAnyMethod();
});
});
return services;
}
public static void MapAgUiEndpoints(this WebApplication app, IHostedAgentBuilder agent)
{
var options = app.Services.GetRequiredService<IOptions<AgUiOptions>>().Value;
if (!options.Enabled || options.DevelopmentOnly && !app.Environment.IsDevelopment())
{
return;
}
var agUiEndpoint = app.MapAGUI(agent, options.EndpointPath);
if (!app.Environment.IsDevelopment() || !options.EnableDojoCompatibility)
{
return;
}
agUiEndpoint.RequireCors(DojoCorsPolicyName);
app.MapGet($"{options.EndpointPath}/capabilities", (IOptions<AgentOptions> agentOptions) => TypedResults.Json(CreateCapabilities(agentOptions.Value, options)))
.RequireCors(DojoCorsPolicyName)
.WithName("GetAgUiDojoCapabilities")
.WithTags("AG-UI")
.WithSummary("Get AG-UI capabilities")
.WithDescription("Returns the development AG-UI transport, supported building blocks, disabled blocks, and helper route templates.");
MapStateSynchronizationEndpoints(app, options);
MapFrontendToolCallEndpoints(app, options);
MapInterruptEndpoints(app, options);
MapGenerativeUiEndpoints(app, options);
MapRunEventEndpoints(app, options);
}
private static void MapStateSynchronizationEndpoints(WebApplication app, AgUiOptions options)
{
if (!options.EnableStateSynchronization)
{
return;
}
app.MapPost($"{options.EndpointPath}/state", (
AgUiStateUpdateRequest request,
IAgUiRunStore store) =>
{
if (string.IsNullOrWhiteSpace(request.ThreadId) || string.IsNullOrWhiteSpace(request.RunId))
{
return Results.BadRequest("threadId and runId are required.");
}
return Results.Ok(store.SaveState(request));
})
.RequireCors(DojoCorsPolicyName)
.WithName("UpdateAgUiState")
.WithTags("AG-UI")
.WithSummary("Publish an AG-UI state snapshot")
.WithDescription("Stores a thread state snapshot and records a state.snapshot run event for the specified run.");
app.MapGet($"{options.EndpointPath}/state/{{threadId}}", (
string threadId,
IAgUiRunStore store) =>
{
var snapshot = store.GetState(threadId);
return snapshot is null ? Results.NotFound() : Results.Ok(snapshot);
})
.RequireCors(DojoCorsPolicyName)
.WithName("GetAgUiState")
.WithTags("AG-UI")
.WithSummary("Get an AG-UI state snapshot")
.WithDescription("Returns the latest state snapshot stored for the specified AG-UI thread.");
}
private static void MapFrontendToolCallEndpoints(WebApplication app, AgUiOptions options)
{
if (!options.EnableFrontendToolCalls)
{
return;
}
app.MapPost($"{options.EndpointPath}/tool-calls", (
AgUiFrontendToolCallRequest request,
IAgUiRunStore store) =>
{
if (string.IsNullOrWhiteSpace(request.ThreadId)
|| string.IsNullOrWhiteSpace(request.RunId)
|| string.IsNullOrWhiteSpace(request.Name))
{
return Results.BadRequest("threadId, runId, and name are required.");
}
return Results.Ok(store.CreateToolCall(request));
})
.RequireCors(DojoCorsPolicyName)
.WithName("CreateAgUiFrontendToolCall")
.WithTags("AG-UI")
.WithSummary("Create an AG-UI frontend tool call")
.WithDescription("Creates a pending frontend tool call request and records a frontend_tool_call.requested run event.");
app.MapPost($"{options.EndpointPath}/tool-results", (
AgUiFrontendToolResultRequest request,
IAgUiRunStore store) =>
{
if (string.IsNullOrWhiteSpace(request.ToolCallId))
{
return Results.BadRequest("toolCallId is required.");
}
var completed = store.CompleteToolCall(request);
return completed is null ? Results.Conflict("Tool call is missing or already completed.") : Results.Ok(completed);
})
.RequireCors(DojoCorsPolicyName)
.WithName("CompleteAgUiFrontendToolCall")
.WithTags("AG-UI")
.WithSummary("Complete an AG-UI frontend tool call")
.WithDescription("Submits the browser/client result for a pending frontend tool call.");
}
private static void MapInterruptEndpoints(WebApplication app, AgUiOptions options)
{
if (!options.EnableInterrupts)
{
return;
}
app.MapPost($"{options.EndpointPath}/runs/{{runId}}/interrupt", (
string runId,
AgUiInterruptRequest request,
IAgUiRunStore store) =>
{
if (string.IsNullOrWhiteSpace(runId)
|| string.IsNullOrWhiteSpace(request.ThreadId)
|| string.IsNullOrWhiteSpace(request.Reason))
{
return Results.BadRequest("runId, threadId, and reason are required.");
}
return Results.Ok(store.CreateInterrupt(runId, request));
})
.RequireCors(DojoCorsPolicyName)
.WithName("CreateAgUiInterrupt")
.WithTags("AG-UI")
.WithSummary("Create an AG-UI interrupt")
.WithDescription("Marks a run as interrupted while waiting for user or frontend input.");
app.MapPost($"{options.EndpointPath}/runs/{{runId}}/resume", (
string runId,
AgUiResumeRequest request,
IAgUiRunStore store) =>
{
var resumed = store.ResumeInterrupt(runId, request);
return resumed is null ? Results.Conflict("Run is missing or is not interrupted.") : Results.Ok(resumed);
})
.RequireCors(DojoCorsPolicyName)
.WithName("ResumeAgUiInterrupt")
.WithTags("AG-UI")
.WithSummary("Resume an AG-UI interrupt")
.WithDescription("Submits resume payload for an interrupted AG-UI run.");
app.MapPost($"{options.EndpointPath}/runs/{{runId}}/cancel", (
string runId,
IAgUiRunStore store) =>
{
var canceled = store.CancelRun(runId);
return canceled is null ? Results.NotFound() : Results.Ok(canceled);
})
.RequireCors(DojoCorsPolicyName)
.WithName("CancelAgUiRun")
.WithTags("AG-UI")
.WithSummary("Cancel an AG-UI run")
.WithDescription("Cancels a run that has an interrupt record.");
}
private static void MapGenerativeUiEndpoints(WebApplication app, AgUiOptions options)
{
if (!options.EnableGenerativeUi)
{
return;
}
app.MapPost($"{options.EndpointPath}/ui", (
AgUiGeneratedUiRequest request,
IAgUiRunStore store) =>
{
if (string.IsNullOrWhiteSpace(request.ThreadId)
|| string.IsNullOrWhiteSpace(request.RunId)
|| string.IsNullOrWhiteSpace(request.Component.Type)
|| string.IsNullOrWhiteSpace(request.Component.Key))
{
return Results.BadRequest("threadId, runId, component.type, and component.key are required.");
}
try
{
return Results.Ok(store.CreateGeneratedUi(request));
}
catch (InvalidOperationException ex)
{
return Results.BadRequest(ex.Message);
}
})
.RequireCors(DojoCorsPolicyName)
.WithName("CreateAgUiGeneratedUi")
.WithTags("AG-UI")
.WithSummary("Create an AG-UI generated UI component")
.WithDescription("Stores an allowlisted generated UI component and records a generative_ui.created run event.");
app.MapGet($"{options.EndpointPath}/ui/{{threadId}}", (
string threadId,
IAgUiRunStore store) => Results.Ok(store.GetGeneratedUi(threadId)))
.RequireCors(DojoCorsPolicyName)
.WithName("GetAgUiGeneratedUi")
.WithTags("AG-UI")
.WithSummary("List AG-UI generated UI components")
.WithDescription("Returns generated UI components stored for a thread.");
}
private static void MapRunEventEndpoints(WebApplication app, AgUiOptions options)
{
if (!options.EnableStateSynchronization
&& !options.EnableFrontendToolCalls
&& !options.EnableInterrupts
&& !options.EnableGenerativeUi)
{
return;
}
app.MapGet($"{options.EndpointPath}/runs/{{runId}}/events", async (
string runId,
HttpContext context,
IAgUiRunStore store) =>
{
context.Response.Headers.CacheControl = "no-cache";
context.Response.Headers.ContentType = "text/event-stream";
foreach (var runEvent in store.GetRunEvents(runId))
{
await WriteSseAsync(context, runEvent.Type, runEvent, context.RequestAborted);
}
})
.RequireCors(DojoCorsPolicyName)
.WithName("GetAgUiRunEvents")
.WithTags("AG-UI")
.WithSummary("Stream AG-UI run events")
.WithDescription("Streams the recorded AG-UI run events as text/event-stream payloads.");
}
private static object CreateCapabilities(AgentOptions agentOptions, AgUiOptions options)
{
var supported = new List<string>
{
"run-input",
"text-streaming"
};
var disabled = new List<string>();
AddFeature("state-synchronization", options.EnableStateSynchronization);
AddFeature("frontend-tool-calls", options.EnableFrontendToolCalls);
AddFeature("interrupts", options.EnableInterrupts);
AddFeature("generative-ui", options.EnableGenerativeUi);
return new
{
name = agentOptions.DisplayName,
protocol = "ag-ui",
endpoint = options.EndpointPath,
transport = "http-sse",
dojoCompatibility = supported.Count > 2 ? "building-blocks" : "basic",
supported,
disabled,
routes = new
{
state = options.EnableStateSynchronization ? $"{options.EndpointPath}/state" : null,
toolCalls = options.EnableFrontendToolCalls ? $"{options.EndpointPath}/tool-calls" : null,
toolResults = options.EnableFrontendToolCalls ? $"{options.EndpointPath}/tool-results" : null,
interrupt = options.EnableInterrupts ? $"{options.EndpointPath}/runs/{{runId}}/interrupt" : null,
resume = options.EnableInterrupts ? $"{options.EndpointPath}/runs/{{runId}}/resume" : null,
cancel = options.EnableInterrupts ? $"{options.EndpointPath}/runs/{{runId}}/cancel" : null,
ui = options.EnableGenerativeUi ? $"{options.EndpointPath}/ui" : null,
events = supported.Count > 2 ? $"{options.EndpointPath}/runs/{{runId}}/events" : null
}
};
void AddFeature(string name, bool enabled)
{
if (enabled)
{
supported.Add(name);
}
else
{
disabled.Add(name);
}
}
}
private static async Task WriteSseAsync(
HttpContext context,
string eventName,
object data,
CancellationToken cancellationToken)
{
await context.Response.WriteAsync($"event: {eventName}\n", cancellationToken);
await context.Response.WriteAsync($"data: {JsonSerializer.Serialize(data)}\n\n", cancellationToken);
await context.Response.Body.FlushAsync(cancellationToken);
}
}AgUiRunStore.cs
using System.Collections.Concurrent;
using System.Text.Json;
using System.Text.Json.Nodes;
public interface IAgUiRunStore
{
AgUiStateSnapshot SaveState(AgUiStateUpdateRequest request);
AgUiStateSnapshot? GetState(string threadId);
AgUiFrontendToolCall CreateToolCall(AgUiFrontendToolCallRequest request);
AgUiFrontendToolCall? CompleteToolCall(AgUiFrontendToolResultRequest request);
AgUiInterrupt CreateInterrupt(string runId, AgUiInterruptRequest request);
AgUiInterrupt? ResumeInterrupt(string runId, AgUiResumeRequest request);
AgUiInterrupt? CancelRun(string runId);
AgUiGeneratedUi CreateGeneratedUi(AgUiGeneratedUiRequest request);
IReadOnlyList<AgUiGeneratedUi> GetGeneratedUi(string threadId);
IReadOnlyList<AgUiRunEvent> GetRunEvents(string runId);
}
public sealed class InMemoryAgUiRunStore : IAgUiRunStore
{
private static readonly HashSet<string> AllowedComponentTypes = new(StringComparer.Ordinal)
{
"choice-list",
"form",
"status-card",
"markdown-panel"
};
private readonly ConcurrentDictionary<string, AgUiStateSnapshot> states = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, AgUiFrontendToolCall> toolCalls = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, AgUiInterrupt> interrupts = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, ConcurrentQueue<AgUiGeneratedUi>> generatedUi = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, ConcurrentQueue<AgUiRunEvent>> runEvents = new(StringComparer.Ordinal);
public AgUiStateSnapshot SaveState(AgUiStateUpdateRequest request)
{
var snapshot = new AgUiStateSnapshot(
request.ThreadId,
request.RunId,
CloneObject(request.State),
DateTimeOffset.UtcNow);
states[request.ThreadId] = snapshot;
AddEvent(request.RunId, "state.snapshot", snapshot);
return snapshot;
}
public AgUiStateSnapshot? GetState(string threadId) =>
states.TryGetValue(threadId, out var snapshot) ? snapshot : null;
public AgUiFrontendToolCall CreateToolCall(AgUiFrontendToolCallRequest request)
{
var call = new AgUiFrontendToolCall(
Guid.NewGuid().ToString("n"),
request.ThreadId,
request.RunId,
request.Name,
CloneObject(request.Arguments),
"pending",
null,
DateTimeOffset.UtcNow,
null);
toolCalls[call.ToolCallId] = call;
AddEvent(call.RunId, "frontend_tool_call.requested", call);
return call;
}
public AgUiFrontendToolCall? CompleteToolCall(AgUiFrontendToolResultRequest request)
{
if (!toolCalls.TryGetValue(request.ToolCallId, out var existing) || existing.Status != "pending")
{
return null;
}
var completed = existing with
{
Status = "completed",
Result = CloneNode(request.Result),
CompletedAt = DateTimeOffset.UtcNow
};
toolCalls[request.ToolCallId] = completed;
AddEvent(completed.RunId, "frontend_tool_call.completed", completed);
return completed;
}
public AgUiInterrupt CreateInterrupt(string runId, AgUiInterruptRequest request)
{
var interrupt = new AgUiInterrupt(
Guid.NewGuid().ToString("n"),
request.ThreadId,
runId,
request.Reason,
CloneObject(request.Payload),
"interrupted",
null,
DateTimeOffset.UtcNow,
null);
interrupts[runId] = interrupt;
AddEvent(runId, "interrupt.created", interrupt);
return interrupt;
}
public AgUiInterrupt? ResumeInterrupt(string runId, AgUiResumeRequest request)
{
if (!interrupts.TryGetValue(runId, out var existing) || existing.Status != "interrupted")
{
return null;
}
var resumed = existing with
{
Status = "resumed",
ResumePayload = CloneNode(request.Payload),
CompletedAt = DateTimeOffset.UtcNow
};
interrupts[runId] = resumed;
AddEvent(runId, "interrupt.resumed", resumed);
return resumed;
}
public AgUiInterrupt? CancelRun(string runId)
{
if (!interrupts.TryGetValue(runId, out var existing))
{
return null;
}
var canceled = existing with
{
Status = "canceled",
CompletedAt = DateTimeOffset.UtcNow
};
interrupts[runId] = canceled;
AddEvent(runId, "run.canceled", canceled);
return canceled;
}
public AgUiGeneratedUi CreateGeneratedUi(AgUiGeneratedUiRequest request)
{
if (!AllowedComponentTypes.Contains(request.Component.Type))
{
throw new InvalidOperationException($"Unsupported AG-UI component type '{request.Component.Type}'.");
}
var generated = new AgUiGeneratedUi(
Guid.NewGuid().ToString("n"),
request.ThreadId,
request.RunId,
new AgUiComponent(
request.Component.Type,
request.Component.Key,
CloneObject(request.Component.Props)),
DateTimeOffset.UtcNow);
generatedUi.GetOrAdd(request.ThreadId, _ => new()).Enqueue(generated);
AddEvent(request.RunId, "generative_ui.created", generated);
return generated;
}
public IReadOnlyList<AgUiGeneratedUi> GetGeneratedUi(string threadId) =>
generatedUi.TryGetValue(threadId, out var items) ? items.ToArray() : [];
public IReadOnlyList<AgUiRunEvent> GetRunEvents(string runId) =>
runEvents.TryGetValue(runId, out var events) ? events.ToArray() : [];
private void AddEvent(string runId, string type, object payload)
{
var runEvent = new AgUiRunEvent(
Guid.NewGuid().ToString("n"),
runId,
type,
JsonSerializer.SerializeToNode(payload) ?? new JsonObject(),
DateTimeOffset.UtcNow);
runEvents.GetOrAdd(runId, _ => new()).Enqueue(runEvent);
}
private static JsonObject CloneObject(JsonObject value) =>
CloneNode(value)?.AsObject() ?? new JsonObject();
private static JsonNode? CloneNode(JsonNode? value) =>
value?.DeepClone();
}
public sealed record AgUiStateUpdateRequest(
string ThreadId,
string RunId,
JsonObject State);
public sealed record AgUiStateSnapshot(
string ThreadId,
string RunId,
JsonObject State,
DateTimeOffset UpdatedAt);
public sealed record AgUiFrontendToolCallRequest(
string ThreadId,
string RunId,
string Name,
JsonObject Arguments);
public sealed record AgUiFrontendToolResultRequest(
string ToolCallId,
JsonNode? Result);
public sealed record AgUiFrontendToolCall(
string ToolCallId,
string ThreadId,
string RunId,
string Name,
JsonObject Arguments,
string Status,
JsonNode? Result,
DateTimeOffset CreatedAt,
DateTimeOffset? CompletedAt);
public sealed record AgUiInterruptRequest(
string ThreadId,
string Reason,
JsonObject Payload);
public sealed record AgUiResumeRequest(JsonNode? Payload);
public sealed record AgUiInterrupt(
string InterruptId,
string ThreadId,
string RunId,
string Reason,
JsonObject Payload,
string Status,
JsonNode? ResumePayload,
DateTimeOffset CreatedAt,
DateTimeOffset? CompletedAt);
public sealed record AgUiGeneratedUiRequest(
string ThreadId,
string RunId,
AgUiComponent Component);
public sealed record AgUiGeneratedUi(
string UiId,
string ThreadId,
string RunId,
AgUiComponent Component,
DateTimeOffset CreatedAt);
public sealed record AgUiComponent(
string Type,
string Key,
JsonObject Props);
public sealed record AgUiRunEvent(
string EventId,
string RunId,
string Type,
JsonNode Payload,
DateTimeOffset CreatedAt);IterationLimitedChatClient.cs
using Microsoft.Extensions.AI;
public sealed class IterationLimitedChatClient(IChatClient innerClient, int defaultMaxIterations) : DelegatingChatClient(innerClient)
{
private static readonly AsyncLocal<IterationScope?> CurrentScope = new();
public static IDisposable BeginScope(int maxIterations)
{
var previousScope = CurrentScope.Value;
CurrentScope.Value = new(maxIterations);
return new ScopeLease(previousScope);
}
public override Task<ChatResponse> GetResponseAsync(
IEnumerable<ChatMessage> messages,
ChatOptions? options = null,
CancellationToken cancellationToken = default)
{
ThrowIfExceeded();
return base.GetResponseAsync(messages, options, cancellationToken);
}
public override IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
IEnumerable<ChatMessage> messages,
ChatOptions? options = null,
CancellationToken cancellationToken = default)
{
ThrowIfExceeded();
return base.GetStreamingResponseAsync(messages, options, cancellationToken);
}
private void ThrowIfExceeded()
{
var scope = CurrentScope.Value;
var iterationCount = scope is null
? 1
: Interlocked.Increment(ref scope.IterationCount);
var maxIterations = scope?.MaxIterations ?? defaultMaxIterations;
if (iterationCount > maxIterations)
{
throw new InvalidOperationException($"Agent exceeded the configured maximum of {maxIterations} model iterations.");
}
}
private sealed class IterationScope(int maxIterations)
{
public int MaxIterations { get; } = maxIterations;
public int IterationCount;
}
private sealed class ScopeLease(IterationScope? previousScope) : IDisposable
{
public void Dispose()
{
CurrentScope.Value = previousScope;
}
}
}OpenAICompatibleEndpoints.cs
using Microsoft.Agents.AI.Hosting;
using Microsoft.Agents.AI.Hosting.OpenAI;
using Microsoft.Extensions.Options;
public static class OpenAICompatibleEndpoints
{
private const string DevUIResponsesPath = "/v1/responses";
public static IApplicationBuilder UseOpenAICompatiblePathAliases(this WebApplication app)
{
app.Use(async (context, next) =>
{
var options = context.RequestServices.GetRequiredService<IOptions<OpenAICompatibleOptions>>().Value;
if (!app.Environment.IsDevelopment()
|| !options.Enabled
|| string.Equals(options.ResponsesPath, DevUIResponsesPath, StringComparison.OrdinalIgnoreCase)
|| !context.Request.Path.Equals(options.ResponsesPath, StringComparison.OrdinalIgnoreCase))
{
await next(context);
return;
}
var originalPath = context.Request.Path;
context.Request.Path = DevUIResponsesPath;
try
{
await next(context);
}
finally
{
context.Request.Path = originalPath;
}
});
return app;
}
public static void MapOpenAICompatibleEndpoints(this WebApplication app, IHostedAgentBuilder agent)
{
var options = app.Services.GetRequiredService<IOptions<OpenAICompatibleOptions>>().Value;
if (!options.Enabled || options.DevelopmentOnly && !app.Environment.IsDevelopment())
{
return;
}
app.MapOpenAIChatCompletions(
agent,
options.ChatCompletionsPath,
new OpenAIChatCompletionsMapOptions());
app.MapOpenAIResponses(
agent,
app.Environment.IsDevelopment() ? DevUIResponsesPath : options.ResponsesPath,
new OpenAIResponsesMapOptions());
if (options.EnableConversations)
{
app.MapOpenAIConversations();
}
}
}ProtocolHosting.cs
using Microsoft.Extensions.DependencyInjection;
public static class ProtocolHosting
{
public static IServiceCollection AddProtocolHosting(
this IServiceCollection services,
IConfiguration configuration)
{
services.AddOpenAIChatCompletions();
services.AddOpenAIResponses();
services.AddOpenAIConversations();
services.AddAGUI();
services.AddSingleton<IAgUiRunStore, InMemoryAgUiRunStore>();
services.AddAgUiDojoCompatibility(configuration);
return services;
}
public static void MapProtocolEndpoints(this WebApplication app, Microsoft.Agents.AI.Hosting.IHostedAgentBuilder agent)
{
app.MapA2AEndpoints(agent);
app.MapOpenAICompatibleEndpoints(agent);
app.MapAgUiEndpoints(agent);
if (app.Environment.IsDevelopment())
{
app.MapTckCompatibilityEndpoints();
}
}
}ProtocolOptions.cs
public sealed class OpenAICompatibleOptions
{
public const string SectionName = "OpenAICompatible";
public bool Enabled { get; init; } = true;
public bool DevelopmentOnly { get; init; } = true;
public string ChatCompletionsPath { get; init; } = "/agent/v1/chat/completions";
public string ResponsesPath { get; init; } = "/agent/v1/responses";
public bool EnableConversations { get; init; } = true;
}
public sealed class AgUiOptions
{
public const string SectionName = "AgUi";
public bool Enabled { get; init; } = true;
public bool DevelopmentOnly { get; init; } = true;
public string EndpointPath { get; init; } = "/ag-ui/agent";
public bool EnableDojoCompatibility { get; init; } = true;
public bool EnableStateSynchronization { get; init; }
public bool EnableFrontendToolCalls { get; init; }
public bool EnableInterrupts { get; init; }
public bool EnableGenerativeUi { get; init; }
public string[] DojoAllowedOrigins { get; init; } =
[
"https://dojo.ag-ui.com",
"http://localhost:3000",
"http://localhost:5173",
"http://127.0.0.1:3000",
"http://127.0.0.1:5173"
];
}ProtocolPaths.cs
public static class ProtocolPaths
{
public const string A2A = "/a2a/agent";
public const string A2AAgentCard = A2A + "/v1/card";
public const string InspectorA2A = "/a2a/agent/v1";
public const string InspectorAgentCard = "/.well-known/agent-card.json";
public const string TckA2A = "/a2a/tck";
public const string TckAgentCard = "/a2a/tck/.well-known/agent-card.json";
}TckCompatibilityEndpoints
using System.Collections.Concurrent;
using System.Text.Json;
using System.Text.Json.Nodes;
using A2A;
public static class TckCompatibilityEndpoints
{
private static readonly ConcurrentDictionary<string, TckTaskState> Tasks = new();
public static void MapTckCompatibilityEndpoints(this WebApplication app)
{
app.MapGet(ProtocolPaths.TckAgentCard, (HttpContext context) =>
TypedResults.Json(CreateTckAgentCardDocument(context)))
.WithName("GetA2ATckAgentCard")
.WithTags("A2A TCK")
.WithSummary("Get the A2A TCK agent card")
.WithDescription("Returns the development-only deterministic A2A agent card used by compatibility checks.");
app.MapPost($"{ProtocolPaths.TckA2A}/message:send", async (HttpContext context) =>
{
if (!IsJsonPost(context))
{
return TckError(415, "INVALID_ARGUMENT", "ContentTypeNotSupportedError", "CONTENT_TYPE_NOT_SUPPORTED");
}
if (IsUnsupportedA2AVersion(context))
{
return TckError(400, "UNIMPLEMENTED", "VersionNotSupportedError", "VERSION_NOT_SUPPORTED");
}
var request = await context.Request.ReadFromJsonAsync<JsonObject>();
if (request?["message"] is not JsonObject message)
{
return TckError(400, "INVALID_ARGUMENT", "InvalidRequestError", "INVALID_REQUEST");
}
var response = HandleTckSendMessage(message);
return response ?? TckError(400, "FAILED_PRECONDITION", "UnsupportedOperationError", "UNSUPPORTED_OPERATION");
})
.WithName("SendA2ATckMessage")
.WithTags("A2A TCK")
.WithSummary("Send an A2A TCK message")
.WithDescription("Creates deterministic direct-message, task, artifact, or input-required responses based on the messageId prefix.");
app.MapPost($"{ProtocolPaths.TckA2A}/message:stream", async (HttpContext context) =>
{
if (!IsJsonPost(context))
{
await WriteTckErrorAsync(context, 415, "INVALID_ARGUMENT", "ContentTypeNotSupportedError", "CONTENT_TYPE_NOT_SUPPORTED");
return;
}
if (IsUnsupportedA2AVersion(context))
{
await WriteTckErrorAsync(context, 400, "UNIMPLEMENTED", "VersionNotSupportedError", "VERSION_NOT_SUPPORTED");
return;
}
var request = await context.Request.ReadFromJsonAsync<JsonObject>();
var message = request?["message"] as JsonObject;
var taskId = CreateTckTaskId(message, "stream");
var contextId = CreateTckContextId(message, taskId);
var submitted = CreateTckTask(taskId, contextId, "TASK_STATE_SUBMITTED");
var working = CreateTckTask(taskId, contextId, "TASK_STATE_WORKING");
var completed = CreateTckTask(taskId, contextId, "TASK_STATE_COMPLETED", [CreateTextArtifact("Generated text content")]);
Tasks[taskId] = new TckTaskState(completed);
await WriteSseAsync(context, new JsonObject { ["task"] = submitted });
await WriteSseAsync(context, new JsonObject { ["task"] = working });
await WriteSseAsync(context, new JsonObject { ["task"] = completed });
})
.WithName("StreamA2ATckMessage")
.WithTags("A2A TCK")
.WithSummary("Stream an A2A TCK task lifecycle")
.WithDescription("Streams submitted, working, and completed task events for deterministic A2A compatibility checks.");
app.MapGet($"{ProtocolPaths.TckA2A}/tasks/{{id}}", (string id, int? historyLength) =>
{
if (Tasks.TryGetValue(id, out var state))
{
return TypedResults.Json(ApplyHistoryLength(state.Task, historyLength));
}
return TckError(404, "NOT_FOUND", "TaskNotFoundError", "TASK_NOT_FOUND");
})
.WithName("GetA2ATckTask")
.WithTags("A2A TCK")
.WithSummary("Get an A2A TCK task")
.WithDescription("Returns a stored deterministic A2A task by id, optionally trimming history.");
app.MapGet($"{ProtocolPaths.TckA2A}/tasks", (string? contextId) =>
{
var items = Tasks.Values
.Where(task => contextId is null || task.ContextId == contextId)
.Select(task => task.Task.DeepClone())
.ToArray();
return TypedResults.Json(new JsonObject { ["tasks"] = new JsonArray(items) });
})
.WithName("ListA2ATckTasks")
.WithTags("A2A TCK")
.WithSummary("List A2A TCK tasks")
.WithDescription("Lists stored deterministic A2A tasks, optionally filtered by contextId.");
app.MapPost($"{ProtocolPaths.TckA2A}/tasks/{{id}}:cancel", (string id) =>
{
if (!Tasks.TryGetValue(id, out var state))
{
return TckError(404, "NOT_FOUND", "TaskNotFoundError", "TASK_NOT_FOUND");
}
if (state.IsTerminal)
{
return TckError(409, "FAILED_PRECONDITION", "TaskNotCancelableError", "TASK_NOT_CANCELABLE");
}
var canceled = CreateTckTask(id, state.ContextId, "TASK_STATE_CANCELED", history: state.History);
Tasks[id] = state with { Task = canceled };
return TypedResults.Json(canceled);
})
.WithName("CancelA2ATckTask")
.WithTags("A2A TCK")
.WithSummary("Cancel an A2A TCK task")
.WithDescription("Cancels a non-terminal deterministic A2A task.");
app.MapPost($"{ProtocolPaths.TckA2A}/tasks/{{id}}:subscribe", async (string id, HttpContext context) =>
{
if (!Tasks.TryGetValue(id, out var state))
{
await WriteTckErrorAsync(context, 404, "NOT_FOUND", "TaskNotFoundError", "TASK_NOT_FOUND");
return;
}
if (state.IsTerminal)
{
await WriteTckErrorAsync(context, 409, "FAILED_PRECONDITION", "TaskNotCancelableError", "TASK_NOT_CANCELABLE");
return;
}
await WriteSseAsync(context, new JsonObject { ["task"] = state.Task.DeepClone() });
var terminal = state.Task;
for (var attempt = 0; attempt < 30; attempt++)
{
if (Tasks.TryGetValue(id, out var current) && current.IsTerminal)
{
terminal = current.Task;
break;
}
await Task.Delay(100, context.RequestAborted);
}
await WriteSseAsync(context, new JsonObject { ["task"] = terminal.DeepClone() });
})
.WithName("SubscribeA2ATckTask")
.WithTags("A2A TCK")
.WithSummary("Subscribe to A2A TCK task events")
.WithDescription("Streams the current task event and waits briefly for a terminal task state.");
app.MapPost($"{ProtocolPaths.TckA2A}/tasks/{{id}}/pushNotificationConfigs", (string id) =>
TckError(400, "UNIMPLEMENTED", "PushNotificationNotSupportedError", "PUSH_NOTIFICATION_NOT_SUPPORTED"))
.WithName("CreateUnsupportedA2ATckPushNotificationConfig")
.WithTags("A2A TCK")
.WithSummary("Reject A2A TCK push notification config")
.WithDescription("Returns the deterministic unsupported-push-notification error used by compatibility checks.");
app.MapGet($"{ProtocolPaths.TckA2A}/extendedAgentCard", () =>
TckError(400, "FAILED_PRECONDITION", "ExtendedAgentCardNotConfiguredError", "EXTENDED_AGENT_CARD_NOT_CONFIGURED"))
.WithName("GetUnsupportedA2ATckExtendedAgentCard")
.WithTags("A2A TCK")
.WithSummary("Reject A2A TCK extended agent card")
.WithDescription("Returns the deterministic extended-agent-card-not-configured error used by compatibility checks.");
}
private static JsonObject CreateTckAgentCardDocument(HttpContext context)
{
var agentUrl = CreateTckAgentUrl(context);
return new()
{
["name"] = "A2A TCK Compatibility Agent",
["description"] = "Development-only deterministic A2A TCK compatibility endpoint.",
["version"] = "1.0",
["url"] = agentUrl,
["preferredTransport"] = ProtocolBindingNames.HttpJson,
["supportedInterfaces"] = new JsonArray
{
new JsonObject
{
["url"] = agentUrl,
["protocolBinding"] = ProtocolBindingNames.HttpJson,
["protocolVersion"] = "1.0"
}
},
["capabilities"] = new JsonObject
{
["streaming"] = true,
["pushNotifications"] = false,
["extendedAgentCard"] = false
},
["skills"] = new JsonArray
{
new JsonObject
{
["id"] = "tck-compatibility",
["name"] = "TCK compatibility",
["description"] = "Returns deterministic responses for A2A TCK scenarios.",
["tags"] = new JsonArray("tck", "test")
}
},
["defaultInputModes"] = new JsonArray("text"),
["defaultOutputModes"] = new JsonArray("text")
};
}
private static IResult? HandleTckSendMessage(JsonObject message)
{
var messageId = GetString(message, "messageId");
var taskId = GetString(message, "taskId");
if (!string.IsNullOrWhiteSpace(taskId))
{
if (!Tasks.TryGetValue(taskId, out var existing))
{
return TckError(404, "NOT_FOUND", "TaskNotFoundError", "TASK_NOT_FOUND");
}
var contextId = GetString(message, "contextId");
if (!string.IsNullOrWhiteSpace(contextId) && contextId != existing.ContextId)
{
return TckError(400, "INVALID_ARGUMENT", "InvalidRequestError", "INVALID_REQUEST");
}
if (existing.IsTerminal)
{
return TckError(400, "UNIMPLEMENTED", "UnsupportedOperationError", "UNSUPPORTED_OPERATION");
}
var completed = messageId.Contains("complete-task", StringComparison.OrdinalIgnoreCase);
var newState = completed ? "TASK_STATE_COMPLETED" : "TASK_STATE_INPUT_REQUIRED";
var history = existing.History.Add(CreateUserHistoryMessage(message, existing.ContextId, taskId));
var updatedTask = CreateTckTask(taskId, existing.ContextId, newState, history: history);
Tasks[taskId] = existing with { Task = updatedTask, History = history };
return TypedResults.Json(new JsonObject { ["task"] = updatedTask });
}
if (messageId.Contains("message-response", StringComparison.OrdinalIgnoreCase))
{
return TypedResults.Json(new JsonObject
{
["message"] = CreateTckMessage("Direct message response", messageId, CreateTckContextId(message, messageId))
});
}
if (messageId.Contains("artifact-text", StringComparison.OrdinalIgnoreCase))
{
return CreateAndStoreTckTaskResponse(message, "TASK_STATE_COMPLETED", [CreateTextArtifact("Generated text content")]);
}
if (messageId.Contains("artifact-file-url", StringComparison.OrdinalIgnoreCase))
{
return CreateAndStoreTckTaskResponse(message, "TASK_STATE_COMPLETED", [CreateFileArtifact(url: "https://example.com/output.txt")]);
}
if (messageId.Contains("artifact-file", StringComparison.OrdinalIgnoreCase))
{
return CreateAndStoreTckTaskResponse(message, "TASK_STATE_COMPLETED", [CreateFileArtifact(raw: "ZmlsZSBjb250ZW50")]);
}
if (messageId.Contains("artifact-data", StringComparison.OrdinalIgnoreCase))
{
return CreateAndStoreTckTaskResponse(message, "TASK_STATE_COMPLETED", [CreateDataArtifact()]);
}
if (messageId.Contains("input-required", StringComparison.OrdinalIgnoreCase))
{
return CreateAndStoreTckTaskResponse(message, "TASK_STATE_INPUT_REQUIRED");
}
if (messageId.Contains("complete-task", StringComparison.OrdinalIgnoreCase))
{
return CreateAndStoreTckTaskResponse(message, "TASK_STATE_COMPLETED", [CreateTextArtifact("Generated text content")]);
}
return CreateAndStoreTckTaskResponse(message, "TASK_STATE_COMPLETED");
}
private static IResult CreateAndStoreTckTaskResponse(
JsonObject message,
string state,
JsonObject[]? artifacts = null)
{
var taskId = CreateTckTaskId(message, "task");
var contextId = CreateTckContextId(message, taskId);
var history = TckHistory.Empty.Add(CreateUserHistoryMessage(message, contextId, taskId));
var task = CreateTckTask(taskId, contextId, state, artifacts, history);
Tasks[taskId] = new TckTaskState(task, history);
return TypedResults.Json(new JsonObject { ["task"] = task });
}
private static JsonObject CreateTckTask(
string taskId,
string contextId,
string state,
JsonObject[]? artifacts = null,
TckHistory? history = null)
{
var task = new JsonObject
{
["id"] = taskId,
["contextId"] = contextId,
["status"] = new JsonObject
{
["state"] = state,
["message"] = CreateTckMessage($"Task is {state}.", $"{taskId}-status", contextId, taskId),
["timestamp"] = DateTime.UtcNow.ToString("yyyy-MM-dd'T'HH:mm:ss.fffffff'Z'")
}
};
if (artifacts is { Length: > 0 })
{
task["artifacts"] = new JsonArray(artifacts.Select(artifact => artifact.DeepClone()).ToArray());
}
if (history is { Count: > 0 })
{
task["history"] = new JsonArray(history.Messages.Select(historyMessage => historyMessage.DeepClone()).ToArray());
}
return task;
}
private static JsonObject ApplyHistoryLength(JsonObject task, int? historyLength)
{
var clone = task.DeepClone().AsObject();
if (historyLength is null || clone["history"] is not JsonArray history)
{
return clone;
}
var limit = Math.Max(historyLength.Value, 0);
clone["history"] = new JsonArray(history.Take(limit).Select(message => message?.DeepClone()).ToArray());
return clone;
}
private static JsonObject CreateTckMessage(string text, string messageId, string contextId, string? taskId = null)
{
var message = new JsonObject
{
["role"] = "ROLE_AGENT",
["parts"] = new JsonArray(new JsonObject { ["text"] = text }),
["messageId"] = messageId,
["contextId"] = contextId
};
if (!string.IsNullOrWhiteSpace(taskId))
{
message["taskId"] = taskId;
}
return message;
}
private static JsonObject CreateUserHistoryMessage(JsonObject message, string contextId, string taskId)
{
var text = message["parts"] is JsonArray parts && parts.FirstOrDefault() is JsonObject part
? GetString(part, "text")
: "TCK message";
return new JsonObject
{
["role"] = "ROLE_USER",
["parts"] = new JsonArray(new JsonObject { ["text"] = text }),
["messageId"] = GetString(message, "messageId"),
["contextId"] = contextId,
["taskId"] = taskId
};
}
private static JsonObject CreateTextArtifact(string text) => new()
{
["artifactId"] = $"artifact-{Guid.NewGuid():N}",
["parts"] = new JsonArray(new JsonObject { ["text"] = text })
};
private static JsonObject CreateFileArtifact(string? raw = null, string? url = null)
{
var part = new JsonObject
{
["filename"] = "output.txt",
["mediaType"] = "text/plain"
};
if (!string.IsNullOrWhiteSpace(raw))
{
part["raw"] = raw;
}
if (!string.IsNullOrWhiteSpace(url))
{
part["url"] = url;
}
return new JsonObject
{
["artifactId"] = $"artifact-{Guid.NewGuid():N}",
["parts"] = new JsonArray(part)
};
}
private static JsonObject CreateDataArtifact() => new()
{
["artifactId"] = $"artifact-{Guid.NewGuid():N}",
["parts"] = new JsonArray(new JsonObject
{
["data"] = new JsonObject
{
["key"] = "value",
["count"] = 42
}
})
};
private static string CreateTckTaskId(JsonObject? message, string fallback) =>
$"task-{(GetString(message, "messageId") is { Length: > 0 } messageId ? messageId : fallback)}";
private static string CreateTckContextId(JsonObject? message, string taskId) =>
GetString(message, "contextId") is { Length: > 0 } contextId
? contextId
: $"context-{taskId}";
private static string CreateTckAgentUrl(HttpContext context) =>
$"{context.Request.Scheme}://{context.Request.Host}{context.Request.PathBase}{ProtocolPaths.TckA2A}";
private static string GetString(JsonObject? obj, string propertyName) =>
obj?[propertyName]?.GetValue<string>() ?? string.Empty;
private static bool IsJsonPost(HttpContext context) =>
context.Request.ContentType?.Contains("application/json", StringComparison.OrdinalIgnoreCase) == true;
private static bool IsUnsupportedA2AVersion(HttpContext context) =>
context.Request.Headers.TryGetValue("A2A-Version", out var version)
&& !string.IsNullOrWhiteSpace(version)
&& version != "1.0";
private static IResult TckError(int statusCode, string status, string message, string reason) =>
TypedResults.Json(CreateTckErrorBody(statusCode, status, message, reason), statusCode: statusCode);
private static async Task WriteTckErrorAsync(HttpContext context, int statusCode, string status, string message, string reason)
{
context.Response.StatusCode = statusCode;
context.Response.ContentType = "application/json";
await JsonSerializer.SerializeAsync(
context.Response.Body,
CreateTckErrorBody(statusCode, status, message, reason),
new JsonSerializerOptions(JsonSerializerDefaults.Web),
context.RequestAborted);
}
private static JsonObject CreateTckErrorBody(int statusCode, string status, string message, string reason) => new()
{
["error"] = new JsonObject
{
["code"] = statusCode,
["status"] = status,
["message"] = message,
["details"] = new JsonArray(new JsonObject
{
["@type"] = "type.googleapis.com/google.rpc.ErrorInfo",
["reason"] = reason,
["domain"] = "a2a-protocol.org"
})
}
};
private static async Task WriteSseAsync(HttpContext context, JsonObject payload)
{
if (!context.Response.HasStarted)
{
context.Response.StatusCode = StatusCodes.Status200OK;
context.Response.ContentType = "text/event-stream";
}
await context.Response.WriteAsync($"data: {payload.ToJsonString(new JsonSerializerOptions(JsonSerializerDefaults.Web))}\n\n", context.RequestAborted);
await context.Response.Body.FlushAsync(context.RequestAborted);
}
}
public sealed record TckTaskState(JsonObject Task, TckHistory? History = null)
{
public string ContextId => Task["contextId"]?.GetValue<string>() ?? string.Empty;
public bool IsTerminal => Task["status"] is JsonObject status
&& status["state"]?.GetValue<string>() is "TASK_STATE_COMPLETED" or "TASK_STATE_FAILED" or "TASK_STATE_CANCELED" or "TASK_STATE_REJECTED";
public TckHistory History { get; init; } = History ?? TckHistory.Empty;
}
public sealed record TckHistory(JsonObject[] Messages)
{
public static TckHistory Empty { get; } = new([]);
public int Count => Messages.Length;
public TckHistory Add(JsonObject message) => new([.. Messages, message]);
}With the core functionality now in place, let's start exploring all the endpoints and see how the tools help you understand and interact with them.
This sub-section briefly covers the implementation details of each protocol and includes a pictorial representation.
A2A Protocol

The primary endpoint, /a2a/agent, serves as the real A2A endpoint, while a set of development-only compatibility endpoints /a2a/agent/v1, /.well-known/agent-card.json, /a2a/tck, and /a2a/tck/.well-known/agent-card.json exist solely to support local tooling and protocol checks. A2AEndpoints.cs maps the native A2A HTTP+JSON endpoint via MapA2AHttpJson and, in Development, also maps the Inspector bridge, since current Inspector/a2a-sdk assumptions require a specific well-known card and a /v1/message:send shape; TckCompatibilityEndpoints.cs, by contrast, is kept deliberately separate because the TCK demands deterministic task, artifact, and error behaviour that must not leak into the real Agent endpoint.
OpenAI-Compatible Endpoints

The primary endpoints /agent/v1/chat/completions, /agent/v1/responses, and /v1/conversations expose an OpenAI-compatible surface, alongside a development-only Responses route: /v1/responses, the real mapped endpoint required by DevUI, and /agent/v1/responses, a path alias rewritten to it. ProtocolHosting.cs registers the OpenAI-compatible hosting services, while OpenAICompatibleEndpoints.cs reads OpenAICompatibleOptions and maps Chat Completions, Responses, and Conversations to the same hosted Agent, giving clients and SDKs that already understand the OpenAI API shape a way to exercise the agent through familiar request models—simply by pointing at a custom base URL without touching the agent implementation itself. Because DevUI requires the default OpenAI Responses route at /v1/responses, the service maps that endpoint once in Development and rewrites /agent/v1/responses to it before routing, avoiding duplicate endpoint names that would otherwise result from mapping the Agent Framework Responses endpoint twice. The key boundary to keep in mind: OpenAI-compatible endpoints are protocol adapters only, and must never contain Agent behaviour, model selection logic, or tool logic.
AG-UI Protocol

The primary endpoint, /ag-ui/agent, is registered through ProtocolHosting.cs, which sets up AG-UI hosting services, and AgUiEndpoints.cs, which maps the AG-UI endpoint via MapAGUI; for basic AG-UI Dojo compatibility, it also adds a scoped CORS policy and a development-only capability helper endpoint at /ag-ui/agent/capabilities. Current Dojo support covers run input, text streaming, and browser CORS for configured development origins, while optional development-only building-block helpers—state synchronisation, frontend tool calls, interrupts, generative UI, and run-event inspection over SSE, can be enabled through AgUi configuration; these helpers are explicit adjuncts to the AG-UI endpoint, storing transient run state in InMemoryAgUiRunStore for local workbench and Dojo compatibility experiments rather than durable production workflow state. The key boundary is this: Dojo compatibility and AG-UI helper routes are development aids and should remain explicit and scoped, never expanding into broad production CORS policies or durable application state.
Let's run the app
Start with aspire run to launch the Aspire Dashboard.
aspire run

As you can see from the images above, the Aspire Dashboard includes the web application alongside the other projects and tools though it has not been discussed or developed in this post. The web application is not mandatory for validating the core functionality; the endpoints and tools covered here are sufficient to explore everything end to end. That said, it does make the experience significantly more intuitive. To keep this post focused and concise, the web application code is not covered here; however, but if any reader is interested, feel free to reach out, and I will be happy to share it.
The default URL for the Agent is configured to invoke DevUI, but can easily be changed to call the Scalar UI instead.


A2A Protocol Inspector helps to inspect, debug, and validate servers.

The A2A Protocol-TCK resource requires an explicit start. It validates the A2A Protocol implementation and produces a report to surface findings.

Play with Aspire's various options to learn more about what happens when you submit a prompt or access any exposed endpoint.

Conclusion
This walkthrough shows how quickly an agent can outgrow a single chat endpoint once the hosting layer is treated as a first-class design element. With Microsoft Agent Framework handling the agent abstraction, Aspire orchestrating the local environment, and protocol adapters exposing A2A, OpenAI-compatible, and AG-UI surfaces, the same agent can be exercised by different tools and clients without duplicating core logic. The key strength here is separation of responsibilities: the agent owns its instructions, tools, model options, and telemetry, while protocol endpoints stay focused purely on transport and client compatibility. Development-only routes for A2A Inspector, A2A TCK, DevUI, Scalar, and AG-UI Dojo make the system easier to inspect and validate locally, keeping compatibility work cleanly separated from the production-facing agent surface.
Package maturity is worth flagging, too. Several packages used here including the Agent Framework hosting packages for A2A, AG-UI, OpenAI-compatible endpoints, and DevUI are explicitly preview or alpha. That's fine for learning and prototyping, but production use demands care, since APIs, route conventions, and tooling assumptions can still shift between builds. Pin exact versions, isolate protocol hosting behind small endpoint modules, disable development-only helpers outside development, and validate every upgrade with automated tests, Inspector checks, TCK runs, and end-to-end client tests. Ultimately, this pattern offers a practical workbench for agent interoperability build once, expose through multiple emerging standards, observe via Aspire and OpenTelemetry, and test with real protocol tooling. The tradeoff: the NuGet stack is still moving, so stay experimental but disciplined, keeping the implementation modular enough that package changes don't ripple through the whole application.