← Pekka Ylenius

Building Agentic AI with Microsoft.Agents.AI: A Getting Started Guide (part 1)

31 Jan 2026 · 12 min read · Originally published on Medium

Traditional AI chatbots answer questions. Agentic AI takes action. Instead of just telling you about your notes, an agentic assistant can search them, create new ones, update existing content, and manage topics — all through natural conversation.

In this guide, I’ll show you how to build an agentic AI system using Microsoft.Agents.AI (the Microsoft Agent Framework). We’ll walk through the framework step-by-step, using code from a production notes application. By the end, you’ll have a solid foundation for adding agentic capabilities to your own .NET applications.

Note: This article builds on the search foundation covered in Building Intelligent Search with Azure Cosmos DB. The search tools we create here use the hybrid search capabilities from that implementation.
Framework Note: Microsoft.Agents.AI builds on top of the Microsoft.Extensions.AI abstractions. You’ll see imports from the “Microsoft.Extensions.AI” namespace — this is the core abstraction layer that Microsoft.Agents.AI extends with agentic capabilities.

What We’re Building

Our agent will be able to:

  • Search notes using natural language (semantic search)
  • Create, read, update, and delete notes
  • Manage subjects (topics/threads) for organizing notes
  • Answer questions based on note content

The key insight is that we’re giving the AI tools — functions it can call to interact with our system. The AI decides when and how to use these tools based on the user’s request.

Prerequisites and Setup

NuGet Packages

You’ll need these packages:

<PackageReference Include="Microsoft.Agents.AI" Version="1.0.0-preview.260127.1" />
<PackageReference Include="Microsoft.Agents.AI.Abstractions" Version="1.0.0-preview.260127.1" />
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.0.0-preview.260127.1" />
<PackageReference Include="OpenAI" Version="2.8.0" />
Note: When writing this, the Microsoft.Agents.AI packages are currently in preview. Check NuGet for the latest versions.

The framework is model-agnostic. While we’re using OpenAI in this guide, you can swap in Azure OpenAI, Claude, or other providers with minimal changes. New models appear regularly — the abstraction layer means you can upgrade without rewriting your tool code.

Configuration

Add your settings to “appsettings.json”:

{
"OpenAI": {
"ApiKey": "your-api-key-here",
"AgentModel": "gpt-5",
"ReasoningEffort": "low"
}
}

Create an options class for configuration:

public sealed class OpenAiOptions
{
public const string SectionName = "OpenAI";

[Required]
public string ApiKey { get; set; }
public string AgentModel { get; set; } = "gpt-4o";
public string ReasoningEffort { get; set; } = "low";
}

Dependency Injection Setup

Register the chat client and services in “Program.cs”:

using Microsoft.Extensions.AI;
using OpenAI;

// Configuration
builder.Services.AddOptions<OpenAiOptions>()
.Bind(builder.Configuration.GetSection(OpenAiOptions.SectionName))
.ValidateDataAnnotations()
.ValidateOnStart();

// Chat client from Microsoft.Agents.AI.OpenAI
builder.Services.AddSingleton<IChatClient>(sp =>
{
var options = sp.GetRequiredService<IOptions<OpenAiOptions>>().Value;
var openAiClient = new OpenAIClient(options.ApiKey);
return openAiClient.GetChatClient(options.AgentModel).AsIChatClient();
});

// Agent services
builder.Services.AddScoped<IAgentFactory, AgentFactory>();
builder.Services.AddScoped<IAgentChatService, AgentChatService>();

The “.AsIChatClient()” extension method wraps the OpenAI client in the “IChatClient” interface, giving you the unified API that Microsoft.Agents.AI provides. Note that the framework uses the “Microsoft.Extensions.AI” namespace for its core types like “IChatClient” and “AITool”.

Core Concept: Tools

Tools are the heart of agentic AI. A tool is simply a method that the AI can call. The framework handles all the complexity of:

  • Describing your tools to the AI
  • Parsing the AI’s request to call a tool
  • Executing the tool and returning results

The magic happens through the “[Description]” attribute. This tells the AI what each tool does and when to use it.

Here’s a simple search tool:

