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.
Protocol
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:
- AgentCard - what the agent can do
- Agent handler - the server-side logic
- A2AClient - sends messages to a remote agent
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)]
PoC setup
- Two agents: Assortment and SupplyChain that support A2A
- Orchestrator: agent that routes user requests to agents and aggregates results
- LLM: Ollama based on Microsoft.Extensions.AI as an abstraction that support tool calling. Can be swapped for any
IChatClient(Azure OpenAI, Bedrock, OpenAI, etc.). - Aspire host, which connects and runs all services together and helps to understand flow using OpenTelemetry
Agent implementation
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);
}
Orchestrator
There are several ways to orchestrate agents
- We craft Manual explicit route
- Define Workflow engine
- Let LLM route
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
Conclusion
- The result is visualized with traces in the cover image
- Protocol has a stable version, but .NET libs are still in preview
- Communication between agents and tool calls takes time. It is better suited to long-running tasks than for immediate responses.
- Managing chat history is also a pain point.
A2A Multi-Agent Orchestrator
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).
Architecture
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…
Top comments (1)
The AgentCard-as-tool conversion is the part I'd underline. It's the same shape we use for MCP servers in our multi-agent setup on a VPS — every remote capability becomes a function the orchestrator LLM can call, and adding a service doesn't touch the orchestrator's code. Once you've worked that way, hard-coded routing feels like writing switch statements over HTTP endpoints.
FunctionInvokingChatClientwithAllowConcurrentInvocationis a neat fit for the parallel fan-out in your sequence diagram — merging the catalog and stock answers in a single LLM pass instead of two serialized round trips.Two failure modes I'd love to hear how you handle:
When one sub-agent times out or returns garbage mid-loop, does the
FunctionInvokingChatClientsurface the error to the LLM so it can retry or degrade, or does the wholeGetResponseAsyncthrow? We ended up wrapping dispatches in retry-plus-a-failure-summary tool result, otherwise one flaky agent poisons the merged answer.Remote agent replies are model-controlled tool results, so a compromised or hallucinating downstream agent can inject instructions into the orchestrator's context. Do you sanitize or at least tag the extracted text before it goes back into the loop?
The Aspire + OpenTelemetry traces sound like the pragmatic heart of the PoC, honestly. "Why did the orchestrator call stock before catalog" is exactly the kind of question you want answered from a timeline, not from a debugger.