Skip to content

AI scenarios

Tool approval

Bit.BlazorUI.AI Scripted transport

A destructive tool stops and waits for a human. Allow once, allow always, or decline - and a provider can ask for the same decision itself.

Live demo

Ask for something destructive: delete_records is declared destructive, so the session will not run it unattended.

\n**A call that waits for a decision**: ```razor @implements IAsyncDisposable
``` ```csharp private BitAiChatSession? session; private readonly string[] suggestions = ["Delete the archived records"]; [Inject] private IBitAiToolRegistry Tools { get; set; } = default!; [Inject] private IBitAiToolApprovalPolicy Policy { get; set; } = default!; protected override void OnInitialized() => session = new BitAiChatSession( AiDemoToolTransports.CallsTool( "delete_records", """{"filter":"status = 'archived' AND updated < '2024-01-01'"}""", "Deleted 128 archived records. Nothing else was touched.", // What the model says when the user declines: the call comes back to it as a failure. declinedAnswer: "Understood - I did not delete anything; the archived records are still there."), tools: Tools, approvalPolicy: Policy); public ValueTask DisposeAsync() => session?.DisposeAsync() ?? ValueTask.CompletedTask; ``` `Program.cs`: ```csharp builder.Services.AddBitBlazorUIAIServices(ai => { ai.UseTransport(AiDemoTransports.Echo()); // Destructive: the default approval policy stops every call of it for a human decision. ai.AddTool(BitAiTool.Create( "delete_records", "Permanently delete records matching a filter.", AiDemoJsonContext.Default.DeleteArgs, AiDemoJsonContext.Default.DeleteResult, async (args, token) => { await Task.Delay(600, token); return new DeleteResult(args.Filter, 128); }, definition => definition with { Title = "Delete records", Icon = BitIconName.Delete, Risk = BitAiToolRisk.Destructive, })); }); ``` `AiDemoJsonContext.cs`: ```csharp public sealed record DeleteArgs(string Filter); public sealed record DeleteResult(string Filter, int Deleted); // The tool's arguments and result are read and written through generated metadata, so the tool is safe to trim. [JsonSourceGenerationOptions(JsonSerializerDefaults.Web)] [JsonSerializable(typeof(DeleteArgs))] [JsonSerializable(typeof(DeleteResult))] public partial class AiDemoJsonContext : JsonSerializerContext { } ``` `AiDemoToolTransports.cs`: ```csharp // AiScriptedTransport and AiScript are the demo's helpers - see the Basic chat scenario. public static IBitAiTransport CallsTool(string toolName, string arguments, string answer, string? declinedAnswer = null) => new AiScriptedTransport((request, token) => AiScript.Stream(token, async yielder => { // The outcome of the tool this turn ran, if it ran one. var outcome = AiScript.ToolOutcomeThisTurn(request); await yielder(new BitAiMessageStartedEvent()); // First round trip: ask for the tool and stop. The session decides whether it needs approval. if (outcome is null) { await yielder(new BitAiStatusEvent("Deciding what to look up")); await AiScript.Pause(500, token); var callId = BitAiId.NewToolCallId(); await yielder(new BitAiToolCallStartedEvent(callId, toolName)); foreach (var slice in AiScript.Slice(arguments, 6)) { await AiScript.Pause(40, token); await yielder(new BitAiToolCallDeltaEvent(callId, slice)); } await yielder(new BitAiToolCallCompletedEvent(callId)); await yielder(new BitAiMessageCompletedEvent(BitAiFinishReason.ToolCalls)); return; } // Second round trip: a declined call comes back as a failure, and the answer says nothing was done. var reply = outcome.IsSuccess ? answer : declinedAnswer ?? $"I did not run {toolName}, so nothing was changed."; await foreach (var evt in AiScript.Write(reply, 4, 12, token)) await yielder(evt); await yielder(AiScript.Usage(request, reply)); await yielder(new BitAiMessageCompletedEvent()); })); ```

Approval the transport asks for

Ask about the weather: get_weather is read-only, so this app's policy would run it unattended - but this transport asks for the user's consent itself, and says why.

