Skip to content

Let's Create Our First Agent Using .NET

In the previous section we wrote an Agent by hand and saw how much manual work that is. Now we will build the same thing with a library that does the heavy lifting for us.

In this lesson you will create your first real Agent: Alfred, who runs entirely on your computer and can call C# methods to answer questions.

Microsoft.Extensions.AI is Microsoft’s library for working with LLMs in .NET. It is small, but it covers exactly the things we wrote ourselves in the previous lesson:

In the Dummy Agent we wrote it ourselves With Microsoft.Extensions.AI
Ollama.cs: requests to the model with HttpClient IChatClient: one interface for every LLM provider
ChatMessage.cs: our own record for one message ChatMessage: a ready-made type with the same name
A tool description written by hand into the system prompt AIFunctionFactory: makes a tool out of an ordinary C# method
Tools.Run and the while loop UseFunctionInvocation(): runs the whole cycle automatically

There is also one difference under the surface. Our dummy Agent was a JSON Agent: we asked the model to write Action: {...} in its text and parsed it ourselves. The library uses function calling (see Actions): the model returns the tool call as structured data, so there is no format to get wrong.

We will need Ollama and the model from the previous lesson. Make sure Ollama is running before you start.

Open a terminal and run:

Terminal window
dotnet new console -n FirstAgent
cd FirstAgent
dotnet add package Microsoft.Extensions.AI
dotnet add package OllamaSharp

The first two commands create the project, and the other two add the two NuGet packages. OllamaSharp is the client for Ollama. It implements the IChatClient interface, so the rest of the code does not depend on Ollama. If you ever switch model providers, you change one line.

Open the FirstAgent folder in your editor. We will work with three files.

Create a file AlfredTools.cs. It holds Alfred’s only tool: a method that returns the current time in a given timezone.

AlfredTools.cs
using System.ComponentModel;
public static class AlfredTools
{
[Description("Gets the current local time in a given timezone.")]
public static string GetCurrentTime(
[Description("An IANA timezone name, for example 'Europe/Sofia'.")]
string timezone)
{
try
{
TimeZoneInfo zone = TimeZoneInfo.FindSystemTimeZoneById(timezone);
DateTime now = TimeZoneInfo.ConvertTime(DateTime.UtcNow, zone);
return $"The current time in {timezone} is {now:yyyy-MM-dd HH:mm:ss}";
}
catch (TimeZoneNotFoundException)
{
return $"Unknown timezone '{timezone}'.";
}
}
}

Three things make a method a good tool:

  • A [Description] attribute on the method and on every parameter. That is everything the model knows about the tool (see Tools).
  • Clear types for the parameters and the result.
  • Errors are returned as text. If the model passes an invalid timezone, it will read the message as an Observation and may try again.

Open Program.cs, delete its contents and put the following:

Program.cs
using Microsoft.Extensions.AI;
using OllamaSharp;
const string Model = "qwen3.5:9b";
const string SystemPrompt =
"You are Alfred, a helpful and polite AI assistant. " +
"Use your tools when they can help and never invent their results.";
IChatClient model = new OllamaApiClient(new Uri("http://localhost:11434"), Model);
IChatClient agent = new ChatClientBuilder(model)
.UseFunctionInvocation()
.Build();
var options = new ChatOptions
{
Temperature = 0,
Tools = [AIFunctionFactory.Create(AlfredTools.GetCurrentTime)],
};
List<ChatMessage> history =
[
new(ChatRole.System, SystemPrompt),
new(ChatRole.User, "What time is it in Tokyo right now?"),
];
ChatResponse response = await agent.GetResponseAsync(history, options);
AgentLog.PrintSteps(response);
Console.WriteLine($"Alfred: {response.Text}");

The code has four parts:

  1. The model. OllamaApiClient is the connection to the local model, exposed through the IChatClient interface. It takes the place of our Ollama.cs.
  2. The Agent. UseFunctionInvocation() wraps the model and adds the Thought → Action → Observation loop. When the model asks for a tool, the library calls it, returns the result and asks the model again, until it gives a final answer. To keep it from spinning forever, the loop has a limit of 40 rounds per request.
  3. Tools. AIFunctionFactory.Create reads the method name, the [Description] attributes and the parameter types and passes them to the model.
  4. The conversation. history is the list of messages: the system prompt and one question. GetResponseAsync sends it to the Agent and returns the answer.

The line AgentLog.PrintSteps(response) does not compile yet, because that method does not exist. We will write it in the next step.

The library runs the whole cycle inside GetResponseAsync. To see what happened, we will print the steps the Agent went through. Create a file AgentLog.cs:

AgentLog.cs
using System.Text.Json;
using Microsoft.Extensions.AI;
public static class AgentLog
{
public static void PrintSteps(ChatResponse response)
{
foreach (AIContent content in response.Messages.SelectMany(m => m.Contents))
{
switch (content)
{
case TextReasoningContent thought:
Console.WriteLine($" Thought: {thought.Text}");
break;
case FunctionCallContent call:
string arguments = JsonSerializer.Serialize(call.Arguments);
Console.WriteLine($" Action: {call.Name}({arguments})");
break;
case FunctionResultContent result:
Console.WriteLine($" Observation: {result.Result}");
break;
}
}
}
}

