AI – How to create an AI Agent in .NET

By | 06/05/2026

This post is the .NET adaptation of the AI – How to create an AI Agent in Python post I published previously: the same Weather Agent, the same logic, the same structure but rewritten from scratch in C#.

What we will cover
[Environment setup]: How to create a .NET console project and configure the API key safely.
[Libraries]: What each NuGet package does and how to install it correctly.
[Code]: A full walkthrough of the agent split into 4 blocks.
[Testing]: A set of real inputs to validate every scenario.

Libraries we will use
Here is a quick overview of the stack before we install anything:
[Microsoft.SemanticKernel]: The core framework to manage the reasoning loop, tool dispatch, and prompt construction. It is Microsoft’s official answer to LangChain, purpose-built for .NET.
[Microsoft.SemanticKernel.Agents.Core]: Provides ChatCompletionAgent and AgentGroupChat, the idiomatic way to build agents in Semantic Kernel, equivalent to. AgentExecutor in LangChain.
[Microsoft.Extensions.Configuration.Json]: Loads the OPENAI_API_KEY from an appsettings.json file into the app safely.

What is Semantic Kernel and why are we using it?
Semantic Kernel is an open-source SDK developed by Microsoft that simplifies building applications powered by Large Language Models.
It was released in 2023 and quickly became the standard AI orchestration library for the .NET ecosystem. Without Semantic Kernel, building an agent means writing all the plumbing yourself: the reasoning loop, tool call parsing, observation injection, conversation history management, and error handling.

[ENVIRONMENT SETUP]
[Step 1]: Create the project folder and the console app.

mkdir WeatherAgent
cd WeatherAgent
dotnet new console

[Step 2]: Add NuGet packages.

dotnet add package Microsoft.SemanticKernel
dotnet add package Microsoft.SemanticKernel.Agents.Core
dotnet add package Microsoft.Extensions.Configuration.Json

[Step 3]: Create the appsettings.json file.
The appsettings.json file stores our OpenAI API key securely, keeping it out of our source code and git repository.

{
  "OpenAI": {
    "ApiKey": "sk-proj-##############################",
    "ModelId": "gpt-4o"
  }
}


[CODE]
The complete agent is in the Program.cs file, with around 250 lines of code.
Rather than dumping the entire file at once, I have split it into 4 logical blocks. Each block has a specific responsibility, and I will explain what it does and why before showing the code.

Block 1 – Configuration & Kernel bootstrap
Load dependencies and validate the API key at startup.

using System.ComponentModel;
using System.Text.Json;
using Microsoft.Extensions.Configuration;
using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.Agents;
using Microsoft.SemanticKernel.Connectors.OpenAI;

// Load appsettings.json and inject its contents into IConfiguration.
// This keeps secrets out of source code.
var configuration = new ConfigurationBuilder()
    .AddJsonFile("appsettings.json", optional: false)
    .Build();

var apiKey  = configuration["OpenAI:ApiKey"]
              ?? throw new InvalidOperationException("OPENAI_API_KEY not found. Please add it to appsettings.json.");

var modelId = configuration["OpenAI:ModelId"] ?? "gpt-4o";

// Build the Kernel.
// It wires together the LLM, the plugins, and the execution settings.
var kernel = Kernel.CreateBuilder()
    .AddOpenAIChatCompletion(modelId, apiKey)
    .Build();

Block 2 – The Plugin: WeatherPlugin
In Semantic Kernel, tools are called plugins. The [KernelFunction] attribute registers the method with the kernel, and the [Description] attributes on both the method and its parameters are read by the SDK and converted into a JSON Schema sent to GPT-4o on every request.
This is exactly the same role as the @tool decorator and the docstring in the Python version: the description is not documentation, it is the contract between the LLM and our code.

public sealed class WeatherPlugin
{
    // HttpClient is injected via constructor never instantiate it inside a method.
    // Creating a new HttpClient() per-call causes socket exhaustion under load
    // because TCP connections are not released immediately after disposal.
    // A single shared instance reuses connections safely.
    private readonly HttpClient _http;

    public WeatherPlugin(HttpClient http) => _http = http;

