Mert Özen Further With Every Line
Artificial Intelligence AI Agents and MCP — #07

Human-in-the-Loop: Tool Approval, Checkpoints and Long-Running Agents

Mert Özen Nov 8, 2026 10 min 4 views
Human-in-the-Loop: Tool Approval, Checkpoints and Long-Running Agents

We put write tools behind approval with ApprovalRequiredAIFunction and, when approval takes hours, write the agent session to JSON and resume in another process. Reasoned rejections, an attempt to change the argument in the approval response (approval-response binding) and resuming a workflow from a checkpoint with RequestPort, all with real runs.

Version note: The code in this post was built with .NET 10, Microsoft.Extensions.AI 10.11.0, OllamaSharp 5.5.2, Microsoft.Agents.AI 1.28.0 and Microsoft.Agents.AI.Workflows 1.28.0 and run against the qwen3:4b model on a local Ollama 0.34.4.

Guess First

At a customer's request, the agent wants to call cancel_order(orderId: 1044) and the call goes to approval. Someone on the support team sees "Cancel 1044?" on screen and approves. But on its way to the server (because of a bug, a proxy or a malicious client) the approval response's argument changes to 1043, an order that has already shipped.

What happens? Does the approval count for 1043 too, does the framework re-check the argument, or does nothing happen? And one more question: if the approver answers two hours later, does the agent have to wait in memory for those two hours?

Which Actions Need Approval?

Human-in-the-loop means the agent stops at some point and waits for a person's decision. Putting approval on every tool is easy, but useless: if nine out of ten approval prompts say "search books", the user approves the tenth without reading it. This is called approval fatigue and it reduces the security value of approval to zero.

The split I use is this. Read tools (search_books, get_order_status) run without approval. Reversible, low-impact writes (add_to_cart) usually run without approval but are logged. Three classes always require approval: moving money (refunds, payments, discounts), irreversible deletion or cancellation, and messages that leave the system (an email to the customer, an order to a supplier). What they have in common is that when they run wrongly, the model saying "sorry" fixes nothing.

Let's also say up front that approval doesn't replace authorization. cancel_order must only be able to cancel the signed-in customer's orders; the tool itself checks that, not the approval screen. In LLM (Large Language Model) Fundamentals 12 we covered this as "take authorization from the session, not from the model".

ApprovalRequiredAIFunction and ToolApprovalRequestContent

We wrap the tool that needs approval in ApprovalRequiredAIFunction. The wrapper doesn't change the tool's behavior; it only tells the function invocation layer "ask before running this":

public AITool[] All() =>
[
    AIFunctionFactory.Create(GetOrderStatus, "get_order_status"),
    // Writes data: a person must approve every call
    new ApprovalRequiredAIFunction(AIFunctionFactory.Create(CancelOrder, "cancel_order")),
];

In LLM Fundamentals 12 we used the same mechanism with a plain IChatClient, and post 6 of the AI (Artificial Intelligence) with .NET series moves it into an HTTP (HyperText Transfer Protocol) API (Application Programming Interface). Here we work with Microsoft Agent Framework's (MAF) ChatClientAgent and its session, because the real question is an approval that takes a long time:

AgentSession session = await agent.CreateSessionAsync();
AgentResponse response = await agent.RunAsync("Please cancel my order 1044.", session);
foreach (ToolApprovalRequestContent req in response.Messages.SelectMany(m => m.Contents).OfType<ToolApprovalRequestContent>())
{
    FunctionCallContent call = (FunctionCallContent)req.ToolCall;
    // The approval screen must show the exact arguments
    Console.WriteLine($"approval needed: {call.Name}({JsonSerializer.Serialize(call.Arguments)}) request={req.RequestId}");
}

// Nobody is waiting on this thread: persist the session and exit
JsonElement saved = await agent.SerializeSessionAsync(session);
await File.WriteAllTextAsync(SessionFile, saved.GetRawText());
Console.WriteLine($"session saved, {new FileInfo(SessionFile).Length} bytes");

Real output:

