So far, the agent only reads. In this lesson, for the first time, it may do something that acts outward: send a quote to a contact. A wrongly sent quote can't be taken back. It may name a price the office is bound to, or it may go to the wrong person. That's why the module's most important rule applies from now on: The agent may draft quotes, but it may send them only if a person has approved. And then exactly once.
In the end, this rule stands in three places: in the framework, which stops before sending; in the quote model, which doesn't send without an approval; and in the tool, which never sends twice. Each place protects against a different mistake.
A tool that has to ask
For tools with approval, Microsoft.Extensions.AI has a wrapper of its own, the ApprovalRequiredAIFunction. You wrap it around a normal
AIFunction. If the model wants to call such a tool, the loop doesn't run it. Instead, the agent's run ends with an approval request,
a ToolApprovalRequestContent. It holds the call with name and arguments. Your code shows it to a person, and their answer goes into the
same session as a new message with CreateResponse(true) or CreateResponse(false). Only then does the loop run the tool, or not.
The file-based app shows the flow without a model:
// approval.cs
#:package Microsoft.Agents.AI@1.28.0
using System.ComponentModel;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
int sent = 0;
AIFunction send = new ApprovalRequiredAIFunction(AIFunctionFactory.Create(
[Description("Sends an email to a contact.")] (string contactId) => $"Email sent to {contactId} ({++sent}).",
name: "SendMail"));
AIAgent agent = new SendingModel().AsAIAgent(instructions: "You help with sending.", tools: [send]);
AgentSession session = await agent.CreateSessionAsync();
AgentResponse first = await agent.RunAsync("Send Jana Beispiel the email.", session);
ToolApprovalRequestContent request = first.Messages.SelectMany(m => m.Contents).OfType<ToolApprovalRequestContent>().Single();
var call = (FunctionCallContent)request.ToolCall;
Console.WriteLine($"Approval needed: {call.Name}({string.Join(", ", call.Arguments!.Select(a => $"{a.Key}: {a.Value}"))}), sent so far: {sent}");
AgentResponse second = await agent.RunAsync(new ChatMessage(ChatRole.User, [request.CreateResponse(approved: true)]), session);
Console.WriteLine($"After the approval: {second.Text} Sent: {sent}");
// Plays the model: it asks for the tool until it sees a result, then it reports it.
sealed class SendingModel : IChatClient
{
public Task<ChatResponse> GetResponseAsync(
IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
{
FunctionResultContent? result = messages.Last().Contents.OfType<FunctionResultContent>().FirstOrDefault();
ChatMessage answer = result is null
? new(ChatRole.Assistant, [new FunctionCallContent("call-1", "SendMail", new Dictionary<string, object?> { ["contactId"] = "K-17" })])
: new(ChatRole.Assistant, $"Done: {result.Result}");
return Task.FromResult(new ChatResponse(answer));
}
public IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default) =>
throw new NotSupportedException();
public object? GetService(Type serviceType, object? serviceKey = null) => null;
public void Dispose()
{
}
}
dotnet run approval.cs
Approval needed: SendMail(contactId: K-17), sent so far: 0
After the approval: Done: Email sent to K-17 (1). Sent: 1
After the first run, nothing is sent. The agent stopped and asked. Only the answer in the same session runs the tool.
Two properties of this flow matter for security, and Agent Framework has switched both on by default:
- An answer counts only for a request that the framework itself posed, in the same session. The open request is in the session's
state. An approval that someone sends into another session or assembles themselves is discarded. The switch
DisableApprovalResponseBindingin theChatClientAgentOptionsturns that off; leave it off. - An unanswered request is rejected on the next run. If the person writes a new question instead of an answer, the model gets as the result: "Tool call invocation was rejected by the approver and must not be retried. No approval response was provided for this approval required tool call." The tool doesn't run.
For this to work, the session has to be kept between the request and the answer. In a web application, hours often lie in between. More on that in a moment.