Monday, October 19, 2026

Microsoft Agent Framework: Agent to Agent (A2A) with .NET

In this post, let's see how we can expose an agent over the Agent-to-Agent (A2A) protocol, and then call it from another application as if it were a local agent.

So far in this series, whoever called our agents either ran them in the same process or knew exactly how they were hosted. A2A is an open protocol for the case where they don't: an agent publishes a card describing itself, and any A2A client, built with any framework, can discover it and talk to it. Agent Framework has packages for both sides.

We'll use the same weekend-planner agent as in Getting Started with Microsoft Agent Framework 1.0.0 in .NET, with its three tools: GetWeather, GetActivities and GetCurrentDate.

Exposing the agent

The server is an ASP.NET Core application with these packages (latest as of today, it will change).
<PackageVersion Include="Microsoft.Agents.AI.Foundry" Version="1.25.0-preview.261014.1" />
<PackageVersion Include="Microsoft.Agents.AI.Hosting.A2A.AspNetCore" Version="1.25.0-preview.261014.1" />
The agent itself is created the usual way.
AIAgent agent = new AIProjectClient(new Uri(endpoint), credential)
    .AsAIAgent(
        model: deploymentName,
        name: "weekend-planner",
        description: "Plans weekends and suggests activities based on the weather.",
        instructions: """
            You help users plan their weekends and choose the best activities for the given weather.
            If an activity would be unpleasant in weather, don't suggest it.
            Include date of the weekend in response.
            """,
        tools: [
            AIFunctionFactory.Create(GetWeather, nameof(GetWeather)),
            AIFunctionFactory.Create(GetActivities, nameof(GetActivities)),
            AIFunctionFactory.Create(GetCurrentDate, nameof(GetCurrentDate))
        ]);

The agent card

Then the agent card. This is what other agents read to discover ours before they send it anything, and the client we write later starts from it.
// What other agents read to discover this one, before they send it anything
AgentCard agentCard = new()
{
    Name = agent.Name!,
    Description = agent.Description!,
    Version = "1.0.0",
    DefaultInputModes = ["text/plain"],
    DefaultOutputModes = ["text/plain"],
    Capabilities = new()
    {
        Streaming = false,
        PushNotifications = false
    },
    Skills =
    [
        new()
        {
            Id = "weekend-planning",
            Name = "Weekend planning",
            Description = "Suggests weekend activities for a city, based on the weather.",
            Examples = ["What should I do this weekend in Auckland?"]
        }
    ],
    SupportedInterfaces =
    [
        new()
        {
            Url = agentUrl,
            ProtocolBinding = ProtocolBindingNames.JsonRpc,
            ProtocolVersion = "1.0"
        }
    ]
};
There is no method that builds the card from the agent, so it is all ours to fill in. What the agent already knows, its name and description, we take from it, so they are defined in one place. SupportedInterfaces is what tells a client where to send messages and which binding to use, so it has to match the endpoint we map next.

Hosting it

Now we register an A2A server for the agent, map its endpoint, and serve the card.
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.AddA2AServer(agent);

// Sessions are not kept by default, so every message would start a new conversation
builder.Services
    .AddKeyedSingleton<AgentSessionStore>(agent.Name, new InMemoryAgentSessionStore());

WebApplication app = builder.Build();

app.MapA2AJsonRpc(agent, "/");

// Served at /.well-known/agent-card.json
app.MapWellKnownAgentCard(agentCard);

app.Run();
A few things to note here.

A2A defines two protocol bindings, JSON-RPC and HTTP+JSON, and there is a map method for each: MapA2AJsonRpc and MapA2AHttpJson. You can map both if you want clients to pick. Here I am using JSON-RPC only.

MapWellKnownAgentCard comes from the A2A SDK (A2A.AspNetCore), not from Agent Framework, and serves the card at /.well-known/agent-card.json. 
/.well-known/agent-card.json
Only one card can be served per host, so if you host several agents in one application, only one of them is discoverable this way.

The AgentSessionStore line is easy to miss. Without it, the server doesn't keep any sessions, so a follow-up message from the same client has no memory of the first one. InMemoryAgentSessionStore keeps them in the process, which is fine for a sample. It is still experimental, so you need to suppress MAAI001.

Calling it over HTTP

Before writing a client, let's see what is on the wire. Run the server, and use the following .http file.
@host = http://localhost:5000

POST {{host}}/
Content-Type: application/json
A2A-Version: 1.0

{
  "jsonrpc": "2.0",
  "id": "1",
  "method": "SendMessage",
  "params": {
    "message": {
      "messageId": "1",
      "role": "ROLE_USER",
      "parts": [
        {
          "text": "What should I do this weekend in Auckland?"
        }
      ]
    }
  }
}

Runs the agent, tools and all, and the answer comes back as an A2A message.
Agent Run
The contextId is the conversation. Send it back on the next message and the server picks up the same session from the session store; leave it out and a new conversation starts.

Note that the reply is a message, not a task. Agent Framework returns every response as an immediate message for now, as background responses aren't supported yet for A2A hosted agents.