approval needed: cancel_order({"orderId":1044}) request=ficc_eb5be626
session saved, 12193 bytes

RunAsync returned a response, but not text: it contains a ToolApprovalRequestContent. The tool didn't run. RequestId (with a ficc_ prefix) is this request's identity, and ToolCall is the call to approve: name and arguments. The approval screen should show exactly these two; a question without arguments such as "Do you want to cancel the order?" means the user doesn't know what they are approving.

The User Answers Hours Later: Persisting the Session

In real life approval doesn't come in a second. The person on the support team may be at lunch; the customer may click the link in the email the next day. Keeping the process alive and parked on an await for that long means losing everything on a deploy, a restart or a scale event. That's why the code above writes the session as JSON (JavaScript Object Notation) with SerializeSessionAsync and exits. In the example it's a file; in production a database row or a blob.

When the approval arrives, most likely in a different process, the session is restored and the pending request is found in the history:

// Hours later, maybe on another server
JsonElement saved = JsonDocument.Parse(await File.ReadAllTextAsync(SessionFile)).RootElement;
AgentSession session = await agent.DeserializeSessionAsync(saved);
session.TryGetInMemoryChatHistory(out List<ChatMessage>? history);
ToolApprovalRequestContent pending = history!.SelectMany(m => m.Contents).OfType<ToolApprovalRequestContent>().Last();
FunctionCallContent call = (FunctionCallContent)pending.ToolCall;
Console.WriteLine($"restored: {history!.Count} messages, pending {call.Name}({JsonSerializer.Serialize(call.Arguments)})");

AIContent answer = mode switch
{
    "approve" => pending.CreateResponse(approved: true),
    "reject" => pending.CreateResponse(approved: false, reason: "Support must call the customer before cancelling. Do not retry."),
    // A response whose arguments differ from what was shown to the person
    _ => new ToolApprovalResponseContent(pending.RequestId, true,
        new FunctionCallContent(call.CallId, call.Name, new Dictionary<string, object?> { ["orderId"] = 1043 })),
};
AgentResponse response = await agent.RunAsync(new ChatMessage(ChatRole.User, [answer]), session);

Output of the approved run (in a new process):

restored: 2 messages, pending cancel_order({"orderId":1044})
  >>> CancelOrder EXECUTED for 1044
agent: Order 1044 has been cancelled.
orders: 1043=Shipped, 1044=Cancelled, 1040=Delivered

The first process had exited; the second one rebuilt the history and the pending request from the session JSON, RunAsync was called with the approval response, only then did the tool run, and the model turned the result into a sentence. How much time passed in between doesn't matter; the agent keeps nothing in memory.

Passing a Rejection to the Model Meaningfully

A rejection is also an answer and the model has to interpret it correctly. Send CreateResponse(approved: false) without a reason and the model doesn't know why; it either retries the same call or tells the user a made-up reason (we saw this in the experiment in LLM Fundamentals 12). The MAF samples also suggest telling the model not to retry when rejecting. The run with a reasoned rejection:

restored: 2 messages, pending cancel_order({"orderId":1044})
agent: Support must be contacted before cancelling order 1044. I cannot proceed with this request.
orders: 1043=Shipped, 1044=Processing, 1040=Delivered