public class SearchTools
{
private readonly ISearchService _searchService;
private readonly AgentContext _context;

public SearchTools(ISearchService searchService, AgentContext context)
{
_searchService = searchService;
_context = context;
}

[Description("Search for notes using natural language. Returns relevant notes with content and metadata.")]
public async Task<SearchToolResult> SearchNotesAsync(
[Description("The search query in natural language")] string query,
[Description("Maximum number of results to return (default: 10, max: 50)")] int limit = 10,
CancellationToken ct = default)
{
var effectiveLimit = Math.Clamp(limit, 1, 50);
var result = await _searchService.HybridSearchNotesAsync(
_context.ContextId,
query,
new HybridSearchOptions
{
Limit = effectiveLimit,
TextWeight = 0.3f,
VectorWeight = 0.7f
},
ct);

return new SearchToolResult
{
TotalCount = result.TotalCount,
Notes = result.Items.Select(i => new NoteSearchResult
{
Id = i.Item.Id,
SubjectId = i.Item.SubjectId,
SubjectTitle = i.Item.SubjectTitle,
Content = TruncateContent(i.Item.Content, 500),
CreatedAt = i.Item.CreatedAt,
Score = i.Score
}).ToList()
};
}

private static string TruncateContent(string content, int maxLength)
=> content.Length <= maxLength ? content : content[..maxLength] + "...";
}

Notice how “[Description]” appears on both the method and each parameter. The AI uses these descriptions to understand:

  • What the tool does (method description)
  • What each parameter means (parameter descriptions)
  • Default values and constraints (from the method signature)

Building a Complete Tool Class

Let’s build a more complete tool class for managing notes. This demonstrates CRUD operations with proper descriptions:

public class NoteTools
{
private readonly INoteService _noteService;
private readonly AgentContext _context;

public NoteTools(INoteService noteService, AgentContext context)
{
_noteService = noteService;
_context = context;
}

[Description("Create a new note within a subject. The note content supports markdown formatting.")]
public async Task<NoteToolResult> CreateNoteAsync(
[Description("The subject ID to add the note to")] Guid subjectId,
[Description("The note content in markdown format")] string content,
[Description("Whether to pin this note to the top (default: false)")] bool isPinned = false,
CancellationToken ct = default)
{
var dto = new NoteCreateDto
{
SubjectId = subjectId,
Content = content,
ContentType = "markdown",
IsPinned = isPinned,
Source = "agent"
};

var note = await _noteService.CreateAsync(
_context.ContextId,
dto,
_context.UserId,
_context.OrganizationId,
ct);

return new NoteToolResult
{
Success = true,
NoteId = note.Id,
Message = "Note created successfully."
};
}

[Description("Get a specific note by its ID")]
public async Task<NoteDto?> GetNoteAsync(
[Description("The note ID")] Guid noteId,
CancellationToken ct = default)
{
return await _noteService.GetAsync(_context.ContextId, noteId, ct);
}

[Description("List notes within a subject, ordered by creation date (newest first)")]
public async Task<NoteListResultDto> ListNotesAsync(
[Description("The subject ID")] Guid subjectId,
[Description("Maximum number of notes to return (default: 20, max: 50)")] int limit = 20,
[Description("Number of notes to skip for pagination (default: 0)")] int skip = 0,
CancellationToken ct = default)
{
var effectiveLimit = Math.Clamp(limit, 1, 50);
return await _noteService.ListBySubjectAsync(
_context.ContextId,
subjectId,
effectiveLimit,
skip,
ct);
}

[Description("Update an existing note's content or pin status")]
public async Task<NoteToolResult> UpdateNoteAsync(
[Description("The note ID to update")] Guid noteId,
[Description("The new content (optional, leave null to keep current)")] string? content = null,
[Description("Whether to pin/unpin the note (optional)")] bool? isPinned = null,
CancellationToken ct = default)
{
var dto = new NoteUpdateDto
{
Content = content,
IsPinned = isPinned
};

var note = await _noteService.UpdateAsync(_context.ContextId, noteId, dto, ct);

return note == null
? new NoteToolResult { Success = false, Message = "Note not found." }
: new NoteToolResult { Success = true, NoteId = note.Id, Message = "Note updated successfully." };
}

[Description("Delete a note permanently. This action cannot be undone.")]
public async Task<NoteToolResult> DeleteNoteAsync(
[Description("The note ID to delete")] Guid noteId,
CancellationToken ct = default)
{
var deleted = await _noteService.DeleteAsync(_context.ContextId, noteId, ct);
return new NoteToolResult
{
Success = deleted,
NoteId = deleted ? noteId : null,
Message = deleted ? "Note deleted successfully." : "Note not found."
};
}
}