Calling it from another agent application

The client is a console application with just one package.
<PackageVersion Include="Microsoft.Agents.AI.A2A" Version="1.25.0-preview.261014.1" />
It only needs the server's base address. It reads the card, and turns it into an AIAgent.
using A2A;
using Microsoft.Agents.AI;

string agentUrl = Environment.GetEnvironmentVariable("A2A_AGENT_URL")!;
// Reads the agent card from /.well-known/agent-card.json A2ACardResolver cardResolver = new(new Uri(agentUrl)); AgentCard agentCard = await cardResolver.GetAgentCardAsync(); Console.WriteLine($"[A2A] Found '{agentCard.Name}': {agentCard.Description}"); // The remote agent becomes an AIAgent, called like any local one AIAgent agent = agentCard.AsAIAgent(); // One session, so the follow-up continues the same conversation on the remote side AgentSession session = await agent.CreateSessionAsync(); string[] userInputs = [ "What should I do this weekend in Auckland?", "What about the weekend after that?" ]; foreach (string userInput in userInputs) { Console.WriteLine(); Console.WriteLine("--- User ---"); Console.WriteLine(userInput); AgentResponse response = await agent.RunAsync(userInput, session); Console.WriteLine(); Console.WriteLine("--- Agent Response ---"); Console.WriteLine(response.ToString()); }
Console.ReadLine();
That's the nice part. Once we have the AIAgent, nothing in the rest of the code knows it is remote. The session carries the contextId for us, so the second question is answered in the context of the first.
[A2A] Found 'weekend-planner': Plans weekends and suggests activities based on the weather.

--- User ---
What should I do this weekend in Auckland?

--- Agent Response ---
Weekend dates: **Saturday 2026-10-24 and Sunday 2026-10-25** in **Auckland**.
Forecast: **~18°C and rainy**, so I’d avoid **the beach** and **hiking** (wet, windy, slippery tracks).
// Omitted for brevity

--- User ---
What about the weekend after that?

--- Agent Response ---
Weekend dates: **Saturday 2026-10-31 and Sunday 2026-11-01** in **Auckland**.
Forecast: **~18°C and rainy**, so I’d skip **the beach** and **hiking** again (not great in wet weather).
// Omitted for brevity
And on the server, the tools ran for both weekends.
[Tool] Getting current date
[Tool] Getting weather for 'Auckland'.
[Tool] Getting activities for 'Auckland' on '2026-10-24'.
[Tool] Getting activities for 'Auckland' on '2026-10-25'.
[Tool] Getting weather for 'Auckland'.
[Tool] Getting activities for 'Auckland' on '2026-10-31'.
[Tool] Getting activities for 'Auckland' on '2026-11-01'.
Note there's no second GetCurrentDate call. The agent already knew today's date from the first turn, which shows the server really did keep the session.

The complete sample is here.

More read:

Hope this helps.

Happy Coding.

Regards,
Jaliya

Saturday, October 17, 2026

Microsoft Agent Framework: Human in the Loop Tool Approval with .NET

In this post, let's make an agent stop and ask a person before it does something we don't want it doing on its own.

In the last two posts, Microsoft Agent Framework: Handoff Orchestration with .NET and Microsoft Agent Framework: Hosting a Handoff Workflow in Microsoft Foundry with .NET, we built a weekend concierge, an activities agent and a dining agent handing the conversation between them, and then hosted it in Microsoft Foundry. The dining agent can book a table, and that's the kind of thing worth a human's yes before it happens.

The idea is simple enough. Some tools are fine for an agent to call on its own, like looking up the weather. Others change something in the real world, and for those we want the run to stop, show a person exactly what is about to happen, and carry on only if they say yes. Agent Framework has this built in, so we don't have to invent a pause of our own.

Requiring approval

The tool itself doesn't change. We just wrap it.
AIAgent diningAgent = projectClient.AsAIAgent(
    model: deploymentName,
    name: "dining",
    description: "Finds restaurants and books tables.",
    instructions: """
        You help users find a restaurant and book a table.
        Always look up restaurants before suggesting one, and never invent a restaurant.
        Never ask the user to confirm in chat, just call the booking tool.
        If the booking tool reports it was not approved, say it was not booked and don't try again.
        If the user asks about activities or the weather, hand off to the activities agent.
        """,
    tools: [
        AIFunctionFactory.Create(GetRestaurants, nameof(GetRestaurants)),
        // Booking a table is worth a human's confirmation, so the run pauses before it happens
        new ApprovalRequiredAIFunction(AIFunctionFactory.Create(BookTable, nameof(BookTable)))
    ]);
The instructions matter as much as the wrapper. The agent has to be told to call the booking tool rather than ask in the chat, otherwise it politely asks for permission in conversation and the real gate never fires.

What it looks like in a console application

When the agent runs in your own process, the run comes back early, before the tool has run, holding a request for approval. You decide, hand the answer back, and the run continues from where it stopped. In code that request is a ToolApprovalRequestContent, and it can create its own matching response, so you never have to line up ids yourself.
AgentResponse response = await weekendConcierge.RunAsync(userInput, session);

