Table of Contents

Intercept and Control Tool Invocations

When an LLM decides to call a tool, you might need to inspect the call before it executes, log it for auditing, block it based on security rules, or modify the result after execution. LM-Kit.NET exposes BeforeToolInvocation and AfterToolInvocation events on MultiTurnConversation and AgentExecutor that give you full control over the tool execution lifecycle. The BeforeToolInvocationEventArgs includes the Tool reference (with full metadata) and a PermissionResult property reflecting the outcome of any configured ToolPermissionPolicy. For tools that require approval, the ToolApprovalRequired event enables human-in-the-loop workflows. This tutorial builds a system with pre-invocation filtering, post-invocation logging, permission policy integration, and approval-based tool blocking.


Why Tool Interception Matters

Two enterprise problems that tool invocation control solves:

  1. Security auditing in regulated environments. Every tool call (web search, file access, API call) must be logged with the exact arguments for compliance. Before/after events provide a clean interception point without modifying the tools themselves or the agent's reasoning logic.
  2. Cost control for expensive operations. Blocking or rate-limiting tool calls that hit paid APIs (web search, external services) prevents unexpected costs from runaway agent loops. A single misconfigured agent can make hundreds of API calls in minutes without guardrails.

Prerequisites

Requirement Minimum
.NET SDK 8.0+
VRAM 4+ GB
Disk ~3 GB free for model download

Step 1: Create the Project

dotnet new console -n ToolInterceptQuickstart
cd ToolInterceptQuickstart
dotnet add package LM-Kit.NET

Step 2: Basic Tool Invocation Logging

using System.Text;
using LMKit.Model;
using LMKit.TextGeneration;
using LMKit.TextGeneration.Chat;
using LMKit.Agents;
using LMKit.Agents.Tools.BuiltIn;

LMKit.Licensing.LicenseManager.SetLicenseKey("");

Console.InputEncoding = Encoding.UTF8;
Console.OutputEncoding = Encoding.UTF8;

// ──────────────────────────────────────
// 1. Load model
// ──────────────────────────────────────
Console.WriteLine("Loading model...");
using LM model = LM.LoadFromModelID("qwen3:4b",
    loadingProgress: p => { Console.Write($"\rLoading: {p * 100:F0}%   "); return true; });
Console.WriteLine("\n");

// ──────────────────────────────────────
// 2. Create a conversation with tools
// ──────────────────────────────────────
var chat = new MultiTurnConversation(model)
{
    SystemPrompt = "You are a helpful assistant with access to tools.",
    MaximumCompletionTokens = 512
};

chat.Tools.Register(BuiltInTools.CalcArithmetic);
chat.Tools.Register(BuiltInTools.DateTimeNow);

// ──────────────────────────────────────
// 3. Log every tool call before execution
// ──────────────────────────────────────
chat.BeforeToolInvocation += (sender, e) =>
{
    Console.ForegroundColor = ConsoleColor.Yellow;
    Console.WriteLine($"\n  [TOOL CALL] {e.ToolCall.Name}");
    Console.WriteLine($"  [ARGS] {e.ToolCall.ArgumentsJson}");

    // Access the tool reference and permission result
    if (e.Tool != null)
    {
        Console.WriteLine($"  [META] Category={e.Tool.Category}, " +
                          $"Risk={e.Tool.RiskLevel}, ReadOnly={e.Tool.IsReadOnly}");
    }
    Console.WriteLine($"  [PERMISSION] {e.PermissionResult}");
    Console.ResetColor();
};

// ──────────────────────────────────────
// 4. Log every tool result after execution
// ──────────────────────────────────────
chat.AfterToolInvocation += (sender, e) =>
{
    Console.ForegroundColor = ConsoleColor.DarkGray;
    Console.WriteLine($"  [RESULT] {e.ToolCallResult.ResultJson}");
    Console.ResetColor();
};

chat.AfterTextCompletion += (_, e) =>
{
    if (e.SegmentType == TextSegmentType.UserVisible)
        Console.Write(e.Text);
};

