A customer types "cancel my upgrade, I clicked it by mistake." The model hears "cancel," picks CancelSubscription, and the Pro plan is gone before anyone reads the sentence twice. Nothing crashed. The logs look clean. That's what makes it scary.
I stopped trusting "the system prompt says ask first" a while ago. A prompt is a request. What I want is a gate: the tool call pauses, gets stored, and only runs after a named human decides. In .NET 10 with Microsoft.Extensions.AI, the chat pipeline already has that gate built in. You just have to wire it on purpose.
This post covers the idea and the shape of each step. The code is skeleton code, and every omission is marked. The complete working, tested project (Core library, minimal API, CLI demo, 56 offline tests, no API key needed) is in Tech Skill Builder.
Tech Skill Builder is $5/month for a very limited time only (normally $10). Grab it now before it goes back to $10.
The mental model: one tool call, two requests
Without a gate, a destructive tool runs inside the same request as the chat message. With a gate, it's split in two:
Request 1 POST /chat "cancel subscription SUB-77"
model -> CancelSubscription(SUB-77)
pipeline -> does NOT invoke, returns an approval request
app -> stores it as Pending, replies "waiting for approval"
Request 2 POST /approvals/{requestId}/approve { decidedBy: "ops-lead" }
app -> marks Approved, writes audit row
pipeline -> invokes CancelSubscription, model writes the final answer
Everything below is about making that split reliable.
Step 1: Mark only the dangerous tools
Read-only tools stay plain AIFunctions and auto-run. Anything that moves money or can't be undone gets wrapped:
public IList<AITool> CreateTools() =>
[
AIFunctionFactory.Create(LookupOrder, name: nameof(LookupOrder)),
new ApprovalRequiredAIFunction(
AIFunctionFactory.Create(RefundOrder, name: nameof(RefundOrder))),
new ApprovalRequiredAIFunction(
AIFunctionFactory.Create(CancelSubscription, name: nameof(CancelSubscription))),
];
// LookupOrder / RefundOrder / CancelSubscription bodies:
// full implementation in the complete project
Pass name: explicitly. If you don't, local methods and lambdas can get compiler-generated names, and the tool the model asks for won't match the tool you registered.
One thing to know before you rely on this: ApprovalRequiredAIFunction is only a marker. On its own it stops nothing.
Step 2: Let the pipeline do the pausing
The pausing comes from FunctionInvokingChatClient, which you add with UseFunctionInvocation():
IChatClient client = new ChatClientBuilder(inner)
.UseFunctionInvocation()
.Build();
var options = new ChatOptions
{
Tools = tools,
AllowMultipleToolCalls = false, // see below
Instructions = "...", // your support-assistant prompt
};
When the model asks for a marked tool, the invoker swaps that call for a ToolApprovalRequestContent and hands it back to you instead of running it.
About AllowMultipleToolCalls = false: if one model response contains several tool calls and any of them needs approval, every call in that response needs approval, including the harmless lookup. Asking for one tool call per response keeps the safe tools fast and the gate focused. The loop can still chain tools across turns.
Step 3: Persist the pause
This is the step tutorials tend to skip. The approval request has to survive until a human acts, possibly minutes later, possibly from another browser tab:
foreach (var request in response.Messages
.SelectMany(m => m.Contents)
.OfType<ToolApprovalRequestContent>())
{
// keep the original object: the decision must be created from it
session.OpenApprovalRequests[request.RequestId] = request;
// reads FunctionCallContent: CallId, Name, Arguments as display strings
var (callId, name, args) = DescribeToolCall(request.ToolCall);
_pending.Upsert(new PendingApproval(
RequestId: request.RequestId,
SessionId: session.SessionId,
CallId: callId,
FunctionName: name,
Arguments: args,
CreatedAtUtc: now));
}
// DescribeToolCall, DTO mapping, reply text:
// full implementation in the complete project
Two rules I follow here:
- Keep the full message history, including the assistant message that carried the approval request. Drop it and the decision can't be matched to the call.
- The args snapshot is for humans and auditors. What actually runs is the original tool call held on the request object, not whatever the UI sends back.
Step 4: Decide exactly once
The decision endpoint does four things in order: check, record, audit, continue.
public async Task<ChatTurnResult> DecideAsync(
string sessionId, string requestId, bool approved,
string decidedBy, string? reason = null, CancellationToken ct = default)
{
// load the session and the stored requestContent (session.OpenApprovalRequests)
// 1. already decided? report who decided, do NOT re-invoke
// 2. _pending.TryDecide(...) -> Approved / Denied, decidedBy, timestamp
// 3. _audit.Append(new AuditEntry(...))
var decision = new ChatMessage(ChatRole.User,
[requestContent.CreateResponse(approved, reason)]);
session.Messages.Add(decision);
var response = await _chat.GetResponseAsync(session.Messages, options, ct);
// full implementation in the complete project
}
On approve, the pipeline runs the tool and the model writes the confirmation. On deny, nothing runs, and the model gets a rejection result it can explain ("I didn't cancel it, here's why"). Always pass the reason through. A deny that looks like a hang is how people end up clicking Approve just to make the screen move.
Check number 1 is what stops the "two operators, one queue, cancelled twice" bug. If Alice approves and Eve clicks Deny a second later, Eve gets "already Approved by alice," and the tool count stays at one.
Prove it with a test, not a demo
I don't consider the gate done until a test shows the destructive method didn't run before the decision:
[Fact]
public async Task Cancel_waits_for_a_human()
{
var (chat, tools, _) = TestHelpers.CreateChat(); // offline model, no API key
var session = chat.CreateSession();
var first = await chat.SendAsync(session.SessionId, "cancel subscription SUB-77");
Assert.True(first.AwaitingApproval);
Assert.Equal(0, tools.CancelInvocationCount); // paused, not run
await chat.DecideAsync(session.SessionId,
first.PendingApprovals[0].RequestId, approved: true, decidedBy: "ops");
Assert.Equal(1, tools.CancelInvocationCount);
}
// deny, double-decide, collateral mode, API integration tests:
// in the complete project
The trick is an offline IChatClient that emits FunctionCallContent from keywords. CI stays fast and free, and the test checks your gate instead of a model's mood.
My pre-production checklist
- [ ] Every irreversible tool is wrapped. Nothing that only reads is.
- [ ]
UseFunctionInvocation()is actually on the pipeline you inject. - [ ]
AllowMultipleToolCalls = falsewhere money is involved. - [ ] Pending approvals live in a shared store (a database, not memory) once you run more than one node.
- [ ] The person who can call
/approveis authorized separately from the chat caller, anddecidedByis a real identity. - [ ] Audit rows don't log secrets. Amounts are fine. Card numbers are not.
- [ ] Deny reasons reach the user.
Get the complete project
The snippets above show the shape. The full .NET 10 solution has all of it working end to end: the tools, the pipeline, the pending store and audit trail, approve/deny minimal API endpoints, a CLI that shows approve, deny, lookup and the collateral trap, an offline function-calling client, a PDF walkthrough and 56 passing tests. It's in Tech Skill Builder, along with a growing library of other complete, tested .NET + AI projects.
Tech Skill Builder is $5/month for a very limited time only (normally $10). Grab it now before it goes back to $10. 👉 Join Tech Skill Builder
Which tool in your agent would you gate first? Mine was refunds. Tell me yours in the comments.
Top comments (1)
Splitting the destructive call into two requests with a stored Pending state feels so much safer than hoping the system prompt's "ask first" holds.