⚠️ This blog post was created with the help of AI tools. Yes, I used a bit of magic from language models to organize my thoughts and automate the boring parts, but the geeky fun and the 🤖 in C# are 100% mine.

Hi!

During the live Q&A in the second session of the From Model to Agent: The Agent Framework Harness, Live in C# series on September 10, 2026, someone in the audience asked a practical question:

What happens if an agent requests approval, but the user does not answer? Can the approval expire or retry?

That question is easy to overlook in a happy-path demo. We usually show the agent requesting a tool, the user selecting Approve or Deny, and the workflow continuing. Real applications also need to handle silence, invalid answers, repeated approval requests, cancellation, and unavailable input.

So, after the session, I created a new sample: 22 – Approval Retries and Timeouts.

The sample adds a bounded approval policy to a Microsoft Agent Framework agent running with Harness:

  • five seconds to answer each approval request
  • five attempts maximum
  • y or yes approves immediately
  • n or no denies immediately
  • missing or invalid input retries
  • cancellation and input failures deny the action
  • exhausting the attempts denies automatically
  • one denial remains in effect for the rest of the prompt
  • repeated approval rounds from the model are also bounded

The important principle is simple:

Silence is not consent.

About the MafClaw series

MafClaw is a four-part Microsoft Reactor live-coding series where we build a C# agent incrementally with .NET, Microsoft Agent Framework, and Harness.

The running sample is a finance education assistant. We start with a model, turn it into an agent, add tools and planning, introduce safe access to data, require human approval for side effects, add memory, and then move toward more advanced and production-ready scenarios.

You can also follow the four sessions on the .NET YouTube channel:

  1. Meet Your Claw: A Harness in Three Lines of C#
  2. Working With Your Data, Safely: Files, Approvals and Memory
  3. Scaling the Claw: Skills, Shell, CodeAct and Background Agents
  4. Production Ready: Observability, Governance and Deployment

The audience question that inspired this sample came from session 2, where we explored file boundaries, human approval, and agent memory.

The scenario

The user asks the agent:

Buy 10 shares of MSFT.

This is a simulated trade. No real transaction is created, but it represents a side effect that should not execute only because a model requested it.

The tool is wrapped with ApprovalRequiredAIFunction:

internal static class ApprovalGateTools
{
[Description("Places a demo-only simulated trade after the Harness approval flow allows it.")]
public static string RequestSimulatedTradeOrder(
[Description("Trade side, for example buy or sell.")] string side,
[Description("Ticker symbol, for example MSFT.")] string symbol,
[Description("Number of shares.")] int shares)
{
var normalizedSide = side.Trim().ToLowerInvariant();
var normalizedSymbol = symbol.Trim().ToUpperInvariant();
if (shares <= 0)
{
return "Denied: share quantity must be greater than zero.";
}
return $"Approved: simulated {normalizedSide} order for {shares} shares " +
$"of {normalizedSymbol}. This is not a real transaction.";
}
public static AIFunction RequestSimulatedTrade { get; } =
new ApprovalRequiredAIFunction(
AIFunctionFactory.Create(
RequestSimulatedTradeOrder,
"request_simulated_trade"));
}

The model can request request_simulated_trade, but Harness pauses before executing the C# function. The host application, not the model, owns the approval decision.

Configure a bounded approval policy

The sample configures the policy with a small block:

const int maxApprovalAttempts = 5;
var approvalTimeout = TimeSpan.FromSeconds(5);
var approvalPolicy = new TimedApprovalPolicy(
maxApprovalAttempts,
approvalTimeout);

These numbers are intentionally short for a live demo. A production application should choose deadlines and retry limits that match its user experience and the risk of the requested action.

The policy validates its configuration:

public TimedApprovalPolicy(
int maxAttempts,
TimeSpan timeout,
Func<TimeSpan, CancellationToken, Task<string?>>? readLineAsync = null)
{
if (maxAttempts <= 0)
{
throw new ArgumentOutOfRangeException(
nameof(maxAttempts),
"Approval attempts must be greater than zero.");
}
if (timeout <= TimeSpan.Zero)
{
throw new ArgumentOutOfRangeException(
nameof(timeout),
"Approval timeout must be greater than zero.");
}
this.maxAttempts = maxAttempts;
this.timeout = timeout;
this.readLineAsync = readLineAsync ?? ReadConsoleLineWithTimeoutAsync;
}

The injectable input reader is useful beyond this console sample. It keeps the approval policy separate from the user interface and makes the decision logic deterministic to test.

Retry missing or invalid input

The approval loop retries only when the user has not made a valid decision:

for (var attempt = 1; attempt <= maxAttempts; attempt++)
{
Console.Write(
$"Approve tool {toolName}({arguments})? [y/n] " +
$"Attempt {attempt}/{maxAttempts}, " +
$"{timeout.TotalSeconds:0}s timeout: ");
var input = await readLineAsync(timeout, cancellationToken);
if (input is null)
{
Console.WriteLine(attempt == maxAttempts
? "Timed out. Approval denied after the final attempt."
: "Timed out. Retrying approval.");
continue;
}
input = input.Trim();
if (input.Equals("y", StringComparison.OrdinalIgnoreCase) ||
input.Equals("yes", StringComparison.OrdinalIgnoreCase))
{
return new ApprovalDecision(
true,
$"Approved by console user on attempt {attempt}.");
}
if (input.Equals("n", StringComparison.OrdinalIgnoreCase) ||
input.Equals("no", StringComparison.OrdinalIgnoreCase))
{
return new ApprovalDecision(
false,
$"Denied by console user on attempt {attempt}.");
}
Console.WriteLine(attempt == maxAttempts
? "Invalid response. Approval denied after the final attempt."
: "Invalid response. Enter y or n; retrying approval.");
}
return new ApprovalDecision(
false,
$"Denied automatically after {maxAttempts} unanswered or invalid attempts.");

