In the previous article, we learned what an AI Agent is, how the agent loop works, and how agents can use tools to accomplish tasks.
Now let's build a simple AI Agent using C# and .NET 8.
The goal of this tutorial is not to build a complicated autonomous system. Instead, we will create a simple agent that demonstrates the core concepts:
- LLM integration
- Tool calling
- Agent decision-making
- Tool execution
- Observations
- Agent loop
Once you understand this basic architecture, you can extend it with RAG, MCP, databases, APIs, memory, and multiple tools.
What We Are Building
We will create a simple customer-support AI agent.
The user will be able to ask something like:
"What is the status of order 1001?"
The agent will decide that it needs to call an order lookup tool.
User ↓ AI Agent ↓ LLM ↓ Choose Tool ↓ get_order_status() ↓ Order System ↓ Tool Result ↓ LLM ↓ Final Answer
Prerequisites
Before starting, make sure you have:
- .NET 8 SDK
- Visual Studio 2022 or Visual Studio Code
- Basic C# knowledge
- Basic understanding of APIs and JSON
- Access to an LLM API or a locally running compatible model
You can also use a local LLM if your model and client support tool/function calling.
Step 1: Create a .NET 8 Console Application
Open a terminal and create a new console application:
dotnet new console -n SimpleAiAgent cd SimpleAiAgent
Open the project in Visual Studio or Visual Studio Code.
Step 2: Install the OpenAI .NET SDK
For this example, we will use the official OpenAI .NET client.
Install the NuGet package:
dotnet add package OpenAI
The same general architecture can also be adapted to other LLM providers that support compatible tool/function calling.
Step 3: Configure the API Key
For a quick local test, you can configure your API key as an environment variable.
On Windows:
setx OPENAI_API_KEY "YOUR_API_KEY"
Restart your terminal after setting the environment variable.
Step 4: Create an Order Service
Let's create a simple C# service that represents an external business system.
Create a class named OrderService.cs:
public class OrderService
{
public string GetOrderStatus(int orderId)
{
if (orderId == 1001)
{
return "Order 1001 is shipped and is expected to arrive tomorrow.";
}
if (orderId == 1002)
{
return "Order 1002 is being processed.";
}
return $"Order {orderId} was not found.";
}
}
In a real application, this method could call:
- A REST API
- SQL Server
- An ERP system
- An order-management system
- A microservice
The AI agent does not need to know how the data is retrieved. It only needs a well-defined tool.
Step 5: Define the Agent Tool
We need to expose the order lookup operation to the LLM.
The tool definition tells the model:
- What the tool is called
- What the tool does
- What parameters it accepts
A conceptual tool definition looks like:
Tool Name: get_order_status Description: Gets the current status of a customer order. Parameter: orderId - The order number to look up.
The description is important because the model uses it to determine when the tool should be used.
Step 6: Create the Tool Definition in C#
Using the OpenAI .NET SDK, we can describe the function to the model.
using OpenAI.Chat;
var getOrderStatusTool = ChatTool.CreateFunctionTool(
functionName: "get_order_status",
functionDescription: "Gets the current status of a customer order.",
functionParameters: BinaryData.FromString("""
{
"type": "object",
"properties": {
"orderId": {
"type": "integer",
"description": "The order number to look up."
}
},
"required": ["orderId"]
}
""")
);
The JSON schema tells the model what arguments the function expects.
Step 7: Create the Chat Client
Now we can create the chat client.
using OpenAI.Chat;
string apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY")
?? throw new InvalidOperationException("OPENAI_API_KEY is not configured.");
ChatClient client = new(
model: "gpt-4o-mini",
apiKey: apiKey
);
Step 8: Send the User Request
Let's start with a simple request:
string userMessage =
"What is the status of order 1001?";
We now send the message to the LLM along with the tool definition.
Listmessages = [ new UserChatMessage(userMessage) ]; ChatCompletionOptions options = new() { Tools = { getOrderStatusTool } }; ChatCompletion completion = client.CompleteChat(messages, options);
Step 9: Detect a Tool Call
The model may determine that it needs to use the get_order_status tool.
We can inspect the response:
if (completion.FinishReason == ChatFinishReason.ToolCalls)
{
Console.WriteLine("The agent requested a tool.");
}
The model can return a tool call containing the function name and arguments.
Conceptually, the response may look like:
Function:
get_order_status
Arguments:
{
"orderId": 1001
}
Step 10: Execute the Tool
Now our C# application needs to execute the requested function.
OrderService orderService = new();
string result =
orderService.GetOrderStatus(1001);
Console.WriteLine(result);
The output will be:
Order 1001 is shipped and is expected to arrive tomorrow.
This is an important concept:
Step 11: Send the Tool Result Back to the LLM
After executing the tool, the application sends the result back to the model.
messages.Add(new AssistantChatMessage(completion));
messages.Add(
new ToolChatMessage(
toolCallId,
result
)
);
completion = client.CompleteChat(messages, options);
The LLM now has the information returned by the tool.
Step 12: Generate the Final Answer
The model can now generate a natural-language response.
Console.WriteLine(completion.Content[0].Text);
Example:
Order 1001 has been shipped and is expected to arrive tomorrow.
The complete flow is:
User ↓ "What is the status of order 1001?" ↓ LLM ↓ Tool Call ↓ get_order_status(1001) ↓ C# OrderService ↓ Order System ↓ Tool Result ↓ LLM ↓ Final Answer
Step 13: Build the Agent Loop
A real agent may need to perform more than one action.
For example:
User: "Find order 1001 and tell me whether it is likely to arrive tomorrow." Agent: 1. Get order status 2. Get shipment information 3. Check delivery estimate 4. Analyze results 5. Generate answer
Therefore, we need a loop.
while (true)
{
Send messages to LLM
if (LLM requests a tool)
{
Execute tool
Add tool result to messages
Continue
}
Return final response
break;
}
This is the foundation of an agent loop.
Complete Simplified Agent Example
The following example demonstrates the overall architecture:
using OpenAI.Chat;
string apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY")
?? throw new InvalidOperationException("OPENAI_API_KEY is not configured.");
ChatClient client = new(
model: "gpt-4o-mini",
apiKey: apiKey
);
OrderService orderService = new();
var tool = ChatTool.CreateFunctionTool(
functionName: "get_order_status",
functionDescription: "Gets the current status of a customer order.",
functionParameters: BinaryData.FromString("""
{
"type": "object",
"properties": {
"orderId": {
"type": "integer",
"description": "The order number."
}
},
"required": ["orderId"]
}
""")
);
List messages =
[
new UserChatMessage(
"What is the status of order 1001?"
)
];
ChatCompletionOptions options = new()
{
Tools = { tool }
};
while (true)
{
ChatCompletion completion =
client.CompleteChat(messages, options);
if (completion.FinishReason == ChatFinishReason.ToolCalls)
{
messages.Add(new AssistantChatMessage(completion));
foreach (ChatToolCall toolCall in completion.ToolCalls)
{
if (toolCall.FunctionName == "get_order_status")
{
int orderId =
int.Parse(toolCall.FunctionArguments
.ToString()
.Replace("{\"orderId\":", "")
.Replace("}", ""));
string result =
orderService.GetOrderStatus(orderId);
messages.Add(
new ToolChatMessage(
toolCall.Id,
result
)
);
}
}
continue;
}
Console.WriteLine(completion.Content[0].Text);
break;
}
System.Text.Json, validate all inputs, and handle malformed requests safely.
Production-Ready Argument Parsing
Instead of manually parsing JSON strings, use System.Text.Json.
using System.Text.Json;
public class OrderRequest
{
public int OrderId { get; set; }
}
OrderRequest request =
JsonSerializer.Deserialize<OrderRequest>(
toolCall.FunctionArguments.ToString()
)!;
Then execute:
string result =
orderService.GetOrderStatus(request.OrderId);
This is safer and easier to maintain.
What Makes This an AI Agent?
You might ask:
"Isn't this just function calling?"
Function calling is one of the building blocks.
The agent behavior comes from combining the model with a loop that can:
- Understand the user's goal
- Choose a tool
- Execute the tool
- Observe the result
- Decide whether another action is required
- Return the final result
In simplified form:
LLM ↓ Decision ↓ Tool ↓ Observation ↓ LLM ↓ Decision ↓ Tool ↓ Observation ↓ Final Answer
Adding Multiple Tools
An agent becomes more useful when it has multiple tools.
For example:
get_order_status() get_customer() check_inventory() get_shipment() search_knowledge_base() send_email()
The model can select the appropriate tool based on the user's request.
For example:
User:
"Is Product 123 available?"
↓
Agent
↓
check_inventory(123)
Another request might result in:
User:
"Where is my order?"
↓
Agent
↓
get_order_status(1001)
Tool Dispatcher Pattern
When you have many tools, it is useful to create a central dispatcher.
switch (toolCall.FunctionName)
{
case "get_order_status":
return GetOrderStatus(toolCall);
case "get_customer":
return GetCustomer(toolCall);
case "check_inventory":
return CheckInventory(toolCall);
default:
throw new InvalidOperationException(
$"Unknown tool: {toolCall.FunctionName}");
}
This keeps the agent loop separate from the business logic.
Recommended .NET Architecture
For a production application, avoid putting everything inside Program.cs.
A cleaner structure could be:
SimpleAiAgent/ │ ├── Agents/ │ ├── AiAgent.cs │ └── AgentOptions.cs │ ├── Tools/ │ ├── IAgentTool.cs │ ├── OrderTool.cs │ ├── CustomerTool.cs │ └── InventoryTool.cs │ ├── Services/ │ ├── OrderService.cs │ └── CustomerService.cs │ ├── Models/ │ ├── Order.cs │ └── Customer.cs │ ├── Program.cs └── appsettings.json
This separation makes the application easier to test and maintain.
Adding an Interface for Tools
You can define a common interface:
public interface IAgentTool
{
string Name { get; }
string Description { get; }
Task<string> ExecuteAsync(
string arguments);
}
Each tool can implement this interface.
public class OrderTool : IAgentTool
{
public string Name => "get_order_status";
public string Description =>
"Gets the current status of a customer order.";
public Task<string> ExecuteAsync(string arguments)
{
// Validate arguments
// Call order service
// Return result
return Task.FromResult(
"Order is shipped."
);
}
}
Adding RAG to the Agent
We can extend the agent by adding a RAG tool.
search_company_documents()
Now the agent can answer questions using company documentation.
User ↓ Agent ↓ LLM ↓ Need company information? ↓ RAG Tool ↓ Vector Database ↓ Relevant Documents ↓ LLM ↓ Answer
This is a powerful combination of AI agents and RAG.
Adding MCP
MCP can also be incorporated into an agent architecture.
AI Agent
↓
MCP Client
↓
MCP Server
↓
Tools
↓
External Systems
Instead of implementing every integration directly inside the agent application, MCP can provide a standardized way for compatible AI applications to discover and use external capabilities.
Adding Memory
An agent can also maintain conversation or task state.
Conversation User: "My order is 1001." Agent: "Order 1001 is shipped." User: "When will it arrive?" Agent: "Order 1001 is expected tomorrow."
The agent needs the previous context to understand what the user means by it.
For longer-lived applications, memory can be stored in databases or other persistent storage systems.
Adding Guardrails
Production agents should not be allowed to execute arbitrary operations without controls.
Consider a tool such as:
delete_customer() refund_payment() cancel_order()
These operations can have real business consequences.
Therefore, implement:
- Authentication
- Authorization
- Input validation
- Tool allowlists
- Rate limiting
- Timeouts
- Maximum agent steps
- Audit logging
- Human approval for sensitive operations
Maximum Agent Steps
An agent should not be allowed to loop forever.
For example:
const int maxSteps = 10;
for (int step = 0; step < maxSteps; step++)
{
// Ask LLM
// Execute tools
// Continue or finish
}
If the maximum number of steps is reached, stop the agent and return an appropriate message.
Error Handling
Tools can fail.
For example:
Database unavailable API timeout Invalid order ID Authorization failure Network failure
Your tool layer should handle these errors gracefully.
try
{
var result = await orderService.GetOrderStatusAsync(orderId);
return result;
}
catch (Exception ex)
{
// Log exception
// Return safe tool result
}
The agent can then decide whether it should retry, use another tool, or report the problem.
Logging an AI Agent
Agent applications should have detailed logging.
Useful information includes:
- User request
- Agent execution ID
- Tool selected
- Tool arguments
- Tool execution time
- Tool result status
- LLM response time
- Number of agent steps
- Errors
Be careful not to log sensitive information such as passwords, API keys, payment information, or unnecessary personal data.
Simple Agent Architecture for .NET
User
↓
ASP.NET Core
↓
AI Agent
↓
LLM
↓
Tool Dispatcher
↓
┌────────────┼────────────┐
↓ ↓ ↓
Order API Database RAG
↓ ↓ ↓
Data Data Documents
└────────────┼────────────┘
↓
Tool Result
↓
LLM
↓
Final Answer
Agent vs Normal API
It is important to understand when an agent is actually necessary.
If your application simply needs:
GET /orders/1001
you probably do not need an AI agent.
A normal API is simpler and more predictable.
An agent becomes more useful when the request is open-ended:
"Investigate why order 1001 is delayed and tell me what action we should take."
The agent may need to combine several operations:
Get Order ↓ Check Payment ↓ Check Shipment ↓ Search Documentation ↓ Analyze ↓ Recommend Action
Common Mistakes When Building AI Agents
1. Giving the Agent Too Many Tools
Providing hundreds of tools can make tool selection more difficult and increase complexity.
2. Poor Tool Descriptions
Tool descriptions should clearly explain what the tool does and when it should be used.
3. No Validation
Never trust tool arguments simply because they came from an LLM.
4. No Step Limit
Always protect the application from endless agent loops.
5. No Authorization
Agents should operate with only the permissions they actually need.
6. Using an Agent for Everything
Simple deterministic operations are often better implemented using normal application logic.
Agentic Application Design Principle
A useful principle is:
For example:
AI: "What should I do next?" Code: "How do I execute this operation safely?"
The AI can select the tool, while your application remains responsible for validation, security, business rules, and actual execution.
Complete Flow
Our simple AI agent can now be represented as:
User
↓
"Check order 1001"
↓
AI Agent
↓
LLM
↓
Choose Tool
↓
get_order_status()
↓
Validate Input
↓
Execute C# Code
↓
Order Service
↓
Business System
↓
Tool Result
↓
LLM
↓
Final Answer
Key Takeaways
- An AI agent can be built using an LLM, tools, application logic, and an execution loop.
- Function calling is an important building block for tool-based agents.
- The LLM decides which tool may be useful, but the application executes the tool.
- Tool arguments must be validated before execution.
- Multiple tools allow agents to perform more complex tasks.
- RAG can provide an agent with access to external knowledge.
- MCP can provide a standardized way to connect compatible AI applications with external capabilities.
- Memory can maintain useful conversation or task state.
- Production agents need security, authorization, logging, error handling, and step limits.
- Not every application needs an AI agent.
- Deterministic code should continue to handle critical business rules and operations.
Conclusion
Building an AI agent does not require a huge framework to understand the fundamentals.
At its core, an agent can be thought of as:
Goal ↓ LLM ↓ Choose Tool ↓ Execute Tool ↓ Observe Result ↓ LLM ↓ Next Action ↓ Repeat ↓ Final Answer
With C# and .NET 8, you can build this architecture using familiar concepts such as classes, interfaces, dependency injection, APIs, services, logging, and asynchronous programming.
Once the basic agent works, you can gradually add RAG, MCP, memory, additional tools, authentication, observability, and human approval.
The important thing is to start with a small, well-defined agent and expand its capabilities only when the use case requires them.
No comments:
Post a Comment