Table of Contents

Create an AI Agent with Tools

An AI agent goes beyond simple text generation: it can reason about a problem, decide which tools to call, interpret the results, and iterate until it has a complete answer. This tutorial builds an agent that uses web search, a calculator, and a custom tool you write yourself.


What You Will Build

A research assistant that can:

  1. Search the web for current information (using DuckDuckGo, no API key needed).
  2. Perform calculations when the user's question involves math.
  3. Call a custom tool you define, demonstrating the ITool interface.

Prerequisites

Requirement Minimum
.NET SDK 8.0+
VRAM 4+ GB (for a 4B model with tool-calling support)

You need a model that supports tool calling. Check model.HasToolCalls after loading. Recommended: qwen3:4b or gemma3:4b.


Step 1: Create the Project

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

Step 2: Understand the Agent Architecture

┌──────────────────────────────────────────────────────┐
│                     Agent                            │
│  ┌─────────────┐  ┌──────────────┐  ┌──────────────┐ │
│  │  Identity   │  │   Planning   │  │    Tools     │ │
│  │  (persona)  │  │  (ReAct)     │  │  (registry)  │ │
│  └─────────────┘  └──────────────┘  └──────────────┘ │
└──────────────────────┬───────────────────────────────┘
                       │ Run("user query")
                       ▼
              ┌──────────────────┐
              │  Thought-Action  │ ◄── ReAct loop
              │  Observation     │
              │  ... repeat ...  │
              │  Final Answer    │
              └──────────────────┘
Component Purpose
Agent.CreateBuilder() Fluent API to configure the agent
PlanningStrategy How the agent reasons (None, ReAct, ChainOfThought, etc.)
ToolRegistry Collection of tools the agent can call
BuiltInTools Growing catalog of pre-built tools for data, text, numeric, security, utility, IO, networking, and document workflows
ITool / IToolMetadata Interface for custom tools, with optional metadata (category, risk level, side effects)
ToolPermissionPolicy Fluent policy controlling which tools are allowed, denied, or require approval
AgentExecutionResult The agent's response, including tool call history

Step 3: Agent with Built-In Tools

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

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

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

// 1. Load a tool-calling model
Console.WriteLine("Loading model...");
using LM model = LM.LoadFromModelID("qwen3:4b",
    downloadingProgress: (_, len, read) =>
    {
        if (len.HasValue) Console.Write($"\r  Downloading: {(double)read / len.Value * 100:F1}%   ");
        return true;
    },
    loadingProgress: p => { Console.Write($"\r  Loading: {p * 100:F0}%   "); return true; });

Console.WriteLine($"\n  Tool calling supported: {model.HasToolCalls}\n");

// 2. Build the agent
var agent = Agent.CreateBuilder(model)
    .WithPersona("Research Assistant")
    .WithInstruction("You help users find information and answer questions accurately. " +
                     "Use tools when you need current data or calculations. " +
                     "Cite your sources when using web search.")
    .WithPlanning(PlanningStrategy.ReAct)
    .WithTools(tools =>
    {
        tools.Register(BuiltInTools.WebSearch);   // DuckDuckGo, no API key
        tools.Register(BuiltInTools.CalcArithmetic);
        tools.Register(BuiltInTools.DateTimeNow);
    })
    .WithMaxIterations(10)
    .Build();

// 3. Interactive loop
Console.WriteLine("Agent ready. Type a question (or 'quit' to exit):\n");

while (true)
{
    Console.ForegroundColor = ConsoleColor.Green;
    Console.Write("You: ");
    Console.ResetColor();

    string? input = Console.ReadLine();
    if (string.IsNullOrWhiteSpace(input) || input.Equals("quit", StringComparison.OrdinalIgnoreCase))
        break;

    var result = await agent.RunAsync(input);

    if (result.IsSuccess)
    {
        Console.ForegroundColor = ConsoleColor.Cyan;
        Console.WriteLine($"\nAssistant: {result.Content}");
        Console.ResetColor();

        // Show tool usage
        if (result.ToolCalls.Count > 0)
        {
            Console.ForegroundColor = ConsoleColor.DarkGray;
            Console.WriteLine($"  Tools used: {string.Join(", ", result.ToolCalls.Select(tc => tc.ToolName))}");
            Console.WriteLine($"  Duration: {result.Duration.TotalSeconds:F1}s, Inferences: {result.InferenceCount}");
            Console.ResetColor();
        }
    }
    else
    {
        Console.ForegroundColor = ConsoleColor.Red;
        Console.WriteLine($"\nError: {result.Error?.Message}");
        Console.ResetColor();
    }

    Console.WriteLine();
}

