How to Implement AI Agent Human-in-the-Loop Approval in ASP.NET Core 10 Using C# (2026)

An AI agent can propose an action in milliseconds, but human review may take minutes or hours. If the workflow holds an open request or approves mutable text, a retry or late decision can execute something different from what the reviewer saw.

This guide implements a human-in-the-loop approval checkpoint in ASP.NET Core 10. It stores the exact proposal in SQLite, separates requester and approver credentials for a local lab, and commits an approval decision and execution receipt atomically.

What does a secure approval checkpoint store?

An approver reviews the stored payload. A transaction can transition a pending row only once and writes its receipt in the same commit.
An approver reviews the stored payload. A transaction can transition a pending row only once and writes its receipt in the same commit.

Store the proposal ID, action, target and payload, the actor and reviewer in a real service, the expiration time, and a durable decision. An approval is permission for one exact proposal, not permission for arbitrary future model output. The lab’s effect is a SQLite receipt. It does not send a message or call a third party.

State Can transition? Result
Pending and unexpired Yes Approve or reject once
Pending but expired No Conflict, requires a new proposal
Approved or rejected No Replay receives conflict

This workflow is application-level state. MCP elicitation collects client input, and MCP sampling requests a model result. Neither feature replaces durable authorization for a sensitive action. The approval process here remains valid even without a model connection.

Build the approval API

Install .NET 10 and create an empty directory. The sample creates its database at startup. Use two independent random keys and bind to loopback. They demonstrate role separation, not a remote identity solution. In a deployed system, authenticate users, require separate scoped roles, and verify tenant, object, and reviewer identity. Enforce separation of duties where policy requires it.

Lab.csproj
<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup><TargetFramework>net10.0</TargetFramework><ImplicitUsings>enable</ImplicitUsings><Nullable>enable</Nullable></PropertyGroup>
  <ItemGroup><PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.11" /></ItemGroup>
</Project>
Program.cs
using Microsoft.Data.Sqlite;
using System.Security.Cryptography;
using System.Text;

var b = WebApplication.CreateBuilder(args);
var requestKey = Hash(b.Configuration["RequestKey"] ?? "");
var approveKey = Hash(b.Configuration["ApproveKey"] ?? "");
if (CryptographicOperations.FixedTimeEquals(requestKey, approveKey))
    throw new InvalidOperationException("Use separate requester and approver keys.");
b.WebHost.ConfigureKestrel(o => o.Limits.MaxRequestBodySize = 8192);
var cs = new SqliteConnectionStringBuilder { DataSource = "approval-lab.db", DefaultTimeout = 5 }.ToString();
await using (var db = new SqliteConnection(cs))
{
    await db.OpenAsync();
    await using var cmd = db.CreateCommand();
    cmd.CommandText = """
        CREATE TABLE IF NOT EXISTS approvals (
          id TEXT PRIMARY KEY, payload TEXT NOT NULL, expires INTEGER NOT NULL,
          status TEXT NOT NULL, decided INTEGER
        );
        CREATE TABLE IF NOT EXISTS receipts (id TEXT PRIMARY KEY, payload TEXT NOT NULL);
        """;
    await cmd.ExecuteNonQueryAsync();
}