Key patterns to notice:

1. Return clear result types — Records work great for tool results:

public record NoteToolResult
{
public bool Success { get; init; }
public Guid? NoteId { get; init; }
public string Message { get; init; } = "";
}

2. Include success/failure messages — The AI can relay these to the user
3. Clamp numeric inputs — Protect against unreasonable values
4. Make destructive operations explicit — The delete description warns about permanence

Tool Registration with AIFunctionFactory

The “AIFunctionFactory.Create()” method wraps your tool methods into “AITool” objects that the framework understands:

public class AgentFactory : IAgentFactory
{
private readonly IServiceProvider _serviceProvider;
private readonly IChatClient _baseChatClient;

public AgentFactory(
IServiceProvider serviceProvider,
IChatClient baseChatClient)
{
_serviceProvider = serviceProvider;
_baseChatClient = baseChatClient;
}

public IChatClient CreateAgent(AgentContext context)
{
return new ChatClientBuilder(_baseChatClient)
.UseFunctionInvocation()
.Build();
}

public IList<AITool> GetTools(AgentContext context)
{
// Resolve services from DI
var searchService = _serviceProvider.GetRequiredService<ISearchService>();
var noteService = _serviceProvider.GetRequiredService<INoteService>();
var subjectService = _serviceProvider.GetRequiredService<ISubjectService>();

// Create tools with the scoped context
var searchTools = new SearchTools(searchService, context);
var noteTools = new NoteTools(noteService, context);
var subjectTools = new SubjectTools(subjectService, context);

// Wrap methods as AITools
return
[
// Search tools
AIFunctionFactory.Create(searchTools.SearchNotesAsync),
AIFunctionFactory.Create(searchTools.SearchSubjectsAsync),

// Note tools
AIFunctionFactory.Create(noteTools.CreateNoteAsync),
AIFunctionFactory.Create(noteTools.GetNoteAsync),
AIFunctionFactory.Create(noteTools.ListNotesAsync),
AIFunctionFactory.Create(noteTools.UpdateNoteAsync),
AIFunctionFactory.Create(noteTools.DeleteNoteAsync),

// Subject tools
AIFunctionFactory.Create(subjectTools.CreateSubjectAsync),
AIFunctionFactory.Create(subjectTools.GetSubjectAsync),
AIFunctionFactory.Create(subjectTools.ListSubjectsAsync),
AIFunctionFactory.Create(subjectTools.ArchiveSubjectAsync)
];
}
}

The factory pattern lets you create agents with scoped tools. Each request gets its own context, ensuring data isolation.

Scoped Context for Multi-Tenancy

In multi-tenant applications, every tool needs to know which tenant, user, and organization it’s operating for. The “AgentContext” class provides this security boundary:

public class AgentContext
{
/// <summary>
/// The context (tenant) this agent session belongs to
/// </summary>
public Guid ContextId { get; set; }

/// <summary>
/// The authenticated user's ID
/// </summary>
public Guid UserId { get; set; }

/// <summary>
/// The user's organization ID
/// </summary>
public Guid OrganizationId { get; set; }

/// <summary>
/// Display name of the user (for prompts)
/// </summary>
public string? UserName { get; set; }

/// <summary>
/// Display name of the organization (for prompts)
/// </summary>
public string? OrganizationName { get; set; }
}

Why is this important?

Every tool receives this context through its constructor. When “SearchNotesAsync” runs, it automatically filters by “ContextId”. The AI can’t accidentally (or intentionally) access data from other tenants because the context is baked into every operation.

This is a critical security pattern: context flows down through every tool call.

Wiring It Up: The Chat Service

Now let’s put it all together with a service that handles chat interactions:

