Build a Mini AI Agent in C# .NET Console From Scratch (Real Code, Real Thinking)
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:
- Takes a user goal
- Thinks about what tool to use
- Calls the tool
- Observes the result
- 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.Computedoesn't support functions likesqrt()orpow(). For advanced math, swap in a library like MathNet.Numerics or build an expression parser. We'll keep it simple here.
Tool 2: Simple Knowledge Base (Fake Web Search)
// 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.Computetrick gives you eval without a dependency.
π¦ Full Project Structure
MiniAIAgent/
βββ Program.cs
βββ AgentLoop.cs
βββ AgentTool.cs
βββ Tools/
βββ CalculatorTool.cs
βββ KnowledgeTool.cs
π Resources
- Anthropic .NET SDK
- ReAct Paper (Reason + Act)
- Claude API Docs
- MathNet.Numerics β for real math eval
Tags: #dotnet #csharp #ai #aiagent #llm #claude #programming #softwareengineering #dotnetcs