Step 4: Add a Custom Tool

Implement the ITool interface to give the agent any capability you want. Here is a tool that looks up stock-like information (simulated):

using System.Text.Json;
using LMKit.Agents.Tools;

public class CompanyLookupTool : ITool
{
    public string Name => "lookup_company";

    public string Description => "Look up key information about a company by ticker symbol";

    public string InputSchema => """
        {
            "type": "object",
            "properties": {
                "ticker": {
                    "type": "string",
                    "description": "Stock ticker symbol (e.g. MSFT, AAPL)"
                }
            },
            "required": ["ticker"]
        }
        """;

    public Task<string> InvokeAsync(string arguments, CancellationToken cancellationToken = default)
    {
        var args = JsonDocument.Parse(arguments);
        string ticker = args.RootElement.GetProperty("ticker").GetString()!.ToUpperInvariant();

        // In a real app, call a financial data API here
        var data = ticker switch
        {
            "MSFT" => new { name = "Microsoft Corp", sector = "Technology", employees = 228000 },
            "AAPL" => new { name = "Apple Inc", sector = "Technology", employees = 164000 },
            _ => new { name = "Unknown", sector = "N/A", employees = 0 }
        };

        return Task.FromResult(JsonSerializer.Serialize(data));
    }
}

Register it alongside built-in tools:

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

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

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

// 1. Load a tool-calling model
Console.WriteLine("Loading model...");
using LM model = LM.LoadFromModelID("qwen3:4b",
    downloadingProgress: (_, len, read) =>
    {
        if (len.HasValue) Console.Write($"\r  Downloading: {(double)read / len.Value * 100:F1}%   ");
        return true;
    },
    loadingProgress: p => { Console.Write($"\r  Loading: {p * 100:F0}%   "); return true; });

.WithTools(tools =>
{
    tools.Register(BuiltInTools.WebSearch);
    tools.Register(BuiltInTools.CalcArithmetic);
    tools.Register(new CompanyLookupTool());
})

The agent will now call lookup_company when a question involves company information, and fall back to web search for everything else.


Step 5: Alternative: Attribute-Based Tools

For simpler tools, use LMFunctionAttribute instead of implementing ITool:

using System.Text.Json;
using LMKit.Agents.Tools;

public class UtilityTools
{
    [LMFunction("convert_temperature", "Convert temperature between Celsius and Fahrenheit")]
    public string ConvertTemperature(double value, string from_unit)
    {
        double result = from_unit.ToLower() switch
        {
            "celsius" => value * 9.0 / 5.0 + 32,
            "fahrenheit" => (value - 32) * 5.0 / 9.0,
            _ => throw new ArgumentException($"Unknown unit: {from_unit}")
        };

        string toUnit = from_unit.ToLower() == "celsius" ? "fahrenheit" : "celsius";
        return JsonSerializer.Serialize(new { result, unit = toUnit });
    }

    [LMFunction("word_count", "Count words in a text")]
    public int WordCount(string text) => text.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length;
}

Register the class (all annotated methods are discovered automatically):

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

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

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

// 1. Load a tool-calling model
Console.WriteLine("Loading model...");
using LM model = LM.LoadFromModelID("qwen3:4b",
    downloadingProgress: (_, len, read) =>
    {
        if (len.HasValue) Console.Write($"\r  Downloading: {(double)read / len.Value * 100:F1}%   ");
        return true;
    },
    loadingProgress: p => { Console.Write($"\r  Loading: {p * 100:F0}%   "); return true; });

.WithTools(tools =>
{
    tools.Register(new UtilityTools());  // registers both tools
})

Planning Strategies

The planning strategy controls how the agent reasons between tool calls.

Strategy Behavior Best For
None Direct response, no explicit reasoning Simple Q&A, fast responses
ReAct Thought → Action → Observation loop Tool-using agents (recommended default)
ChainOfThought Step-by-step reasoning before answering Math, logic, multi-step analysis
PlanAndExecute Generate full plan, then execute steps Complex multi-step tasks
Reflection Generate, self-critique, refine Tasks requiring accuracy
TreeOfThought Explore multiple reasoning paths Problems with multiple solution approaches

