Human Approval Gates in .NET AI Agent Workflows

Build safe AI agents with human-in-the-loop approval gates using Microsoft Agent Framework. Practical .NET patterns for sensitive operations.

0

Why Approval Gates Matter in Production AI Agents

When an AI agent operates autonomously, every decision carries weight. A fraud detection agent that flags a transaction, a supply chain system that approves a large purchase order, a healthcare referral that routes a patient to specialist care. These are not abstract algorithmic exercises. They affect real people and real business outcomes.

The challenge is not whether to automate these workflows. It’s how to automate them responsibly. An approval gate is a deliberate pause point where a human reviewer examines what the agent proposes, makes a judgment call, and either approves, rejects, or modifies the action. This pattern is foundational for enterprise AI, especially in regulated industries like finance, healthcare, and supply chain management across the UAE and globally.

Microsoft Agent Framework gives us the tools to build this. The framework provides built-in support for tool approval workflows where the agent proposes a tool call and waits for human approval before executing. The key is understanding how to detect approval requests, persist workflow state, notify a human, wait for their decision, and then resume execution with that decision baked in. Let’s walk through how to structure this in .NET.

The Core Pattern: Propose, Pause, Review, Resume

The approval gate workflow follows a simple conceptual model:

  • Agent evaluates a situation and proposes a tool call (e.g., ‘approve this payment transfer’)
  • Before executing the tool, the framework captures the proposal and pauses
  • A human reviewer is notified via queue or dashboard
  • The reviewer examines the proposal and makes a decision
  • The agent resumes with the decision and either executes the tool or takes an alternative path

This is different from simple logging or monitoring. The approval gate is a hard stop in the agent’s execution flow. The agent does not proceed until someone approves.

In Microsoft Agent Framework, you implement this by marking specific tools as approval-required during construction. When the agent attempts to call an approval-gated tool, the framework does not execute it immediately. Instead, the agent run completes early and returns a response containing a FunctionApprovalRequestContent object. Your application detects this, surfaces it to a human, waits for their decision, and then resumes the agent session with that decision.

Marking Tools as Approval-Required

The first step is to designate which tools require approval. Microsoft Agent Framework provides the ApprovalRequiredAIFunction wrapper for this purpose. Here’s how to set it up:

using Microsoft.Extensions.AI;

public class ApprovalGateSetup
{
    public static AIFunction CreateApprovalGatedTool(
        Func<Dictionary<string, object>, Task<object>> toolImplementation,
        string toolName,
        string toolDescription)
    {
        var baseFunction = new AIFunction(
            toolImplementation,
            toolName,
            toolDescription);

        var approvalWrapped = new ApprovalRequiredAIFunction(baseFunction);
        return approvalWrapped;
    }
}

// Usage: when building your agent
var transferTool = ApprovalGateSetup.CreateApprovalGatedTool(
    async (args) => await ExecuteTransferAsync(args),
    "execute_transfer",
    "Execute a financial transfer between accounts");

var agent = new Agent(
    model: chatClient,
    tools: new[] { transferTool, otherTools });

The ApprovalRequiredAIFunction wrapper tells the framework that this tool should not execute immediately. Instead, when the model decides to call it, the framework will pause and emit a FunctionApprovalRequestContent in the response.

Detecting Approval Requests in Agent Responses

After each agent run, you must check the response for approval requests. Here’s the pattern:

public class ApprovalDetectionLoop
{
    private readonly IAgent _agent;
    private readonly IApprovalRepository _approvalRepo;
    private readonly IApprovalNotifier _notifier;

    public async Task<string> RunAgentWithApprovalsAsync(
        string sessionId,
        string userMessage,
        CancellationToken cancellationToken = default)
    {
        var messages = new List<ChatMessage>
        {
            new ChatMessage(ChatRole.User, userMessage)
        };

        while (true)
        {
            var response = await _agent.RunAsync(
                messages,
                cancellationToken);

            var approvalRequests = response.Content
                .OfType<FunctionApprovalRequestContent>()
                .ToList();

            if (approvalRequests.Any())
            {
                foreach (var approvalRequest in approvalRequests)
                {
                    var approval = new ApprovalRequest
                    {
                        Id = Guid.NewGuid().ToString(),
                        SessionId = sessionId,
                        ToolName = approvalRequest.FunctionCall.Name,
                        ToolArguments = approvalRequest.FunctionCall.Arguments,
                        CreatedAt = DateTime.UtcNow,
                        Status = ApprovalStatus.Pending
                    };

                    await _approvalRepo.SaveAsync(approval);
                    await _notifier.NotifyPendingApprovalAsync(approval);
                }

                return "Approval pending. Awaiting human review.";
            }

            if (response.StopReason == ChatFinishReason.Stop)
            {
                return response.Content
                    .OfType<TextContent>()
                    .FirstOrDefault()?.Text ?? "No response";
            }
        }
    }
}