    private static readonly Dictionary<int, string> WmoCodes = new()
    {
        { 0,  "Clear sky"           }, { 1,  "Mainly clear"       },
        { 2,  "Partly cloudy"       }, { 3,  "Overcast"           },
        { 45, "Foggy"               }, { 48, "Icy fog"            },
        { 51, "Light drizzle"       }, { 61, "Slight rain"        },
        { 63, "Moderate rain"       }, { 65, "Heavy rain"         },
        { 71, "Slight snow"         }, { 73, "Moderate snow"      },
        { 75, "Heavy snow"          }, { 80, "Slight showers"     },
        { 81, "Moderate showers"    }, { 82, "Violent showers"    },
        { 95, "Thunderstorm"        }, { 99, "Thunderstorm + hail"},
    };

    [KernelFunction]
    [Description("""
        Fetches the daily weather forecast for a given city and date.
        Only call this function when BOTH city and date are clearly identified
        from the user's message. The date must be in ISO format YYYY-MM-DD.
        """)]
    public async Task<string> GetWeatherForecastAsync(
        [Description("The city name extracted from the user's message.")]
        string city,

        [Description("The target date in ISO format YYYY-MM-DD, converted from whatever format the user provided.")]
        string dateStr)
    {
        // Step 1 — Validate date format early (fail fast).
        // We only check that the string is a valid date: we don't need the parsed value here
        // because dateStr is passed directly to the API as a query parameter.
        if (!DateOnly.TryParseExact(dateStr, "yyyy-MM-dd", out _))
            return $"Invalid date format received: '{dateStr}'. Expected YYYY-MM-DD.";

        // Step 2 — Geocode the city using the Open-Meteo geocoding API (free, no key required).
        var geoUrl  = $"https://geocoding-api.open-meteo.com/v1/search?name={Uri.EscapeDataString(city)}&count=1&language=en&format=json";
        var geoJson = await _http.GetStringAsync(geoUrl);
        using var geoDoc = JsonDocument.Parse(geoJson);

        if (!geoDoc.RootElement.TryGetProperty("results", out var results) || results.GetArrayLength() == 0)
            return $"Could not find the city '{city}'. Please check the spelling.";

        var first = results[0];
        var lat   = first.GetProperty("latitude").GetDouble();
        var lon   = first.GetProperty("longitude").GetDouble();

        // Step 3 — Call the Open-Meteo forecast API.
        // start_date == end_date to get data for one specific day.
        var forecastUrl = FormattableString.Invariant(
            $"https://api.open-meteo.com/v1/forecast?latitude={lat}&longitude={lon}&daily=temperature_2m_max,temperature_2m_min,precipitation_sum,windspeed_10m_max,weathercode&timezone=auto&start_date={dateStr}&end_date={dateStr}");

        var forecastJson = await _http.GetStringAsync(forecastUrl);
        using var forecastDoc = JsonDocument.Parse(forecastJson);

        var daily = forecastDoc.RootElement.GetProperty("daily");

        var code        = daily.GetProperty("weathercode")[0].GetInt32();
        var description = WmoCodes.GetValueOrDefault(code, $"Unknown (WMO {code})");
        var tempMax     = daily.GetProperty("temperature_2m_max")[0].GetDouble();
        var tempMin     = daily.GetProperty("temperature_2m_min")[0].GetDouble();
        var precip      = daily.GetProperty("precipitation_sum")[0].GetDouble();
        var wind        = daily.GetProperty("windspeed_10m_max")[0].GetDouble();

        // Return a structured, human-readable string for the LLM to interpret.
        return $"""
            City:           {city}
            Date:           {dateStr}
            Description:    {description}
            Temp max:       {tempMax}°C
            Temp min:       {tempMin}°C
            Precipitation:  {precip} mm
            Wind max:       {wind} km/h
            """;
    }
}

Block 3 – Agent builder: ChatCompletionAgent
This is where the approach diverges most clearly from a naive implementation. Instead of manually managing a ChatHistory and calling IChatCompletionAgent ourselves, we use ChatCompletionAgent, the idiomatic Semantic Kernel abstraction for a single conversational agent.