// ──────────────────────────────────────
// 5. Run a prompt that triggers tool usage
// ──────────────────────────────────────
Console.Write("Assistant: ");
chat.Submit("What is 15% of $2,340? Also, what day of the week is it today?");
Console.WriteLine("\n");

The BeforeToolInvocationEventArgs now includes two additional properties:

Property Type Description
Tool ToolInfo? Full tool metadata including Category, RiskLevel, SideEffect, IsReadOnly, and IsIdempotent
PermissionResult ToolPermissionResult The result of evaluating the active ToolPermissionPolicy (Allowed, Denied, or ApprovalRequired)

These properties let your event handlers make decisions based on structured metadata rather than hard-coded tool name checks.


Step 3: Blocking Tool Calls (Security Filtering)

Use the Cancel property on BeforeToolInvocationEventArgs to prevent a tool from executing. This is useful for enforcing security rules or disabling specific tools during sensitive sessions:

using System.Text;
using LMKit.Model;
using LMKit.TextGeneration;
using LMKit.TextGeneration.Chat;
using LMKit.Agents;
using LMKit.Agents.Tools.BuiltIn;

LMKit.Licensing.LicenseManager.SetLicenseKey("");

Console.InputEncoding = Encoding.UTF8;
Console.OutputEncoding = Encoding.UTF8;

// ──────────────────────────────────────
// 1. Load model
// ──────────────────────────────────────
Console.WriteLine("Loading model...");
using LM model = LM.LoadFromModelID("qwen3:4b",
    loadingProgress: p => { Console.Write($"\rLoading: {p * 100:F0}%   "); return true; });
Console.WriteLine("\n");

// ──────────────────────────────────────
// 2. Create a conversation with tools
// ──────────────────────────────────────
var chat = new MultiTurnConversation(model)
{
    SystemPrompt = "You are a helpful assistant with access to tools.",
    MaximumCompletionTokens = 512
};

chat.Tools.Register(BuiltInTools.CalcArithmetic);
chat.Tools.Register(BuiltInTools.DateTimeNow);

// ──────────────────────────────────────
// 3. Log every tool call before execution
// ──────────────────────────────────────
chat.BeforeToolInvocation += (sender, e) =>
{
    Console.ForegroundColor = ConsoleColor.Yellow;
    Console.WriteLine($"\n  [TOOL CALL] {e.ToolCall.Name}");
    Console.WriteLine($"  [ARGS] {e.ToolCall.ArgumentsJson}");
    Console.ResetColor();
};

// ──────────────────────────────────────
// 4. Log every tool result after execution
// ──────────────────────────────────────
chat.AfterToolInvocation += (sender, e) =>
{
    Console.ForegroundColor = ConsoleColor.DarkGray;
    Console.WriteLine($"  [RESULT] {e.ToolCallResult.ResultJson}");
    Console.ResetColor();
};

chat.AfterTextCompletion += (_, e) =>
{
    if (e.SegmentType == TextSegmentType.UserVisible)
        Console.Write(e.Text);
};

// ──────────────────────────────────────
// 5. Run a prompt that triggers tool usage
// ──────────────────────────────────────
Console.Write("Assistant: ");
chat.Submit("What is 15% of $2,340? Also, what day of the week is it today?");
Console.WriteLine("\n");

// Block specific tool calls based on rules
chat.BeforeToolInvocation += (sender, e) =>
{
    // Example: block web search during sensitive sessions
    if (e.ToolCall.Name == "web_search")
    {
        Console.ForegroundColor = ConsoleColor.Red;
        Console.WriteLine($"  [BLOCKED] Web search is disabled in this session.");
        Console.ResetColor();
        e.Cancel = true;  // prevents the tool from executing
    }
};

When a tool call is cancelled, the model receives a cancellation signal and adapts its response accordingly. It may attempt to answer without the tool or explain that the requested operation is unavailable.


Step 4: Rate Limiting Tool Calls

Prevent runaway agents from making too many tool calls by counting invocations and blocking once a threshold is reached:

using System.Text;
using LMKit.Model;
using LMKit.TextGeneration;
using LMKit.TextGeneration.Chat;
using LMKit.Agents;
using LMKit.Agents.Tools.BuiltIn;