public class AgentChatService : IAgentChatService
{
private readonly IAgentFactory _agentFactory;
private readonly IConversationService _conversationService;
private readonly OpenAiOptions _options;

public AgentChatService(
IAgentFactory agentFactory,
IConversationService conversationService,
IOptions<OpenAiOptions> options)
{
_agentFactory = agentFactory;
_conversationService = conversationService;
_options = options.Value;
}

public async Task<ChatResponseDto> ChatAsync(
AgentContext context,
ChatRequestDto request,
CancellationToken ct = default)
{
// Get or create conversation for history
var (conversation, isNew) = await _conversationService.GetOrCreateAsync(
context.ContextId,
request.ConversationId,
context.UserId,
context.OrganizationId,
ct);

// Build chat history with system prompt
var messages = new List<ChatMessage>
{
new(ChatRole.System, BuildSystemPrompt(context))
};

// Add existing messages from conversation
foreach (var msg in conversation.Messages)
messages.Add(new ChatMessage(MapRole(msg.Role), msg.Content));

// Add new user message
messages.Add(new ChatMessage(ChatRole.User, request.Message));

// Create agent and get tools
var agent = _agentFactory.CreateAgent(context);
var tools = _agentFactory.GetTools(context);

// Configure chat options with tools
var chatOptions = new ChatOptions
{
Tools = tools,
AdditionalProperties = new AdditionalPropertiesDictionary
{
["reasoning_effort"] = _options.ReasoningEffort
}
};

// Get response
var response = await agent.GetResponseAsync(messages, chatOptions, ct);

// Save messages to conversation
await _conversationService.AddMessagesAsync(
context.ContextId,
conversation.UID,
request.Message,
response.Text ?? "",
ct);

return new ChatResponseDto
{
ConversationId = conversation.UID,
Message = response.Text ?? ""
};
}

private static string BuildSystemPrompt(AgentContext context)
{
return $"""
You are a helpful assistant for the Notes system.
You help users manage their notes and subjects (topics/threads).

## Your Capabilities
- Search for notes and subjects using natural language
- Create, read, update, and delete notes
- Create and manage subjects (topics/threads)
- Answer questions based on the content of notes

## Context
- Current user: {context.UserName ?? "Unknown"}
- Organization: {context.OrganizationName ?? "Unknown"}

## Guidelines
1. When searching, use semantic search for natural language queries
2. Always confirm before deleting content
3. When creating notes, use markdown formatting
4. Be concise but thorough in your responses
5. If you don't have enough information, ask clarifying questions
""";
}

private static ChatRole MapRole(string role) => role.ToLower() switch
{
"user" => ChatRole.User,
"assistant" => ChatRole.Assistant,
"system" => ChatRole.System,
"tool" => ChatRole.Tool,
_ => ChatRole.User
};
}

Key elements:

1. System prompt with dynamic context — Include user info so the AI can personalize responses
2. Conversation history — The AI needs context from previous messages to understand follow-up questions
3. ChatOptions with tools — This is where you pass your tool list to the AI
4. Save conversation — Persist messages for continuity across requests

Adding Streaming Support

For a better user experience, stream responses in real-time instead of waiting for the complete answer:

public async IAsyncEnumerable<ChatStreamChunkDto> ChatStreamingAsync(
AgentContext context,
ChatRequestDto request,
[EnumeratorCancellation] CancellationToken ct = default)
{
var (conversation, isNew) = await _conversationService.GetOrCreateAsync(
context.ContextId,
request.ConversationId,
context.UserId,
context.OrganizationId,
ct);

// Yield conversation ID first so client can track it
yield return new ChatStreamChunkDto { ConversationId = conversation.UID };

// Build messages (same as non-streaming)
var messages = new List<ChatMessage>
{
new(ChatRole.System, BuildSystemPrompt(context))
};

foreach (var msg in conversation.Messages)
messages.Add(new ChatMessage(MapRole(msg.Role), msg.Content));

messages.Add(new ChatMessage(ChatRole.User, request.Message));

// Create agent and tools
var agent = _agentFactory.CreateAgent(context);
var tools = _agentFactory.GetTools(context);

var chatOptions = new ChatOptions
{
Tools = tools,
AdditionalProperties = new AdditionalPropertiesDictionary
{
["reasoning_effort"] = _options.ReasoningEffort
}
};

// Stream response
var fullResponse = new StringBuilder();

await foreach (var update in agent.GetStreamingResponseAsync(messages, chatOptions, ct))
{
if (!string.IsNullOrEmpty(update.Text))
{
fullResponse.Append(update.Text);
yield return new ChatStreamChunkDto { Text = update.Text };
}
}

// Save complete conversation
await _conversationService.AddMessagesAsync(
context.ContextId,
conversation.UID,
request.Message,
fullResponse.ToString(),
ct);

yield return new ChatStreamChunkDto { IsFinal = true };
}

