Human Approval for Agent Tools with Microsoft Agent Framework in .NET
Require a human yes before an agent runs a risky tool in Microsoft Agent Framework. Tested numbers for approve and reject, and the local-function naming trap that made it look broken.
By Ajith Joseph · · Updated · 5 min read · intermediate
An agent that can only read is safe. An agent that can cancel an order, send an email or change a record is not, and the usual fix is to make a person click yes before the risky call runs. Microsoft Agent Framework supports this directly. It reached 1.0 in April 2026, and the approval pieces come from Microsoft.Extensions.AI underneath it.
I built the smallest possible version, ran it, and counted how many times the tool really executed. The numbers are below, along with a trap I hit that made my first test look broken for the wrong reason.
Everything here compiled and ran against Microsoft.Agents.AI 1.28.0 and Microsoft.Extensions.AI 10.11.0, the newest versions I could restore on November 7. The model is a fake one that always asks to call the tool, so the test needs no API key and the result does not depend on a real model's mood.
The pattern
Wrap the risky function in ApprovalRequiredAIFunction, give the agent a session, and handle the pause:
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
AIAgent agent = chatClient.AsAIAgent(
instructions: "You help with orders.",
name: "orders",
tools:
[
AIFunctionFactory.Create(GetOrderStatus, name: "GetOrderStatus"),
new ApprovalRequiredAIFunction(
AIFunctionFactory.Create(CancelOrder, name: "CancelOrder")),
]);
AgentSession session = await agent.CreateSessionAsync();
AgentResponse first = await agent.RunAsync("Cancel order A-1002", session);
var request = first.Messages
.SelectMany(m => m.Contents)
.OfType<ToolApprovalRequestContent>()
.Single();
// Show request.ToolCall (a FunctionCallContent) to a person: name and arguments.
var decision = new ChatMessage(ChatRole.User, [request.CreateResponse(approved: true)]);
AgentResponse second = await agent.RunAsync(decision, session);
Read-only tools run freely. The wrapped one makes the agent stop and hand back a ToolApprovalRequestContent instead of an answer.
What the run showed
I ran the same flow twice, once approving and once rejecting, and counted executions of the real function:
| Step | Tool executions |
|---|---|
| Agent paused, waiting for a decision | 0 |
| After approving | 1 |
| After rejecting | 0 |
When I rejected, the agent still received a result for the call: the text "Tool call invocation was rejected by the approver and must not be retried." So the model is told no, and told not to try again. That wording matters, because a model that is simply left without a result may retry or invent one.
The trap: tool names
My first run looked like approval did not work. The agent answered "Done, order cancelled" with no approval request. The real cause was different: the tool never ran at all.
I had declared CancelOrder as a local function inside top-level statements. The C# compiler generates a mangled name for those, and AIFunctionFactory.Create uses that name. When the model asked for CancelOrder, nothing matched, the framework returned an error string as the tool result ("Requested function CancelOrder not found"), and my fake model happily replied "Done."
Two lessons:
- Always pass an explicit
name:toAIFunctionFactory.Create, or put tools in a class with ordinary methods. The name is part of your contract with the model. - Never trust the final text. A model will say "done" after a tool error. Assert on whether the function actually executed, as I did with a counter.
Design rules I would follow
- Approve by risk, not by tool count. Anything irreversible or external, such as cancelling, paying, emailing or deleting, needs approval. Lookups do not.
- Show people the real arguments. The
FunctionCallContentcarries the name and argument values. Display them as written, not as the model's summary of what it intends to do. - Keep the session. Pass the same
AgentSessionback when you send the decision, as above, so the agent resumes the same conversation. - Time out and fail closed. If nobody answers, treat it as a rejection.
- Log the decision with who approved, when and with which arguments.
- Give the agent its own identity with the least access it needs. Approval is a second lock, not a replacement for permissions. I wrote about that in Agents Need Identities, Not Shared API Keys.
MCP tools too
The same idea applies to tools that come from an MCP server. The framework documentation describes creating an MCP client, listing its tools and passing them to the agent, and approval requests for MCP tools surface as approval request content in the same way. I only ran the local function version above, so check the current MCP samples in the Agent Framework repository for the exact types before you rely on that path.
What I could not verify
I used a scripted model, so I did not test how a real model behaves after a rejection or whether it respects "must not be retried" across long conversations. Test that with your own model. I also did not test approval over a hosted agent or a web front end, where you need to persist the pending request between HTTP calls.