public class ApprovalRequest
{
    public string Id { get; set; }
    public string SessionId { get; set; }
    public string ToolName { get; set; }
    public Dictionary<string, object> ToolArguments { get; set; }
    public DateTime CreatedAt { get; set; }
    public DateTime? ReviewedAt { get; set; }
    public string ReviewerUserId { get; set; }
    public ApprovalStatus Status { get; set; }
    public string ReviewNotes { get; set; }
}

public enum ApprovalStatus
{
    Pending,
    Approved,
    Rejected,
    Modified
}

The key is to check for FunctionApprovalRequestContent after every agent run. The FunctionCall property contains the tool name and arguments that the model selected. This is what you show to the reviewer. Do not assume there is only one approval request per run. A planner agent might batch multiple tool calls, resulting in multiple approval requests in a single response.

Handling the Approval Decision and Resuming

Once a human makes a decision, you build a FunctionApprovalResponseContent and resume the agent session:

public class ApprovalProcessor
{
    private readonly IApprovalRepository _approvalRepo;
    private readonly IAgent _agent;

    public async Task ProcessApprovalDecisionAsync(
        string approvalId,
        bool approved,
        string reviewerUserId,
        string reviewNotes,
        Dictionary<string, object> modifiedArguments = null)
    {
        var approval = await _approvalRepo.GetByIdAsync(approvalId);
        if (approval == null)
            throw new InvalidOperationException($"Approval {approvalId} not found.");

        approval.ReviewedAt = DateTime.UtcNow;
        approval.ReviewerUserId = reviewerUserId;
        approval.ReviewNotes = reviewNotes;
        approval.Status = approved ? ApprovalStatus.Approved : ApprovalStatus.Rejected;

        await _approvalRepo.UpdateAsync(approval);

        var approvalResponse = new FunctionApprovalResponseContent(
            approved: approved,
            toolCallId: approval.ToolName);

        var resumeMessage = new ChatMessage(
            ChatRole.User,
            new object[] { approvalResponse });

        var messages = await _approvalRepo.GetSessionMessagesAsync(approval.SessionId);
        messages.Add(resumeMessage);

        var response = await _agent.RunAsync(messages);

        var finalText = response.Content
            .OfType<TextContent>()
            .FirstOrDefault()?.Text ?? "Approval processed.";

        await _approvalRepo.LogDecisionAsync(
            approval,
            approved,
            reviewerUserId,
            reviewNotes);
    }
}

When you call CreateResponse on the FunctionApprovalRequestContent, you pass true to approve or false to reject. The framework handles routing this back into the agent’s conversation context. The agent then either executes the tool (if approved) or proceeds without it (if rejected).

Structuring Approval State and Persistence

Approval requests and decisions must survive system restarts. Here’s a foundational data model:

public class ApprovalAuditLog
{
    public string ApprovalId { get; set; }
    public string SessionId { get; set; }
    public string ToolName { get; set; }
    public Dictionary<string, object> OriginalArguments { get; set; }
    public ApprovalStatus FinalStatus { get; set; }
    public string ReviewerUserId { get; set; }
    public DateTime DecisionTime { get; set; }
    public string DecisionReason { get; set; }
    public string IpAddress { get; set; }
    public string UserAgent { get; set; }
}

public interface IApprovalRepository
{
    Task SaveAsync(ApprovalRequest request);
    Task UpdateAsync(ApprovalRequest request);
    Task<ApprovalRequest> GetByIdAsync(string id);
    Task<List<ApprovalRequest>> GetPendingAsync();
    Task<List<ApprovalRequest>> GetPendingOlderThanAsync(DateTime threshold);
    Task LogDecisionAsync(
        ApprovalRequest approval,
        bool approved,
        string reviewerUserId,
        string notes);
}

Store this in a persistent layer. Azure Cosmos DB works well for this pattern, but SQL Server or any durable store will do. The key is that approval requests and decisions must survive system restarts and be queryable by approval ID, session ID, and status.

Notification and Scalability with Azure Service Bus