The “IAsyncEnumerable<T>” return type enables streaming. Each chunk is yielded as soon as it’s available from the AI.

API Endpoints

Here’s a simple controller structure for exposing the chat functionality:

[Route("chat")]
[ApiController]
[Authorize]
public class ChatController : ControllerBase
{
private readonly IAgentChatService _agentChatService;

public ChatController(IAgentChatService agentChatService)
{
_agentChatService = agentChatService;
}

/// <summary>
/// Send a message and get a response
/// </summary>
[HttpPost]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ChatResponseDto))]
public async Task<IActionResult> Chat(
[FromQuery] Guid contextId,
[FromBody] ChatRequestDto request,
CancellationToken ct)
{
var agentContext = await GetAgentContextAsync(contextId, ct);
if (agentContext == null)
return Unauthorized();

var response = await _agentChatService.ChatAsync(agentContext, request, ct);
return Ok(response);
}

/// <summary>
/// Send a message and stream the response
/// </summary>
[HttpPost("stream")]
public async IAsyncEnumerable<ChatStreamChunkDto> StreamChat(
[FromQuery] Guid contextId,
[FromBody] ChatRequestDto request,
[EnumeratorCancellation] CancellationToken ct)
{
var agentContext = await GetAgentContextAsync(contextId, ct);
if (agentContext == null)
{
yield return new ChatStreamChunkDto { Error = "Unauthorized" };
yield break;
}

await foreach (var chunk in _agentChatService.ChatStreamingAsync(agentContext, request, ct))
{
yield return chunk;
}
}

private async Task<AgentContext?> GetAgentContextAsync(Guid contextId, CancellationToken ct)
{
// Extract user info from JWT claims
// Build and return AgentContext
// Return null if unauthorized
}
}

The streaming endpoint returns “IAsyncEnumerable<T>”, which ASP.NET Core automatically handles as a streaming response.

Here is my old article about streaming responses from Net8 to Vue.js.

Putting It All Together

Let’s trace through what happens when a user sends “Find notes about shipping delays”:

1. Request arrives at “POST /chat”
2. AgentContext is built from JWT claims (user, organization, context)
3. Chat history is loaded from the conversation service
4. System prompt is generated with user context
5. Tools are created with the scoped AgentContext
6. AI receives the message, history, system prompt, and tool descriptions
7. AI decides to call “SearchNotesAsync” with query “shipping delays”
8. Tool executes within the security boundary of AgentContext
9. Results return to the AI
10. AI formulates a response summarizing the findings
11. Response streams back to the user
12. Conversation is saved for future context

The power here is that the AI can chain multiple tool calls. For “Create a note about today’s shipping issue in the logistics subject”, the AI might:
1. Call “SearchSubjectsAsync” to find the logistics subject
2. Call “CreateNoteAsync” with the found subject ID

All of this happens automatically — you just define the tools.

Summary

Microsoft.Agents.AI provides a clean, DI-friendly way to build agentic AI systems:

1. Tools are just methods with “[Description]” attributes
2. AIFunctionFactory.Create() wraps them for the framework
3. AgentContext provides security boundaries for multi-tenant apps
4. IChatClient is the unified interface that works across providers
5. Streaming support is built in with “IAsyncEnumerable”

The model-agnostic design means you can start with one provider and switch later without rewriting your tools. As new models emerge with better tool-use capabilities, you can upgrade with a configuration change.

In the next article, we’ll build a Vue.js frontend that consumes this streaming API, providing a real-time chat experience.

The code examples in this article are simplified from a production notes application. The search capabilities shown here are powered by the hybrid search implementation covered in Building Intelligent Search with Azure Cosmos DB.

References

Part 2 about adding more tools can be found here.

Read, clap or comment on Medium →