// The agent stops and asks before running a tool that needs approval
while (GetApprovalRequests(response) is { Count: > 0 } approvalRequests)
{
    List<ChatMessage> approvals = [];
    Dictionary<string, bool> decisions = [];

    foreach (ToolApprovalRequestContent request in approvalRequests)
    {
        // The same tool call can be asked more than once, so decide once and reuse the answer
        if (!decisions.TryGetValue(request.ToolCall.CallId, out bool approved))
        {
            Console.WriteLine();
            Console.WriteLine("--- Approval needed ---");

            if (request.ToolCall is FunctionCallContent toolCall)
            {
                Console.WriteLine($"{toolCall.Name}({string.Join(", ", toolCall.Arguments?.Select(a => $"{a.Key}: {a.Value}") ?? [])})");
            }

            Console.Write("Approve? (y/n): ");

            approved = Console.ReadLine()?.Trim().Equals("y", StringComparison.OrdinalIgnoreCase) == true;
            decisions[request.ToolCall.CallId] = approved;
        }

        // CreateResponse correlates the answer with the request for us
        approvals.Add(new ChatMessage(ChatRole.User, [request.CreateResponse(approved)]));
    }

    response = await weekendConcierge.RunAsync(approvals, session);
}

static List<ToolApprovalRequestContent> GetApprovalRequests(AgentResponse response) =>
    [.. response.Messages
    .SelectMany(message => message.Contents)
    .OfType<ToolApprovalRequestContent>()];
Approving books the table and denying doesn't, but both responses end with an error, which we'll come back to.
--- Approval needed ---
BookTable(restaurant: The Domain Kitchen, date: 2026-10-17, time: 19:00, people: 2)
Approve? (y/n): y
[Tool] Booking a table for 2 at 'The Domain Kitchen' on '2026-10-17' at '19:00'.

--- Agent Response ---
Booked: The Domain Kitchen for 2 people on Saturday 17 Oct 2026 at 7:00pm.
Booking reference: BK-2179
[Error] An error occurred while executing the workflow.

Approve? (y/n): n

--- Agent Response ---
I tried to book The Domain Kitchen for 2 people at 7:00pm on Saturday, but it was not approved,
so it was not booked.
[Error] An error occurred while executing the workflow.

The same thing, hosted