There is an important distinction here:

  • No response is not a denial yet, so the policy may ask again.
  • Invalid input is not approval, so the policy may ask again.
  • Explicit n is a decision, so the policy stops immediately.
  • Explicit y is consent, so the policy stops immediately.
  • No valid answer after the limit becomes an automatic denial.

Retries must never turn uncertainty into approval.

Do not block forever on console input

Console.ReadLine() waits indefinitely and cannot provide the timeout behavior we need for an interactive console.

The sample checks for available keyboard input until the deadline:

var input = new StringBuilder();
var startedAt = Stopwatch.GetTimestamp();
while (Stopwatch.GetElapsedTime(startedAt) < timeout)
{
cancellationToken.ThrowIfCancellationRequested();
if (!Console.KeyAvailable)
{
await Task.Delay(50, cancellationToken);
continue;
}
var key = Console.ReadKey(intercept: true);
if (key.Key == ConsoleKey.Enter)
{
Console.WriteLine();
return input.ToString().Trim();
}
if (!char.IsControl(key.KeyChar))
{
input.Append(key.KeyChar);
Console.Write(key.KeyChar);
}
}
Console.WriteLine();
return null;

The complete implementation also supports redirected input and distinguishes an input timeout from caller cancellation. Cancellation, console I/O errors, and unavailable input are converted into denied decisions instead of allowing the tool to run.

Make denial sticky for the prompt

Limiting keyboard attempts is not enough.

After receiving a denial, the model could theoretically request the same tool again when the agent continues. A new request must not silently reopen the approval process during the same user prompt.

The console runner records whether any approval has already been denied:

var approvalRound = 0;
var approvalDeniedForPrompt = false;
while (true)
{
var approvalRequests = response.Messages
.SelectMany(message => message.Contents)
.OfType<ToolApprovalRequestContent>()
.ToList();
if (approvalRequests.Count == 0)
{
return;
}
approvalRound++;
if (approvalRound > MaxApprovalRoundsPerPrompt)
{
Console.WriteLine(
"Approval denied: the agent exceeded the approval round limit " +
"for this prompt.");
return;
}
var approvalResponses = new List<AIContent>();
foreach (var request in approvalRequests)
{
var functionCall = request.ToolCall as FunctionCallContent;
var toolName = functionCall?.Name ?? request.ToolCall.CallId;
var arguments = functionCall?.Arguments is null
? string.Empty
: string.Join(
", ",
functionCall.Arguments.Select(
item => $"{item.Key}: {item.Value}"));
var decision = approvalDeniedForPrompt
? new ApprovalDecision(
false,
"Denied automatically because another approval was already " +
"denied for this prompt.")
: await approvalPolicy.RequestApprovalAsync(toolName, arguments);
approvalDeniedForPrompt |= !decision.Approved;
approvalResponses.Add(
request.CreateResponse(decision.Approved, decision.Message));
}
response = await agent.RunAsync(
[new ChatMessage(ChatRole.User, approvalResponses)],
session);
}

This adds two separate limits:

  1. The human gets a bounded number of attempts to answer one approval request.
  2. The agent gets a bounded number of approval rounds for one user prompt.

Once a request is denied, later approval requests in that prompt are denied automatically. The model cannot negotiate its way around the user’s answer.

Demo paths

Run the sample:

dotnet run --project `
.\session-02\samples\22-approval-retries-timeouts\MafClaw.Sample22.csproj

Then use the same agent prompt for each path:

Buy 10 shares of MSFT.

Approved

Answer before the deadline:

y

The tool executes and returns a simulated result.

Explicitly denied

Answer:

n

The request is denied immediately. It does not consume the remaining attempts.

Invalid input followed by approval

Answer:

maybe

The policy rejects the value and asks again. Answer y on the next attempt to approve.

No response

Do not type anything.

Each attempt expires after five seconds. After the fifth attempt, the request is denied automatically and the simulated trade does not execute.

Why this matters outside the demo

Human approval is often presented as a yes/no dialog, but the operational behavior around that dialog is part of the security boundary.

A real approval design should answer questions such as:

  • How long is approval valid?
  • What happens when the approver is unavailable?
  • Can an expired request be retried?
  • How many retries are allowed?
  • Does an explicit denial remain final?
  • Can the agent immediately ask again?
  • What happens if multiple tools request approval together?
  • What audit data identifies the user, tool, arguments, decision, and time?
  • Does the approval authorize exactly one execution?

The sample is deliberately small, but it demonstrates the core rule: the host application owns these policies. The language model should not decide how long to wait, how many times to retry, or whether silence means approval.

Try the complete sample

The complete source is available in the public MafClaw repository:

And if you want to see the audience discussion that prompted this additional scenario, watch Working With Your Data, Safely: Files, Approvals and Memory on the .NET YouTube channel.

Happy coding!

Greetings

El Bruno

More posts in my blog ElBruno.com.

More info in https://beacons.ai/elbruno


Leave a comment

Discover more from El Bruno

Subscribe now to keep reading and get access to the full archive.

Continue reading