For enterprise deployments, notifications should be decoupled from the approval detection loop. Use Azure Service Bus or Event Grid to notify reviewers asynchronously:

public interface IApprovalNotifier
{
    Task NotifyPendingApprovalAsync(ApprovalRequest request);
    Task NotifyApprovalEscalationAsync(ApprovalRequest request);
}

public class ServiceBusApprovalNotifier : IApprovalNotifier
{
    private readonly ServiceBusClient _serviceBusClient;

    public ServiceBusApprovalNotifier(ServiceBusClient serviceBusClient)
    {
        _serviceBusClient = serviceBusClient;
    }

    public async Task NotifyPendingApprovalAsync(ApprovalRequest request)
    {
        var sender = _serviceBusClient.CreateSender("approval-notifications");

        var message = new ServiceBusMessage(
            JsonSerializer.Serialize(request))
        {
            Subject = $"Approval Required: {request.ToolName}",
            CorrelationId = request.SessionId,
            TimeToLive = TimeSpan.FromHours(24)
        };

        await sender.SendMessageAsync(message);
    }

    public async Task NotifyApprovalEscalationAsync(ApprovalRequest request)
    {
        var sender = _serviceBusClient.CreateSender("approval-escalations");

        var message = new ServiceBusMessage(
            JsonSerializer.Serialize(request))
        {
            Subject = $"Escalation: {request.ToolName} approval timeout",
            CorrelationId = request.SessionId
        };

        await sender.SendMessageAsync(message);
    }
}

This keeps approval notifications out of the main agent execution path. A separate service consumer processes notifications and routes them to the appropriate reviewer based on rules you define (by department, skill level, availability, etc.).

Practical Example: Banking Fraud Review

Let’s apply this to a concrete scenario. A banking system uses an agent to detect suspicious transfers. Large transfers over a threshold require human review:

public class FraudDetectionWorkflow
{
    private readonly IAgent _fraudAgent;
    private readonly ApprovalDetectionLoop _approvalLoop;
    private readonly IApprovalRepository _approvalRepo;

    public async Task ProcessTransferAsync(
        string transferId,
        decimal amount,
        string sourceAccount,
        string destinationAccount)
    {
        var userMessage = $"Review transfer: {amount} from {sourceAccount} to {destinationAccount}";

        var result = await _approvalLoop.RunAgentWithApprovalsAsync(
            transferId,
            userMessage);

        Console.WriteLine($"Agent response: {result}");
    }
}

The agent analyzes the transfer, checks risk factors, and proposes an action. If the amount exceeds the threshold or risk score is high, the execute_transfer tool is gated. A compliance officer reviews it, sees the agent’s reasoning, and approves or rejects. The agent then completes its loop with the decision.

Handling Timeouts and Escalation

Approvals should not hang forever. Define a timeout policy and escalation logic:

public class ApprovalTimeoutHandler
{
    private readonly IApprovalRepository _approvalRepo;
    private readonly IApprovalNotifier _escalationNotifier;
    private readonly TimeSpan _defaultTimeout = TimeSpan.FromHours(4);

    public async Task CheckAndEscalateExpiredApprovalsAsync()
    {
        var expiredApprovals = await _approvalRepo.GetPendingOlderThanAsync(
            DateTime.UtcNow.Subtract(_defaultTimeout));

        foreach (var approval in expiredApprovals)
        {
            approval.Status = ApprovalStatus.Rejected;
            approval.ReviewNotes = "Escalated: approval timeout exceeded";
            await _approvalRepo.UpdateAsync(approval);

            await _escalationNotifier.NotifyApprovalEscalationAsync(approval);
        }
    }
}

Run this as a scheduled job (Azure Timer Trigger, for example) to catch approvals that slip through the cracks. Escalate them to a supervisor or reject them based on your business rules.

Audit and Compliance Logging

Every approval decision is an audit trail entry. Log comprehensively:

public class AuditLogger
{
    private readonly ILogger<AuditLogger> _logger;
    private readonly IApprovalRepository _approvalRepo;