Why not manage ChatHistory manually?
A manually grown ChatHistory never shrinks. Every turn adds more messages, and after enough questions the total tokens sent to GPT-4o exceed the model’s context window limit, causing the call to fail. AgentGroupChat manages the history internally and is designed to support windowing strategies as the conversation grows.

// Create a single shared HttpClient for the lifetime of the application.
// This avoids socket exhaustion the same instance is reused for every API call
// made by the plugin, instead of opening new TCP connections each time.
var httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };

// Register the plugin, injecting the shared HttpClient via constructor.
kernel.Plugins.AddFromObject(new WeatherPlugin(httpClient), "Weather");

var today = DateTime.Today.ToString("dd/MM/yyyy");

// The system prompt guides the LLM through the reasoning cycle.
// STEP 1: Extract city and date.
// STEP 2: Validate ask for missing info instead of guessing.
// STEP 3: Respond to the user's actual question using the forecast data.
var systemPrompt = $"""
    You are a weather assistant that understands natural language.

    STEP 1 - EXTRACT: identify the CITY and the DATE from the user message.
      The date can be in any of these formats:
      - dd/MM/yyyy (e.g. 08/04/2026)
      - MM/dd/yyyy (e.g. 04/08/2026)
      - written out (e.g. "8th April 2026", "April 8 2026")
      - relative (e.g. "tomorrow", "next Monday")
      If a date is present in ANY of these formats, extract it. Do NOT say the date is missing.

    STEP 2 - VALIDATE:
      - City missing  -> respond: "I could not identify a city. Could you specify where?"
      - Date missing  -> respond: "I could not identify a date. Could you specify when?"
      - Both missing  -> ask for both.
      - Both present  -> convert date to YYYY-MM-DD and call the GetWeatherForecast function.

    STEP 3 - RESPOND: answer the user's ACTUAL question using the forecast data.
      "Do I need a jacket?" -> focus on temperature and wind.
      "Will it rain?"       -> focus on precipitation.
      "What will it be like?" -> give a full summary.
      Always be concise and practical. Today's date is {today}.
    """;

// ChatCompletionAgent is the idiomatic SK abstraction for a single conversational agent.
// It wraps the LLM, the system prompt, the plugin registry, and the execution settings
// into a single object equivalent to LangChain's AgentExecutor.
var agent = new ChatCompletionAgent
{
    Name         = "WeatherAgent",
    Instructions = systemPrompt,
    Kernel       = kernel,
    Arguments    = new KernelArguments(new OpenAIPromptExecutionSettings
    {
        ToolCallBehavior = ToolCallBehavior.AutoInvokeKernelFunctions,
        Temperature      = 0  // Deterministic outputs for consistent tool calling
    })
};

// AgentGroupChat manages the conversation history internally.
// Unlike a raw ChatHistory that grows unboundedly, AgentGroupChat is the
// right foundation for applying a sliding window in the run loop below.
var chat = new AgentGroupChat();

Block 4 – Runner & entry point

Console.WriteLine("Type your question and press Enter. Type 'quit' to exit.\n");

// MAX_HISTORY controls the sliding window size.
// We keep the most recent N user+assistant message pairs.
// When the history exceeds this limit, older messages are dropped,
// preventing context window overflow on long sessions.
const int MaxHistory = 20;

while (true)
{
    Console.Write("Ask: ");
    var userInput = Console.ReadLine()?.Trim();

    if (string.IsNullOrEmpty(userInput)) continue;

    if (userInput.ToLower() is "quit" or "exit" or "q")
    {
        Console.WriteLine("Goodbye!");
        break;
    }

    // Normalise the date format before sending to the LLM.
    var normalised = NormalizeDateInInput(userInput);

    // Add the user message to the AgentGroupChat.
    chat.AddChatMessage(new Microsoft.SemanticKernel.ChatMessageContent(
        Microsoft.SemanticKernel.ChatCompletion.AuthorRole.User, normalised));

    Console.WriteLine(new string('-', 60));

    // InvokeAsync runs the full ReAct loop:
    // the agent reasons, calls the plugin if needed, receives the result,
    // and produces a final natural language response all automatically.
    await foreach (var response in chat.InvokeAsync(agent))
    {
        Console.WriteLine($"Agent: {response.Content}");
    }

    // Sliding window: trim history if it grows beyond MaxHistory messages.
    // This prevents unbounded token growth across long sessions.
    var history = await chat.GetChatMessagesAsync().ToListAsync();
    if (history.Count > MaxHistory)
    {
        // Discard the oldest messages, keeping only the most recent MaxHistory ones.
        var trimmed = history.TakeLast(MaxHistory).ToList();

#pragma warning disable SKEXP0110
        chat = new AgentGroupChat();
#pragma warning restore SKEXP0110
        foreach (var msg in trimmed)
            chat.AddChatMessage(msg);
    }

    Console.WriteLine(new string('-', 60) + "\n");
}