\n**A decision the transport asks for**: ```razor @implements IAsyncDisposable
``` ```csharp private BitAiChatSession? session; private readonly string[] suggestions = ["What is the weather in Lisbon?"]; [Inject] private IBitAiToolRegistry Tools { get; set; } = default!; [Inject] private IBitAiToolApprovalPolicy Policy { get; set; } = default!; protected override void OnInitialized() => session = new BitAiChatSession( AiDemoToolTransports.CallsToolAskingApproval( "get_weather", """{"city":"Lisbon"}""", // Shown as the question of the prompt. reason: "Looking up the weather tells the weather service which city you asked about. Go ahead?", answer: "It is 17 degrees and clear in Lisbon, with 54% humidity.", declinedAnswer: "Understood - I did not look it up, so the weather service was told nothing."), tools: Tools, approvalPolicy: Policy); public ValueTask DisposeAsync() => session?.DisposeAsync() ?? ValueTask.CompletedTask; ``` `Program.cs`: ```csharp builder.Services.AddBitBlazorUIAIServices(ai => { ai.UseTransport(AiDemoTransports.Echo()); // Read-only: the default approval policy runs it unattended. The transport's request is what stops it here. ai.AddTool(BitAiTool.Create( "get_weather", "Look up the current weather for a city.", AiDemoJsonContext.Default.WeatherArgs, AiDemoJsonContext.Default.WeatherResult, async (args, token) => { await Task.Delay(700, token); return Weather(args.City); }, definition => definition with { Title = "Weather", Icon = BitIconName.Globe, Risk = BitAiToolRisk.ReadOnly })); }); static WeatherResult Weather(string city) => city.ToLowerInvariant() switch { "oslo" => new WeatherResult("Oslo", -3, "Snow", 81), "lisbon" => new WeatherResult("Lisbon", 17, "Clear", 54), "auckland" => new WeatherResult("Auckland", 22, "Showers", 72), _ => new WeatherResult(city, 14, "Cloudy", 66), }; ``` `AiDemoJsonContext.cs`: ```csharp public sealed record WeatherArgs( [property: Description("The city to look up, for example Oslo.")] string City, [property: Description("How many days ahead to forecast.")] int Days = 1); public sealed record WeatherResult(string City, int Degrees, string Condition, int Humidity); [JsonSourceGenerationOptions(JsonSerializerDefaults.Web)] [JsonSerializable(typeof(WeatherArgs))] [JsonSerializable(typeof(WeatherResult))] public partial class AiDemoJsonContext : JsonSerializerContext { } ``` `AiDemoToolTransports.cs`: ```csharp // AiScriptedTransport and AiScript are the demo's helpers - see the Basic chat scenario. public static IBitAiTransport CallsToolAskingApproval(string toolName, string arguments, string reason, string answer, string declinedAnswer) => new AiScriptedTransport((request, token) => AiScript.Stream(token, async yielder => { var outcome = AiScript.ToolOutcomeThisTurn(request); await yielder(new BitAiMessageStartedEvent()); if (outcome is null) { await AiScript.Pause(400, token); var callId = BitAiId.NewToolCallId(); await yielder(new BitAiToolCallStartedEvent(callId, toolName) { Arguments = AiScript.Json(arguments) }); // The provider's own policy wants consent: the call waits for the user, whatever the app's policy says. await yielder(new BitAiToolApprovalRequestedEvent(callId) { Reason = reason }); await yielder(new BitAiMessageCompletedEvent(BitAiFinishReason.ToolCalls)); return; } var reply = outcome.IsSuccess ? answer : declinedAnswer; await foreach (var evt in AiScript.Write(reply, 4, 12, token)) await yielder(evt); await yielder(AiScript.Usage(request, reply)); await yielder(new BitAiMessageCompletedEvent()); })); ```

How it works

The transport asks for the tool as any tool call is asked for (BitAiToolCallStartedEvent, the arguments streamed as BitAiToolCallDeltaEvents, BitAiToolCallCompletedEvent) and ends the reply with BitAiFinishReason.ToolCalls. Before the session runs the call, it asks the approval policy (IBitAiToolApprovalPolicy.RequiresApprovalAsync(definition, invocation)). When a decision is needed it moves the call to AwaitingApproval and raises BitAiChatSession.ApprovalRequested, and the call's card shows the prompt, whose buttons record the decision with BitAiChatSession.Approve(toolCallId, decision) - Approved, ApprovedAlways or Rejected.

Allowing once and allowing always are different decisions. Collapsing them into a single button forces the user either to re-approve constantly or to grant more than they meant to, so both are offered, beside a decline.

What needs a decision is the IBitAiToolApprovalPolicy's call. The default one asks for every tool whose Risk is Destructive (or that says RequiresApproval) and remembers the tools a user allowed always - which is why it is scoped on Blazor Server, where a singleton would carry one user's "always" over to every other user. ai.UseApprovalPolicy<T>() replaces it, for a decision that depends on the arguments or on the user.

While a call waits, the turn is genuinely paused: the tool has not run, the model has not been asked again, and stopping the turn cancels the wait. A declined call is marked Rejected and given a failed result, which the transport reads off the call on the next request, so the model can explain that nothing was done.

A provider whose own policy wants consent asks for it with a BitAiToolApprovalRequestedEvent, as the second demo's transport does right after starting the call. It puts the call in AwaitingApproval with its Reason, which the prompt shows as the question, and the session waits for the user just as it would for its own policy - even for a tool that policy runs unattended, and whatever the user allowed always before: the request is the provider's, not the policy's to overrule. The session still runs the tool once it is allowed, so the tool is registered as usual.

Components used

Feedback

Found a mistake, a gap, or something that could be clearer? Every page and every component is one click from its source.