# Multi-Agent Orchestration in .NET Using A2A

> Source: <https://dev.to/ohalay/multi-agent-orchestration-in-net-using-a2a-4m5f>
> Published: 2026-09-08 20:10:36+00:00

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:

``` php
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

``` js
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

``` js
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`](https://github.com/ohalay/a2a-poc/docs/architecture.md) for diagrams and the full request flow, and
[`AGENTS.md`](https://github.com/ohalay/a2a-poc/AGENTS.md)…
