Level: Intermediate | Read time: ~15 min | Hands-on: Yes


🧠 What Are We Building?

Most "AI Agent" tutorials just show you how to call an API and print a response.

That's not an agent β€” that's a chatbot with extra steps.

A real agent has:

  • A goal (not just a prompt)
  • A loop (it keeps running until the goal is achieved)
  • Tools it can call (web search, calculator, file reader, etc.)
  • Memory of what happened before
  • Decision-making β€” it decides which tool to use and when to stop

In this blog, we'll build a minimal but real AI Agent in a C# .NET Console app that:

  1. Takes a user goal
  2. Thinks about what tool to use
  3. Calls the tool
  4. Observes the result
  5. Loops until done β€” or knows it's finished

Let's go.


πŸ—οΈ Project Setup

dotnet new console -n MiniAIAgent
cd MiniAIAgent
dotnet add package Anthropic.SDK
dotnet add package Microsoft.Extensions.Http

We're using the Anthropic Claude API as our LLM brain. You can swap this for OpenAI, Gemini, or any local LLM with minor changes.

Set your API key:

# Windows
set ANTHROPIC_API_KEY=your-key-here

# Linux/Mac
export ANTHROPIC_API_KEY=your-key-here

🧩 The ReAct Pattern β€” How Agents Think

Our agent follows the ReAct pattern (Reasoning + Acting):

THOUGHT β†’ ACTION β†’ OBSERVATION β†’ THOUGHT β†’ ACTION β†’ ... β†’ FINAL ANSWER

Each loop iteration, the LLM:

  • Thinks about what to do next (Thought)
  • Decides which tool to call (Action)
  • Reads the result (Observation)
  • Repeats until it has the final answer

Here's the mental model:

User Goal: "What is 15% tip on a $48.50 dinner?"

Loop 1:
  Thought: I need to calculate 15% of 48.50
  Action: calculator("48.50 * 0.15")
  Observation: 7.275

Loop 2:
  Thought: I now have the tip amount
  Action: FINAL ANSWER β†’ "Your tip is $7.28 (rounded)"

Simple. Elegant. Composable.


πŸ› οΈ Step 1 β€” Define the Tool Abstraction

First, let's create a clean abstraction for "tools" our agent can use.

// AgentTool.cs
namespace MiniAIAgent;

public abstract class AgentTool
{
    public abstract string Name { get; }
    public abstract string Description { get; }
    public abstract Task<string> ExecuteAsync(string input);
}

Every tool has a name (so the LLM can reference it), a description (so the LLM knows when to use it), and an execute method.


πŸ› οΈ Step 2 β€” Build Two Real Tools

Tool 1: Calculator

// Tools/CalculatorTool.cs
using System.Data;

namespace MiniAIAgent.Tools;

public class CalculatorTool : AgentTool
{
    public override string Name => "calculator";
    public override string Description => 
        "Evaluates a math expression and returns the result. " +
        "Input: a valid math expression string like '48.50 * 0.15' or '(100 + 200) / 3'.";

    public override Task<string> ExecuteAsync(string input)
    {
        try
        {
            // DataTable.Compute is a quick eval trick for simple math
            var result = new DataTable().Compute(input, null);
            return Task.FromResult($"Result: {result}");
        }
        catch (Exception ex)
        {
            return Task.FromResult($"Error evaluating expression: {ex.Message}");
        }
    }
}

Real Bug Alert: DataTable.Compute doesn't support functions like sqrt() or pow(). For advanced math, swap in a library like MathNet.Numerics or build an expression parser. We'll keep it simple here.


// Tools/KnowledgeTool.cs
namespace MiniAIAgent.Tools;

public class KnowledgeTool : AgentTool
{
    public override string Name => "knowledge";
    public override string Description => 
        "Looks up factual information. Use for questions about facts, dates, definitions, " +
        "or general knowledge. Input: a short search query string.";

