The moment an agent can do something with consequences — issue a refund, delete a record, send a message to a customer — the interesting engineering stops being about the model and starts being about the pause.
Microsoft Agent Framework gives you two mechanisms for this, and they solve genuinely different problems. Choosing the wrong one is not a style preference; it decides whether your approval survives a deployment.
Tool approval: the in-turn gate
The lighter mechanism marks a function as requiring approval. The agent proposes the call, the framework surfaces it rather than executing it, and your code decides.
The shape is: run, get back a response containing an approval request rather than a result, present it, then send the decision back into the same conversation and continue.
// The tool is declared as needing approval when it is registered.
var refund = AIFunctionFactory.Create(
(string orderId, decimal amount) => _payments.RefundAsync(orderId, amount),
name: "issue_refund",
description: "Refund an order. Requires approval.");
var agent = chatClient.CreateAIAgent(new ChatClientAgentOptions
{
Instructions = "You handle refund requests. Never promise a refund you have not issued.",
ChatOptions = new() { Tools = [refund] },
});
When the run comes back holding an approval request, you show it to somebody, get an answer, and resume the same thread with that answer attached. The conversation continues as though the tool had simply taken a while.
This is the right tool when the approver is present. A support agent watching the interaction, an operator at a console, a “are you sure?” in a chat UI. The whole exchange lives inside one agent turn.
And that is also its limit. The conversation is in memory. If your process restarts — a deploy, a scale-in, a crash — the pending approval goes with it. For an approval that resolves in fifteen seconds, fine. For one that sits until somebody gets back from lunch, you have built something fragile.
Request ports: pausing the workflow properly
The workflow side of Agent Framework models this differently, and better, for anything long-lived.
A request port sits in the workflow graph like an executor, but instead of processing data it stops. It emits a typed request to the outside world and the workflow parks. Some time later — minutes, days — an external response arrives and execution resumes from exactly that node.
// A typed request/response pair. Both are yours to define.
public sealed record RefundApproval(string OrderId, decimal Amount, string Reason);
public sealed record ApprovalDecision(bool Approved, decimal? AdjustedAmount, string? Note);
The important consequence is that the response is a type you design, not a boolean. Real approvers rarely want yes or no. They want “yes, but forty pounds not four hundred”, or “no, and here is who to route it to”. Modelling the decision as a record gets you that for free, and it is a much better fit for how approval actually works in an organisation.
The workflow declares the port as a node, routes high-risk cases to it and low-risk cases straight through, and carries on when the answer lands.
Checkpointing is the part that makes it real
A workflow that pauses in memory is not meaningfully different from a conversation that pauses in memory. What makes the request port worth the extra structure is that pending requests are saved as part of the checkpoint.
Restore from a checkpoint and any outstanding requests are re-emitted as request events, so your host can pick them up and present them again. The process that started the approval does not have to be the process that finishes it — which is the actual requirement, once you have more than one replica and any deployment cadence at all.
That property is what you are buying. Everything else about request ports is ergonomics; checkpoint durability is the reason to use them.
Two practical notes. Checkpoint at the pause, not on a timer — the pause is the only point where you know the state is quiescent and worth persisting. And treat the checkpoint store as production data: it now contains a queue of things a person promised to look at, and losing it silently loses obligations.
The routing decision belongs in the graph
The temptation is to make the model decide whether something needs approval. Do not. Put the threshold in the workflow, in code, where it can be read, tested and audited:
// Deterministic. A prompt cannot argue with it.
if (request.Amount > _policy.AutoApproveCeiling || customer.IsFlagged)
→ route to the approval port
else
→ route straight to execution
A model asked “does this need approval?” will get it right most of the time, and the times it does not are exactly the adversarial ones. AutoApproveCeiling in configuration is boring, reviewable and cannot be talked out of its position by a persuasive prompt. Boring is correct here.
This is also the honest boundary between approval and authorisation. Approval is a judgement call by a human. Authorisation is whether the action was ever permitted. If your refund API will accept any amount because the agent’s credentials allow it, an approval step is a suggestion — enforce the ceiling server-side too, and treat the human gate as the second control rather than the only one. Securing AI agents in .NET covers the wider version of this argument.
Telling the model it was refused
When a rejection comes back, how you phrase it to the model matters more than it should.
Feed back a bare failure and the model does the reasonable thing: it assumes a transient problem and tries again, perhaps with £399 instead of £400. That is not malice, it is the model doing what models do with errors.
Say what happened and what to do instead:
The refund was not approved. Reason: outside the returns window. Do not attempt another refund for this order. Explain the decision to the customer and offer to escalate to a manager.
Explicit, with an alternative path. The difference in behaviour is large and it costs you one sentence.
What this does to your latency budget
A paused workflow has no latency, which is the point — but the user-facing surface does. If someone is waiting on a chat interface while an approval sits in a queue, that is now a UX problem, not an engineering one.
Design the acknowledgement deliberately: tell the user the request has gone for approval, give them a reference, and stop the spinner. An honest “this needs a manager, we will email you within the hour” reads far better than an interface that appears to be thinking for forty minutes. The latency budget argument applies here too — the fix is usually a product decision, not a faster model.
Takeaway
Use tool approval when the approver is present and the decision resolves in seconds. Use a workflow request port when it does not, because the pending request is checkpointed and survives the restart that will eventually happen mid-approval.
Keep the routing threshold in code rather than in a prompt. Make the approval response a type rather than a boolean, so approvers can correct rather than only refuse. Tell the model explicitly when it has been refused and where to go instead. And do not let a human gate stand in for a server-side limit.
If you are building the graph side of this, multi-agent orchestration with graph workflows covers the surrounding structure, and agent memory and state persistence covers what else you should be checkpointing.
Note: Agent Framework’s workflow surface — request ports, checkpoint APIs and the tool-approval flow — has moved between releases. Verify the current type and method names against the human-in-the-loop documentation for the package version you are on before building around them.
Have a correction or a topic you want covered? Email mani.bc72@gmail.com.