The sample hosts the agent in Foundry, because that's where this gets interesting. Deploying it is the same as the earlier hosted agents.
azd ai agent init --no-prompt `
  --src . `
  --agent-name weekend-concierge-approval `
  --project-id "/subscriptions/<subscription>/resourceGroups/<resource-group>/providers/Microsoft.CognitiveServices/accounts/<account>/projects/<project>" `
  --deploy-mode code `
  --runtime dotnet_10 `
  --entry-point HumanInTheLoop.dll `
  --model-deployment <model-deployment> `
  --protocol responses `
  --environment weekend-concierge-approval

azd deploy
A word on why raw HTTP here. There is a client SDK, and for ordinary calls it is nicer: any OpenAI compatible client can talk to the agent endpoint, which is what the Foundry quickstarts use. But mcp_approval_request and mcp_approval_response are items inside the input and output arrays, and the typed clients model the common shapes rather than these, so answering an approval means hand building the item anyway. Seeing it on the wire is also the point of this post.

Then we walk the same conversation over the Responses protocol. First, ask for the booking.
POST {{endpoint}}/agents/{{agent}}/endpoint/protocols/openai/responses?api-version=v1
Authorization: Bearer {{token}}
Content-Type: application/json

{
  "input": "Book The Domain Kitchen for 2 people at 7pm on Saturday.",
  "stream": false
}
The dining agent wants to know where and when before it books anything, so nothing is waiting on us yet.
"text": "What city is The Domain Kitchen in, and what's the date (YYYY-MM-DD) for Saturday?"
So we answer, carrying the conversation on with previous_response_id, which is the id of the response we just got back.
POST {{endpoint}}/agents/{{agent}}/endpoint/protocols/openai/responses?api-version=v1
Authorization: Bearer {{token}}
Content-Type: application/json

{
  "previous_response_id": "<the caresp_... id of the response above>",
  "stream": false,
  "input": "Auckland, and the date is 2026-10-17."
}
Now it has everything it needs to book, so the run pauses and asks. The pause arrives as an mcp_approval_request item, carrying the tool name and the arguments, which is exactly what you'd show an approver. Note there are two of them, for the one BookTable call.
{
  "type": "mcp_approval_request",
  "id": "mcpr_E41E3C55A3EF084A2EE2339780FE661F4219A723CCDEE141E6",
  "server_label": "agent_framework",
  "name": "BookTable",
  "arguments": "{\"restaurant\":\"The Domain Kitchen\",\"date\":\"2026-10-17\",\"time\":\"19:00\",\"people\":2}"
},
{
  "type": "mcp_approval_request",
  "id": "mcpr_A3C8F9317B062E63C739D44A10DA0DE19C79BF37FBFB2A1869",
  "server_label": "agent_framework",
  "name": "BookTable",
  "arguments": "{\"restaurant\":\"The Domain Kitchen\",\"date\":\"2026-10-17\",\"time\":\"19:00\",\"people\":2}"
}
This is the same shape Foundry documents for LangGraph interrupts, where a pending approval also arrives as an mcp_approval_request and is resumed with an mcp_approval_response. One thing caught me out: the response carrying that item is marked completed, not incomplete, at least in the preview packages I tested. So check whether the item is there rather than trusting the status.

Then we answer, and this is the fiddly bit. Two different ids are in play. previous_response_id is the caresp_ id of the response we just received, and each approval_request_id is the mcpr_ id of one of the approval items inside it.
POST {{endpoint}}/agents/{{agent}}/endpoint/protocols/openai/responses?api-version=v1
Authorization: Bearer {{token}}
Content-Type: application/json

{
  "previous_response_id": "<the caresp_... id of the response above>",
  "stream": false,
  "input": [
    {
      "type": "mcp_approval_response",
      "approval_request_id": "mcpr_E41E3C55A3EF084A2EE2339780FE661F4219A723CCDEE141E6",
      "approve": true
    },
    {
      "type": "mcp_approval_response",
      "approval_request_id": "mcpr_A3C8F9317B062E63C739D44A10DA0DE19C79BF37FBFB2A1869",
      "approve": true
    }
  ]
}

And here's where it stands today

And that last call is where it falls over. The table gets booked, and the same response tells you the run failed.
{
  "status": "failed",
  "error": {
    "code": "server_error",
    "message": "An error occurred while executing the workflow."
  },
  "output": [
    {
      "type": "function_call",
      "name": "BookTable"
    },
    {
      "type": "function_call_output",
      "output": "{\"reference\":\"BK-4651\"}"
      // Omitted for brevity
    },
    {
      "type": "message",
      "role": "assistant",
      "content": [
        {
          "type": "output_text",
          "text": "Booked: The Domain Kitchen (Auckland) for 2 people on 2026-10-17 at 19:00. Booking reference: BK-4651"
        }
      ]
    }
  ]
  // Omitted for brevity
}
Note the transport is perfectly happy, it is HTTP 200. The failure is inside the payload, and it is not in the container logs either, so a client that only checks status codes will think this worked. What makes it worse is that the tool did run: the booking reference comes back in the same response that is marked failed.

The agent's playground in the Foundry portal does the same, and so does the console version above. I have raised this as agent-framework issue 8577.

And when you are done with it, delete the agent weekend-concierge-approval, from the portal or over REST. As in the hosted agent post, azd down is not what you want here, the project was already there before we deployed into it.

The complete sample is here, and the console version, the one that runs the workflow in process, is here.

More read:

Hope this helps.

Happy Coding.

Regards,
Jaliya

Monday, October 12, 2026

Microsoft Agent Framework: Hosting a Handoff Workflow in Microsoft Foundry with .NET

In this post, let's take the handoff workflow from the last post and let Microsoft Foundry host it, so the two agents run in Foundry behind a single endpoint.

In the previous post, Microsoft Agent Framework: Handoff Orchestration with .NET, we had an activities agent and a dining agent handing the conversation between them, all inside a console application. And before that, in Microsoft Agent Framework: Hosted Agents in Microsoft Foundry with .NET, we deployed a single agent as a hosted agent. This post is those two put together.

What changes

Surprisingly little. The agents and the handoff wiring stay exactly as they were.
// The model decides who answers next, not us
Workflow workflow = AgentWorkflowBuilder.CreateHandoffBuilderWith(activitiesAgent)
    .WithHandoffs(activitiesAgent, [diningAgent])
    .WithHandoffs(diningAgent, [activitiesAgent])
    // Follow-ups stay with the agent already helping, instead of going back to the first one
    .EnableReturnToPrevious()
    .Build();

// The whole workflow becomes a single agent
AIAgent weekendConcierge = workflow.AsAIAgent(
    name: "weekend-concierge",
    description: "Plans a weekend, and hands off between activities and dining.");
What changes is the ending. Instead of running turns ourselves, we serve the workflow over the Responses protocol.
// Serve the whole workflow as one agent, over the Responses protocol
AgentHostBuilder builder = AgentHost.CreateBuilder(args);
builder.Services.AddFoundryResponses(weekendConcierge);
builder.RegisterProtocol("responses", endpoints => endpoints.MapFoundryResponses());

AgentHostApp app = builder.Build();
app.Run();
And the two environment values now come from Foundry when the agent is hosted with DefaultAzureCredential so the same code runs as us locally and as the agent's own managed identity in Foundry.
// Foundry injects these when the agent is hosted, locally they come from launchSettings.json
string endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT")!;
string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME")!;

// Locally this is the developer, in Foundry it is the agent's own managed identity
var credential = new DefaultAzureCredential();
That's the whole difference. Two agents, one workflow, one hosted agent.

Deploying

Same as the single hosted agent, from the agent's folder.
azd ai agent init --no-prompt `
  --src . `
  --agent-name weekend-concierge `
  --project-id "/subscriptions/<subscription>/resourceGroups/<resource-group>/providers/Microsoft.CognitiveServices/accounts/<account>/projects/<project>" `
  --deploy-mode code `
  --runtime dotnet_10 `
  --entry-point HostedHandoff.dll `
  --model-deployment <DeploymentId> `
  --protocol responses `
  --environment weekend-concierge