    private readonly Dictionary<string, string> _kb = new(StringComparer.OrdinalIgnoreCase)
    {
        ["capital of india"]   = "The capital of India is New Delhi.",
        [".net 8 release"]     = ".NET 8 was officially released on November 14, 2023.",
        ["c# version"]         = "The latest C# version alongside .NET 8 is C# 12.",
        ["ef core version"]    = "Entity Framework Core 8 was released with .NET 8 in November 2023.",
        ["linq"]               = "LINQ (Language Integrated Query) is a set of features in C# for querying data from various sources using a consistent syntax.",
    };

    public override Task<string> ExecuteAsync(string input)
    {
        var key = _kb.Keys.FirstOrDefault(k => 
            input.Contains(k, StringComparison.OrdinalIgnoreCase));

        if (key != null)
            return Task.FromResult(_kb[key]);

        return Task.FromResult(
            $"No direct knowledge found for: '{input}'. " +
            $"Try rephrasing or use a different approach.");
    }
}

In a real system, this would call a vector database (like Qdrant or Pinecone) or a search API. For this walkthrough, an in-memory dictionary does the job.


πŸ› οΈ Step 3 β€” The Agent Loop Engine

This is the heart of everything.

// AgentLoop.cs
using Anthropic.SDK;
using Anthropic.SDK.Messaging;

namespace MiniAIAgent;

public class AgentLoop
{
    private readonly AnthropicClient _client;
    private readonly List<AgentTool> _tools;
    private readonly int _maxIterations;

    public AgentLoop(
        AnthropicClient client,
        List<AgentTool> tools,
        int maxIterations = 8)
    {
        _client = client;
        _tools = tools;
        _maxIterations = maxIterations;
    }

    public async Task<string> RunAsync(string userGoal)
    {
        Console.WriteLine($"\n🎯 Goal: {userGoal}\n");
        Console.WriteLine(new string('─', 60));

        // Build the system prompt that describes the ReAct pattern
        var systemPrompt = BuildSystemPrompt();

        // Conversation history β€” this is the agent's "working memory"
        var messages = new List<Message>
        {
            new() { Role = "user", Content = userGoal }
        };

        for (int iteration = 1; iteration <= _maxIterations; iteration++)
        {
            Console.WriteLine($"\nπŸ”„ Iteration {iteration}");

            var response = await _client.Messages.GetClaudeMessageAsync(
                new MessageParameters
                {
                    Model = AnthropicModels.Claude35Sonnet,
                    MaxTokens = 1024,
                    System = systemPrompt,
                    Messages = messages
                });

            var assistantText = response.Content
                .OfType<TextBlock>()
                .FirstOrDefault()?.Text ?? string.Empty;

            Console.WriteLine($"\nπŸ€– Agent:\n{assistantText}");

            // Add the assistant's response to memory
            messages.Add(new Message
            {
                Role = "assistant",
                Content = assistantText
            });

            // Check if agent is done
            if (assistantText.Contains("FINAL ANSWER:", StringComparison.OrdinalIgnoreCase))
            {
                var finalAnswer = ExtractFinalAnswer(assistantText);
                Console.WriteLine($"\nβœ… Done in {iteration} iteration(s).");
                Console.WriteLine($"\nπŸ“’ Final Answer: {finalAnswer}");
                return finalAnswer;
            }

            // Try to parse an ACTION from the response
            var action = ParseAction(assistantText);
            if (action == null)
            {
                Console.WriteLine("\n⚠️  No action parsed. Asking agent to continue...");
                messages.Add(new Message
                {
                    Role = "user",
                    Content = "Please continue. Either call a tool or provide the FINAL ANSWER."
                });
                continue;
            }

            // Execute the tool
            var tool = _tools.FirstOrDefault(t =>
                t.Name.Equals(action.ToolName, StringComparison.OrdinalIgnoreCase));

            string observation;
            if (tool == null)
            {
                observation = $"ERROR: Tool '{action.ToolName}' not found. " +
                              $"Available tools: {string.Join(", ", _tools.Select(t => t.Name))}";
            }
            else
            {
                Console.WriteLine($"\nπŸ”§ Calling tool: [{tool.Name}] with input: \"{action.Input}\"");
                observation = await tool.ExecuteAsync(action.Input);
            }

            Console.WriteLine($"πŸ‘οΈ  Observation: {observation}");

            // Feed observation back to the agent as a new user message
            messages.Add(new Message
            {
                Role = "user",
                Content = $"Observation: {observation}\n\nContinue reasoning."
            });
        }

        return "Agent reached max iterations without a final answer.";
    }

