---
title: "Approve LLM tool calls in .NET"
url: https://recursiveai.org/onellm/cookbook/approve-agent-tool-calls/
description: "Require human approval for LLM tool calls in .NET. A complete .NET 10 example that runs offline with OneLLM, real output and a runnable download."
version: "1.0.1"
---

# Approve LLM tool calls in .NET

**OneLLM** is a free, open-source LLM and agent library for .NET from Recursive AI. MIT or Apache-2.0, no native dependencies.

[NuGet](https://www.nuget.org/packages/RecursiveAI.OneLlm) · [GitHub](https://github.com/recursive-ai-oss/onellm-csharp) · [Docs](https://recursiveai.org/onellm/docs/)

Hold an LLM’s proposed tool call for human approval.

```shell
dotnet add package RecursiveAI.OneLlm
```

Requires .NET 10 (net10.0); .NET 9 and earlier aren’t supported.

```csharp
using OneLlm.Agents;
// run: the pending run in the full program.
var held = await run.AwaitAsync();
await run.ApproveAsync(
    held.PendingApprovals.Single().ToolCallId);
var done = await run.AwaitAsync();
```

Approves and runs the held call.

**[Download this example (.zip)](https://recursiveai.org/onellm/cookbook/approve-agent-tool-calls/approve-agent-tool-calls.zip)**: unzip, then `dotnet run`.

## The full program

The download runs offline with canned replies, no API key needed.

`gpt-6-luna` is a model name in the shipped OpenAI catalog, not a variable defined by this program ([model names](https://recursiveai.org/onellm/docs/getting-started/#model-names)).

The download registers a `refund` tool marked `[Tool(RequiresApproval = true)]`. Its scripted provider requests a refund. The first await returns with that call held, so the program can show an approval UI instead of running the operation. For a repeatable offline example, the program then approves the call itself; replace that branch with your application’s authorized approval decision.

The `refund.yaml` file in the download is:

```yaml
name: refund-demo
model: gpt-6-luna
systemPrompt: Use the refund tool for order 1182.
initialPrompts:
  - user:
      - text: Refund order 1182.
references:
  - tools: [refund]
```

```csharp
using OneLlm;
using OneLlm.Agents;
using OneLlm.Testing;
using OneLlm.Tools;

var runtime = new Runtime();
var scripted = new ScriptedProvider("openai");
runtime.Providers.Set("openai", scripted);
runtime.Tools.Add<RefundTools>();
scripted.Enqueue(new ScriptStep
{
    ToolCalls = [new ScriptedToolCall("refund-1", "refund", """{"orderId":1182}""")]
});
scripted.EnqueueText("Refund recorded.");

AgentRun run = await runtime.StartAgentAsync("refund.yaml");
AgentResult held = await run.AwaitAsync();
Console.WriteLine($"Before approval: {held.StopReason}");
Console.WriteLine($"Refunds executed: {RefundTools.Calls}");
var pending = held.PendingApprovals.Single();
Console.WriteLine($"Pending: {pending.Tool} ({pending.ToolCallId})");
// In an app, show the pending call to an authorized person first.
// This offline demo approves its own scripted call.
if (args.Contains("--deny"))
    await run.DenyAsync(pending.ToolCallId, "Needs a receipt.");
else
    await run.ApproveAsync(pending.ToolCallId);
AgentResult done = await run.AwaitAsync();
Console.WriteLine($"After decision: {done.StopReason}");
Console.WriteLine($"Refunds executed: {RefundTools.Calls}");

public sealed partial class RefundTools
{
    public static int Calls;
    /// <summary>Records a refund for an order in the demo.</summary>
    [Tool(RequiresApproval = true)]
    public static string Refund(int orderId)
    {
        Calls++;
        return $"Refund recorded for {orderId}.";
    }
}
```

`dotnet run` prints:

```text
Before approval: WaitingForApproval
Refunds executed: 0
Pending: refund (refund-1)
After decision: Waiting
Refunds executed: 1
```

`After decision: Waiting` means the model answered without another tool call, so the run is at rest and can accept another message; it does not mean the refund is still awaiting approval.

If you approve a call that isn’t pending, OneLLM throws `InvalidOperationException`. A denial becomes an error result for the model, not an executed refund.

### With a real model

The [pinned getting-started docs](https://recursiveai.org/onellm/docs/getting-started/#install-and-keys) name `OPENAI_API_KEY` for OpenAI. Set that environment variable to your own credential outside source code. Remove the `ScriptedProvider` declaration, its `runtime.Providers.Set("openai", scripted)` registration, and every `scripted.Enqueue...` call from the full program. The runtime uses the built-in provider instead:

```csharp
using OneLlm;
var runtime = new Runtime();
```

This replaces the scripted setup; keep the remaining task code. Keep `runtime.Tools.Add<RefundTools>()`, `refund.yaml` and the approval code. Replace the demo’s automatic approval branch with your authorized user’s decision before connecting a consequential tool. OneLLM reads the environment key when the first OpenAI request is made, as the docs describe. A missing variable throws `AuthException` naming it.

This live-model path was not run here: no API key was used. The output shown above comes from the scripted provider, not from OpenAI.

## Variations

**Deny instead of approve.** At the decision point in the full program, use:

```csharp
await run.DenyAsync(pending.ToolCallId, "Needs a receipt.");
```

Run the download with `dotnet run -- --deny`. It prints `Refunds executed: 0` after the decision. The scripted final text is not evidence of execution; inspect the tool result or your application’s own operation record.

## Related recipes

- [Persist LLM chat history in C# and resume it](https://recursiveai.org/onellm/cookbook/save-and-resume-a-chat/)
- [Classify support tickets with an LLM in C#](https://recursiveai.org/onellm/cookbook/classify-support-tickets/)
- [Use IChatClient with Anthropic Claude and Gemini in C#](https://recursiveai.org/onellm/cookbook/ichatclient-for-claude-and-gemini/)
- [Generate a Git commit message with an LLM in C#](https://recursiveai.org/onellm/cookbook/commit-message-from-git-diff/)

---

## The short answer

Use a tool whose `RequiresApproval` flag is true, start the run with `StartAgentAsync`, and wait for `WaitingForApproval`. Read the pending call, ask an authorized person, and call `ApproveAsync` or `DenyAsync` with its call ID. The model does not approve its own request.

The download is intentionally offline: `ScriptedProvider` makes the same tool request on every run, and the refund only increments a counter. It demonstrates when the tool executes, not a payment integration or a live model’s behavior. In an application, make the human decision outside the model’s context and keep the actual financial operation in host code.

## What is human-in-the-loop authorization for AI agents?

It is a pause between a model proposing an action and your application performing it. Here, the pending call has a tool name and a tool-call ID. Show the person the proposed operation and its arguments, not just a generic “Continue?” button. A refund review should identify the order and the exact action the person is authorizing.

OneLLM’s [approval documentation](https://recursiveai.org/onellm/docs/agents/#approvals) says that a tool with `RequiresApproval`, or a hook returning `RequireApproval`, holds its call. The runtime saves the turn with the call unanswered and an entry in `pendingApprovals`. Other calls in the same turn run now. This is a per-call gate, not a promise that every other action in the turn has stopped.

Denial reaches the model as an error result with the reason. That lets the model explain the decision or choose another permitted action. It does not make a denial an exception that ends the whole conversation. Likewise, an assistant sentence saying “Refund recorded” is not the operation record: the scripted example deliberately lets you see that distinction with `--deny`.

## How do you implement human-in-the-loop?

Keep three things separate: the proposed call, the person’s decision, and the tool execution. In a web application, return a pending-approval view to the authorized user. Validate their access to the run when their decision arrives, and resolve the specific pending call rather than approving an unrelated run by accident.

An approval can survive a restart. The docs describe loading the same instance with a new `Runtime` over the same store and using `runtime.ApproveAsync(instanceId)` to continue. Use a durable instance store rather than the default in-memory store if a process restart is part of your workflow. Register the tools again in the new runtime; storing an instance does not store your executable C# implementations.

There is also a shell-specific choice. `ShellWithLoopApproval` uses this pending-call gate; `ShellWithApproval(approver)` asks a host delegate inside the tool instead. Choose the loop gate when you need a saved pending decision and the delegate when the host owns the interactive prompt. The [capabilities documentation](https://recursiveai.org/onellm/docs/capabilities/) explains the grants separately: approval is not a reason to give every run broad file or shell access.