var app = b.Build();
app.MapPost("/approvals/{id:guid}", async (Guid id, Proposal p, HttpContext ctx, CancellationToken ct) =>
{
    if (!Authorized(ctx, requestKey)) return Results.Unauthorized();
    if (string.IsNullOrWhiteSpace(p.Text) || p.Text.Length > 500) return Results.BadRequest();
    await using var db = new SqliteConnection(cs);
    await db.OpenAsync(ct);
    await using var cmd = db.CreateCommand();
    cmd.CommandText = "INSERT INTO approvals VALUES ($id,$p,$exp,'pending',NULL) ON CONFLICT(id) DO NOTHING";
    cmd.Parameters.AddWithValue("$id", id.ToString("D"));
    cmd.Parameters.AddWithValue("$p", p.Text);
    cmd.Parameters.AddWithValue("$exp", DateTimeOffset.UtcNow.AddMinutes(10).ToUnixTimeSeconds());
    return await cmd.ExecuteNonQueryAsync(ct) == 1
        ? Results.Created($"/approvals/{id:D}", new { id, status = "pending" }) : Results.Conflict();
});
app.MapGet("/approvals/{id:guid}", async (Guid id, HttpContext ctx, CancellationToken ct) =>
{
    if (!Authorized(ctx, approveKey)) return Results.Unauthorized();
    await using var db = new SqliteConnection(cs);
    await db.OpenAsync(ct);
    await using var cmd = db.CreateCommand();
    cmd.CommandText = "SELECT payload,status,expires FROM approvals WHERE id=$id";
    cmd.Parameters.AddWithValue("$id", id.ToString("D"));
    await using var row = await cmd.ExecuteReaderAsync(ct);
    return await row.ReadAsync(ct)
        ? Results.Ok(new { text = row.GetString(0), status = row.GetString(1), expires = row.GetInt64(2) })
        : Results.NotFound();
});
app.MapPost("/approvals/{id:guid}/decision", async (Guid id, Decision p, HttpContext ctx,
    ILoggerFactory logs, CancellationToken ct) =>
{
    if (!Authorized(ctx, approveKey)) return Results.Unauthorized();
    if (p.Choice is not ("approve" or "reject")) return Results.BadRequest();
    var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
    var status = p.Choice == "approve" ? "approved" : "rejected";
    await using var db = new SqliteConnection(cs);
    await db.OpenAsync(ct);
    await using var tx = db.BeginTransaction();
    await using var cmd = db.CreateCommand();
    cmd.Transaction = tx;
    cmd.CommandText = """
        UPDATE approvals SET status=$s,decided=$now
        WHERE id=$id AND status='pending' AND expires>$now
        """;
    cmd.Parameters.AddWithValue("$id", id.ToString("D"));
    cmd.Parameters.AddWithValue("$s", status);
    cmd.Parameters.AddWithValue("$now", now);
    if (await cmd.ExecuteNonQueryAsync(ct) != 1) return Results.Conflict();
    if (status == "approved")
    {
        cmd.CommandText = "INSERT INTO receipts SELECT id,payload FROM approvals WHERE id=$id";
        await cmd.ExecuteNonQueryAsync(ct);
    }
    await tx.CommitAsync(ct);
    logs.CreateLogger("Approval").LogInformation("Approval {Id} decided {Status}", id, status);
    return Results.Ok(new { id, status });
});
app.MapPost("/approvals/{id:guid}/cancel", async (Guid id, HttpContext ctx,
    ILoggerFactory logs, CancellationToken ct) =>
{
    if (!Authorized(ctx, requestKey)) return Results.Unauthorized();
    await using var db = new SqliteConnection(cs);
    await db.OpenAsync(ct);
    await using var cmd = db.CreateCommand();
    cmd.CommandText = "UPDATE approvals SET status='canceled',decided=$now WHERE id=$id AND status='pending'";
    cmd.Parameters.AddWithValue("$id", id.ToString("D"));
    cmd.Parameters.AddWithValue("$now", DateTimeOffset.UtcNow.ToUnixTimeSeconds());
    if (await cmd.ExecuteNonQueryAsync(ct) != 1) return Results.Conflict();
    logs.CreateLogger("Approval").LogInformation("Approval {Id} canceled", id);
    return Results.Ok(new { id, status = "canceled" });
});
await app.RunAsync();

static byte[] Hash(string secret) => secret.Length >= 32 && secret.Length <= 128
    ? SHA256.HashData(Encoding.UTF8.GetBytes("Bearer " + secret))
    : throw new InvalidOperationException("Set a random 32–128 character key.");