azd deploy

Watching the handoff from outside

This is the part I like. In the console sample, we could see the handoff because we watched the [Tool] lines scroll past in our own process. Now the agent runs in Foundry, and the handoff still shows up, in the response itself.
@agent = weekend-concierge

# For the deployed agent, get a token with:
# az account get-access-token --scope https://ai.azure.com/.default --query accessToken --output tsv
@endpoint = https://<resource>.services.ai.azure.com/api/projects/<projectId>
@token = <token>

### Invoke the deployed agent, the output array shows each agent that ran
POST {{endpoint}}/agents/{{agent}}/endpoint/protocols/openai/responses?api-version=v1
Authorization: Bearer {{token}}
Content-Type: application/json

{
  "input": "Where can we eat in Auckland this weekend?",
  "stream": false
}
The output array of the response contains workflow_action items for each agent that ran, and function_call items for the handoff and the tools.
{
  "id": "caresp_0a6ccab0dcd4e167003Gt1Y2T1IDmnmWlyqnqEC9wodjXo0F20",
  "object": "response",
  "status": "completed",
  "output": [
    // Omitted for brevity
    {
      "type": "workflow_action",
      "action_id": "HandoffStart",
      "status": "completed"
    },
    {
      "type": "workflow_action",
      "action_id": "activities_5fcc719d6e2b4134a0d92ab2df3988bd",
      "status": "in_progress"
    },
    {
      "type": "function_call",
      "call_id": "call_FzQP62eu0leGjPL1cykdGEt4",
      "name": "handoff_to_1",
      "arguments": "{\"reasonForHandoff\":\"User is asking for places to eat (restaurants) in Auckland this weekend.\"}"
    },
    {
      "type": "function_call_output",
      "call_id": "call_FzQP62eu0leGjPL1cykdGEt4",
      "output": "Transferred."
    },
    {
      "type": "workflow_action",
      "action_id": "activities_5fcc719d6e2b4134a0d92ab2df3988bd",
      "status": "completed"
    },
    {
      "type": "workflow_action",
      "action_id": "dining_e079cd98ffb646febbf349ae1a4ba1ff",
      "status": "in_progress"
    },
    {
      "type": "function_call",
      "name": "GetRestaurants",
      "arguments": "{\"city\":\"Auckland\"}"
    },
    {
      "type": "function_call_output",
      "output": "[{\"name\":\"The Domain Kitchen\",\"cuisine\":\"Modern New Zealand\"}]"
      // Omitted for brevity
    },
    {
      "type": "message",
      "role": "assistant",
      "content": [
        {
          "type": "output_text",
          "text": "Here are a few options in Auckland for this weekend: The Domain Kitchen - Modern New Zealand"
          // Omitted for brevity
        }
      ]
    },
    {
      "type": "workflow_action",
      "action_id": "dining_e079cd98ffb646febbf349ae1a4ba1ff",
      "status": "completed"
    },
    // Omitted for brevity
    {
      "type": "workflow_action",
      "action_id": "HandoffEnd",
      "status": "completed"
    }
  ],
  "agent": {
    "type": "agent_id",
    "name": "weekend-concierge",
    "version": "2"
  }
  // Omitted for brevity
}
The question was about food, so the activities agent handed straight over to dining, which looked up the restaurants. Worth noting the handoff is just a tool call like any other, and its arguments carry the model's own reasonForHandoff, so we get to see why it moved the conversation. The tool result is simply Transferred. The same thing is visible in the agent's playground in the Foundry portal, which makes it quite easy to show someone how the routing behaves.
Handoff in Foundry Playground

Can the agents be prompt agents?

This was the first thing I wanted to try. The two agents here are created in code, so their instructions ship with the container. It would be nicer if they were prompt agents, because then someone could edit their instructions in the portal while the handoff wiring stays in the repository.

So I tried it, registering the two agents as prompt agents with their function tools declared in the definition, then passing the tools in so they still run locally.
// The tools run here, the definitions come from Foundry
AIAgent activitiesAgent = projectClient.AsAIAgent(activitiesVersion, tools: activitiesTools);
AIAgent diningAgent = projectClient.AsAIAgent(diningVersion, tools: diningTools);
Their own tools work fine, GetWeather and the rest are called and executed locally as expected. But the handoff never happens. Asked to hand over, the agent writes it out as text instead of calling the tool.
agent: weekend_activities_prompt_weekend_activities_prompt_1
MSG:   Handoff to dining agent: User wants a restaurant recommendation in Auckland
agent: HandoffEnd
The reason is how handoff works. As the Handoff Agent Executor section describes, the workflow injects an extra tool into each agent for every agent it can hand over to. A prompt agent doesn't take tools that way. Per Microsoft Foundry Agent Service, a FoundryAgent uses the model, instructions and hosted tools stored in its Foundry definition, and the client can't replace them at run time. So the injected handoff tools never reach the model, and all it can do is describe the handoff in text. Handoff participants need to be agents created in code.