LMKit.Licensing.LicenseManager.SetLicenseKey("");

Console.InputEncoding = Encoding.UTF8;
Console.OutputEncoding = Encoding.UTF8;

// ──────────────────────────────────────
// 1. Load model
// ──────────────────────────────────────
Console.WriteLine("Loading model...");
using LM model = LM.LoadFromModelID("qwen3:4b",
    loadingProgress: p => { Console.Write($"\rLoading: {p * 100:F0}%   "); return true; });
Console.WriteLine("\n");

// ──────────────────────────────────────
// 2. Create a conversation with tools
// ──────────────────────────────────────
var chat = new MultiTurnConversation(model)
{
    SystemPrompt = "You are a helpful assistant with access to tools.",
    MaximumCompletionTokens = 512
};

chat.Tools.Register(BuiltInTools.CalcArithmetic);
chat.Tools.Register(BuiltInTools.DateTimeNow);

// ──────────────────────────────────────
// 3. Log every tool call before execution
// ──────────────────────────────────────
chat.BeforeToolInvocation += (sender, e) =>
{
    Console.ForegroundColor = ConsoleColor.Yellow;
    Console.WriteLine($"\n  [TOOL CALL] {e.ToolCall.Name}");
    Console.WriteLine($"  [ARGS] {e.ToolCall.ArgumentsJson}");
    Console.ResetColor();
};

// ──────────────────────────────────────
// 4. Log every tool result after execution
// ──────────────────────────────────────
chat.AfterToolInvocation += (sender, e) =>
{
    Console.ForegroundColor = ConsoleColor.DarkGray;
    Console.WriteLine($"  [RESULT] {e.ToolCallResult.ResultJson}");
    Console.ResetColor();
};

chat.AfterTextCompletion += (_, e) =>
{
    if (e.SegmentType == TextSegmentType.UserVisible)
        Console.Write(e.Text);
};

// ──────────────────────────────────────
// 5. Run a prompt that triggers tool usage
// ──────────────────────────────────────
Console.Write("Assistant: ");
chat.Submit("What is 15% of $2,340? Also, what day of the week is it today?");
Console.WriteLine("\n");

int toolCallCount = 0;
const int MaxToolCallsPerSession = 10;

chat.BeforeToolInvocation += (sender, e) =>
{
    toolCallCount++;

    if (toolCallCount > MaxToolCallsPerSession)
    {
        Console.ForegroundColor = ConsoleColor.Red;
        Console.WriteLine($"  [RATE LIMITED] Tool call #{toolCallCount} blocked (max {MaxToolCallsPerSession}).");
        Console.ResetColor();
        e.Cancel = true;
        return;
    }

    Console.ForegroundColor = ConsoleColor.Yellow;
    Console.WriteLine($"  [TOOL #{toolCallCount}/{MaxToolCallsPerSession}] {e.ToolCall.Name}");
    Console.ResetColor();
};

Step 5: Building an Audit Log

Capture every tool invocation with timestamps for compliance reporting:

using System.Text;
using LMKit.Model;
using LMKit.TextGeneration;
using LMKit.TextGeneration.Chat;
using LMKit.Agents;
using LMKit.Agents.Tools.BuiltIn;

LMKit.Licensing.LicenseManager.SetLicenseKey("");

Console.InputEncoding = Encoding.UTF8;
Console.OutputEncoding = Encoding.UTF8;

// ──────────────────────────────────────
// 1. Load model
// ──────────────────────────────────────
Console.WriteLine("Loading model...");
using LM model = LM.LoadFromModelID("qwen3:4b",
    loadingProgress: p => { Console.Write($"\rLoading: {p * 100:F0}%   "); return true; });
Console.WriteLine("\n");

// ──────────────────────────────────────
// 2. Create a conversation with tools
// ──────────────────────────────────────
var chat = new MultiTurnConversation(model)
{
    SystemPrompt = "You are a helpful assistant with access to tools.",
    MaximumCompletionTokens = 512
};

chat.Tools.Register(BuiltInTools.CalcArithmetic);
chat.Tools.Register(BuiltInTools.DateTimeNow);

