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:
Microsoft Agent Framework: Tool Approval
Microsoft Agent Framework: Human in the Loop
Microsoft Agent Framework Workflows Orchestrations - Handoff
Microsoft Agent Framework: Human in the Loop
Microsoft Agent Framework Workflows Orchestrations - Handoff
Hope this helps.
Happy Coding.
Regards,
Jaliya
No comments:
Post a Comment