    // ─── Helpers ────────────────────────────────────────────────────────────

    private string BuildSystemPrompt()
    {
        var toolDescriptions = string.Join("\n", _tools.Select(t =>
            $"- {t.Name}: {t.Description}"));

        return $"""
            You are a goal-oriented AI agent. You think step-by-step and use tools to accomplish tasks.
            
            Available tools:
            {toolDescriptions}
            
            STRICT OUTPUT FORMAT β€” follow this exactly every turn:
            
            Thought: [your reasoning about what to do next]
            Action: TOOL_NAME | INPUT_HERE
            
            OR, when you have the final answer:
            
            Thought: [your reasoning]
            FINAL ANSWER: [your complete answer to the user]
            
            Rules:
            - Always start with Thought:
            - Action format is exactly: Action: toolname | input
            - Never call a tool you haven't been given
            - Stop as soon as you have the answer β€” don't over-iterate
            """;
    }

    private record ParsedAction(string ToolName, string Input);

    private ParsedAction? ParseAction(string text)
    {
        var lines = text.Split('\n', StringSplitOptions.RemoveEmptyEntries);
        var actionLine = lines.FirstOrDefault(l =>
            l.TrimStart().StartsWith("Action:", StringComparison.OrdinalIgnoreCase));

        if (actionLine == null) return null;

        var actionContent = actionLine["Action:".Length..].Trim();
        var parts = actionContent.Split('|', 2);

        if (parts.Length != 2) return null;

        return new ParsedAction(
            ToolName: parts[0].Trim(),
            Input: parts[1].Trim()
        );
    }

    private static string ExtractFinalAnswer(string text)
    {
        var idx = text.IndexOf("FINAL ANSWER:", StringComparison.OrdinalIgnoreCase);
        if (idx < 0) return text;
        return text[(idx + "FINAL ANSWER:".Length)..].Trim();
    }
}

πŸ› οΈ Step 4 β€” Wire It Up in Program.cs

// Program.cs
using Anthropic.SDK;
using MiniAIAgent;
using MiniAIAgent.Tools;

var apiKey = Environment.GetEnvironmentVariable("ANTHROPIC_API_KEY")
    ?? throw new InvalidOperationException("ANTHROPIC_API_KEY not set.");

var client = new AnthropicClient(apiKey);

var tools = new List<AgentTool>
{
    new CalculatorTool(),
    new KnowledgeTool()
};

var agent = new AgentLoop(client, tools, maxIterations: 8);

// --- Try these goals ---

await agent.RunAsync("What is 15% tip on a $48.50 dinner bill?");

// await agent.RunAsync("What is the capital of India and what is 1000 / 4?");

// await agent.RunAsync("Tell me about C# and also calculate 2^10.");

πŸš€ Step 5 β€” Run It

dotnet run

Sample Output:

🎯 Goal: What is 15% tip on a $48.50 dinner bill?

────────────────────────────────────────────────────────────

πŸ”„ Iteration 1

πŸ€– Agent:
Thought: I need to calculate 15% of $48.50. I'll use the calculator tool.
Action: calculator | 48.50 * 0.15

πŸ”§ Calling tool: [calculator] with input: "48.50 * 0.15"
πŸ‘οΈ  Observation: Result: 7.275

