Skip to content

Approval and modes ​

Some tools must not run unasked, such as closing a ticket. When the model calls one, the run ends and hands the call to your app, which approves or rejects it in the conversation's next run. Approval rules, standing approvals and the run's mode decide which calls need asking at all.

The round trip ​

Because no approval rule or standing approval decides the call to close_ticket, the run stores the request in the conversation and ends. Your app sends its answer in the conversation's next run, and the stored call runs once that answer approves it. Requests and answers are Microsoft.Extensions.AI's ToolApprovalRequestContent and ToolApprovalResponseContent.

Requiring approval ​

RequiresApproval() makes every call to a tool wait for approval:

cs
AddTool(CloseTicketAsync)
    .RequiresApproval();

snippet source | anchor

UseMcpServer(...).RequiresApproval() does the same for every tool of an MCP server. A tool that a context provider adds needs approval when you wrap it in ApprovalRequiredAIFunction.

A run that asks ​

When the model calls such a tool, the run ends without making the call. ApprovalRequests, which Typeshift adds to ChatResponse, lists each waiting call, with the tool's name and the arguments the model sent:

cs
var answer = await support.RunAsync("Close ticket 1234.", conversationId: conversationId);

foreach (var request in answer.ApprovalRequests)
    if (request.ToolCall is FunctionCallContent call)
        Console.WriteLine($"Approve {call.Name}({string.Join(", ", call.Arguments!.Values)})?");

snippet source | anchor

If the model called other tools in the same response, those calls wait too, even when they need no approval, and run once your app has answered.

A run that asks needs a conversation to store the request in. A run without a conversation id throws, naming the tool, so run with one, approve the calls with a rule, or use auto mode.

Approving or rejecting ​

Answer each request in the conversation's next run with CreateResponse(approved):

cs
var approvals = answer.ApprovalRequests.Select(request => request.CreateResponse(approved: true));

var approval = new ChatMessage(ChatRole.User, [.. approvals]);

var resumed = await support.RunAsync(approval, conversationId: conversationId);

snippet source | anchor

Approved calls run. A rejected call reaches the model as rejected, and the run goes on. To tell the model why, pass a reason with the rejection: CreateResponse(approved: false, reason). A conversation does not remember a settings id, so the run that answers passes the same settings id as the run that asked, or it runs on the agent's code alone.

WithMaxToolIterations limits how many rounds of tool calls one run makes. The calls you approve run in the next run, because the run that asked for approval has ended. The next run starts its own count, so those calls run even when the run that asked had used up its rounds. Calls that a rule, a standing approval or auto mode approves go on in the same run and count toward its limit. Such a run may make one round more than the limit.

Typeshift matches each answer to the request the conversation stored and runs the call stored with it. An answer can carry a call of its own, but the call that runs is always the one your app saw, with the same arguments. When a run carries more than one answer to a request, the first one counts.

When a request closes ​

A request stays open until the conversation goes on without it. It closes when:

  • a run answers it, or an approval rule does;
  • the conversation's next run starts with other input, such as a new message, since that run drops every request it has no answer for, and the model sees the call as never made;
  • its own run takes steering input after it and answers the input. The run's ApprovalRequests then leave it out. A background agent's answer closes no request, since the run waits for it before it ends.

An answer to a closed request throws ApprovalClosedException before the run stores anything or calls the model, so your app learns that its prompt is out of date. That happens when your user approved in a second tab after the first answer's run ended, when a retry posts the same answers again, or when a UI keeps an old prompt clickable after a new message. Requests says why each request is closed, as an ApprovalClosedReason (Answered, PassedBy or Unknown), so your app can tell the user that the approval has expired:

cs
try
{
    await support.RunAsync(approval, conversationId: conversationId);
}
catch (ApprovalClosedException closed)
{
    foreach (var (request, reason) in closed.Requests)
        Console.WriteLine($"The approval {request} has expired: {reason}.");
}

snippet source | anchor

One closed answer rejects the whole input: the answers to open requests in it don't run, and nothing of it is stored. Run again without the answers Requests names to keep the rest.

Answer every request in the same run

The next run closes every request it has no answer for, so answer all of ApprovalRequests at once. A streaming run sends its requests the moment it asks. If steering input then reaches the run and the run answers it, those requests close although the stream has carried them, and an answer to them throws.

Approving for the conversation ​

ApproveForConversation() approves the call and every later call to the same tool in the conversation. It is stored with the conversation, so it holds across runs and processes:

cs
var again = await support.RunAsync("Close ticket 1234 again.", conversationId: conversationId);

var always = new ChatMessage(ChatRole.User, [again.ApprovalRequests[0].ApproveForConversation()]);

await support.RunAsync(always, conversationId: conversationId);

snippet source | anchor