// ──────────────────────────────────────
// 3. Log every tool call before execution
// ──────────────────────────────────────
chat.BeforeToolInvocation += (sender, e) =>
{
    Console.ForegroundColor = ConsoleColor.Yellow;
    Console.WriteLine($"\n  [TOOL CALL] {e.ToolCall.Name}");
    Console.WriteLine($"  [ARGS] {e.ToolCall.ArgumentsJson}");
    Console.ResetColor();
};

// ──────────────────────────────────────
// 4. Log every tool result after execution
// ──────────────────────────────────────
chat.AfterToolInvocation += (sender, e) =>
{
    Console.ForegroundColor = ConsoleColor.DarkGray;
    Console.WriteLine($"  [RESULT] {e.ToolCallResult.ResultJson}");
    Console.ResetColor();
};

chat.AfterTextCompletion += (_, e) =>
{
    if (e.SegmentType == TextSegmentType.UserVisible)
        Console.Write(e.Text);
};

// ──────────────────────────────────────
// 5. Run a prompt that triggers tool usage
// ──────────────────────────────────────
Console.Write("Assistant: ");
chat.Submit("What is 15% of $2,340? Also, what day of the week is it today?");
Console.WriteLine("\n");

var auditLog = new List<(DateTime Timestamp, string Tool, string Args, string Result)>();

chat.BeforeToolInvocation += (sender, e) =>
{
    Console.ForegroundColor = ConsoleColor.DarkGray;
    Console.WriteLine($"  [AUDIT] Calling {e.ToolCall.Name}...");
    Console.ResetColor();
};

chat.AfterToolInvocation += (sender, e) =>
{
    auditLog.Add((
        DateTime.UtcNow,
        e.ToolCallResult.ToolName,
        e.ToolCallResult.ResultJson,
        e.ToolCallResult.ResultJson
    ));
};

// After the session, export the audit log
Console.WriteLine("\n── Audit Log ──");
foreach (var entry in auditLog)
{
    Console.WriteLine($"  {entry.Timestamp:HH:mm:ss}  {entry.Tool}({entry.Args}) → {entry.Result}");
}

Step 6: Agent-Level Tool Interception

The same BeforeToolInvocation and AfterToolInvocation events are available on AgentExecutor. This works identically to conversation-level interception but applies to the agent's entire execution lifecycle:

using System.Text;
using LMKit.Model;
using LMKit.TextGeneration;
using LMKit.TextGeneration.Chat;
using LMKit.Agents;
using LMKit.Agents.Tools.BuiltIn;

LMKit.Licensing.LicenseManager.SetLicenseKey("");

Console.InputEncoding = Encoding.UTF8;
Console.OutputEncoding = Encoding.UTF8;

// ──────────────────────────────────────
// 1. Load model
// ──────────────────────────────────────
Console.WriteLine("Loading model...");
using LM model = LM.LoadFromModelID("qwen3:4b",
    loadingProgress: p => { Console.Write($"\rLoading: {p * 100:F0}%   "); return true; });
Console.WriteLine("\n");

// ──────────────────────────────────────
// 2. Create a conversation with tools
// ──────────────────────────────────────
var chat = new MultiTurnConversation(model)
{
    SystemPrompt = "You are a helpful assistant with access to tools.",
    MaximumCompletionTokens = 512
};

chat.Tools.Register(BuiltInTools.CalcArithmetic);
chat.Tools.Register(BuiltInTools.DateTimeNow);

// ──────────────────────────────────────
// 3. Log every tool call before execution
// ──────────────────────────────────────
chat.BeforeToolInvocation += (sender, e) =>
{
    Console.ForegroundColor = ConsoleColor.Yellow;
    Console.WriteLine($"\n  [TOOL CALL] {e.ToolCall.Name}");
    Console.WriteLine($"  [ARGS] {e.ToolCall.ArgumentsJson}");
    Console.ResetColor();
};

// ──────────────────────────────────────
// 4. Log every tool result after execution
// ──────────────────────────────────────
chat.AfterToolInvocation += (sender, e) =>
{
    Console.ForegroundColor = ConsoleColor.DarkGray;
    Console.WriteLine($"  [RESULT] {e.ToolCallResult.ResultJson}");
    Console.ResetColor();
};