πŸ”„ Iteration 2

πŸ€– Agent:
Thought: The calculation result is 7.275, which rounds to $7.28.
FINAL ANSWER: Your 15% tip on a $48.50 dinner bill is $7.28.

βœ… Done in 2 iteration(s).

πŸ“’ Final Answer: Your 15% tip on a $48.50 dinner bill is $7.28.

πŸ› Real Bugs I Hit (And How I Fixed Them)

Bug 1 β€” Agent Never Stopped

Symptom: The agent kept running even after printing "FINAL ANSWER."

Cause: I was checking Contains("FINAL ANSWER") case-sensitively and the LLM sometimes returns Final Answer: (different casing).

Fix:

// ❌ Before
if (assistantText.Contains("FINAL ANSWER:"))

// βœ… After
if (assistantText.Contains("FINAL ANSWER:", StringComparison.OrdinalIgnoreCase))

Bug 2 β€” Action Parsing Failed on Multi-Word Tool Names

Symptom: Action parsing returned null when the LLM wrote Action: knowledge base | ....

Cause: The LLM invented a tool name with a space. Our parser did exact match.

Fix: Added fuzzy matching with StartsWith:

var tool = _tools.FirstOrDefault(t =>
    action.ToolName.StartsWith(t.Name, StringComparison.OrdinalIgnoreCase)
    || t.Name.StartsWith(action.ToolName, StringComparison.OrdinalIgnoreCase));

Bug 3 β€” Messages Growing Unboundedly

Symptom: After ~10 iterations, API latency spiked and tokens per request ballooned.

Cause: We're appending every message to the list β€” no pruning. In a long session this is expensive.

Fix (simple): Keep only the last N messages in context:

// In RunAsync, before the API call:
var contextMessages = messages.Count > 10
    ? messages.Take(1).Concat(messages.TakeLast(9)).ToList()
    : messages;

// Use contextMessages in the API call instead of messages

In production: use a sliding window or summarization strategy.


πŸ›οΈ Architecture at a Glance

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  User Goal   β”‚
β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜
       β”‚
       β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                  AgentLoop                        β”‚
β”‚                                                   β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   β”‚
β”‚  β”‚  LLM Call │────▢│  Parse Thought/Action   β”‚   β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜     β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β”‚
β”‚        β–²                        β”‚                 β”‚
β”‚        β”‚                        β–Ό                 β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”      β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”‚
β”‚  β”‚ Observation│◀─────│   Execute Tool       β”‚    β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜      β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜    β”‚
β”‚                                                   β”‚
β”‚         Loop until FINAL ANSWER or max iters      β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

πŸ”­ What's Next β€” Level Up Your Agent

This is a foundation. Here's where to take it next:

Enhancement What it unlocks
Add a web_search tool Real-time information retrieval
Add a file_reader tool Document QA agent
Persist message history Multi-session memory
Add tool schemas (JSON) Structured, reliable tool calls
Switch to Claude's native tool use API More reliable than text parsing
Add a code_runner tool Agent that writes and runs code
Hook up a vector DB Long-term semantic memory

πŸ’‘ Key Takeaways

  • An agent = LLM + loop + tools + memory. Nothing magic.
  • The prompt format (ReAct) is what makes the LLM "think before acting."
  • Parse defensively β€” LLMs are creative with output formats.
  • Context management matters β€” unbounded history = slow and expensive.
  • A simple DataTable.Compute trick gives you eval without a dependency.

πŸ“¦ Full Project Structure

MiniAIAgent/
β”œβ”€β”€ Program.cs
β”œβ”€β”€ AgentLoop.cs
β”œβ”€β”€ AgentTool.cs
└── Tools/
    β”œβ”€β”€ CalculatorTool.cs
    └── KnowledgeTool.cs

πŸ”— Resources


Tags: #dotnet #csharp #ai #aiagent #llm #claude #programming #softwareengineering #dotnetcs