static string NormalizeDateInInput(string input)
{
    // Convert dd/MM/yyyy -> "8 April 2026" before sending to the LLM.
    // This removes date format ambiguity: the LLM always receives a clear,
    // spelled-out date and never misreads dd/MM vs MM/dd.
    var match = System.Text.RegularExpressions.Regex.Match(
        input, @"(\d{1,2})/(\d{1,2})/(\d{4})");

    if (!match.Success) return input;

    if (int.TryParse(match.Groups[1].Value, out var day)   &&
        int.TryParse(match.Groups[2].Value, out var month) &&
        int.TryParse(match.Groups[3].Value, out var year))
    {
        try
        {
            var monthName = new DateTime(year, month, day).ToString("MMMM");
            return input.Replace(match.Value, $"{day} {monthName} {year}");
        }
        catch { /* Invalid date: let the LLM handle it */ }
    }

    return input;
}


[TESTING]
“What will the weather be like in Rome on 08/05/2026?”

“New York on 08/05/2026. What should I wear?”

“I will be in Paris on 08/05/2026. Do I need a jacket?”

City and date both missing

Date missing

City missing


[PROGRAM.CS]

using System.ComponentModel;
using System.Text.Json;
using Microsoft.Extensions.Configuration;
using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.Agents;
using Microsoft.SemanticKernel.Connectors.OpenAI;

// Load appsettings.json and inject its contents into IConfiguration.
// This keeps secrets out of source code.
var configuration = new ConfigurationBuilder()
    .AddJsonFile("appsettings.json", optional: false)
    .Build();

var apiKey  = configuration["OpenAI:ApiKey"]
              ?? throw new InvalidOperationException("OPENAI_API_KEY not found. Please add it to appsettings.json.");

var modelId = configuration["OpenAI:ModelId"] ?? "gpt-4o";

// Build the Kernel.
// It wires together the LLM, the plugins, and the execution settings.
var kernel = Kernel.CreateBuilder()
    .AddOpenAIChatCompletion(modelId, apiKey)
    .Build();


// Create a single shared HttpClient for the lifetime of the application.
// This avoids socket exhaustion the same instance is reused for every API call
// made by the plugin, instead of opening new TCP connections each time.
var httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };

// Register the plugin, injecting the shared HttpClient via constructor.
kernel.Plugins.AddFromObject(new WeatherPlugin(httpClient), "Weather");

var today = DateTime.Today.ToString("dd/MM/yyyy");

// The system prompt guides the LLM through the reasoning cycle.
// STEP 1: Extract city and date.
// STEP 2: Validate ask for missing info instead of guessing.
// STEP 3: Respond to the user's actual question using the forecast data.
var systemPrompt = $"""
    You are a weather assistant that understands natural language.

    STEP 1 - EXTRACT: identify the CITY and the DATE from the user message.
      The date can be in any of these formats:
      - dd/MM/yyyy (e.g. 08/04/2026)
      - MM/dd/yyyy (e.g. 04/08/2026)
      - written out (e.g. "8th April 2026", "April 8 2026")
      - relative (e.g. "tomorrow", "next Monday")
      If a date is present in ANY of these formats, extract it. Do NOT say the date is missing.

    STEP 2 - VALIDATE:
      - City missing  -> respond: "I could not identify a city. Could you specify where?"
      - Date missing  -> respond: "I could not identify a date. Could you specify when?"
      - Both missing  -> ask for both.
      - Both present  -> convert date to YYYY-MM-DD and call the GetWeatherForecast function.

    STEP 3 - RESPOND: answer the user's ACTUAL question using the forecast data.
      "Do I need a jacket?" -> focus on temperature and wind.
      "Will it rain?"       -> focus on precipitation.
      "What will it be like?" -> give a full summary.
      Always be concise and practical. Today's date is {today}.
    """;