chat.AfterTextCompletion += (_, e) =>
{
    if (e.SegmentType == TextSegmentType.UserVisible)
        Console.Write(e.Text);
};

// ──────────────────────────────────────
// 5. Run a prompt that triggers tool usage
// ──────────────────────────────────────
Console.Write("Assistant: ");
chat.Submit("What is 15% of $2,340? Also, what day of the week is it today?");
Console.WriteLine("\n");

var agent = Agent.CreateBuilder(model)
    .WithInstruction("You are a research assistant.")
    .WithTools(tools =>
    {
        tools.Register(BuiltInTools.CalcArithmetic);
        tools.Register(BuiltInTools.DateTimeNow);
    })
    .WithMaxIterations(5)
    .Build();

// Agent-level events are on AgentExecutor
using var executor = new AgentExecutor();

executor.BeforeToolInvocation += (sender, e) =>
{
    Console.WriteLine($"  [AGENT TOOL] {e.ToolCall.Name}: {e.ToolCall.ArgumentsJson}");
};

executor.AfterToolInvocation += (sender, e) =>
{
    Console.WriteLine($"  [AGENT RESULT] {e.ToolCallResult.ResultJson}");
};

executor.AfterTextCompletion += (_, e) =>
{
    if (e.SegmentType == TextSegmentType.UserVisible)
        Console.Write(e.Text);
};

var result = await executor.ExecuteAsync(agent, "Calculate the compound interest on $10,000 at 5% for 3 years.");
Console.WriteLine($"\nAgent: {result.Content}");

Step 7: Permission Policy Integration with Events

When you attach a ToolPermissionPolicy to a ToolRegistry (or via AgentBuilder.WithPermissionPolicy()), the policy is evaluated automatically before each tool call. The evaluation result flows into the event pipeline through BeforeToolInvocationEventArgs.PermissionResult. Here is the complete execution flow:

Tool Call Requested
       │
       ▼
┌──────────────────────┐
│  Evaluate Permission │ ◄── ToolPermissionPolicy
│  Policy              │
└──────────┬───────────┘
           │
     ┌─────┼─────────────────┐
     │     │                 │
     ▼     ▼                 ▼
 Allowed  Denied        ApprovalRequired
     │     │                 │
     │     │                 ▼
     │     │      ┌────────────────────────┐
     │     │      │  ToolApprovalRequired  │ ◄── Human-in-the-loop
     │     │      │  event fires           │
     │     │      └──────────┬─────────────┘
     │     │           ┌─────┴─────┐
     │     │           │           │
     │     │        Approved    Rejected
     │     │           │           │
     │     │           │           ▼
     │     │           │     ToolCallResult.Denied
     │     │           │     (sent to model)
     │     ▼           │
     │  ToolCallResult │
     │  .Denied        │
     │  (sent to       │
     │   model)        │
     │                 │
     ▼                 ▼
┌──────────────────────────────────────┐
│  BeforeToolInvocation event fires    │
│  (e.PermissionResult is set)         │
│  (e.Tool has full metadata)          │
└──────────────────┬───────────────────┘
                   │
            Cancel = false?
                   │
              ┌────┴────┐
              │         │
              ▼         ▼
          Execute    Skip tool
           tool      (cancelled)
              │
              ▼
┌──────────────────────────────────────┐
│  AfterToolInvocation event fires     │
└──────────────────────────────────────┘

Use BeforeToolInvocation to make metadata-aware decisions:

using LMKit.Agents;
using LMKit.Agents.Tools;
using LMKit.Agents.Tools.BuiltIn;

var policy = new ToolPermissionPolicy()
    .SetMaxRiskLevel(ToolRiskLevel.Medium)
    .RequireApprovalForCategory("Net");

var agent = Agent.CreateBuilder(model)
    .WithPersona("Research Assistant")
    .WithPermissionPolicy(policy)
    .WithTools(tools =>
    {
        tools.Register(BuiltInTools.CalcArithmetic);
        tools.Register(BuiltInTools.WebSearch);
        tools.Register(BuiltInTools.JsonParse);
    })
    .Build();