CancelOrder never ran (there is no EXECUTED line), the order is still Processing and the model passed the reason on to the user correctly. When writing the reason, think of two audiences: the model (what not to do) and the end user (why it didn't happen). Putting the approver's internal note ("customer looks suspicious") into the reason as is may mean the model says it to the customer.

Approved Equals Executed

Back to the guess. In the third run we sent a ToolApprovalResponseContent with the pending request's RequestId but with 1043 as the argument; the person had been shown 1044. The real result:

restored: 4 messages, pending cancel_order({"orderId":1044})
  >>> CancelOrder EXECUTED for 1044
agent: Your order 1044 has been successfully cancelled.
orders: 1043=Shipped, 1044=Cancelled, 1040=Delivered

CancelOrder ran, but for 1044, not 1043. The tampered argument in the response was ignored, and the pending call recorded in the session, the one shown to the person, ran. The tool approval page on Microsoft Learn describes this behavior as approval-response binding: by default an approval response authorizes a tool call only when it matches a pending request recorded in the same AgentSession; the MAF 1.28 package has a layer called ApprovalResponseBindingChatClient for it. There is a separate opt-out (DisableApprovalResponseBinding); if you turn it off, you have to build the same protection yourself.

The rule is simple: what was approved and what runs must be the same thing. Don't leave that to the framework alone; guarantee it at your own boundary too. Store (or sign) the name and arguments of the call sent to the approval screen on the server, and when approval arrives use the stored ones, not the arguments coming from the client. Give approval requests an expiry: a refund approved three days ago shouldn't run today with an amount that has changed.

Workflow Checkpoints: RequestPort and the Superstep Boundary

Persisting the session at the agent level is enough for a single conversation to wait. In a process with several steps (compute the refund, ask a manager, issue the refund) the waiting point is in the middle of the workflow. MAF workflows, which we saw in an earlier post, have RequestPort for this: the workflow stops at that point, emits a request to the outside and doesn't move on until the answer arrives.

public static Workflow Build(BookstoreData data)
{
    // Step 1: code computes the refund
    ExecutorBinding prepare = ((Func<int, RefundCase>)(orderId =>
    {
        Order order = data.Orders.Single(o => o.Id == orderId);
        decimal amount = order.BookIds.Sum(id => data.Books.Single(b => b.Id == id).Price);
        return new RefundCase(orderId, amount, "Damaged cover reported by customer");
    })).BindAsExecutor("prepare");

    // Step 2: the workflow stops here until a manager answers
    RequestPort approval = RequestPort.Create<RefundCase, ManagerDecision>("manager-approval");

    // Step 3: runs only with the manager's answer
    ExecutorBinding execute = ((Func<ManagerDecision, string>)(d =>
        d.Approved ? $"Refund issued. Manager note: {d.Note}" : $"Refund declined. Manager note: {d.Note}")).BindAsExecutor("execute");

    return new WorkflowBuilder(prepare)
        .AddEdge(prepare, approval)
        .AddEdge(approval, execute)
        .WithOutputFrom(execute)
        .Build();
}

Since the wait can take hours, we write the workflow's state to disk. MAF workflows checkpoint at superstep boundaries; FileSystemJsonCheckpointStore writes them as JSON files to a folder. The first process exits when it sees the request, the second continues from the latest checkpoint:

DirectoryInfo dir = Directory.CreateDirectory("checkpoints");
using FileSystemJsonCheckpointStore store = new(dir);
CheckpointManager checkpoints = CheckpointManager.CreateJson(store);
Workflow workflow = RefundWorkflow.Build(data);
const string RunId = "refund-1040";

StreamingRun run = mode == "refund-start"
    ? await InProcessExecution.RunStreamingAsync(workflow, 1040, checkpoints, RunId)
    : await InProcessExecution.ResumeStreamingAsync(workflow,
        (await checkpoints.GetLatestCheckpointAsync(RunId))!, checkpoints);

await using (run)
{
    await foreach (WorkflowEvent evt in run.WatchStreamAsync())
    {
        switch (evt)
        {
            case SuperStepCompletedEvent step when step.CompletionInfo?.Checkpoint is { } cp:
                Console.WriteLine($"checkpoint saved: {cp.CheckpointId}");
                break;
            case RequestInfoEvent request when mode == "refund-start":
                RefundCase c = request.Request.Data.As<RefundCase>()!;
                Console.WriteLine($"waiting for manager: order {c.OrderId}, amount {c.Amount}, {c.Reason}");
                Console.WriteLine("process exits; nothing is kept in memory");
                return;
            case RequestInfoEvent request:
                Console.WriteLine($"resumed with pending request: {request.Request.PortInfo.PortId}");
                await run.SendResponseAsync(request.Request.CreateResponse(new ManagerDecision(true, "Approved, photo checked.")));
                break;
            case WorkflowOutputEvent output:
                Console.WriteLine($"output: {output.Data}");
                break;
        }
    }
}

Run in two separate processes:

$ dotnet run -- refund-start
checkpoint saved: 9d7bbacd7620435ea5feaa520cd6555e
waiting for manager: order 1040, amount 35,00, Damaged cover reported by customer
process exits; nothing is kept in memory

$ dotnet run -- refund-resume
resumed with pending request: manager-approval
checkpoint saved: dce0cdc33427489481fd3292ab5d13ae
output: Refund issued. Manager note: Approved, photo checked.
checkpoint saved: 092b424e34734d82958ac721b28bb3d4

The second process found the latest checkpoint with GetLatestCheckpointAsync("refund-1040"), rebuilt the workflow from that point and re-emitted the pending manager-approval request. The manager's answer was sent and execute ran. The checkpoints folder now has four JSON files and an index.jsonl; each record links to the previous one through parentCheckpointId, so the history of a run is kept as a chain.

Looking inside a checkpoint file reveals an important detail: executor types are written with assembly name and version (such as Bookstore.Agents.RefundCase, p07, Version=1.0.0.0). If you deploy a new version of the application while a workflow is waiting, the old checkpoint may not match the types in the new code. For long-waiting workflows, manage records and executor ids like an API contract.

Production Notes

The file system is for the example. For several instances to reach the same checkpoint, use an ICheckpointStore backed by a database or blob storage; the same goes for the agent session JSON. Both contain customer data: they need encryption, a retention period and a deletion policy.

Make resuming idempotent. An approval response can arrive twice (a double click, a retry). The second response must not start a second run from the same checkpoint; mark the pending request as consumed, and design the tool itself to be idempotent too (the same order isn't cancelled twice, the same refund isn't issued twice).

Keep an audit log. Who approved or rejected what, when, with which arguments, and what the rejection reason was. If every side-effecting action of an agent has a human decision behind it, that decision needs a trail too.

Give approval requests an expiry, and reject expired requests automatically. A screen listing pending requests keeps forgotten refunds and cancellations from silently piling up.

In MAF 1.28, ToolApprovalAgent and the harness agent can add standing rules such as "always approve" and auto-approval rules. The tool approval page on Microsoft Learn notes, for the Python side, that these rules live in session state and can match another tool with the same name, so saved rules should be reviewed when the tool set changes. If you turn on standing approval for convenience, read that note.

Common Pitfalls

Not showing the arguments on the approval screen. "Do you approve this action?" means the user doesn't know what they are approving. Show the tool name, all arguments and, if possible, the effect (refund amount, contents of the order to be cancelled).

Arguments changing after approval. Trusting the arguments in the approval response coming from the client punches a hole in the approval mechanism. The approved call is stored on the server and run exactly as stored.

Putting everything behind approval. Put read tools behind approval too and the user starts approving everything without reading, and the approval that matters loses its value.

Keeping pending work in memory. Waiting for hours with Console.ReadLine or a TaskCompletionSource works in a demo and disappears on the first deploy in production. Write the session or checkpoint out.

Rejecting without a reason. If the model doesn't know why, it retries or makes something up. A rejection always goes with a reason that tells the model what not to do.

A Small Experiment

★☆☆ Start with "Please cancel my orders 1043 and 1044." How many approval requests arrive? Approve one, reject the other and check the orders line; since 1043 is already Shipped, what does the tool return?

★★☆ Save the session JSON after the approval request is created, then send approval twice for the pending request (in two separate processes). How many times does CancelOrder run? If it runs twice, add a "consumed requests" table that prevents it.

★★★ Add a new property to the RefundCase record, rebuild the application and run refund-resume from the old checkpoint. What happens? Then change an executor id, repeat the experiment and note which one is more fragile.

Block A ends here: the agent loop, tool design, context management, workflow and multi-agent patterns, and human approval. In the next post we move to the protocol that takes the tools we've written as C# methods so far into a standard form any host can use: what MCP (Model Context Protocol) is, the host, client and server roles, and the 2026-07-28 spec.