// ChatCompletionAgent is the idiomatic SK abstraction for a single conversational agent.
// It wraps the LLM, the system prompt, the plugin registry, and the execution settings
// into a single object equivalent to LangChain's AgentExecutor.
var agent = new ChatCompletionAgent
{
    Name         = "WeatherAgent",
    Instructions = systemPrompt,
    Kernel       = kernel,
    Arguments    = new KernelArguments(new OpenAIPromptExecutionSettings
    {
        ToolCallBehavior = ToolCallBehavior.AutoInvokeKernelFunctions,
        Temperature      = 0  // Deterministic outputs for consistent tool calling
    })
};

// AgentGroupChat manages the conversation history internally.
// Unlike a raw ChatHistory that grows unboundedly, AgentGroupChat is the
// right foundation for applying a sliding window in the run loop below.
#pragma warning disable SKEXP0110
var chat = new AgentGroupChat();
#pragma warning restore SKEXP0110

Console.WriteLine("Type your question and press Enter. Type 'quit' to exit.\n");

// MAX_HISTORY controls the sliding window size.
// We keep the most recent N user+assistant message pairs.
// When the history exceeds this limit, older messages are dropped,
// preventing context window overflow on long sessions.
const int MaxHistory = 20;

while (true)
{
    Console.Write("Ask: ");
    var userInput = Console.ReadLine()?.Trim();

    if (string.IsNullOrEmpty(userInput)) continue;

    if (userInput.ToLower() is "quit" or "exit" or "q")
    {
        Console.WriteLine("Goodbye!");
        break;
    }

    // Normalise the date format before sending to the LLM.
    var normalised = NormalizeDateInInput(userInput);

    // Add the user message to the AgentGroupChat.
    chat.AddChatMessage(new Microsoft.SemanticKernel.ChatMessageContent(
        Microsoft.SemanticKernel.ChatCompletion.AuthorRole.User, normalised));

    Console.WriteLine(new string('-', 60));

    // InvokeAsync runs the full ReAct loop:
    // the agent reasons, calls the plugin if needed, receives the result,
    // and produces a final natural language response — all automatically.
    await foreach (var response in chat.InvokeAsync(agent))
    {
        Console.WriteLine($"Agent: {response.Content}");
    }

    // Sliding window: trim history if it grows beyond MaxHistory messages.
    // This prevents unbounded token growth across long sessions.
    var history = await chat.GetChatMessagesAsync().ToListAsync();
    if (history.Count > MaxHistory)
    {
        // Discard the oldest messages, keeping only the most recent MaxHistory ones.
        var trimmed = history.TakeLast(MaxHistory).ToList();

#pragma warning disable SKEXP0110
        chat = new AgentGroupChat();
#pragma warning restore SKEXP0110
        foreach (var msg in trimmed)
            chat.AddChatMessage(msg);
    }

    Console.WriteLine(new string('-', 60) + "\n");
}

// Local functions must be declared AFTER all top-level statements in Program.cs.
// This is a C# compiler requirement for top-level programs.
static string NormalizeDateInInput(string input)
{
    // Convert dd/MM/yyyy -> "8 April 2026" before sending to the LLM.
    // This removes date format ambiguity: the LLM always receives a clear,
    // spelled-out date and never misreads dd/MM vs MM/dd.
    var match = System.Text.RegularExpressions.Regex.Match(
        input, @"(\d{1,2})/(\d{1,2})/(\d{4})");

    if (!match.Success) return input;

    if (int.TryParse(match.Groups[1].Value, out var day)   &&
        int.TryParse(match.Groups[2].Value, out var month) &&
        int.TryParse(match.Groups[3].Value, out var year))
    {
        try
        {
            var monthName = new DateTime(year, month, day).ToString("MMMM");
            return input.Replace(match.Value, $"{day} {monthName} {year}");
        }
        catch { /* Invalid date: let the LLM handle it */ }
    }

    return input;
}