And when you are done with it, delete the agent weekend-concierge, from the portal or over REST. As in the hosted agent post, azd down is not what you want here, the project was already there before we deployed into it.

The complete sample is here.

More read:

Hope this helps.

Happy Coding.

Regards,
Jaliya

Wednesday, October 7, 2026

Microsoft Agent Framework: Handoff Orchestration with .NET

In this post, let's have a look at handoff orchestration in Microsoft Agent Framework, where the agents themselves decide who should answer next.

Some time back I blogged about Microsoft Agent Framework: Deterministic Multi-Agent Orchestrations with .NET Durable Functions. There, we decided the order. The orchestrator called the first agent, then the second, and it did the same thing on every run.

A handoff is the opposite. We describe who can hand over to whom, and the model picks. That makes it a good fit when the next step depends on what the user just said, rather than on a fixed sequence.

The agents

We need the following packages (latest as of today, it will change).
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.24.0-preview.261006.1" />
<PackageReference Include="Microsoft.Agents.AI.Workflows" Version="1.24.0" />
The first agent is the weekend planner from Getting Started with Microsoft Agent Framework 1.0.0 in .NET, with one line added to its instructions. The second one is new, it finds restaurants and books tables.
string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_ENDPOINT")!;
string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL")!;

// For local development, using AzureCliCredential
var credential = new AzureCliCredential();

AIProjectClient projectClient = new(new Uri(endpoint), credential);

AIAgent activitiesAgent = projectClient.AsAIAgent(
    model: deploymentName,
    name: "activities",
    description: "Plans what to do on the weekend, based on the weather.",
    instructions: """
        You help users plan their weekends and choose the best activities for the given weather.
        If an activity would be unpleasant in weather, don't suggest it.
        Include date of the weekend in response.
        If the user asks about food, restaurants or a table, hand off to the dining agent.
        """,
    tools: [
        AIFunctionFactory.Create(GetWeather, nameof(GetWeather)),
        AIFunctionFactory.Create(GetActivities, nameof(GetActivities)),
        AIFunctionFactory.Create(GetCurrentDate, nameof(GetCurrentDate))
    ]);

AIAgent diningAgent = projectClient.AsAIAgent(
    model: deploymentName,
    name: "dining",
    description: "Finds restaurants and books tables.",
    instructions: """
        You help users find a restaurant and book a table.
        Always look up restaurants before suggesting one, and never invent a restaurant.
        Confirm the restaurant, date, time and number of people before booking.
        If the user asks about activities or the weather, hand off to the activities agent.
        """,
    tools: [
        AIFunctionFactory.Create(GetRestaurants, nameof(GetRestaurants)),
        AIFunctionFactory.Create(BookTable, nameof(BookTable))
    ]);
Note that the tool names are passed explicitly. Local functions in a top level program otherwise get compiler generated names like _Main_g_GetWeather_0_1, and that's what the model sees.

Wiring the handoff

Now we say who starts, and who can hand over to whom.
// The model decides who answers next, not us
Workflow workflow = AgentWorkflowBuilder.CreateHandoffBuilderWith(activitiesAgent)
    .WithHandoffs(activitiesAgent, [diningAgent])
    .WithHandoffs(diningAgent, [activitiesAgent])
    // Follow-ups stay with the agent already helping, instead of going back to the first one
    .EnableReturnToPrevious()
    .Build();

// The whole workflow becomes a single agent
AIAgent weekendConcierge = workflow.AsAIAgent(
    name: "weekend-concierge",
    description: "Plans a weekend, and hands off between activities and dining.");
Two things are happening here. The builder quietly gives each agent a handoff tool for every agent it is allowed to hand over to, which is how the model transfers the conversation. And AsAIAgent turns the whole workflow into a single AIAgent, so from the outside it is just an agent like any other.

Running it

One session, so both agents share the same conversation.
// Run the agent, one session so the agents share the conversation
AgentSession session = await weekendConcierge.CreateSessionAsync();

string[] userInputs =
[
    "What should I do this weekend in Auckland?",
    "Where can we eat afterwards?",
    "Book the first one for 2 people at 7pm on Saturday.",
    "Yes, that's correct."
];

foreach (string userInput in userInputs)
{
    Console.WriteLine();
    Console.WriteLine("--- User ---");
    Console.WriteLine(userInput);

    AgentResponse response = await weekendConcierge.RunAsync(userInput, session);

    // Each message carries the name of the agent that wrote it
    foreach (ChatMessage message in response.Messages
        .Where(m => m.Role == ChatRole.Assistant && !string.IsNullOrWhiteSpace(m.Text)))
    {
        Console.WriteLine();
        Console.WriteLine($"--- Agent: {message.AuthorName} ---");
        Console.WriteLine(message.Text);
    }
}
--- User ---
What should I do this weekend in Auckland?
[Tool] Getting current date
[Tool] Getting weather for 'Auckland'.
[Tool] Getting activities for 'Auckland' on '2026-10-10'.
[Tool] Getting activities for 'Auckland' on '2026-10-11'.