response.Messages holds everything the Agent produced on the way to the answer. Each step of the cycle arrives as a separate .NET type:

Step Type in Microsoft.Extensions.AI
Thought TextReasoningContent
Action FunctionCallContent
Observation FunctionResultContent
Final answer TextContent (also available via response.Text)

In the terminal, in the FirstAgent folder, run:

Terminal window
dotnet run

output:

Thought: The user is asking for the current time in Tokyo. I need to use the GetCurrentTime function with the appropriate timezone for Tokyo. Tokyo is in the Asia/Tokyo timezone.
Action: GetCurrentTime({"timezone":"Asia/Tokyo"})
Observation: The current time in Asia/Tokyo is 2026-11-12 20:32:31
Thought: The function returned the current time in Tokyo as 2026-11-12 20:32:31. I should present this information clearly to the user.
Alfred: The current time in Tokyo is November 12, 2026 at 8:32:31 PM (20:32:31).

On your machine the time and the exact words will differ, but the steps will be the same. Here is what happened inside GetResponseAsync:

  1. The library sent the conversation to the model together with the description of GetCurrentTime.
  2. The model asked for the tool with the argument Asia/Tokyo. That is the Action line.
  3. The library called the real method AlfredTools.GetCurrentTime and returned the result to the model. That is the Observation line.
  4. The model read the result and wrote the final answer.

This is the same cycle as in the Dummy Agent Library, but in our code there is no stop, no parsing and no while.

For now Alfred answers one question written in the code. In Program.cs replace everything from the List<ChatMessage> history line downward with the following:

List<ChatMessage> history = [new(ChatRole.System, SystemPrompt)];
Console.WriteLine("Alfred is ready. Ask a question (or press Enter to exit).");
while (true)
{
Console.Write("\nYou: ");
string? input = Console.ReadLine();
if (string.IsNullOrWhiteSpace(input))
{
break;
}
history.Add(new ChatMessage(ChatRole.User, input));
ChatResponse response = await agent.GetResponseAsync(history, options);
history.AddMessages(response);
AgentLog.PrintSteps(response);
Console.WriteLine($"Alfred: {response.Text}");
}

This while is not the Agent’s loop. It only reads questions from the console. The important lines are the two with history: the model remembers nothing between requests, so we append every question and everything the Agent answered to the list.

Run dotnet run and ask Alfred three questions:

Alfred is ready. Ask a question (or press Enter to exit).
You: What time is it in Tokyo right now?
Thought: The user is asking for the current time in Tokyo. I need to use the GetCurrentTime function with the appropriate timezone for Tokyo. Tokyo is in the Asia/Tokyo timezone.
Action: GetCurrentTime({"timezone":"Asia/Tokyo"})
Observation: The current time in Asia/Tokyo is 2026-11-12 20:33:16
Thought: The function returned the current time in Tokyo. I should present this information clearly to the user.
Alfred: The current time in Tokyo is 8:33 PM on November 12, 2026 (20:33:16).
You: And in Sofia?
Thought: The user is asking for the current time in Sofia. I need to use the GetCurrentTime function with the timezone "Europe/Sofia" since Sofia is in Bulgaria which uses the Europe/Sofia timezone.
Action: GetCurrentTime({"timezone":"Europe/Sofia"})
Observation: The current time in Europe/Sofia is 2026-11-12 13:33:43
Thought: The function returned the current time in Sofia as 2026-11-12 13:33:43. I should present this information clearly to the user, converting it to a more readable format if needed.
Alfred: The current time in Sofia is 1:33 PM on November 12, 2026 (13:33:43).
You: Hello, who are you?
Thought: The user is asking me who I am. I should introduce myself as Alfred, a helpful and polite AI assistant. This doesn't require any tool calls - it's just a conversational response.
Alfred: Hello! I'm Alfred, your helpful and polite AI assistant. How can I help you today?

Notice two things:

  • The second question is only “And in Sofia?”. Alfred understands it because together with it he receives the whole conversation from history.
  • The third question needs no tool, so Alfred answers directly, without an Action. The model decides for itself when to use tools.

Alfred has only one tool. The best way to learn is to give him more. To start, add the Calculator method from the Tools section to the AlfredTools class:

[Description("Multiply two integers.")]
public static int Calculator(int a, int b) => a * b;

and register it in Program.cs:

Tools =
[
AIFunctionFactory.Create(AlfredTools.GetCurrentTime),
AIFunctionFactory.Create(AlfredTools.Calculator),
],

Ask “What is 123 times 456?” and you will see Action: Calculator({"a":123,"b":456}), followed by Observation: 56088.

After that try your own ideas: a tool that reads a file, searches the web with HttpClient, or checks the weather through a public API.


Congratulations, you built your first Agent! 🎉

It is normal at the start for it to be a little slow or to make mistakes. In the next Units we will learn how to build better Agents.

In the next section the final quiz for Unit 1 awaits you.