Investigation of how to orchestrate an agentic system using an A2A protocol based on .NET primitives and create a PoC. Use Case: We have existing agents that support A2A, and we want to build it into a multi-agent system.
Let's start from theory. The agent-to-agent (A2A) protocol is designed to define well-known contracts for communication between agents without humans. Every agent publishes an AgentCard /.well-known/agent-card.json. An Agent client discovers the card, then sends tasks to the agent as messages.
Our building blocks:
graph LR
User([User]) --> Orch[Orchestrator]
Orch -. discover AgentCard .-> A[Assortment agent]
Orch -. discover AgentCard .-> S[SupplyChain agent]
Orch -- A2A SendMessage --> A
Orch -- A2A SendMessage --> S
A --> AT[(Catalog tools)]
S --> ST[(Stock tools)]
IChatClient (Azure OpenAI, Bedrock, OpenAI, etc.).
Microsoft provides an abstraction for the A2A spec for ASP.NET Core
var agentCard = new AgentCard
{
Name = "AssortmentSpecialist",
Description = "Handles shop inventories, product categorizations, catalogs, and store assortments.",
Skills =
[
new AgentSkill
{
Id = "get-product",
Name = "GetProduct",
Description = "Look up a product's SKU, category, active status, and store coverage by name.",
},
],
};
builder.Services.AddA2AAgent<DomainAgentHandler>(agentCard);
var app = builder.Build();
app.MapWellKnownAgentCard(agentCard, "");
app.MapA2A("/");
The handler processes tasks using the LLM and the agent's own tools.
public async Task ExecuteAsync(RequestContext context, AgentEventQueue eventQueue, CancellationToken ct)
{
var responder = new MessageResponder(eventQueue, context.ContextId);
var messages = new List<ChatMessage>
{
new(ChatRole.System, "You are the Assortment specialist. Use the tools to look up real data."),
new(ChatRole.User, context.UserText ?? string.Empty),
};
var options = new ChatOptions { Tools = [AIFunctionFactory.Create(tools.GetProduct)] };
var response = await chatClient.GetResponseAsync(messages, options, ct);
await responder.ReplyAsync(response.Text, ct);
}
There are several ways to orchestrate agents
We chose the last one because we want to have the possibility to add a new agent without any code changes. That way, we register every agent card dynamically as an orchestrator agent tool, and aggregation happens in the same LLM loop.
public async Task<string> HandleAsync(ChatThread thread, string userMessage, CancellationToken ct)
{
var agents = await registry.GetAgents(ct);
var tools = agents.Select(ToTool).Cast<AITool>().ToList();
var messages = new List<ChatMessage> { new(ChatRole.System, SystemPrompt) };
messages.Add(new ChatMessage(ChatRole.User, userMessage));
using var client = new FunctionInvokingChatClient(chatClient)
{
AllowConcurrentInvocation = true,
}.AsBuilder().Build();
var response = await client.GetResponseAsync(
messages,
new ChatOptions { Tools = tools, AllowMultipleToolCalls = true },
ct);
return response.Text;
}
We convert remote agents to AIFunction from AgentCard
private AIFunction ToTool(RemoteAgent agent)
{
var dispatch = async (string request, CancellationToken ct) =>
{
var response = await agent.Client!.SendMessageAsync(request, Role.User, cancellationToken: ct);
return ExtractText(response);
};
return AIFunctionFactory.Create(dispatch, agent.Card!.Name,
$"Ask the {agent.Card.Name} specialist. {agent.Card.Description}");
}
The orchestrator resolves each card with A2ACardResolver, then turns it into a tool.
A request that needs both agents makes the LLM call both agents in parallel and merge their replies into one.
sequenceDiagram
actor User
participant Orch as Orchestrator (LLM loop)
participant A as Assortment agent
participant S as SupplyChain agent
User->>Orch: "Stores carrying the coat AND its stock?"
par send both messages in parallel
Orch->>A: A2A SendMessage(sub-task)
and
Orch->>S: A2A SendMessage(sub-task)
end
A-->>Orch: catalog answer
S-->>Orch: stock answer
Orch->>Orch: merge results
Orch-->>User: one cohesive answer
A .NET 10 PoC for the A2A. Orchestrator discovers agents over HTTP, exposes each as a tool to one LLM loop, and aggregate to one response. All LLM inference runs locally through Ollama (llama3.2).
graph TB
Ollama[("Ollama<br/>llama3.2<br/>local LLM (external)")]
User([User / Browser]) -->|HTTP| Orch
subgraph Orch["Orchestrator"]
API["Minimal API + chat UI<br/>/api/chat"]
Svc["OrchestrationService<br/>one tool-calling LLM loop"]
Reg["AgentRegistry<br/>(AgentCards + A2AClients)"]
Store["ChatStore<br/>(history by threadId)"]
API --> Svc
Svc --> Reg
Svc --> Store
end
Svc -->|LLM: tool loop + synthesis| Ollama
subgraph Assort["AssortmentSpecialist (A2A server)"]
AH["DomainAgentHandler"]
AT["AssortmentTools<br/>GetProduct / GetActiveCatalog"]
AH --> AT
end
subgraph Supply["SupplyChainAnalyst (A2A server)"]
SH["DomainAgentHandler"]
ST["SupplyChainTools<br/>GetStock / GetShipments"]
SH --> ST
end
Reg -.->|discover AgentCard| Assort
Reg -.->|discover AgentCard| Supply
Svc -->|A2A SendMessage / tool call| Assort
Svc -->|A2A SendMessage / tool call| Supply
AH -->|LLM: tool-calling| Ollama
SH -->|LLM: tool-calling| Ollama
See docs/architecture.md for diagrams and the full request flow, and
AGENTS.md…