public sealed class WeatherPlugin
{
    // HttpClient is injected via constructor never instantiate it inside a method.
    // Creating a new HttpClient() per-call causes socket exhaustion under load
    // because TCP connections are not released immediately after disposal.
    // A single shared instance reuses connections safely.
    private readonly HttpClient _http;

    public WeatherPlugin(HttpClient http) => _http = http;

    private static readonly Dictionary<int, string> WmoCodes = new()
    {
        { 0,  "Clear sky"           }, { 1,  "Mainly clear"       },
        { 2,  "Partly cloudy"       }, { 3,  "Overcast"           },
        { 45, "Foggy"               }, { 48, "Icy fog"            },
        { 51, "Light drizzle"       }, { 61, "Slight rain"        },
        { 63, "Moderate rain"       }, { 65, "Heavy rain"         },
        { 71, "Slight snow"         }, { 73, "Moderate snow"      },
        { 75, "Heavy snow"          }, { 80, "Slight showers"     },
        { 81, "Moderate showers"    }, { 82, "Violent showers"    },
        { 95, "Thunderstorm"        }, { 99, "Thunderstorm + hail"},
    };

    [KernelFunction]
    [Description("""
        Fetches the daily weather forecast for a given city and date.
        Only call this function when BOTH city and date are clearly identified
        from the user's message. The date must be in ISO format YYYY-MM-DD.
        """)]
    public async Task<string> GetWeatherForecastAsync(
        [Description("The city name extracted from the user's message.")]
        string city,

        [Description("The target date in ISO format YYYY-MM-DD, converted from whatever format the user provided.")]
        string dateStr)
    {
        // Step 1 — Validate date format early (fail fast).
        // We only check that the string is a valid date: we don't need the parsed value here
        // because dateStr is passed directly to the API as a query parameter.
        if (!DateOnly.TryParseExact(dateStr, "yyyy-MM-dd", out _))
            return $"Invalid date format received: '{dateStr}'. Expected YYYY-MM-DD.";

        // Step 2 — Geocode the city using the Open-Meteo geocoding API (free, no key required).
        var geoUrl  = $"https://geocoding-api.open-meteo.com/v1/search?name={Uri.EscapeDataString(city)}&count=1&language=en&format=json";
        var geoJson = await _http.GetStringAsync(geoUrl);
        using var geoDoc = JsonDocument.Parse(geoJson);

        if (!geoDoc.RootElement.TryGetProperty("results", out var results) || results.GetArrayLength() == 0)
            return $"Could not find the city '{city}'. Please check the spelling.";

        var first = results[0];
        var lat   = first.GetProperty("latitude").GetDouble();
        var lon   = first.GetProperty("longitude").GetDouble();

        // Step 3 — Call the Open-Meteo forecast API.
        // start_date == end_date to get data for one specific day.
        var forecastUrl = FormattableString.Invariant(
            $"https://api.open-meteo.com/v1/forecast?latitude={lat}&longitude={lon}&daily=temperature_2m_max,temperature_2m_min,precipitation_sum,windspeed_10m_max,weathercode&timezone=auto&start_date={dateStr}&end_date={dateStr}");

        var forecastJson = await _http.GetStringAsync(forecastUrl);
        using var forecastDoc = JsonDocument.Parse(forecastJson);

        var daily = forecastDoc.RootElement.GetProperty("daily");

        var code        = daily.GetProperty("weathercode")[0].GetInt32();
        var description = WmoCodes.GetValueOrDefault(code, $"Unknown (WMO {code})");
        var tempMax     = daily.GetProperty("temperature_2m_max")[0].GetDouble();
        var tempMin     = daily.GetProperty("temperature_2m_min")[0].GetDouble();
        var precip      = daily.GetProperty("precipitation_sum")[0].GetDouble();
        var wind        = daily.GetProperty("windspeed_10m_max")[0].GetDouble();

        // Return a structured, human-readable string for the LLM to interpret.
        return $"""
            City:           {city}
            Date:           {dateStr}
            Description:    {description}
            Temp max:       {tempMax}°C
            Temp min:       {tempMin}°C
            Precipitation:  {precip} mm
            Wind max:       {wind} km/h
            """;
    }
}




Leave a Reply

Your email address will not be published. Required fields are marked *