    public async Task LogApprovalDecisionAsync(
        ApprovalRequest approval,
        bool approved,
        string reviewerUserId,
        string ipAddress,
        string notes)
    {
        var auditLog = new ApprovalAuditLog
        {
            ApprovalId = approval.Id,
            SessionId = approval.SessionId,
            ToolName = approval.ToolName,
            OriginalArguments = approval.ToolArguments,
            FinalStatus = approved ? ApprovalStatus.Approved : ApprovalStatus.Rejected,
            ReviewerUserId = reviewerUserId,
            DecisionTime = DateTime.UtcNow,
            DecisionReason = notes,
            IpAddress = ipAddress
        };

        _logger.LogInformation(
            "Approval decision: {ApprovalId} by {Reviewer} with status {Status}",
            auditLog.ApprovalId,
            auditLog.ReviewerUserId,
            auditLog.FinalStatus);

        await _approvalRepo.LogDecisionAsync(
            approval,
            approved,
            reviewerUserId,
            notes);
    }
}

Store audit logs separately and immutably. This satisfies regulatory requirements and gives you full visibility into how your agents are operating and how humans are overseeing them.

Key Considerations for Production Deployment

A few additional points as you build this into production systems:

State Consistency: Ensure that the agent’s session state and the approval request state stay synchronized. Use distributed transactions or event sourcing if your infrastructure supports it. If the agent crashes mid-approval, it should be able to resume cleanly.

Reviewer Workload: Design your approval gates thoughtfully. Too many gates create bottlenecks. Too few defeat the purpose. Use metrics to track approval latency and decision rates. If approvers are overwhelmed, consider refining the agent’s logic to reduce unnecessary escalations.

Contextual Clarity: The approval request should include everything the reviewer needs to make an informed decision: the agent’s reasoning, the proposed action, relevant data, risk scores, historical context. If a reviewer has to hunt for information, they will either rubber stamp approvals or slow down the system.

Reversibility: Some approved actions can be rolled back; others cannot. A transaction can be reversed; a deleted record might not be. Design your approval gate to account for this. Some tools might require post-execution audit rather than pre-execution approval.

Regulatory Alignment: Different regions have different expectations for autonomous systems. The UAE AI Governance Framework and similar regional guidelines emphasize human oversight for high-impact decisions. EU AI Act requirements for high-risk systems similarly demand human involvement. Build approval gates as a first-class feature, not an afterthought.

Conclusion

Human approval gates are not a limitation on AI agent capabilities. They are a design pattern that makes autonomous systems trustworthy and deployable in environments where stakes are high. By implementing approval gates in Microsoft Agent Framework, you create a clear contract: the agent proposes, the human decides, and the system executes with full traceability.

The code patterns shown here are production-ready starting points. Adapt them to your approval workflows, your persistence layer, and your notification infrastructure. Test thoroughly with realistic approval scenarios. Monitor approval latency, decision rates, and agent success rates. Over time, you will refine which actions require approval and which can run fully autonomously.

The most mature AI deployments are not the ones with the most automation. They are the ones where humans and AI work together with clarity about who decides what, and why.

What is an approval gate in an AI agent workflow?

An approval gate is a deliberate pause point where an AI agent proposes an action (such as executing a tool), but does not execute it until a human reviewer examines the proposal and approves, rejects, or modifies it. This pattern ensures human oversight for sensitive operations.

How does Microsoft Agent Framework implement approval gates?

You wrap a tool with ApprovalRequiredAIFunction during agent construction. When the agent attempts to call that tool, the framework returns a FunctionApprovalRequestContent in the response instead of executing the tool. Your application detects this, surfaces it to a human, and then resumes the agent session with the approval decision using FunctionApprovalResponseContent.

How does the agent resume execution after approval?

Once a human makes a decision, you build a FunctionApprovalResponseContent (passing true to approve or false to reject) and add it to the agent’s message history. You then run the agent again on the same session. The agent receives the approval status and either executes the original tool call, rejects it, or proceeds with modified parameters, depending on what the reviewer decided.

Which tools should be gated and which should run freely?

Gate tools that have high business impact, regulatory significance, or irreversible consequences. Examples include financial transactions, database deletes, compliance decisions, and customer data modifications. Tools that are informational, read-only, or low-risk can typically run without approval. Define gating rules based on your business requirements and regulatory obligations.

How do I handle approval timeouts?

Set a timeout policy (for example, 4 hours) for pending approvals. Use a scheduled job to check for expired approvals and either reject them automatically or escalate them to a supervisor. Log all timeout events for audit purposes.

What persistence layer should I use for approval requests?

Any durable store works: Azure Cosmos DB, SQL Server, Azure Table Storage, or similar. The key is that approval requests and decisions must survive system restarts and be queryable by approval ID, session ID, and status. Choose based on your scale, consistency requirements, and existing infrastructure.

Leave a Reply

Your email address will not be published. Required fields are marked *