static bool Authorized(HttpContext ctx, byte[] hash)
{
    var auth = ctx.Request.Headers.Authorization;
    return ctx.Request.Host.Host == "127.0.0.1" && !ctx.Request.Headers.ContainsKey("Origin") &&
        auth.Count == 1 && auth.ToString().Length <= 140 &&
        CryptographicOperations.FixedTimeEquals(hash, SHA256.HashData(Encoding.UTF8.GetBytes(auth.ToString())));
}
record Proposal(string Text);
record Decision(string Choice);

The request endpoint creates an immutable pending row. The reviewer can retrieve its payload and expiry before deciding. The decision statement updates only a matching pending row whose expiry is still in the future. An approved decision inserts exactly one receipt in the same transaction. A failure before commit rolls back both writes. This pattern prevents duplicate local receipts but cannot make a remote external action exactly once.

Test a review and duplicate decision

Configure distinct keys and start the lab
export RequestKey="$(openssl rand -hex 32)"
export ApproveKey="$(openssl rand -hex 32)"
dotnet run --no-launch-profile -- --urls http://127.0.0.1:5080

Use those same key values in a separate terminal without recording them in shared logs. Use a fresh UUID if you rerun the test against the same database.

Create, inspect and approve a proposal
url=http://127.0.0.1:5080/approvals/11111111-1111-1111-1111-111111111111
curl -i "$url" -H "Authorization: Bearer $RequestKey" \
  -H 'Content-Type: application/json' -d '{"text":"Prepare a support response preview."}'
curl -i "$url" -H "Authorization: Bearer $ApproveKey"
curl -i "$url/decision" -H "Authorization: Bearer $ApproveKey" \
  -H 'Content-Type: application/json' -d '{"choice":"approve"}'

Expect 201 for the proposal and 200 for approval. Repeat the decision and expect 409. Use the requester key on the decision endpoint and expect 401. On another proposal, reject and verify that no receipt is written. In a disposable database, test expiration by waiting past the window or setting an old timestamp, then attempting approval.

The C# project compiled. The SQLite loopback test returned 201 for a proposal, 401 for a requester-key approval attempt, 200 for an authorized approval, and 409 on replay. The database contained exactly one receipt. Separate cancel and reject tests passed and created no additional receipt. Approval after cancellation returned 409. Expiry and external side effects were not exercised.

Execute safely beyond the database

Do not hold the SQLite transaction open while calling external services. Store an outbox event in the same transaction as approval and let a worker perform the action. Attach a destination-supported idempotency key and reconcile uncertain outcomes before retrying. See the ASP.NET PB transactional outbox guide.

Make the approver UI show the exact action, target, data, requester and expiry. Do not render proposed text as HTML without sanitization. The requester can POST to the same proposal’s /cancel endpoint. Both cancellation and approval require a pending row, so the first committed transition wins. An approval that already committed requires a separate compensation policy. A disconnected client may lose the response after commit, so expose an authorized status lookup instead of prompting a new approval automatically.

At scale, move from a local SQLite file to a shared transactional database and implement per-tenant authorization and durable audit. Log stable IDs and decision outcomes. Keep sensitive proposal bodies out of application logs. Store audit events with the decision if you need an authoritative record. The lab’s stdout entry is diagnostic and is emitted after commit.

Symptom Check
Reviewer cannot decide Approver role and matching authorization header
Decision returns 409 Already decided, expired or unknown
Duplicate downstream action External idempotency and worker recovery
Pending work lost after restart Database location and persistence
Review approves changed data Immutable payload or canonical hash binding

FAQ and references

Can an agent approve its own proposal? Enforce approver permissions outside the model, with separation of duties when required.

Does a timeout revoke an approval already committed? No. Query durable status and use an explicit cancellation or compensation policy where applicable.

How do I prevent replay? Require an atomic transition from pending and use a unique receipt or execution key.

Test wrong-role, duplicate and expired requests before adding external effects. The approval must be as specific and durable as the action it permits.

Comments