Approval rules ​

A rule decides a call before your app is asked. It approves what you trust, rejects what you never allow, and abstains on the rest. Register it with WithApprovalRule<T>(), and Typeshift resolves it from the run's services:

cs
services.AddTypeshift()
    .WithApprovalRule<ShellGuard>();

snippet source | anchor

Every rule sees every call that requires approval, with the arguments the model sent, so it decides on exactly the call that runs. This one rejects the shell commands it never allows:

cs
internal sealed class ShellGuard : IToolApprovalRule
{
    private static readonly string[] Never = ["sudo", "rm -rf", "curl", "wget", "ssh"];

    // A provider sends each argument as a JsonElement, whose ToString() is the string itself
    public ValueTask<ToolDecision> DecideAsync(FunctionCallContent call, AgentRun run, CancellationToken ct) =>
        ValueTask.FromResult(call is { Name: "run_command", Arguments: { } arguments }
            && arguments.TryGetValue("command", out var value)
            && value?.ToString() is { } command
            && Never.Any(command.Contains)
                ? ToolDecision.Reject("This command is never allowed here.")
                : ToolDecision.Abstain);
}

snippet source | anchor

DecisionMeans
ToolDecision.Reject(reason)The call is rejected in every mode, and the model receives the reason. A rejection wins over every approval.
ToolDecision.ApproveThe call runs, unless another rule rejects it.
ToolDecision.AbstainNo opinion. Another rule, a standing approval or the mode decides: auto mode runs the call, and the default and plan modes ask your app.

Approve only what you can read in full

A command that looks harmless may run code the model wrote. That is why the rule above only rejects and leaves the rest to your app.

The three modes ​

Every run is in one of three modes. The mode decides which tools the model is offered and what happens to the tools that require approval:

ModeOffersTools that require approval
Defaultevery toolask your app, unless a rule or a standing approval decides
Planread-only tools and exit_plan_modeoffered only if also marked ReadOnly(), and then ask as in Default
Autoevery toolrun without asking, unless a rule rejects the call

Setting the mode ​

The mode is a run option. WithMode(mode) in the agent's WithRunOptions() sets the mode runs start in, Default unless set. Add Configurable(r => r.Mode) to the same chain, and the agent's generated Settings gets a Mode, so stored settings or a run can change it. TriageAgent makes Mode configurable, so this run can pass Mode = AgentMode.Plan and start in plan mode:

cs
var plan = new TriageAgent.Settings { Mode = AgentMode.Plan };

var planned = await triage.RunAsync("Sort out ticket 1234.", settingsId, conversationId, plan);

snippet source | anchor

A conversation keeps its mode. The agent's code and its stored settings decide only the mode of a conversation's first run. After that, each run continues in the mode the conversation's last run ended in, unless the settings you pass to the run name a mode, which switches the conversation to it.

A conversation stays in the mode it ended in

After your app approves a plan with ApprovePlan(AgentMode.Auto), later runs of the conversation run in auto mode, whatever mode the agent's code starts runs in. To switch back, pass a mode in a run's settings. That needs Configurable(r => r.Mode), so add it to every agent that can enter plan mode.

Plan mode ​

Plan mode lets the model research and propose a plan before it changes anything, so your app approves the whole plan before any step runs. Approve the plan into auto mode, and its steps run without asking, unless a rule rejects them. The model sees only the tools marked ReadOnly(), a toolset's or an MCP server's, plus its sub-agents and exit_plan_mode, and Typeshift adds a short instruction to research, then present the plan. A sub-agent called in plan mode sees only its own read-only tools and answers its caller, which presents the plan.

exit_plan_mode(plan) ends the run with an approval request that carries the plan:

cs
var proposal = planned.ApprovalRequests.Single();

if (proposal.ToolCall is FunctionCallContent { Arguments: { } arguments })
    Console.WriteLine(arguments["plan"]);

snippet source | anchor

Approve it with ApprovePlan(mode) to continue in the mode your app picks, Default or Auto:

cs
var approval = new ChatMessage(ChatRole.User, [proposal.ApprovePlan(AgentMode.Auto)]);

await triage.RunAsync(approval, settingsId, conversationId);

snippet source | anchor

To reject the plan, answer with CreateResponse(approved: false, reason). The model receives the reason as feedback and goes back to planning.

Plan mode hides every tool without the mark

A tool without ReadOnly() is hidden in plan mode, whatever it does. Mark every tool that only reads, so the model can research with it.

Auto mode ​

In auto mode, tools that require approval run without asking your app. Rules still see each such call first, and a rule that rejects it wins.

In auto mode, only your rules stop a call

Guard auto mode with approval rules, for example a guard that never lets sudo through, or a classifier that asks a model.

Released under the MIT License.