--- Agent: activities ---
For this weekend in Auckland (Sat 10 Oct - Sun 11 Oct 2026), the weather is rainy (~18C), so I'd prioritize indoor
or rain-friendly plans and skip things like the beach or hiking.
// Omitted for brevity

--- User ---
Where can we eat afterwards?
[Tool] Getting restaurants for 'Auckland'.

--- Agent: dining ---
Here are a few good post-museum options in Auckland:
- The Domain Kitchen (Modern New Zealand)
- Ponsonby Pasta (Italian)
// Omitted for brevity

--- User ---
Book the first one for 2 people at 7pm on Saturday.

--- Agent: dining ---
Just to confirm before I book: The Domain Kitchen for 2 people on Saturday 2026-10-10 at 19:00 - is that correct?

--- User ---
Yes, that's correct.
[Tool] Booking a table for 2 at 'The Domain Kitchen' on '2026-10-10' at '19:00'.

--- Agent: dining ---
Booked: The Domain Kitchen for 2 people on Saturday 2026-10-10 at 19:00.
Booking reference: BK-1230.
The first turn is answered by the activities agent. The second one mentions food, so it hands over, and from there the dining agent keeps the conversation, including the confirmation and the booking. That's EnableReturnToPrevious doing its job, without it the third and fourth turns would start again at the activities agent.

A few things I learnt

The instructions do most of the work here. The two lines about when to hand off are what make the routing behave, and without them the agents happily answer questions that aren't theirs, or pass the conversation back and forth. It's also worth being explicit about the direction, telling an agent to "hand off to the dining agent" works better than hoping it figures it out.

And notice the confirmation before booking is just the model asking a question. It works, but nothing outside the conversation knows that a booking is waiting on a human. That's a different feature, and a good topic for another post.

The complete sample is here.

More read:

Hope this helps.

Happy Coding.

Regards,
Jaliya

Monday, September 21, 2026

Microsoft Agent Framework: Hosted Agents in Microsoft Foundry with .NET

In this post, let's see how we can take an agent written with Microsoft Agent Framework and let Microsoft Foundry host it, as a hosted agent.

A while back I blogged about Microsoft Agent Framework: Agents on Azure Functions with .NET, where we hosted the agent ourselves. And in the last post, Microsoft Agent Framework: Prompt Agents in Microsoft Foundry with .NET, we looked at prompt agents, where Foundry stores the agent definition but our code still runs the tools.

A hosted agent is the other end of that. We write the agent in code as usual, and Foundry runs it for us as a container with a managed endpoint, its own Microsoft Entra identity, scaling, versions, and a playground. Our tools run inside that container, not in our application.

The agent

The agent itself is the same weekend planner from the earlier posts. The only difference is that instead of running it, we serve it over the Responses protocol.
// Foundry injects these when the agent is hosted, locally they come from launchSettings.json
string endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT")!;
string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME")!;

// Locally this is the developer, in Foundry it is the agent's own managed identity
var credential = new DefaultAzureCredential();

AIAgent agent = new AIProjectClient(new Uri(endpoint), credential)
    .AsAIAgent(
        model: deploymentName,
        name: "weekend-planner",
        instructions: """
            You help users plan their weekends and choose the best activities for the given weather.
            If an activity would be unpleasant in weather, don't suggest it.
            Include date of the weekend in response.
            """,
        tools: [
            AIFunctionFactory.Create(GetWeather, nameof(GetWeather)),
            AIFunctionFactory.Create(GetActivities, nameof(GetActivities)),
            AIFunctionFactory.Create(GetCurrentDate, nameof(GetCurrentDate))
        ]);

// Serve the agent over the Responses protocol, on port 8088 by default
AgentHostBuilder builder = AgentHost.CreateBuilder(args);
builder.Services.AddFoundryResponses(agent);
builder.RegisterProtocol("responses", endpoints => endpoints.MapFoundryResponses());

AgentHostApp app = builder.Build();
app.Run();
A couple of things to note. DefaultAzureCredential is used on purpose here, so the same code works locally as ourselves, and in Foundry as the agent's identity.

The project targets net10.0, because that's the runtime Foundry hosted agents run on (latest package as of today, it will change).
<PropertyGroup>
  <TargetFramework>net10.0</TargetFramework>
</PropertyGroup>

<ItemGroup>
  <PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.22.0-preview.260918.1" />
</ItemGroup>

Running it locally first

Before deploying anything, the agent is just a web app. dotnet run and it listens on port 8088.
@host = http://localhost:8088

### Invoke the agent running locally
POST {{host}}/responses
Content-Type: application/json

