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:
- 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.
- 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
- Create an AI Agent with Tools: build agents with the
IToolinterface for richer tool integration. - Build a Function-Calling Agent: route natural language prompts to typed C# methods.
- Build a Resilient Production Agent: error handling, retries, and observability for production agents.
- Build a Content Moderation Filter: use tool interception as part of a moderation pipeline.
- Add Middleware Filters to Agents and Conversations: composable middleware pattern for prompts, completions, and tool calls (complementary to events).