Recursive AI

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.

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

Terminal
dotnet add package RecursiveAI.OneLlm

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

C#
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): 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).

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]
C#
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 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:

C#
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:

C#
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.


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 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 explains the grants separately: approval is not a reason to give every run broad file or shell access.

All onellm recipes