# Human-in-the-loop tool approval in C#

> Make an Agent Framework agent wait for a person to approve the exact tool call, with its arguments, before it runs.

Published: 19 Oct 2026
Series: Microsoft Agent Framework in C#, part 4
URL: https://kootstra.io/writing/human-in-the-loop-tool-approval-csharp/

**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#](https://kootstra.io/writing/tool-calling-ai-agents-csharp/), and kept a conversation in a session, as in [Remembering the conversation with agent sessions](https://kootstra.io/writing/agent-framework-sessions-csharp/).

**Versions:** Microsoft Agent Framework 1.24.0, .NET 10.

**Code:** [samples/agent-framework/04-tool-approval](https://github.com/nkootstra/samples/tree/main/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](https://kootstra.io/writing/microsoft-agent-framework-first-csharp-agent/#swap-the-provider-by-changing-one-statement) 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:

```csharp
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:

```csharp
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:

```csharp
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( // [!code highlight]
            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:

```csharp
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`:

```csharp
static List<ToolApprovalRequestContent> Pending(
    AgentResponse response) =>
    response.Messages
        .SelectMany(m => m.Contents)
        .OfType<ToolApprovalRequestContent>()
        .ToList();
```

```text
Waiting for approval: 1
Shopping list: milk, bread
```

When 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:

```csharp
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)}");
```

```text
Waiting for approval: 1
Shopping list: milk, bread
add_to_shopping_list {"item":"eggs","quantity":"12"}
Approve? (y/n) y
Added a dozen eggs (12) to your shopping list, which now has 3 items.
Shopping list: milk, bread, 12 eggs
```

It 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. [Agent Framework workflow checkpoints in C# with Claude](https://kootstra.io/writing/agent-framework-workflow-checkpoints-csharp/) does the same for a workflow, which saves a checkpoint while it waits and resumes in a new process.

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:

1. The agent raises a `ToolApprovalRequestContent` and records it in the session's `StateBag`, a store for per-session data that is saved with the session.
2. The person sees the tool name and arguments.
3. A response only counts if a recorded request with the same ID is pending.
4. The call that runs is the recorded one, with the name and arguments the person saw, whatever the response carries.
5. 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](#when-the-person-types-something-else).

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

```csharp
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:

1. Print each pending call: the tool name and its arguments as JSON.
2. Ask once for the whole batch: y, n, or a new message.
3. Turn the answer into messages: an approval, a rejection with a reason, or a rejection followed by the new message.
4. Send them on the same session and collect the new requests from the response.

Each kind of rejection gets its own reason:

```csharp
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), // [!code highlight]
            new(ChatRole.User, answer), // [!code highlight]
        ],
    };

    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.

```text
Waiting for approval: 1
Shopping list: milk, bread
add_to_shopping_list {"item":"eggs","quantity":"12"}
Approve? (y/n, or type a new message) y
I've added a dozen eggs (12) to the shopping list, which now has 3 items.
Shopping list: milk, bread, 12 eggs
```

```text
Waiting for approval: 1
Shopping list: milk, bread
add_to_shopping_list {"item":"eggs","quantity":"12"}
Approve? (y/n, or type a new message) Actually, make that six
add_to_shopping_list {"item":"eggs","quantity":"6"}
Approve? (y/n, or type a new message) y
I've added 6 eggs to the shopping list, so the list now has 3 items.
Shopping list: milk, bread, 6 eggs
```

The 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's `StateBag`, 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.AutoApprovalRules` takes functions that see each call and return `true` to 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.
