Human-in-the-loop tool approval in C#
Summarize with AI
The assistants open a new chat that asks for a short summary of this post.
The idea: a tool that changes something should wait for a person, and the approval applies to exactly the call the person saw.
A tool that only reads is harmless when the model calls it at the wrong moment. A tool that writes is not: it adds things to a shopping list nobody asked for, or places an order because “sure, sounds good” looked like consent. For those tools a person should see the exact call, with its arguments, and say yes before it runs. Here a small kitchen assistant proposes adding eggs to the shopping list, the run stops, a person approves or rejects the call in the console, and the conversation carries on either way.
Who this is for: C# developers who have given an agent tools, as in Tool calling for AI agents in C#, and kept a conversation in a session, as in Remembering the conversation with agent sessions.
Versions: Microsoft Agent Framework 1.24.0, .NET 10.
Code: samples/agent-framework/04-tool-approval, a runnable version of this post with its tests.
Note
The code runs on Claude Haiku 5.5, but it depends only on IChatClient: the first post shows the swap to OpenAI or Ollama.
Start with one read tool and one write tool
The setup is a console app with two packages, Microsoft.Agents.AI and Microsoft.Agents.AI.Anthropic, and one statement that picks the model:
using System.ComponentModel;using System.Text.Encodings.Web;using System.Text.Json;using Anthropic;using Anthropic.Core;using Microsoft.Agents.AI;using Microsoft.Extensions.AI;
var apiKey = Environment.GetEnvironmentVariable("ANTHROPIC_API_KEY") ?? throw new InvalidOperationException("Set ANTHROPIC_API_KEY.");
IChatClient chatClient = new AnthropicClient(new ClientOptions { ApiKey = apiKey }) .AsIChatClient("claude-haiku-5-5", 1024);The household’s shopping list is an in-memory list with one tool that reads it and one that writes to it:
List<string> shoppingList = ["milk", "bread"];
[Description("Returns the household's shopping list.")]string GetShoppingList() => string.Join(", ", shoppingList);
[Description("Adds one item to the household's shopping list.")]string AddToShoppingList( [Description("The item, for example 'eggs'.")] string item, [Description("How much, for example '12' or '500 g'.")] string quantity){ shoppingList.Add($"{quantity} {item}"); return $"Added {quantity} {item}. " + $"The list has {shoppingList.Count} items.";}Wrap the write tool in ApprovalRequiredAIFunction
The agent gets both tools. Only the write tool is wrapped:
AIAgent agent = chatClient.AsAIAgent( name: "kitchen-assistant", instructions: """ You help one household with cooking and shopping. Answer in at most three sentences. """, tools: [ AIFunctionFactory.Create( GetShoppingList, name: "get_shopping_list"), new ApprovalRequiredAIFunction( AIFunctionFactory.Create( AddToShoppingList, name: "add_to_shopping_list")), ]);ApprovalRequiredAIFunction is in the Microsoft.Extensions.AI namespace. It changes nothing about the function itself. The model sees the same name, description and parameter schema, and can call it the same way. The wrapper is only a marker. It tells FunctionInvokingChatClient, the layer inside the agent that runs tool calls, to ask before it runs this one.
The run stops with an approval request
Ask for something that needs the write tool, on a session so the conversation can continue afterward:
AgentSession session = await agent.CreateSessionAsync();AgentResponse response = await agent.RunAsync( "We're out of eggs, put a dozen on the list.", session);
List<ToolApprovalRequestContent> requests = Pending(response);Console.WriteLine($"Waiting for approval: {requests.Count}");Console.WriteLine($"Shopping list: {string.Join(", ", shoppingList)}");Pending collects the approval requests from the response messages; it goes at the bottom of Program.cs:
static List<ToolApprovalRequestContent> Pending( AgentResponse response) => response.Messages .SelectMany(m => m.Contents) .OfType<ToolApprovalRequestContent>() .ToList();Waiting for approval: 1Shopping list: milk, breadWhen the model calls add_to_shopping_list, the agent does not run it. RunAsync returns, and among the response’s messages is a ToolApprovalRequestContent instead of a tool result. The shopping list still holds milk and bread. Only the wrapped tool needs an answer. If the model also calls get_shopping_list in the same response, the agent holds that call back without showing it and stores it in the session as approved. It runs on the next run, together with the answered request, whatever the answer is: rejecting the write does not stop the read. To get an approval request for that call as well, set DisableApprovalNotRequiredFunctionBypassing on ChatClientAgentOptions.
A ToolApprovalRequestContent has a RequestId that ties the answer to the question, and a ToolCall with the call the model proposed. ToolCall has the general type ToolCallContent. For a C# function it is a FunctionCallContent, which carries the tool’s Name and its Arguments, so the loop below casts to that type.
Answer each request on the same session
The answer is another message from the user. request.CreateResponse(approved) turns the request into a ToolApprovalResponseContent, and that goes back to the agent in a user message on the same session. On approval, the agent runs AddToShoppingList with the proposed arguments, sends the result to the model, and returns the model’s reply. The list then ends with the eggs.
The smallest loop that works asks y or n for each pending request, sends the answers on the same session, and repeats until no request is waiting:
JsonSerializerOptions readable = new(){ Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,};
while (requests.Count > 0){ List<ChatMessage> replies = requests.ConvertAll(request => { var call = (FunctionCallContent)request.ToolCall; Console.WriteLine( $"{call.Name} {JsonSerializer.Serialize(call.Arguments, readable)}"); Console.Write("Approve? (y/n) "); bool approved = Console.ReadLine()?.Trim() == "y"; return new ChatMessage( ChatRole.User, [request.CreateResponse(approved)]); });
response = await agent.RunAsync(replies, session); requests = Pending(response);}
Console.WriteLine(response.Text);Console.WriteLine($"Shopping list: {string.Join(", ", shoppingList)}");Waiting for approval: 1Shopping list: milk, breadadd_to_shopping_list {"item":"eggs","quantity":"12"}Approve? (y/n) yAdded a dozen eggs (12) to your shopping list, which now has 3 items.Shopping list: milk, bread, 12 eggsIt is a loop because one answer can lead to the next request. The model may add a second item after the first is approved, or propose two calls in one response, and each needs an answer before the conversation can go on. Anything other than y counts as a no.
The console prompt lives in the caller, not in the tool. A Console.ReadLine() inside AddToShoppingList would work in this demo and nowhere else: not behind a web API, not anywhere the person answers minutes later. With the question outside the tool, a web app can send the request to the browser as JSON, store the session while it waits, and send the answer back when the person clicks.
The loop prints the arguments in full. A summary like “add eggs” hides the quantity, and the person approves whatever the arguments say, not what the summary said. That is also why the JSON uses JavaScriptEncoder.UnsafeRelaxedJsonEscaping. With the default encoder, crème fraîche prints as cr\u00E8me fra\u00EEche. The relaxed encoder still escapes line breaks, so an argument cannot start a line of its own in the console.
Going further
The approval is bound to what the person saw
Without binding, a person could approve one call while a different call runs. An approval response is content in a user message, and user messages come from the client. A client could send an approval for a call that was never proposed, or change the arguments on the way back.
The agent binds each response to a request it raised itself:
- The agent raises a
ToolApprovalRequestContentand records it in the session’sStateBag, a store for per-session data that is saved with the session. - The person sees the tool name and arguments.
- A response only counts if a recorded request with the same ID is pending.
- The call that runs is the recorded one, with the name and arguments the person saw, whatever the response carries.
- The entry is consumed once the run succeeds, so an approval works once. A response with no matching request is ignored. If that leaves the request without an answer, the run throws the exception described below.
So the session is part of the approval. The answer must go back on the session that produced the request. The StateBag is saved with the session, so a web app that saves the session while it waits, and restores it for the answer, keeps the binding. An answer sent on a new session, or on a copy saved before the request, has nothing to bind to.
A failed run keeps the entry, so after an error the same answer can be sent again. A tampered request ID does not slip through quietly either: the response is dropped, the request is left unanswered, and the run fails. The binding is on by default; DisableApprovalResponseBinding on ChatClientAgentOptions turns it off, which the framework recommends only when the app enforces the same binding itself.
A rejection is an answer, not a stop
CreateResponse(false) does not end anything. The function does not run, and the model receives a tool result that reads Tool call invocation rejected. Then the run continues, and the model decides what to do with that. A bare rejection reads like a technical failure, and nothing stops the model from proposing the same call again.
CreateResponse takes an optional second argument, a reason, and FunctionInvokingChatClient appends it to that sentence. A reason that says what the rejection means gives the model something to act on.
When the person types something else
The simple loop treats anything but y as a no and drops what the person typed. “Actually, make that six” is a reasonable thing to say while a request is waiting.
Sent to the agent as plain text, that message leaves the request without an answer. The unanswered ToolApprovalRequestContent stays in the session’s history, and FunctionInvokingChatClient refuses to continue from it. The run throws an InvalidOperationException with the message ToolApprovalRequestContent found with FunctionCall.CallId(s) '...' that have no matching ToolApprovalResponseContent. Every later run on that session throws the same exception until the request gets an answer, because the request is still there.
The fix is to treat a new message as a rejection: answer every pending request with false and a reason, then send the text. Both go in the same RunAsync call.
Answer builds one user message with a response for every pending request. It goes at the bottom of Program.cs, next to Pending:
static ChatMessage Answer( List<ToolApprovalRequestContent> requests, bool approved, string? reason) => new(ChatRole.User, [.. requests.Select(r => r.CreateResponse(approved, reason))]);This loop replaces the simple one and repeats these steps until no request is waiting:
- Print each pending call: the tool name and its arguments as JSON.
- Ask once for the whole batch: y, n, or a new message.
- Turn the answer into messages: an approval, a rejection with a reason, or a rejection followed by the new message.
- Send them on the same session and collect the new requests from the response.
Each kind of rejection gets its own reason:
const string Rejected = "The person reviewed this call and turned it down, so nothing changed. " + "Ask them what they would like instead of proposing it again.";const string MovedOn = "The person sent a new message instead, so this was not done.";
JsonSerializerOptions readable = new(){ Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,};
while (requests.Count > 0){ foreach (ToolApprovalRequestContent request in requests) { var call = (FunctionCallContent)request.ToolCall; Console.WriteLine( $"{call.Name} {JsonSerializer.Serialize(call.Arguments, readable)}"); }
Console.Write("Approve? (y/n, or type a new message) "); string answer = Console.ReadLine()?.Trim() ?? "";
List<ChatMessage> reply = answer.ToLowerInvariant() switch { "y" => [Answer(requests, true, null)], "n" or "" => [Answer(requests, false, Rejected)], _ => [ Answer(requests, false, MovedOn), new(ChatRole.User, answer), ], };
response = await agent.RunAsync(reply, session); requests = Pending(response);}
Console.WriteLine(response.Text);Console.WriteLine($"Shopping list: {string.Join(", ", shoppingList)}");Case doesn’t matter, so Y approves too. An empty answer counts as n. ReadLine returns an empty string when the person presses Enter, and null when the input has ended, and both become "". Without the "" case, that empty string would go to the model as a rejection saying the person sent a new message, followed by a message with no text. In a test run the model then proposed the same call again and finally replied that it couldn’t see any new message.
Waiting for approval: 1Shopping list: milk, breadadd_to_shopping_list {"item":"eggs","quantity":"12"}Approve? (y/n, or type a new message) yI've added a dozen eggs (12) to the shopping list, which now has 3 items.Shopping list: milk, bread, 12 eggsWaiting for approval: 1Shopping list: milk, breadadd_to_shopping_list {"item":"eggs","quantity":"12"}Approve? (y/n, or type a new message) Actually, make that sixadd_to_shopping_list {"item":"eggs","quantity":"6"}Approve? (y/n, or type a new message) yI've added 6 eggs to the shopping list, so the list now has 3 items.Shopping list: milk, bread, 6 eggsThe first proposal is rejected with a reason, the new message reaches the model in the same turn, and the model can propose a corrected call, which goes through the same approval. Nothing was added that the person did not see and approve.
A plain n ends differently. The rejection’s reason tells the model to ask what the person wants; when it does, no request is left, so this console demo prints the reply and ends. In a chat app that question is simply the next turn, on the same session.
In a web app the same rule belongs on the server rather than in a console loop: before passing a new message to the agent, answer whatever is still pending on that session with a rejection. Skip that step and every run on that session fails until the request is answered.
Let the framework remember an approval
The loop in this post is written by hand, and for a few calls that is enough. Microsoft.Agents.AI 1.24.0 also ships a middleware agent for the parts that get repetitive: ToolApprovalAgent, added with agent.AsBuilder().UseToolApproval().Build(). It is not marked experimental. Requests are still ToolApprovalRequestContent, answers are still ToolApprovalResponseContent or a wrapper around one, and it adds three things:
- Standing approvals. Answer with
request.CreateAlwaysApproveToolResponse()and later calls to that tool on the same session are approved without asking.CreateAlwaysApproveToolWithArgumentsResponse()covers only later calls with exactly the same arguments. The rules are saved in the session’sStateBag, so they last as long as the session. - One request at a time. When the model proposes several calls that need approval, the caller gets the first, and the rest wait in the session. Each is checked against the standing rules again before it is shown, so “always” on the first call can cover the others.
- Rules in code.
ToolApprovalAgentOptions.AutoApprovalRulestakes functions that see each call and returntrueto approve it without a person. The framework warns that a rule that matches by name approves any tool with that name.
A standing rule stores the tool’s name, not the tool, so a different tool registered later under the same name matches it too. It suits calls a person would approve the same way every time, like adding to a shopping list. For a call that should always be seen, like placing an order, a plain CreateResponse keeps every call in front of the person. AsHarnessAgent, from the Microsoft.Agents.AI.Harness package, adds this middleware by default; HarnessAgentOptions.DisableToolAutoApproval removes it without removing the approval requirement of ApprovalRequiredAIFunction.
Try it
- Reject the call with a reason and read what the model does next with that reason.
- Remove the rule that rejects pending requests, type a new message while an approval is waiting, and read the exception the next run throws.