Build Human-in-the-Loop AI Agents in C# with ASP.NET Core
Learn how to build human-in-the-loop AI agents in .NET with ASP.NET Core, approval workflows, EF Core, and SignalR for controlled AI automation.
human-in-the-loop-ai-agents-dotnet`
human-in-the-loop AI agents in .NET
Introduction
AI agents can call APIs, update customer records, create invoices, and interact with external systems. But letting an agent execute every action automatically isn't always a good idea.
Consider a finance assistant that can prepare payment requests. It receives a request to transfer $15,000 to a supplier, understands it, finds the payment tool, and prepares the operation. Should the application run that payment immediately? For low-risk operations, maybe. For sensitive or high-impact ones, a human should review the proposed action first.
That's the idea behind human-in-the-loop AI agents in .NET: a controlled approval step between the agent's decision and the execution of a sensitive operation.
In this tutorial we'll design a practical approval workflow with C#, ASP.NET Core, Entity Framework Core, and SignalR. We'll create approval requests, store pending actions, let authorized users approve or reject them, and execute approved operations safely.
1. What Is Human-in-the-Loop AI?
It's an architecture pattern where a human takes part in an AI-driven workflow at specific decision points. The application pauses selected operations until an authorized person reviews them:
- A user sends a request to the agent.
- The agent proposes an action through a tool or function.
- The application checks whether the action requires approval.
- An approval request is created and an authorized human reviews it.
- The action is approved or rejected.
- If approved, the application executes it, and the result is recorded.
An agent may answer a product question on its own but require approval before issuing a refund, approving a payment, deleting a record, sending a contract, updating important customer information, or publishing generated content. The key distinction: the agent proposes, and the application decides whether the action can run.
2. The Workflow Architecture
text
User Request
|
v
AI Agent → Proposed Tool Action
|
v
Application Validation
|
Does the action require approval?
|
No ─────────→ Execute Action → Return Result
|
Yes
|
v
Create Approval → Human Review
|
Approve / Reject
| |
Execute Action Record Rejection
|
Save Result
The components: the AI agent proposes actions, the tool layer validates arguments and decides if approval is needed, the approval service tracks requests, the database stores state, a review interface lets humans decide, and an execution worker runs approved actions. AI reasoning stays separate from business authorization and execution.
3. Set Up the Project and Model
bash
dotnet new webapi -n HumanApprovalAI
cd HumanApprovalAI
dotnet add package Microsoft.EntityFrameworkCore.SqlServer
dotnet add package Microsoft.EntityFrameworkCore.Design
An approval request stores what a reviewer needs to understand what the agent wants to do:
`csharp
public enum ApprovalStatus
{
Pending, Approved, Rejected, Executing, Completed, Failed, Expired
}
public class ApprovalRequest
{
public Guid Id { get; set; }
public string ActionName { get; set; } = "";
public string RequestedBy { get; set; } = "";
public string RequestedForTenant { get; set; } = "";
public string ArgumentsJson { get; set; } = "";
public string Reason { get; set; } = "";
public ApprovalStatus Status { get; set; } = ApprovalStatus.Pending;
public string? ReviewedBy { get; set; }
public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow;
public DateTime ExpiresAtUtc { get; set; }
public DateTime? ReviewedAtUtc { get; set; }
public DateTime? ExecutedAtUtc { get; set; }
public string? ExecutionResult { get; set; }
}
`
ArgumentsJson holds the proposed tool arguments, for example {"customerId": 1042, "refundAmount": 250, "currency": "USD"}. Never store passwords, API keys or tokens in approval records, and treat stored arguments as untrusted: the execution layer must validate them again.
Register the context and apply the schema:
`csharp
public class AppDbContext(DbContextOptions options) : DbContext(options)
{
public DbSet ApprovalRequests => Set();
protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity<ApprovalRequest>(entity => { entity.HasKey(x => x.Id); entity.Property(x => x.ActionName).HasMaxLength(150).IsRequired(); entity.Property(x => x.RequestedBy).HasMaxLength(200).IsRequired(); entity.Property(x => x.RequestedForTenant).HasMaxLength(100).IsRequired(); entity.Property(x => x.Status).HasConversion<string>(); entity.HasIndex(x => new { x.RequestedForTenant, x.Status, x.CreatedAtUtc }); }); } }
builder.Services.AddDbContext(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));
`
bash
dotnet ef migrations add InitialApprovalSchema
dotnet ef database update
Keep connection strings in secure configuration, not in source control.
4. The Approval Service
The service owns creating and managing requests:
`csharp
public record CreateApprovalDto(
string ActionName, string ArgumentsJson, string Reason, DateTime ExpiresAtUtc);
public class ApprovalService(AppDbContext db)
{
public async Task CreateAsync(CreateApprovalDto request, string userId, string tenantId)
{
var approval = new ApprovalRequest
{
Id = Guid.NewGuid(),
ActionName = request.ActionName,
ArgumentsJson = request.ArgumentsJson,
Reason = request.Reason,
RequestedBy = userId,
RequestedForTenant = tenantId,
ExpiresAtUtc = request.ExpiresAtUtc,
Status = ApprovalStatus.Pending
};
db.ApprovalRequests.Add(approval); await db.SaveChangesAsync(); return approval.Id; } }
builder.Services.AddScoped();
`
userId and tenantId must come from verified server-side identity, never from AI-generated arguments or untrusted request fields.
5. Approve and Reject Safely
Reviewers need an API to list pending requests (restricted by an authorization policy, and by tenant scope in a multi-tenant app). An approve action must verify that the request exists, is still pending, hasn't expired, and that the reviewer is authorized. Use an atomic conditional update so two reviewers can't both win:
`csharp
[HttpPost("{id:guid}/approve")]
[Authorize(Policy = "CanReviewApprovals")]
public async Task Approve(Guid id)
{
var reviewerId = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (string.IsNullOrWhiteSpace(reviewerId))
return Unauthorized();
var updated = await _db.ApprovalRequests .Where(x => x.Id == id && x.Status == ApprovalStatus.Pending && x.ExpiresAtUtc > DateTime.UtcNow) .ExecuteUpdateAsync(setters => setters .SetProperty(x => x.Status, ApprovalStatus.Approved) .SetProperty(x => x.ReviewedBy, reviewerId) .SetProperty(x => x.ReviewedAtUtc, DateTime.UtcNow)); return updated == 0 ? Conflict("Approval is unavailable, expired, or already reviewed.") : Ok(new { Message = "Approval recorded successfully." }); }
`
Reject works the same way with ApprovalStatus.Rejected. A production version should also check tenant scope in the database predicate and write the decision to an audit log.
6. Execute Only Approved Actions
Approving and executing are separate steps. The agent must never run the original tool call while approval is pending. A background worker finds approved requests and executes them:
`csharp
public interface IApprovedActionExecutor
{
Task ExecuteAsync(ApprovalRequest approval, CancellationToken cancellationToken);
}
public class ApprovalWorker(IServiceScopeFactory scopeFactory) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
using var scope = scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService();
var executor = scope.ServiceProvider.GetRequiredService();
var approvals = await db.ApprovalRequests .Where(x => x.Status == ApprovalStatus.Approved) .OrderBy(x => x.ReviewedAtUtc) .Take(10) .ToListAsync(stoppingToken); foreach (var approval in approvals) { try { await executor.ExecuteAsync(approval, stoppingToken); approval.Status = ApprovalStatus.Completed; approval.ExecutedAtUtc = DateTime.UtcNow; } catch (Exception) { approval.Status = ApprovalStatus.Failed; } await db.SaveChangesAsync(stoppingToken); } await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken); } } }
builder.Services.AddHostedService();
`
This is a teaching example, not a production queue. Two workers could pick the same request, and a crash could happen after an external action succeeds but before the database updates. In production, claim work atomically (Approved → Executing), use a durable queue or transactional outbox, add retry policies, and use idempotency keys so a retry can never run the same payment or refund twice.
7. Notify Reviewers with SignalR
Reviewers shouldn't have to refresh a dashboard. A SignalR hub can push new requests:
`csharp
[Authorize(Policy = "CanReviewApprovals")]
public class ApprovalHub : Hub { }
builder.Services.AddSignalR();
app.MapHub("/hubs/approvals");
public class ApprovalNotifier(IHubContext hub)
{
public Task NotifyReviewersAsync(Guid approvalId) =>
hub.Clients.All.SendAsync("ApprovalRequested",
new { ApprovalId = approvalId, Message = "A new approval request is available." });
}
`
In a real application, send notifications only to authorized reviewer groups or tenant-specific groups, not to every connected client. SignalR is optional: a dashboard that polls pending records works too. Docs: https://learn.microsoft.com/en-us/aspnet/core/signalr/background-services
8. Connect the Workflow to an AI Agent
Say the agent has a CreateRefundRequest function. It must not issue the refund. It creates an approval request:
`csharp
public class RefundTools(ApprovalService approvals, ITenantContext tenant, IUserContext user)
{
public async Task CreateRefundRequest(int customerId, decimal amount, string reason)
{
if (amount <= 0)
throw new ArgumentException("Refund amount must be positive.");
var arguments = JsonSerializer.Serialize(new { CustomerId = customerId, Amount = amount, Reason = reason }); var approvalId = await approvals.CreateAsync( new CreateApprovalDto("IssueRefund", arguments, reason, DateTime.UtcNow.AddHours(24)), user.UserId, tenant.TenantId); return $"Refund request submitted for approval. Approval ID: {approvalId}"; } }
`
The function only creates a pending request. The refund executor then independently validates that the customer belongs to the authorized tenant, the amount is within limits, the original transaction is refundable, the approval is valid and unexpired, and the action hasn't already run.
If you use Microsoft Agent Framework, function-tool approval can pause an agent run when a tool needs human input and resume after an approval or rejection response. Reference: https://github.com/MicrosoftDocs/semantic-kernel-docs/blob/main/agent-framework/agents/tools/tool-approval.md
9. Production Considerations
- Expiration. Give every request an expiry (a payment request might expire after 24 hours). Expired requests must never run, even if a delayed worker finds them.
- Role-based approval. Support agents approve small adjustments, finance managers approve payments, admins approve configuration changes. Enforce this with authorization policies.
- Separation of duties. The person who requests a sensitive action shouldn't be the one who approves it.
- Audit history. Record who requested it, what the AI proposed, the arguments, who decided, when, and whether execution succeeded. Keep secrets and unnecessary personal data out.
- Idempotent execution. Use unique operation IDs and idempotency keys supported by the downstream service.
- Durable state. Never keep pending approvals in memory. Persist them so the workflow survives restarts.
10. Test the Workflow
| Test scenario | Expected behavior |
|---|---|
| AI proposes a sensitive action | Approval request is created |
| Reviewer approves | Action becomes eligible for execution |
| Reviewer rejects | Action is not executed |
| Approval expires | Execution is blocked |
| Two reviewers approve at once | Only one transition succeeds |
| Unauthorized user approves | Request is denied |
| Worker retries an operation | Duplicate execution is prevented |
| Application restarts | Pending approvals remain available |
| Invalid tool arguments | Request is rejected before execution |
Conclusion
Human-in-the-loop AI agents in .NET give you a practical way to let agents work on real business operations without giving them unchecked execution power. With C#, ASP.NET Core, EF Core, SQL Server and SignalR, you can store proposed actions, notify reviewers, record decisions, and run approved operations through controlled services.
The principle is simple: let the AI propose, let humans approve sensitive decisions, and let deterministic application code control execution.
More practical AI engineering guides for .NET developers: AI Engineering Foundry
FAQ
What is a human-in-the-loop AI agent?
An AI system that pauses selected operations and asks for human input or approval before continuing.
Should an agent execute an action before approval?
No. Store the proposed operation and block execution until an authorized reviewer approves it.
Can this be built in ASP.NET Core?
Yes. ASP.NET Core handles approval APIs, authentication, authorization, background execution, persistence, and real-time notifications through SignalR.
Is SignalR required?
No. It only adds real-time notifications. A polling dashboard works too.
References
- Microsoft Agent Framework, function tools with human-in-the-loop approvals: https://github.com/MicrosoftDocs/semantic-kernel-docs/blob/main/agent-framework/agents/tools/tool-approval.md
- Microsoft Learn, Host ASP.NET Core SignalR in background services: https://learn.microsoft.com/en-us/aspnet/core/signalr/background-services
- Microsoft Learn, Human-in-the-loop process example: https://learn.microsoft.com/en-us/semantic-kernel/frameworks/process/examples/example-human-in-loop

