Table of Contents
In the previous article, we looked at multimodal agents and the boundary around uploaded files. This post moves to a more uncomfortable boundary: actions.
The rule I care about here is simple:
The model can ask for a risky action. It should not be the authority that decides whether that action is allowed to run.
Prompts can explain how the agent should behave, but approval is an application boundary. If an agent can deploy to production, send an email, place an order, delete data, change permissions, or publish content, the system needs a real stop before the tool executes.
Microsoft Agent Framework gives you two useful building blocks for that:
- approval-required function tools
- workflow request and response handling
The first one is enough for many interactive agent flows. The second one matters once approval can be delayed, audited, resumed, or folded into a larger process.
Approval is not a nicer prompt
A weak design usually starts here:
You are a careful assistant.
Always ask the user before doing anything dangerous.
That instruction is not worthless. It can improve the agent’s behavior. It is still not a boundary.
The model can misunderstand the situation. A later message can override the instruction. Another agent can hide the tool call. The UI can show a friendly summary while the actual function arguments say something else.
A stronger design looks like this:
agent wants to call tool
-> framework emits approval request
-> application shows exact tool name and arguments
-> human approves or rejects
-> application sends the decision back
-> tool executes only if approved
That is the boundary. The model requests the action. The application decides whether the action continues.
Start with the side effects
Before adding Agent Framework approval code, classify the tools.
Not every tool needs human review. If every read-only lookup asks for approval, users will stop trusting the flow and developers will eventually disable it. Approval should sit around real risk.
| Tool type | Example | Approval default |
|---|---|---|
| Read-only lookup | Search docs, check deployment status, read inventory | Usually no |
| Drafting | Draft email, create deployment plan, prepare support reply | Usually no, unless auto-published |
| Reversible write | Create ticket, update draft record, reserve item temporarily | Sometimes |
| External side effect | Send email, deploy release, charge card, place order | Usually yes |
| Destructive action | Delete data, revoke access, overwrite production config | Yes, and maybe do not expose it |
This classification belongs in application policy, not in the agent prompt.
For example:
public enum ToolRisk
{
ReadOnly,
DraftOnly,
ReversibleWrite,
ExternalSideEffect,
Destructive
}
public sealed record ToolPolicy(
string ToolName,
ToolRisk Risk,
bool RequiresApproval);
The agent can still receive instructions such as “explain before requesting deployment”. The code decides which tools are wrapped with approval.
A tool that must not run silently
Imagine a deployment assistant. It can check staging status automatically. It must not deploy to production without review.
The read-only tool is normal:
using System.ComponentModel;
using Microsoft.Extensions.DependencyInjection;
[Description("Checks the current staging deployment status for a release.")]
public static Task<DeploymentStatus> CheckStagingStatusAsync(
[Description("Release identifier, for example release-2026-06-24.")] string releaseId,
IServiceProvider services,
CancellationToken cancellationToken)
{
var deployments = services.GetRequiredService<IDeploymentService>();
return deployments.CheckStagingStatusAsync(
releaseId,
cancellationToken);
}
The side-effecting tool is different:
[Description("Deploys an approved release to production.")]
public static async Task<DeploymentResult> DeployToProductionAsync(
[Description("Release identifier that should be deployed.")] string releaseId,
[Description("Approved change ticket for this production deployment.")] string changeTicket,
IServiceProvider services,
CancellationToken cancellationToken)
{
var user = services.GetRequiredService<CurrentUser>();
var authorization = services.GetRequiredService<IDeploymentAuthorization>();
var deployments = services.GetRequiredService<IDeploymentService>();
if (!await authorization.CanDeployToProductionAsync(
user,
releaseId,
changeTicket,
cancellationToken))
{
throw new UnauthorizedAccessException(
"The current user is not allowed to deploy this release.");
}
return await deployments.DeployToProductionAsync(
releaseId,
changeTicket,
cancellationToken);
}
Notice the authorization check inside the tool.
Human approval is not a replacement for permissions. The reviewer can approve the action, but the user on whose behalf the action runs still needs permission to do it.
Wrap the risky tool with approval
In Agent Framework, function tools can be wrapped with ApprovalRequiredAIFunction.
That tells the runtime that the agent may request the function call, but the function should not execute until approval has been provided.
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
AIFunction checkStaging =
AIFunctionFactory.Create(CheckStagingStatusAsync);
AIFunction deployToProduction =
AIFunctionFactory.Create(DeployToProductionAsync);
AIFunction approvalRequiredDeploy =
new ApprovalRequiredAIFunction(deployToProduction);
AIAgent deploymentAgent = chatClient.AsAIAgent(
name: "deployment-agent",
instructions: """
You help operators prepare production deployments.
Always check staging status before requesting a production deployment.
Explain the release id, target environment, and change ticket before
requesting deployment.
If the user does not provide a change ticket, ask for it.
""",
tools:
[
checkStaging,
approvalRequiredDeploy
],
services: app.Services);
The prompt still matters. It shapes the explanation and the plan.
The wrapper matters more for execution. The deployment tool now sits behind a runtime gate.
Handle approval requests after each run
When the agent tries to call an approval-required tool, the run may return an approval request instead of a final answer.
In C#, check the response messages for FunctionApprovalRequestContent.
AgentSession session = await deploymentAgent.CreateSessionAsync();
AgentResponse response = await deploymentAgent.RunAsync(
"Deploy release-2026-06-24 to production using CHG-1042.",
session,
cancellationToken: cancellationToken);
List<FunctionApprovalRequestContent> approvalRequests =
response.Messages
.SelectMany(message => message.Contents)
.OfType<FunctionApprovalRequestContent>()
.ToList();
if (approvalRequests.Count > 0)
{
FunctionApprovalRequestContent request = approvalRequests[0];
Console.WriteLine(
$"Approval required for {request.FunctionCall.Name}");
Console.WriteLine(
$"Arguments: {request.FunctionCall.Arguments}");
}
This sample reads approvalRequests[0] only to keep the first example small.
Do not turn that into a production assumption.
Modern models can request multiple tool calls in one turn, including multiple calls that require approval.
A real application should iterate through all pending approval requests, show them as a batch, and return one decision per request.
For a console sample, printing the function name and arguments is enough. For a real application, convert the request into a domain approval record.
public sealed record PendingApproval(
string ApprovalId,
string SessionId,
string FunctionName,
string ArgumentsJson,
string RequestedBy,
DateTimeOffset RequestedAt,
DateTimeOffset ExpiresAt);
Do not show only the model’s prose to the reviewer. Show the actual function call.
The approval UI should display at least:
- tool name
- exact arguments
- target resource or environment
- requesting user
- generated explanation or plan
- relevant retrieved evidence or status checks
- expiration time
- approve and reject actions
If the reviewer cannot see what will execute, the approval screen is theater.
Send the decision back to the same session
After the user approves or rejects the call, create a response from the original approval request and pass it back to the agent in the same session.
bool approved = true;
ChatMessage approvalMessage = new(
ChatRole.User,
[approvalRequests[0].CreateResponse(approved)]);
AgentResponse finalResponse = await deploymentAgent.RunAsync(
approvalMessage,
session,
cancellationToken: cancellationToken);
Console.WriteLine(finalResponse.Text);
If approved is true, the tool may execute.
If approved is false, the agent can explain that the action was rejected and either stop or ask for a revised plan.
If your application allows parallel tool calls, do the same thing here: respond to all pending approval requests, not only the first one.
You should check for approval requests after every run. One approved function call can lead to another function call that also requires approval. The loop ends only when the agent returns a normal final response or the application stops the process.
const int maxApprovalRounds = 5;
for (int approvalRound = 0; approvalRound < maxApprovalRounds; approvalRound++)
{
AgentResponse response = await deploymentAgent.RunAsync(
input,
session,
cancellationToken: cancellationToken);
if (ResponseContainsToolExecutionFailure(response))
{
throw new InvalidOperationException(
"Tool execution failed. Stop the approval loop and surface the failure.");
}
List<FunctionApprovalRequestContent> approvals =
response.Messages
.SelectMany(message => message.Contents)
.OfType<FunctionApprovalRequestContent>()
.ToList();
if (approvals.Count == 0)
{
return response.Text;
}
ApprovalPrompt[] prompts = approvals
.Select(approval => new ApprovalPrompt(
approval,
approval.FunctionCall.Name,
approval.FunctionCall.Arguments))
.ToArray();
IReadOnlyList<ApprovalDecision> decisions =
await approvalUi.RequestDecisionsAsync(
prompts,
cancellationToken);
input = new ChatMessage(
ChatRole.User,
decisions
.Select(decision =>
decision.Request.CreateResponse(decision.Approved))
.ToArray());
}
throw new InvalidOperationException(
$"The agent exceeded {maxApprovalRounds} approval rounds.");
The failure check is intentionally application-specific. The control flow is the important part.
Do not leave approval handling as an unbounded while (true) that asks the model again after every tool exception.
If a tool failed because staging is down, inventory changed, a downstream API rejected the request, or the approval expired, stop the loop and show the failure to the user.
Do not let the model immediately request the same side effect again.
This pattern works well when the human is present and approval is immediate. For example:
- a chat UI asks “Approve deployment?”
- an operator clicks approve
- the same server process continues the agent run
Once approval can happen later, you need more state.
Approval state belongs outside the model
The model does not own approval state. Your application does.
At minimum, persist:
- approval id
- conversation or session id
- workflow run id, if using workflows
- checkpoint id, if the run can be resumed later
- requested tool name
- requested arguments
- requester
- approver
- decision
- timestamp
- expiration
- final execution result
- correlation id or trace id
The approval record should have a normal state machine:
public enum ApprovalStatus
{
Pending,
Approved,
Rejected,
Expired,
Executed,
Failed
}
That avoids vague states such as “the UI showed approved, but the tool never ran” or “the tool ran twice after a retry”.
Use the approval id as an idempotency key when the tool performs the side effect. Retries happen. Users double-click. Servers restart. Workflow runs resume.
The side-effecting system should be able to say:
I already executed approval approval-123 for release release-2026-06-24.
Do not execute it again.
That is not an agent problem. That is normal distributed systems work.
Revalidate before execution
Approval is a point-in-time decision. The world can change between request and execution.
For a deployment assistant, staging may fail after the approval screen is shown. For an order assistant, inventory or price may change. For an email assistant, someone may edit the recipient list.
Do not treat approval as a permanent permission slip.
Revalidate inside the tool:
if (approval.ExpiresAt < DateTimeOffset.UtcNow)
{
throw new InvalidOperationException("The approval has expired.");
}
DeploymentStatus status = await deployments.CheckStagingStatusAsync(
releaseId,
cancellationToken);
if (!status.IsHealthy)
{
throw new InvalidOperationException(
"Staging is no longer healthy. Production deployment stopped.");
}
For high-risk actions, approval should usually expire quickly. The user should approve the exact action that will run, under current conditions.
When a workflow is the better implementation
Approval-required tools are fine for short interactive flows.
Use workflows when approval is part of a process that needs to pause, emit events, checkpoint, and resume later.
Examples:
- production deployment review
- support refund approval
- access request approval
- legal or compliance review
- content publishing with editorial sign-off
- multi-step remediation plan
In a workflow, human input is not a chat detail. It is part of the process.
The shape looks like this:
parse request
-> check current state
-> draft action plan
-> request human approval
-> execute approved action
-> verify result
-> persist audit record
That belongs in a workflow because the pause has state.
Tool approval inside a workflow
Agent Framework workflows emit events while they run.
When an agent inside a workflow calls an approval-required tool, the workflow can pause and emit a RequestInfoEvent.
For tool approval, that request contains ToolApprovalRequestContent.
A simplified sequential workflow might look like this:
using Microsoft.Agents.AI.Workflows;
ChatClientAgent deployAgent = new(
chatClient,
"""
You prepare production deployments.
Check staging first, then request production deployment when appropriate.
""",
"DeployAgent",
"Prepares and requests production deployments",
[
AIFunctionFactory.Create(CheckStagingStatusAsync),
new ApprovalRequiredAIFunction(
AIFunctionFactory.Create(DeployToProductionAsync))
]);
ChatClientAgent verifyAgent = new(
chatClient,
"You verify deployment results and summarize operational status.",
"VerifyAgent",
"Verifies deployments");
Workflow workflow =
AgentWorkflowBuilder.BuildSequential([deployAgent, verifyAgent]);
Then consume the workflow event stream:
List<ChatMessage> messages =
[
new(ChatRole.User, "Deploy release-2026-06-24 to production using CHG-1042.")
];
await using StreamingRun run =
await InProcessExecution.RunStreamingAsync(workflow, messages);
await run.TrySendMessageAsync(new TurnToken(emitEvents: true));
await foreach (WorkflowEvent evt in run.WatchStreamAsync())
{
if (evt is RequestInfoEvent requestEvent &&
requestEvent.Request.TryGetDataAs(
out ToolApprovalRequestContent? approvalRequest))
{
bool approved = await approvalUi.RequestDecisionAsync(
approvalRequest.FunctionCall.Name,
approvalRequest.FunctionCall.Arguments,
cancellationToken);
await run.SendResponseAsync(
requestEvent.Request.CreateResponse(
approvalRequest.CreateResponse(approved)));
}
if (evt is WorkflowOutputEvent output)
{
Console.WriteLine(output.Data);
break;
}
}
That is the workflow version of the same boundary:
- the agent requests the tool call
- the workflow emits a request event
- the application asks the human
- the application sends the response back
- the workflow continues
This is more code than the direct agent version. Use it when the extra structure gives you something concrete.
Checkpoints matter for delayed approval
If approval happens five seconds later, an in-memory session may be enough. If approval happens tomorrow, it is not.
Workflows support checkpoints. A checkpoint captures workflow state at a point in execution, including pending messages, shared state, and pending requests.
That matters for human review because the process can stop at:
waiting for approval
and later continue from the same state.
The implementation shape is usually:
workflow emits approval request
-> application stores approval record
-> application stores checkpoint reference
-> process returns to caller
-> human approves later
-> application resumes workflow from checkpoint
-> workflow re-emits pending request
-> application sends approval response
-> workflow continues
Do not rebuild the process from the model’s last answer. Resume from workflow state.
Also treat checkpoint storage as private application infrastructure. Checkpoint data may contain prompts, messages, pending tool calls, arguments, and intermediate outputs. Do not store it somewhere that broader users or unrelated services can read.
Do not make approval the only guardrail
Human review is one boundary. It is not the whole safety model.
For side-effecting tools, I would still keep these checks:
- authentication before the agent runs
- authorization inside the tool
- argument validation before execution
- business rule validation before execution
- approval for risky actions
- idempotency for retries
- audit logging after execution
- monitoring for unusual tool call patterns
The approval UI is not the place to hide missing application logic.
If a user is not allowed to deploy to production, the tool must reject the call even if someone clicks approve. If the arguments are invalid, the tool must reject the call. If the action already ran, the tool must not run it again.
The agent runtime is part of your application. It should not bypass your application.
Human edits are not the same as approval
Sometimes the reviewer should not only approve or reject. They should edit.
For example:
- edit an outbound support email
- change the deployment window
- remove a recipient
- adjust a public status message
Do not silently mutate the original tool call arguments and pretend the same approval still applies.
Treat edits as new input. If the reviewer significantly changes the payload, reject or cancel the original approval request. Then add the edited action back into the process as user-provided state, a user message, or a system/application note. Do not create an “approved” response for the original tool call while secretly executing different arguments.
That distinction matters for the model’s history. The model remembers the tool call it requested. If the application returns a successful tool result for a materially different call, the conversation history now lies about what happened. That can confuse follow-up reasoning, especially when later steps depend on the original arguments.
For a draft email, a good flow is:
agent drafts email
-> agent requests send email with that body
-> human edits body
-> application stores edited body
-> application rejects the original send request
-> edited body is added as user-provided content or process state
-> agent or application creates a new send action for that exact body
-> human approves the exact action that will execute
For a deployment, a changed release id, environment, or change ticket should create a new approval request. The human must approve the exact operation that will execute.
Make approval observable
Approval flows need logs that explain what happened.
Useful fields include:
approvalIdconversationIdworkflowRunIdcheckpointIdtoolNametoolArgumentsHashrequestedByapprovedBydecisiondecisionReasoncreatedAtdecidedAtexecutedAtexecutionResulttraceId
Avoid logging raw secrets or confidential payloads. But log enough to answer operational questions:
- What did the agent try to do?
- Who approved it?
- What exactly executed?
- Did the tool run once or more than once?
- Was the approval still valid at execution time?
- Which workflow checkpoint resumed the process?
If you cannot answer those questions, the approval flow is not production-ready yet.
When I would use direct tool approval
Use direct approval-required tools when:
- the user is present in the current interaction
- approval happens immediately
- the process can stay inside one agent session
- the action is a single tool call
- the surrounding application already owns the UI state
- you do not need workflow-level checkpointing
Examples:
- “Send this email?” in a chat UI
- “Create this support ticket?” after the user reviews it
- “Run this one deployment command?” while an operator is watching
- “Place this order?” inside an authenticated session
Start here if the process is small. The implementation is easier to read and easier to test.
When I would use workflows
Use workflows when:
- approval may happen later
- the process has multiple steps before or after approval
- the pause must be checkpointed
- the approval path needs auditability
- multiple humans or systems may respond
- the process should emit progress events
- the workflow may resume after a restart
- the process itself is product behavior
Examples:
- deployment approval with verification after release
- access request with manager and security review
- refund approval with payment execution and audit
- document publishing with editorial review
- incident remediation with operator approval before changes
In those cases, the workflow is not ceremony. It is the state model for the process.
When I would not use human approval
Do not add human approval just because an agent is involved.
Avoid it when:
- the tool is read-only
- the action is cheap and fully reversible
- the user already explicitly clicked a normal product button
- deterministic validation is enough
- approval would only hide weak authorization logic
- the reviewer cannot understand the action being approved
Bad approval flows create fatigue. If users approve twenty harmless requests, they will approve the dangerous one too.
Use approval for meaningful risk. Use validation, permissions, and deterministic code for everything else.
Conclusion
Human-in-the-loop agents are not about making the prompt more polite. They are about putting a real boundary between model intent and application side effects.
For simple interactive cases, wrap risky tools with ApprovalRequiredAIFunction, inspect FunctionApprovalRequestContent, show the exact tool call to the user, and pass the approval response back into the same session.
For long-running or delayed review, use workflows.
Handle RequestInfoEvent, persist approval state, checkpoint the workflow, and resume from state instead of rebuilding the process from text.
The design I would carry forward is:
- let the model request actions
- let application policy decide which actions require approval
- show humans the actual tool call, not only the model’s summary
- keep authorization and validation inside the tool
- persist approval and workflow state outside the model
- make side effects idempotent and auditable
That keeps the agent useful without giving it silent control over irreversible work.
The next post will move to the frontend boundary: AG-UI, streaming, tool progress, approval requests, state updates, and exposing agent behavior as more than a plain chat box. After that, observability is the natural follow-up, because these flows need traces across model calls, tools, approvals, workflow events, and frontend state.