Start with ReAct for any agent that uses tools. It provides the reasoning/action loop that tool-calling agents need.


Configuring Web Search Providers

The default BuiltInTools.WebSearch uses DuckDuckGo (free, no API key). For higher-quality results:

using LMKit.Agents.Tools.BuiltIn.Net;

// Brave Search (free tier: 2,000 queries/month)
var braveSearch = BuiltInTools.CreateWebSearch(
    WebSearchTool.Provider.Brave,
    "your-brave-api-key"
);

// Tavily (optimized for AI/RAG workloads)
var tavilySearch = BuiltInTools.CreateWebSearch(
    WebSearchTool.Provider.Tavily,
    Environment.GetEnvironmentVariable("TAVILY_API_KEY")
);

// Custom options
var customSearch = BuiltInTools.CreateWebSearch(new WebSearchTool.Options
{
    SearchProvider = WebSearchTool.Provider.Brave,
    ApiKey = "your-key",
    DefaultMaxResults = 10,
    Timeout = TimeSpan.FromSeconds(30)
});

Controlling Agent Behavior

MaxIterations

Caps how many reasoning + tool-call cycles the agent can perform before returning:

.WithMaxIterations(15)  // default: 10

If the agent hits the limit, it returns whatever partial answer it has.

Permission Policies

Control which tools the agent is allowed to call by attaching a ToolPermissionPolicy. The policy evaluates each tool call and returns Allowed, Denied, or ApprovalRequired.

using LMKit.Agents.Tools;

var policy = new ToolPermissionPolicy()
    .AllowCategory("Data")
    .AllowCategory("Numeric")
    .DenyCategory("IO")
    .RequireApproval("web_search")
    .SetMaxRiskLevel(ToolRiskLevel.Medium);

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

For detailed examples of permission policies and approval workflows, see Equip an Agent with Built-In Tools and Intercept and Control Tool Invocations.

Inspecting the Reasoning Trace

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

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

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

// 1. Load a tool-calling model
Console.WriteLine("Loading model...");
using LM model = LM.LoadFromModelID("qwen3:4b",
    downloadingProgress: (_, len, read) =>
    {
        if (len.HasValue) Console.Write($"\r  Downloading: {(double)read / len.Value * 100:F1}%   ");
        return true;
    },
    loadingProgress: p => { Console.Write($"\r  Loading: {p * 100:F0}%   "); return true; });

Console.WriteLine($"\n  Tool calling supported: {model.HasToolCalls}\n");

// 2. Build the agent
var agent = Agent.CreateBuilder(model)
    .WithPersona("Research Assistant")
    .WithInstruction("You help users find information and answer questions accurately. " +
                     "Use tools when you need current data or calculations. " +
                     "Cite your sources when using web search.")
    .WithPlanning(PlanningStrategy.ReAct)
    .WithTools(tools =>
    {
        tools.Register(BuiltInTools.WebSearch);   // DuckDuckGo, no API key
        tools.Register(BuiltInTools.CalcArithmetic);
        tools.Register(BuiltInTools.DateTimeNow);
    })
    .WithMaxIterations(10)
    .Build();

var result = await agent.RunAsync("What is the population of France divided by 3?");

// See the agent's internal reasoning (Thought/Action/Observation steps)
if (!string.IsNullOrEmpty(result.ReasoningTrace))
    Console.WriteLine($"Reasoning:\n{result.ReasoningTrace}");

// See each tool call
foreach (var call in result.ToolCalls)
    Console.WriteLine($"  Tool: {call.ToolName}, Status: {call.Type}, Result: {call.ResultJson}");

Common Issues

Problem Cause Fix
Agent never calls tools Model doesn't support tool calling Check model.HasToolCalls. Use qwen3:4b or gemma3:4b
Agent loops without making progress Planning strategy mismatch Use ReAct for tool-using agents. Increase MaxIterations if task is complex
Web search returns empty DuckDuckGo rate-limiting Switch to Brave or Tavily provider, or add a delay between queries
Custom tool errors silently InvokeAsync throws exception The agent sees the error and may retry. Log exceptions inside your tool

Next Steps

Share