using var executor = new AgentExecutor();

// Handle approval requests for Net-category tools
executor.ToolApprovalRequired += (sender, e) =>
{
    Console.WriteLine($"  [APPROVAL] {e.ToolCall.Name} requires approval.");
    Console.Write("  Allow? (y/n): ");
    e.Approved = Console.ReadLine()?.Trim().Equals("y", StringComparison.OrdinalIgnoreCase) == true;
};

// Log permission results alongside tool metadata
executor.BeforeToolInvocation += (sender, e) =>
{
    string status = e.PermissionResult switch
    {
        ToolPermissionResult.Allowed => "ALLOWED",
        ToolPermissionResult.Denied => "DENIED",
        ToolPermissionResult.ApprovalRequired => "NEEDS APPROVAL",
        _ => "UNKNOWN"
    };

    var meta = e.Tool as IToolMetadata;
    Console.WriteLine($"  [{status}] {e.ToolCall.Name} " +
                      $"(Risk: {meta?.RiskLevel}, Category: {meta?.Category})");
};

Step 8: Handle Approval Requests with ToolApprovalRequiredEventArgs

The ToolApprovalRequired event fires whenever a permission policy returns ApprovalRequired for a tool call. The ToolApprovalRequiredEventArgs provides the tool call details and metadata, and you set Approved to indicate whether the call should proceed.

ToolApprovalRequiredEventArgs properties:

Property Type Description
ToolCall ToolCall The pending tool call with name and arguments
Tool ITool The tool instance that would be invoked
RiskLevel ToolRiskLevel? The tool's risk level (if it implements IToolMetadata)
SideEffect ToolSideEffect? The tool's side effect classification (if it implements IToolMetadata)
Approved bool Set to true to allow the call, false to deny it. Defaults to false.
DenialReason string Optional reason to include in the denial result sent to the model
// Interactive console approval
executor.ToolApprovalRequired += (sender, e) =>
{
    Console.ForegroundColor = ConsoleColor.Yellow;
    Console.WriteLine($"\n  ┌─ APPROVAL REQUEST ─────────────────");
    Console.WriteLine($"  │ Tool:      {e.ToolCall.Name}");
    Console.WriteLine($"  │ Arguments: {e.ToolCall.ArgumentsJson}");
    Console.WriteLine($"  └──────────────────────────────────────");
    Console.Write("  Approve? [y/N]: ");
    Console.ResetColor();

    string? input = Console.ReadLine();
    e.Approved = input?.Trim().Equals("y", StringComparison.OrdinalIgnoreCase) == true;

    Console.ForegroundColor = e.Approved ? ConsoleColor.Green : ConsoleColor.Red;
    Console.WriteLine(e.Approved ? "  ✓ Approved" : "  ✗ Denied");
    Console.ResetColor();
};

For automated environments (CI/CD, batch processing), you can implement non-interactive approval logic:

// Auto-approve based on custom logic
executor.ToolApprovalRequired += (sender, e) =>
{
    // Auto-approve read-only network calls, deny writes
    e.Approved = e.ToolCall.Name switch
    {
        "web_search" => true,    // Always allow search
        "http_get" => true,      // Allow HTTP reads
        "http_post" => false,    // Block HTTP writes
        "smtp_send" => false,    // Block email sends
        _ => false               // Deny by default
    };
};

Common Issues

Problem Cause Fix
Events not firing No tools registered on the conversation Add tools via chat.Tools.Register() before submitting
Cancelled tool causes bad response Model expects tool result but gets none The model receives a cancellation signal and adapts its response
Audit log missing entries Event handler not attached before first Submit Attach handlers immediately after creating the conversation
Agent events not firing Attached to wrong object Use executor.BeforeToolInvocation, not the underlying conversation
PermissionResult always Allowed No permission policy configured Attach a ToolPermissionPolicy via WithPermissionPolicy() or set ToolRegistry.PermissionPolicy
ToolApprovalRequired not firing Policy does not return ApprovalRequired Use RequireApproval() or RequireApprovalForCategory() in your policy

Next Steps

Share