{
  "input": "What should I do this weekend in Auckland?",
  "stream": false
}
The response contains the function_call items for the tools and the final message, so we can see the agent working before it goes anywhere near Azure.
Running Locally

Deploying

Deployment is done with Azure Developer CLI and the Foundry extension.
azd extension install microsoft.foundry
azd auth login
Then, from the agent's folder, initialize it against an existing Foundry project. The project id is the ARM resource id of the Foundry project.
azd ai agent init `
--no-prompt ` --src . ` --agent-name weekend-planner ` --project-id "/subscriptions/<subscription>/resourceGroups/<resource-group>/providers/Microsoft.CognitiveServices/accounts/<account>/projects/<project>" ` --deploy-mode code ` --runtime dotnet_10 ` --entry-point HostedAgent.dll ` --model-deployment <DeploymentId> ` --protocol responses ` --environment weekend-planner
That writes an azure.yaml, an .agentignore, and a .gitignore that keeps the .azure environment folder out of source control.
# yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json

name: 07-hosted-agent
services:
    ai-project:
        host: azure.ai.project
        endpoint: ${FOUNDRY_PROJECT_ENDPOINT}
    weekend-planner:
        project: .
        host: azure.ai.agent
        language: csharp
        uses:
            - ai-project
        env:
            AZURE_AI_MODEL_DEPLOYMENT_NAME: ${AZURE_AI_MODEL_DEPLOYMENT_NAME}
        codeConfiguration:
            dependencyResolution: remote_build
            entryPoint: HostedAgent.dll
            runtime: dotnet_10
        container:
            resources:
                cpu: "0.5"
                memory: 1Gi
        kind: hosted
        name: weekend-planner
        protocols:
            - protocol: responses
              version: 2.0.0
infra:
    provider: microsoft.foundry

One thing in there worth a second look is version: 2.0.0 under protocols. The SDK and REST examples in the docs still show 1.0.0, and the manifest and the hosting package have to agree, so copying the version out of those examples while referencing Microsoft.Agents.AI.Foundry.Hosting 1.22.0 will not work.

And then deploy. Note that there is no azd provision here, because the Foundry project and the model deployment already exist.
azd deploy
azd deploy
One thing worth knowing about remote_build: only the agent's own folder is zipped and uploaded, and Foundry restores and builds it there. So the project needs to stand on its own. If your agent references another project in your solution, switch to dependencyResolution: bundled instead, where the zip holds dotnet publish output rather than sources. With azd you can see it doing the publish in the deploy output.

Invoking the deployed agent

azd ai agent invoke weekend-planner "What should I do this weekend in Auckland?"
Invoking the deployed agent
Note the tools ran inside the container this time, not in our process. The agent also shows up in the Foundry portal with a playground, and that is the part worth trying. With the prompt agent it stopped at the first tool call and asked us to enter the function output as JSON, because there was nothing up there to run the tools. Here they are in the container, so the playground answers on its own.
Invoking in Foundry Playground
Every azd deploy creates a new version, and container logs are available too.
azd ai agent show weekend-planner
azd ai agent monitor weekend-planner --session-id <session-id>

Calling it from code

That is fine for a quick check, but an application would call the agent from code.
string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT")!;
const string agentName = "weekend-planner";

// For local development, using AzureCliCredential
var credential = new AzureCliCredential();

AIProjectClient projectClient = new(new Uri(endpoint), credential);

// A hosted agent is called through its own endpoint, not the project one
ProjectResponsesClient responsesClient = projectClient.ProjectOpenAIClient
    .GetProjectResponsesClientForAgentEndpoint(agentName);

string userInput = "What should I do this weekend in Auckland?";

Console.WriteLine("--- User ---");
Console.WriteLine(userInput);

ResponseResult response = responsesClient.CreateResponse(userInput);

Console.WriteLine();
Console.WriteLine("--- Agent Response ---");
Console.WriteLine(response.GetOutputText());

Cleaning up

Worth knowing that azd down is not the thing to reach for here. That removes the environment azd provisioned, and we didn't provision one, we deployed into a project that already existed. What we want is to delete the agent itself, from the portal or over REST.
@agent = weekend-planner

# For the deployed agent, get a token with:
# az account get-access-token --scope https://ai.azure.com/.default --query accessToken --output tsv
@endpoint = https://<resource>.services.ai.azure.com/api/projects/<projectId>
@token = <token>

### Delete the agent, its versions, and any active sessions
DELETE {{endpoint}}/agents/{{agent}}?api-version=v1&force=true
Authorization: Bearer {{token}}
Deleting an agent removes all of its versions and terminates any active sessions, and it can't be undone. The force=true is what cascades to the sessions, without it a deletion with sessions still open comes back as a 409.

So where does this leave us

With prompt agents, Foundry holds the definition and our application runs the tools. With hosted agents, Foundry runs the whole thing, code and tools, and gives us an endpoint, an identity, versions and a playground. And if we want to host it ourselves, Azure Functions is still there, which is what the earlier posts covered.

The complete sample is here.

More read:

Hope this helps.

Happy Coding.

Regards,
Jaliya