Build an AI Agent with Microsoft Agent Framework and .NET 10
Jun 25, 2026
.NETAIAPI DesignArchitectureAzureLLMProduct EngineeringReliabilityWorkflow Design
A practical step-by-step guide for .NET developers: create a Microsoft Agent Framework agent, connect Azure OpenAI, expose C# tools, add session memory, stream responses, and prepare it for production.
Microsoft has finally given .NET teams a dedicated Agent Framework.
That does not mean agents were impossible before. We could already build them with raw LLM calls, Semantic Kernel, custom orchestration, function calling, queues, and our own tool wrappers.
The important change is the shape.
Microsoft Agent Framework gives this work a cleaner structure: agents, tools, sessions, streaming, approvals, workflows, hosting, and provider support, while still keeping application logic in normal C#.
As an architect, that is the part I care about most. A real enterprise agent is not just a prompt sitting beside an API. It has to work inside the same boundaries as the rest of the system: authentication, authorization, data ownership, audit, observability, deployment, rollback, and support.
This guide builds a complete Ops Triage Agent. By the end, you will have a .NET 10 console application that can:
- connect to Azure OpenAI,
- create an agent with Microsoft Agent Framework,
- expose narrow C# methods as tools,
- inspect mock incident and deployment data,
- search a mock operational runbook,
- estimate operational impact,
- preserve context across turns,
- stream responses to the console,
- persist and restore a session,
- and provide a clear path toward ASP.NET Core hosting.
The example is intentionally production-shaped, but small enough to understand in one sitting. The first version is read-only because that is the safest place to start.
Version note: This guide was reviewed on June 25, 2026. It pinsMicrosoft.Agents.AI.OpenAI1.11.0and the compatible Azure OpenAI preview package currently used by the Agent Framework repository,Azure.AI.OpenAI2.9.0-beta.1. Recheck package versions and release notes before using the code in a later release cycle.
Client choice: Microsoft recommends the Responses client for scenarios that need hosted tools such as code interpreter, file search, hosted MCP, or hosted web search. This tutorial intentionally uses the Chat Completion client because it needs only local function tools and benefits from broad model compatibility.
Context and operating problem
A simple chatbot answers a question.
A production-shaped agent has a larger responsibility. It can reason over a request, decide whether it needs a tool, call that tool, observe the result, and continue until it can produce a useful answer.
That difference matters in real software systems.
For example, a production-support assistant may need to:
- look up an incident,
- inspect recent deployments,
- search a runbook,
- estimate impact,
- suggest safe first actions,
- and write a clear update for the engineering team.
The LLM should not own production data.
The LLM should not directly access every internal system.
The safer architecture is this:
Let the model reason, but let your C# code own the facts, rules, permissions, and side effects.
That is the core idea behind tool-using agents.
Common mistake: building agents as one large prompt
A common mistake is to write one large prompt and expect the model to behave like an application.
That approach usually breaks down when the use case becomes real.
A serious agent needs boundaries:
- What is the agent allowed to do?
- Which data can it access?
- Which tools are read-only?
- Which actions need approval?
- How is context stored?
- How are tool calls logged?
- How do we trace latency and errors?
- What happens when the model is uncertain?
In this guide, the agent will only receive narrow tools. It will not get direct database access. It will not make production changes. It will only use C# methods that we explicitly expose.
That is the practical starting point.
Microsoft Agent Framework mental model
A working .NET agent usually has these parts:
1. Model client: Azure OpenAI, OpenAI, Foundry, Ollama, or another provider. 2. Agent instructions: the role, behavior, boundaries, and output style. 3. Tools: C# functions the agent can call when it needs data or action. 4. Session state: context across multiple user turns. 5. Run mode: streaming or non-streaming execution. 6. Hosting layer: console app, ASP.NET Core API, A2A, AG-UI, or another interface. 7. Operational controls: logging, telemetry, safety checks, approvals, and monitoring.
In older Semantic Kernel implementations, you may have exposed tools with [KernelFunction] and plugin classes. In Microsoft Agent Framework, the simple path is to expose C# functions with AIFunctionFactory.Create(...). You can add Description attributes so the model has a better chance of choosing the right tool at the right time.
What we will build
We will build this structure:
OpsAgentDemo/
├── OpsAgentDemo.csproj
├── Program.cs
├── IncidentRecord.cs
├── IncidentData.cs
└── OpsAgentTools.cs
The agent will support prompts like:
Incident INC-1001 is causing intermittent timeout during work-order sync. Triage it and suggest the first safe actions.
The model can then call tools like:
GetIncidentSearchRunbookGetRecentDeploymentsEstimateImpact
The final answer should be grounded in tool output, not generated from a generic prompt alone.
Prerequisites
You need:
- The .NET 10 SDK.
- An Azure OpenAI resource.
- A deployed model that supports function calling.
- The Azure OpenAI endpoint.
- The deployment name configured in Azure.
- Azure CLI if you want to use Microsoft Entra authentication locally.
- Basic C# and command-line familiarity.
The Azure deployment name is not always the same as the base model name. Use the name shown for your deployment in Azure.
You can use Visual Studio, Visual Studio Code, Rider, or another editor.
This guide supports two local authentication modes:
1. Microsoft Entra identity through DefaultAzureCredential and az login. 2. API key through the AZURE_OPENAI_API_KEY user secret.
For Entra authentication, the signed-in identity must have permission to call the deployed model. For Azure OpenAI inference, Cognitive Services OpenAI User is the normal least-privilege role.
For production, use a specific production credential such as ManagedIdentityCredential or another explicitly configured credential. DefaultAzureCredential is convenient during development, but its credential chain is broader than most production applications need.
Step 1: Create the .NET 10 project
Create a new console app:
dotnet new console -n OpsAgentDemo -f net10.0
cd OpsAgentDemo
Check your SDK:
dotnet --version
You should see a .NET 10 SDK version.
Step 2: Install the required NuGet packages
Install the package versions used by this guide:
dotnet add package Azure.AI.OpenAI --version 2.9.0-beta.1
dotnet add package Azure.Identity --version 1.21.0
dotnet add package Microsoft.Agents.AI.OpenAI --version 1.11.0
dotnet add package Microsoft.Extensions.AI --version 10.6.0
dotnet add package OpenAI --version 2.10.0
dotnet add package System.ClientModel --version 1.13.0
dotnet add package Microsoft.Extensions.Configuration --version 10.0.9
dotnet add package Microsoft.Extensions.Configuration.UserSecrets --version 10.0.9
dotnet add package Microsoft.Extensions.Configuration.EnvironmentVariables --version 10.0.9
Why these packages?
Azure.AI.OpenAIprovidesAzureOpenAIClientand the Chat Completion client used by this sample.Azure.IdentityprovidesDefaultAzureCredentialand production credentials such asManagedIdentityCredential.Microsoft.Agents.AI.OpenAIadapts OpenAI and Azure OpenAI clients to Microsoft Agent Framework.Microsoft.Extensions.AIprovides shared AI abstractions andAIFunctionFactory.OpenAIprovides the underlyingOpenAI.Chat.ChatClienttype used by the Azure client.System.ClientModelprovidesApiKeyCredentialfor API-key authentication.- The configuration packages load user secrets and environment variables.
The Agent Framework package is stable at 1.11.0, while the Azure OpenAI SDK version used by the official Agent Framework repository at the time of review is 2.9.0-beta.1. This guide pins that known-compatible combination instead of using an unconstrained --prerelease switch. It also references OpenAI and System.ClientModel directly because the sample names types from those packages. Preview packages can change, so revalidate the package set whenever you update one of these dependencies.
Pinning versions makes a tutorial reproducible. In an application you maintain, update packages deliberately, read the release notes, and rerun build and behavior tests before deployment.
Step 3: Configure Azure OpenAI settings
If you do not already have a resource and deployment, create them first:
1. In the Azure portal, search for Azure OpenAI and create an Azure OpenAI resource. 2. Open Azure AI Foundry for that resource. 3. Deploy a chat model that supports function calling, such as gpt-4o. 4. In Foundry, open the deployed model and copy the endpoint and key.
For this sample, use the Azure OpenAI resource base endpoint:
https://<your-resource-name>.openai.azure.com/
Example:
https://my-ai-resource.openai.azure.com/
If the Foundry page shows a longer endpoint such as https://my-ai-resource.openai.azure.com/openai/v1, use only the base resource URL with AzureOpenAIClient: https://my-ai-resource.openai.azure.com/.
Initialize user secrets for the console project:
dotnet user-secrets init
Set the Azure OpenAI endpoint and your Azure deployment name:
dotnet user-secrets set AZURE_OPENAI_ENDPOINT "https://<your-resource-name>.openai.azure.com/"
dotnet user-secrets set AZURE_OPENAI_DEPLOYMENT_NAME "<your-deployment-name>"
Example:
dotnet user-secrets set AZURE_OPENAI_ENDPOINT "https://my-ai-resource.openai.azure.com/"
dotnet user-secrets set AZURE_OPENAI_DEPLOYMENT_NAME "ops-agent-model"
The second value is the deployment name, not a value copied blindly from the model catalogue.
Option A: Use Microsoft Entra identity locally
Sign in through Azure CLI:
az login
If several subscriptions are available, select the correct one:
az account set --subscription "<subscription-id-or-name>"
Make sure the selected identity has permission to call the Azure OpenAI resource.
Option B: Use an API key locally
Store the key as a user secret:
dotnet user-secrets set AZURE_OPENAI_API_KEY "<your-azure-openai-key>"
The application will use the API key when it is present. Otherwise, it will use DefaultAzureCredential.
Do not hardcode credentials in source code, commit them to Git, or include them in logs.
Step 4: Add the incident domain records
Create a file named IncidentRecord.cs:
public sealed record IncidentRecord(
string Id,
string Severity,
string Component,
string Workflow,
string Symptom,
string Status,
DateTimeOffset CreatedAtUtc);
public sealed record DeploymentRecord(
string Component,
string Version,
DateTimeOffset DeployedAtUtc,
string Summary);
public sealed record RunbookEntry(
string Area,
string Symptom,
string Guidance);
These records represent the kind of data a real enterprise system may store in a database or service.
For this demo, we will keep everything in memory.
Step 5: Add mock operational data
Create a file named IncidentData.cs:
public static class IncidentData
{
private static readonly IncidentRecord[] Incidents =
[
new(
Id: "INC-1001",
Severity: "High",
Component: "MES API",
Workflow: "Work-order sync",
Symptom: "Intermittent timeout while syncing work orders to ERP",
Status: "Open",
CreatedAtUtc: DateTimeOffset.UtcNow.AddMinutes(-42)),
new(
Id: "INC-1002",
Severity: "Medium",
Component: "Mobile Release Pipeline",
Workflow: "Android signing",
Symptom: "Signing step failed during release build",
Status: "Resolved",
CreatedAtUtc: DateTimeOffset.UtcNow.AddHours(-5)),
new(
Id: "INC-1003",
Severity: "Critical",
Component: "Payroll API",
Workflow: "Monthly payroll calculation",
Symptom: "Payroll calculation queue is not processing new requests",
Status: "Open",
CreatedAtUtc: DateTimeOffset.UtcNow.AddMinutes(-18))
];
private static readonly DeploymentRecord[] Deployments =
[
new(
Component: "MES API",
Version: "2026.06.24.3",
DeployedAtUtc: DateTimeOffset.UtcNow.AddHours(-2),
Summary: "Changed ERP sync retry policy and work-order payload mapping."),
new(
Component: "Mobile Release Pipeline",
Version: "2026.06.23.1",
DeployedAtUtc: DateTimeOffset.UtcNow.AddDays(-1),
Summary: "Updated signing task and build artifact naming convention."),
new(
Component: "Payroll API",
Version: "2026.06.24.1",
DeployedAtUtc: DateTimeOffset.UtcNow.AddHours(-4),
Summary: "Updated background queue worker configuration.")
];
private static readonly RunbookEntry[] Runbooks =
[
new(
Area: "MES API",
Symptom: "timeout",
Guidance: "Check recent deployments, ERP latency, database connection pool saturation, retry storms, and queue backlog. Start with read-only diagnostics. Avoid restarting services until impact and rollback options are clear."),
new(
Area: "Mobile Release Pipeline",
Symptom: "signing",
Guidance: "Validate signing certificate expiry, keystore path, CI secret availability, build agent permissions, and recent pipeline variable changes."),
new(
Area: "Payroll API",
Symptom: "queue",
Guidance: "Check worker health, queue depth, poison messages, lock duration, database blocking, and recent configuration changes. Prepare a manual-processing fallback if the payroll deadline is at risk."),
new(
Area: "Generic",
Symptom: "unknown",
Guidance: "Use the generic incident checklist: confirm scope, inspect recent changes, check logs and metrics, identify rollback options, assign an owner, communicate status, and document the next update time.")
];
public static IncidentRecord? FindIncident(string? incidentId)
{
if (string.IsNullOrWhiteSpace(incidentId))
{
return null;
}
string normalizedId = incidentId.Trim();
return Incidents.FirstOrDefault(i =>
string.Equals(i.Id, normalizedId, StringComparison.OrdinalIgnoreCase));
}
public static IReadOnlyList<DeploymentRecord> FindRecentDeployments(string? component)
{
if (string.IsNullOrWhiteSpace(component))
{
return [];
}
string normalizedComponent = component.Trim();
return Deployments
.Where(d => Matches(d.Component, normalizedComponent))
.OrderByDescending(d => d.DeployedAtUtc)
.ToArray();
}
public static IReadOnlyList<RunbookEntry> SearchRunbooks(
string? area,
string? symptom)
{
string normalizedArea = area?.Trim() ?? string.Empty;
string normalizedSymptom = symptom?.Trim() ?? string.Empty;
var scoredMatches = Runbooks
.Where(r => !string.Equals(
r.Area,
"Generic",
StringComparison.OrdinalIgnoreCase))
.Select(r => new
{
Entry = r,
Score = (Matches(r.Area, normalizedArea) ? 2 : 0)
+ (Matches(r.Symptom, normalizedSymptom) ? 1 : 0)
})
.Where(x => x.Score > 0)
.ToArray();
if (scoredMatches.Length == 0)
{
return Runbooks
.Where(r => string.Equals(
r.Area,
"Generic",
StringComparison.OrdinalIgnoreCase))
.ToArray();
}
int bestScore = scoredMatches.Max(x => x.Score);
return scoredMatches
.Where(x => x.Score == bestScore)
.Select(x => x.Entry)
.ToArray();
}
private static bool Matches(string candidate, string query)
{
return !string.IsNullOrWhiteSpace(query)
&& (candidate.Contains(query, StringComparison.OrdinalIgnoreCase)
|| query.Contains(candidate, StringComparison.OrdinalIgnoreCase));
}
}
The null and empty-value checks are important. Tool arguments are model-generated input and should be treated as untrusted input, even when the generated schema marks a parameter as required.
The runbook lookup scores matches instead of returning every entry that matches either field. This avoids returning an unrelated runbook simply because one broad term happened to match.
In a real system, this class would likely call:
- an incident-management API,
- an application database,
- Azure Monitor or Application Insights,
- a search index,
- a release-history service,
- or an internal runbook repository.
The agent does not need direct access to those systems. It only needs controlled tools that return approved data.
Step 6: Expose C# methods as agent tools
Create a file named OpsAgentTools.cs:
using System.ComponentModel;
using System.Text.Json;
public static class OpsAgentTools
{
private static readonly JsonSerializerOptions JsonOptions =
new(JsonSerializerDefaults.Web)
{
WriteIndented = false
};
[Description("Get incident details by incident id. Use this before making assumptions about an incident.")]
public static string GetIncident(
[Description("The incident id, for example INC-1001.")] string? incidentId)
{
if (string.IsNullOrWhiteSpace(incidentId))
{
return "No incident id was provided. Ask the user for a valid incident id.";
}
string normalizedId = incidentId.Trim();
IncidentRecord? incident = IncidentData.FindIncident(normalizedId);
return incident is null
? $"No incident was found for id '{normalizedId}'. Ask the user to verify the incident id."
: JsonSerializer.Serialize(incident, JsonOptions);
}
[Description("Search the production-support runbook by system area and symptom.")]
public static string SearchRunbook(
[Description("The affected system or component, for example MES API or Payroll API.")]
string? area = null,
[Description("The observed symptom, for example timeout, queue, signing, latency, or unknown.")]
string? symptom = null)
{
if (string.IsNullOrWhiteSpace(area) && string.IsNullOrWhiteSpace(symptom))
{
return "No system area or symptom was provided. Ask for at least one before selecting runbook guidance.";
}
IReadOnlyList<RunbookEntry> results =
IncidentData.SearchRunbooks(area, symptom);
return JsonSerializer.Serialize(results, JsonOptions);
}
[Description("Get recent deployments for an affected component. Use this when investigating whether a recent change may be related to an incident.")]
public static string GetRecentDeployments(
[Description("The affected component, for example MES API, Payroll API, or Mobile Release Pipeline.")]
string? component)
{
if (string.IsNullOrWhiteSpace(component))
{
return "No component was provided. Ask the user or inspect the incident first.";
}
string normalizedComponent = component.Trim();
IReadOnlyList<DeploymentRecord> deployments =
IncidentData.FindRecentDeployments(normalizedComponent);
return deployments.Count == 0
? $"No recent deployments were found for component '{normalizedComponent}'."
: JsonSerializer.Serialize(deployments, JsonOptions);
}
[Description("Estimate operational impact from severity and the affected workflow.")]
public static string EstimateImpact(
[Description("Severity such as Low, Medium, High, or Critical.")] string? severity,
[Description("The affected business or technical workflow.")] string? workflow)
{
string normalizedSeverity =
severity?.Trim().ToLowerInvariant() ?? string.Empty;
string normalizedWorkflow = string.IsNullOrWhiteSpace(workflow)
? "the affected workflow"
: workflow.Trim();
return normalizedSeverity switch
{
"critical" => $"Critical impact is likely. {normalizedWorkflow} may be blocked or at high business risk. Escalate immediately, assign an incident owner, prepare mitigation, and communicate frequently.",
"high" => $"High impact is possible. Investigate {normalizedWorkflow} with priority. Confirm affected users, check recent changes, and prepare rollback or mitigation options.",
"medium" => $"Moderate impact. Triage and monitor {normalizedWorkflow}, assign an owner, and watch for an increase in reports or business impact.",
"low" => $"Low impact based on the supplied severity. Continue monitoring {normalizedWorkflow}, collect evidence, and avoid unnecessary changes.",
_ => "Impact is unclear. Collect severity, affected users, workflow, error rate, and business deadline before deciding the escalation level."
};
}
}
The tools follow the rules I would expect in a real application:
- They are read-only.
- Their names describe a single responsibility.
- Parameters include descriptions.
- Inputs are validated and normalized.
- Missing data is returned explicitly instead of being invented.
- Structured records are serialized as JSON.
- Business rules remain in C#.
This is safer than giving the model direct database, shell, or administrative access.
Step 7: Create the agent and run the loop
Replace Program.cs with this code:
using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Configuration;
using OpenAI.Chat;
using System.ClientModel;
IConfigurationRoot config = new ConfigurationBuilder()
.AddUserSecrets<Program>(optional: true)
.AddEnvironmentVariables()
.Build();
string endpoint = config["AZURE_OPENAI_ENDPOINT"]
?? throw new InvalidOperationException(
"Missing AZURE_OPENAI_ENDPOINT. Set it with user secrets or an environment variable.");
string deploymentName = config["AZURE_OPENAI_DEPLOYMENT_NAME"]
?? throw new InvalidOperationException(
"Missing AZURE_OPENAI_DEPLOYMENT_NAME. Set it with user secrets or an environment variable.");
if (!Uri.TryCreate(endpoint, UriKind.Absolute, out Uri? endpointUri))
{
throw new InvalidOperationException(
"AZURE_OPENAI_ENDPOINT is not a valid absolute URI.");
}
string? apiKey = config["AZURE_OPENAI_API_KEY"];
AzureOpenAIClient azureClient = string.IsNullOrWhiteSpace(apiKey)
? new AzureOpenAIClient(endpointUri, new DefaultAzureCredential())
: new AzureOpenAIClient(endpointUri, new ApiKeyCredential(apiKey));
var chatClient = azureClient.GetChatClient(deploymentName);
AIAgent agent = chatClient.AsAIAgent(
name: "OpsTriageAgent",
instructions: """
You are OpsTriageAgent, a careful senior production-support assistant.
Your job is to help engineers triage incidents safely.
You have tools for incident details, runbook guidance, recent deployments,
and impact estimation.
Rules:
- Use tools before making factual claims about an incident.
- Never invent incident details, deployments, metrics, or actions.
- Never claim that you restarted, rolled back, deployed, approved, or changed anything.
- Prefer read-only diagnosis first.
- Distinguish evidence from hypotheses.
- Call out missing evidence clearly.
- Recommend safe, ordered next actions.
- Keep the response practical and concise.
- For an incident plan, include: summary, evidence checked, likely area,
risk, first safe actions, missing evidence, and a proposed next update.
""",
tools:
[
AIFunctionFactory.Create(OpsAgentTools.GetIncident),
AIFunctionFactory.Create(OpsAgentTools.SearchRunbook),
AIFunctionFactory.Create(OpsAgentTools.GetRecentDeployments),
AIFunctionFactory.Create(OpsAgentTools.EstimateImpact)
]);
AgentSession session = await agent.CreateSessionAsync();
Console.WriteLine("OpsTriageAgent is ready.");
Console.WriteLine("Try: Incident INC-1001 is causing timeout during work-order sync. Triage it.");
Console.WriteLine("Type 'exit' to quit.");
Console.WriteLine();
while (true)
{
Console.ForegroundColor = ConsoleColor.Green;
Console.Write("You: ");
Console.ResetColor();
string? input = Console.ReadLine();
if (string.IsNullOrWhiteSpace(input))
{
continue;
}
if (input.Equals("exit", StringComparison.OrdinalIgnoreCase))
{
break;
}
Console.ForegroundColor = ConsoleColor.Cyan;
Console.Write("Agent: ");
Console.ResetColor();
try
{
await foreach (AgentResponseUpdate update in
agent.RunStreamingAsync(input, session))
{
Console.Write(update.Text);
}
}
catch (Exception ex)
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine($"Agent run failed: {ex.Message}");
Console.ResetColor();
}
Console.WriteLine();
Console.WriteLine();
}
Two implementation details matter here.
First, current AzureOpenAIClient API-key authentication uses System.ClientModel.ApiKeyCredential, not AzureKeyCredential.
Second, streaming updates can contain content other than display text. update.Text prints only the text portion intended for the console.
The execution flow is:
user request -> model decides whether a tool is needed -> C# tool executes
-> tool result returns to the model -> model continues -> final response
The model does not directly access the mock data. It can only call the functions you registered.
Step 8: Build and run the agent
Restore and build before starting the interactive loop:
dotnet restore
dotnet build --no-restore
dotnet run --no-build
Try this prompt:
Incident INC-1001 is causing intermittent timeout during work-order sync. Triage it and suggest the first safe actions.
The exact wording will vary by model, but the facts should remain grounded. A useful response should identify that:
INC-1001exists and is open.- Its severity is high.
- The affected component is MES API.
- The workflow is work-order sync.
- A recent deployment changed the ERP retry policy and payload mapping.
- The first steps should be read-only diagnostics.
- Restart or rollback should not be presented as already performed.
Now test the negative path:
Triage incident INC-9999.
The agent should report that the incident was not found and ask for a valid identifier. It should not invent an incident.
Finally, test context across turns:
Remember that I am the incident coordinator for INC-1001.
Then ask:
Which incident am I coordinating, and what should I check first?
Because the same AgentSession is passed to every run, the agent can reuse the conversation context held by that session.
Do not treat one successful response as a production test. Run the same scenarios several times, inspect tool calls, test missing arguments, and verify that the agent stays inside its read-only boundary.
Step 9: Add a non-streaming run for service and job scenarios
Streaming is useful when a person is waiting for incremental output. A service, scheduled job, or workflow step often needs one complete response.
Use RunAsync for that case:
AgentResponse response = await agent.RunAsync(
"Create a concise incident update for INC-1001.",
session);
Console.WriteLine(response.Text);
RunAsync is non-streaming. It does not automatically make the work a background job. Your hosting application still owns scheduling, cancellation, retries, idempotency, and execution lifetime.
Step 10: Improve the prompt contract
Agent instructions matter.
A weak instruction says:
You are a helpful assistant.
A better instruction says:
You are a careful production-support assistant.
Use tools before making factual claims.
Do not invent incident data.
Prefer read-only diagnosis first.
Return summary, evidence, risk, actions, and next update.
The more operational the use case, the more explicit the boundaries should be.
For production systems, include rules such as:
- Never expose secrets.
- Never perform destructive actions without approval.
- Ask for missing identifiers.
- Prefer low-risk diagnostic steps first.
- Explain uncertainty.
- Escalate when severity or business impact is high.
Prompts are not a security boundary, but they are still part of the behavior contract.
Step 11: Understand session context, persistence, and long-term knowledge
An AgentSession provides conversation continuity across runs that reuse the same session.
That context can include:
- the current incident,
- the user's role,
- previous clarifications,
- earlier tool results,
- and the current investigation direction.
A session is not automatically durable. If the process stops, serialize the session and store the complete payload.
Example:
using System.Text.Json;
JsonElement serializedSession = await agent.SerializeSessionAsync(session);
await File.WriteAllTextAsync(
"ops-session.json",
serializedSession.GetRawText());
Restore it later with the same agent and provider configuration:
string json = await File.ReadAllTextAsync("ops-session.json");
using JsonDocument document = JsonDocument.Parse(json);
AgentSession restoredSession = await agent.DeserializeSessionAsync(
document.RootElement.Clone());
Treat the serialized session as an opaque framework object. Do not persist only the visible message text and assume it is equivalent. Also do not restore a session into a different agent configuration without validating compatibility.
A persisted session is still not the same as long-term organizational knowledge.
For long-term knowledge, use a permission-aware retrieval layer such as:
- Azure AI Search,
- a document index,
- SQL Server full-text search,
- a vector store,
- Redis,
- or a custom knowledge service.
Expose that retrieval layer through a controlled tool:
[Description("Search internal engineering documentation for a topic.")]
public static string SearchEngineeringDocs(
[Description("The topic or question to search for.")] string? query)
{
if (string.IsNullOrWhiteSpace(query))
{
return "No search query was provided.";
}
// In production, call the approved retrieval service.
// Enforce document permissions before returning snippets.
return "Search results would be returned here.";
}
The retrieval system owns document access, filtering, ranking, freshness, and permissions. The agent receives only the result it is allowed to use.
Step 12: Require approval before write actions
The current agent is read-only. Keep it that way until authentication, authorization, validation, auditing, and recovery behavior are designed.
Microsoft Agent Framework supports human approval by wrapping an AIFunction in ApprovalRequiredAIFunction.
First, add this demonstration method inside the OpsAgentTools class. It returns a mock result and does not change a production system.
[Description("Create an incident note. This action changes incident data and requires human approval.")]
public static string CreateIncidentNote(
[Description("The incident id.")] string? incidentId,
[Description("The note to add.")] string? note)
{
if (string.IsNullOrWhiteSpace(incidentId)
|| string.IsNullOrWhiteSpace(note))
{
return "Incident id and note are required.";
}
return $"Demo only: note prepared for {incidentId.Trim()}. No production system was changed.";
}
Create and wrap the function before constructing the agent:
AIFunction createIncidentNote =
AIFunctionFactory.Create(OpsAgentTools.CreateIncidentNote);
AIFunction approvalRequiredCreateIncidentNote =
new ApprovalRequiredAIFunction(createIncidentNote);
Then add approvalRequiredCreateIncidentNote to the agent's tools collection.
When the model requests this tool, the framework returns one or more ToolApprovalRequestContent items instead of executing the tool immediately. The caller must display the proposed action, collect a decision, and continue with the same session.
The following helper handles repeated approval rounds and multiple approval requests in one response:
static async Task<AgentResponse> RunWithApprovalsAsync(
AIAgent agent,
AgentSession session,
string prompt)
{
AgentResponse response = await agent.RunAsync(prompt, session);
while (true)
{
List<ToolApprovalRequestContent> requests = response.Messages
.SelectMany(message => message.Contents)
.OfType<ToolApprovalRequestContent>()
.ToList();
if (requests.Count == 0)
{
return response;
}
List<AIContent> approvalResponses = [];
foreach (ToolApprovalRequestContent request in requests)
{
if (request.ToolCall is FunctionCallContent functionCall)
{
Console.WriteLine(
$"Approval required for: {functionCall.Name}");
if (functionCall.Arguments is { } arguments)
{
foreach (KeyValuePair<string, object?> argument in arguments)
{
Console.WriteLine(
$" {argument.Key}: {argument.Value}");
}
}
}
else
{
Console.WriteLine(
$"Approval required for tool call: {request.ToolCall.CallId}");
}
Console.Write("Approve this tool call? (y/n): ");
bool approved = string.Equals(
Console.ReadLine(),
"y",
StringComparison.OrdinalIgnoreCase);
approvalResponses.Add(
request.CreateResponse(
approved,
approved ? null : "Rejected by the user."));
}
Microsoft.Extensions.AI.ChatMessage approvalMessage = new(
ChatRole.User,
approvalResponses);
response = await agent.RunAsync(approvalMessage, session);
}
}
Use it like this:
AgentResponse response = await RunWithApprovalsAsync(
agent,
session,
"Add a note to INC-1001 saying ERP latency is being investigated.");
Console.WriteLine(response.Text);
In production, the approval screen should display the exact action and arguments. The server must recheck authorization when the approved tool executes.
Approval is an additional control. It does not replace:
- user authentication,
- server-side authorization,
- input validation,
- idempotency,
- audit logging,
- concurrency control,
- or rollback and recovery design.
Step 13: Move toward dependency injection
The console app is intentionally simple. In a real application, tool classes usually depend on services.
For example:
public interface IIncidentService
{
IncidentRecord? FindIncident(string incidentId);
IReadOnlyList<DeploymentRecord> FindRecentDeployments(string component);
}
Then the tool becomes an instance class instead of a static class:
using System.ComponentModel;
using System.Text.Json;
public sealed class IncidentTools
{
private readonly IIncidentService _incidents;
public IncidentTools(IIncidentService incidents)
{
_incidents = incidents;
}
[Description("Get incident details by incident id.")]
public string GetIncident(string? incidentId)
{
if (string.IsNullOrWhiteSpace(incidentId))
{
return "Incident id is required.";
}
IncidentRecord? incident =
_incidents.FindIncident(incidentId.Trim());
return incident is null
? $"No incident found for {incidentId}."
: JsonSerializer.Serialize(incident);
}
}
Add a service implementation. This demo adapter reuses the in-memory data from the earlier steps:
public sealed class IncidentService : IIncidentService
{
public IncidentRecord? FindIncident(string incidentId)
{
return IncidentData.FindIncident(incidentId);
}
public IReadOnlyList<DeploymentRecord> FindRecentDeployments(
string component)
{
return IncidentData.FindRecentDeployments(component);
}
}
Inside an ASP.NET Core project, register the model client, service, tool class, and agent through DI:
using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using OpenAI.Chat;
using System.ClientModel;
builder.Services.AddSingleton<IIncidentService, IncidentService>();
builder.Services.AddSingleton<IncidentTools>();
builder.Services.AddSingleton<ChatClient>(serviceProvider =>
{
IConfiguration config =
serviceProvider.GetRequiredService<IConfiguration>();
string endpoint = config["AZURE_OPENAI_ENDPOINT"]
?? throw new InvalidOperationException(
"Missing AZURE_OPENAI_ENDPOINT.");
string deploymentName = config["AZURE_OPENAI_DEPLOYMENT_NAME"]
?? throw new InvalidOperationException(
"Missing AZURE_OPENAI_DEPLOYMENT_NAME.");
if (!Uri.TryCreate(endpoint, UriKind.Absolute, out Uri? endpointUri))
{
throw new InvalidOperationException(
"AZURE_OPENAI_ENDPOINT is not a valid absolute URI.");
}
string? apiKey = config["AZURE_OPENAI_API_KEY"];
AzureOpenAIClient azureClient = string.IsNullOrWhiteSpace(apiKey)
? new AzureOpenAIClient(
endpointUri,
new DefaultAzureCredential())
: new AzureOpenAIClient(
endpointUri,
new ApiKeyCredential(apiKey));
return azureClient.GetChatClient(deploymentName);
});
builder.Services.AddSingleton<AIAgent>(serviceProvider =>
{
ChatClient chatClient =
serviceProvider.GetRequiredService<ChatClient>();
IncidentTools tools =
serviceProvider.GetRequiredService<IncidentTools>();
return chatClient.AsAIAgent(
name: "OpsTriageAgent",
instructions: "Use tools before making factual claims.",
tools: [AIFunctionFactory.Create(tools.GetIncident)]);
});
The design goal is simple:
- repositories own data access,
- services own business rules,
- tools expose narrow operations,
- the agent coordinates the conversation.
Do not put all business logic inside prompts.
Step 14: Expose the agent through ASP.NET Core
Once the console version works, you can expose a stateless version through a minimal API.
From the parent folder, create the project:
cd ..
dotnet new web -n OpsAgentApi -f net10.0
cd OpsAgentApi
Install the packages used by the API:
dotnet add package Azure.AI.OpenAI --version 2.9.0-beta.1
dotnet add package Azure.Identity --version 1.21.0
dotnet add package Microsoft.Agents.AI.OpenAI --version 1.11.0
dotnet add package Microsoft.Extensions.AI --version 10.6.0
dotnet add package OpenAI --version 2.10.0
dotnet add package System.ClientModel --version 1.13.0
For this tutorial, copy these files from the console project into the API project:
IncidentRecord.cs
IncidentData.cs
OpsAgentTools.cs
In a real solution, move the domain records, services, and tool classes into a shared class library instead of copying them.
Configure secrets for the new web project because it has its own user-secrets identifier:
dotnet user-secrets init
dotnet user-secrets set AZURE_OPENAI_ENDPOINT "https://<your-resource-name>.openai.azure.com/"
dotnet user-secrets set AZURE_OPENAI_DEPLOYMENT_NAME "<your-deployment-name>"
Add the API key only when you are not using Entra authentication:
dotnet user-secrets set AZURE_OPENAI_API_KEY "<your-azure-openai-key>"
Replace Program.cs with:
using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using OpenAI.Chat;
using System.ClientModel;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<AIAgent>(serviceProvider =>
{
IConfiguration config =
serviceProvider.GetRequiredService<IConfiguration>();
string endpoint = config["AZURE_OPENAI_ENDPOINT"]
?? throw new InvalidOperationException(
"Missing AZURE_OPENAI_ENDPOINT.");
string deploymentName = config["AZURE_OPENAI_DEPLOYMENT_NAME"]
?? throw new InvalidOperationException(
"Missing AZURE_OPENAI_DEPLOYMENT_NAME.");
if (!Uri.TryCreate(endpoint, UriKind.Absolute, out Uri? endpointUri))
{
throw new InvalidOperationException(
"AZURE_OPENAI_ENDPOINT is not a valid absolute URI.");
}
string? apiKey = config["AZURE_OPENAI_API_KEY"];
AzureOpenAIClient azureClient = string.IsNullOrWhiteSpace(apiKey)
? new AzureOpenAIClient(endpointUri, new DefaultAzureCredential())
: new AzureOpenAIClient(
endpointUri,
new ApiKeyCredential(apiKey));
return azureClient
.GetChatClient(deploymentName)
.AsAIAgent(
name: "OpsTriageAgent",
instructions: """
You are a careful production-support assistant.
Use tools before making factual claims.
Prefer read-only diagnosis.
Distinguish evidence from hypotheses.
Do not invent incident data or claim that an action was performed.
""",
tools:
[
AIFunctionFactory.Create(OpsAgentTools.GetIncident),
AIFunctionFactory.Create(OpsAgentTools.SearchRunbook),
AIFunctionFactory.Create(OpsAgentTools.GetRecentDeployments),
AIFunctionFactory.Create(OpsAgentTools.EstimateImpact)
]);
});
var app = builder.Build();
app.MapGet("/health", () =>
Results.Ok(new { status = "ok" }));
app.MapPost(
"/agent/chat",
async (
AgentChatRequest request,
AIAgent agent,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(request.Message))
{
return Results.BadRequest(
new { error = "Message is required." });
}
AgentResponse response = await agent.RunAsync(
request.Message,
cancellationToken: cancellationToken);
return Results.Ok(
new AgentChatResponse(response.Text));
});
app.Run();
public sealed record AgentChatRequest(string Message);
public sealed record AgentChatResponse(string Message);
Build and run it:
dotnet restore
dotnet build --no-restore
dotnet run --no-build
Use the URL printed by ASP.NET Core to test /health, then send a POST request to /agent/chat with this body:
{
"message": "Triage incident INC-1001 and suggest the first safe actions."
}
This API is intentionally stateless because it does not pass an AgentSession to RunAsync. Every request starts without prior conversation context.
For a real chat API, map each authenticated conversation to its own serialized AgentSession. Do not share one session across users. Do not use an unbounded static dictionary as production session storage. You need expiration, cleanup, concurrency control, encryption where required, and a storage strategy that works across multiple application instances.
Microsoft Agent Framework also provides dedicated hosting libraries for A2A, AG-UI, OpenAI-compatible endpoints, and durable execution. The custom endpoint above is useful for understanding the basics, but use a framework hosting adapter when you need one of those protocols or lifecycle models.
Step 15: When to use an agent and when to use a workflow
Not every AI feature should be an agent.
Use an agent when:
- the task is open-ended,
- the user may ask follow-up questions,
- the model needs to choose between tools,
- the path is not fully known in advance,
- and reasoning over evidence is useful.
Use a workflow when:
- the steps are known,
- order matters,
- auditability is critical,
- retries and checkpoints are required,
- or multiple systems must coordinate predictably.
For example:
Help me investigate this incident.
This is a good agent use case.
Generate release notes, validate version, request approval, publish to staging, and notify the team.
This is probably a workflow. An agent may be useful inside one or two steps, but the whole process should not be left to open-ended autonomy.
Step 16: Troubleshoot common issues
Package restore fails
Confirm the package source is reachable and inspect the versions in the project:
dotnet nuget list source
dotnet list package
dotnet restore
Do not replace every dependency with an unconstrained --prerelease switch. This guide pins a known-compatible package set. Microsoft.Agents.AI.OpenAI is stable, while Azure.AI.OpenAI is intentionally pinned to the specific preview version used by the current Agent Framework repository.
Also confirm the target framework:
<TargetFramework>net10.0</TargetFramework>
AzureKeyCredential does not compile
Current AzureOpenAIClient API-key authentication uses:
using System.ClientModel;
new ApiKeyCredential(apiKey)
It does not use AzureKeyCredential for this constructor.
AsAIAgent or AIFunctionFactory cannot be found
Confirm these package references and namespaces are present:
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using OpenAI.Chat;
Also verify that Microsoft.Agents.AI.OpenAI is installed.
Authentication fails with DefaultAzureCredential
Run:
az login
az account show
Then verify:
- the correct Azure account and subscription are selected,
- the identity has permission on the Azure OpenAI resource,
- the endpoint belongs to that resource,
- and local network or tenant restrictions are not blocking access.
For Azure OpenAI inference, the normal least-privilege role is Cognitive Services OpenAI User.
For local testing, the API-key path is also available:
dotnet user-secrets set AZURE_OPENAI_API_KEY "<your-key>"
The deployment cannot be found
Check that AZURE_OPENAI_DEPLOYMENT_NAME contains the Azure deployment name, not only the base model name.
Also verify that the endpoint and deployment belong to the same Azure OpenAI resource.
If you see HTTP 404 or Resource not found, also verify the endpoint shape. With AzureOpenAIClient, use the resource base endpoint:
https://<your-resource-name>.openai.azure.com/
Do not use a longer Foundry endpoint path such as:
https://<your-resource-name>.openai.azure.com/openai/v1
The agent does not call tools
Improve tool descriptions and make the request explicit.
Instead of:
Help with this issue.
Use:
Incident INC-1001 is causing timeout during work-order sync. Use the available tools to triage it.
Also confirm that the selected deployment supports function calling.
The response is too generic
Inspect the tool output. A model cannot produce a grounded investigation when the tools return vague or incomplete data.
Improve the data contract before making the prompt larger.
The model invents facts
Use explicit instructions:
Do not invent incident data. If a tool does not return evidence, state what is missing.
Also make the negative path explicit in each tool. Returning "not found" is safer than returning an empty or ambiguous string.
The app works locally but fails in production
Check:
- managed identity and role assignments,
- secret or environment-variable configuration,
- private endpoints, DNS, firewalls, and outbound network rules,
- the deployment name and endpoint,
- provider quotas and rate limits,
- request timeouts and cancellation,
- logging and redaction,
- and differences between local and production credentials.
Use a specific production credential such as ManagedIdentityCredential instead of relying on the full DefaultAzureCredential chain.
Production design checklist
Before moving an agent toward production, review this checklist.
Tool safety
- Are tools narrow and purpose-specific?
- Are write actions separated from read actions?
- Are tool inputs validated?
- Are dangerous actions blocked or approval-gated?
- Are tool outputs filtered to avoid exposing secrets?
Identity and permissions
- Is the user authenticated?
- Are tool calls authorized against the user's permissions?
- Does the agent run with least privilege?
- Are service credentials stored securely?
Observability
- Are prompts, tool calls, latency, and failures traceable?
- Are sensitive values redacted from logs?
- Are token usage and tool-call counts monitored?
- Can support engineers replay or inspect an incident safely?
Reliability
- Are timeouts configured?
- Are retries used only where safe?
- Are tool failures handled gracefully?
- Is there a fallback response when the model or provider is unavailable?
- Can the user escalate to a human?
Data and memory
- Is session state stored appropriately?
- Is long-term memory permission-aware?
- Are retrieval results grounded in trusted sources?
- Is stale knowledge handled?
Delivery
- Is the agent covered by tests?
- Are prompts versioned?
- Are tool contracts reviewed like APIs?
- Is there a deployment and rollback path?
- Are changes reviewed with security and operations teams?
A better architecture direction
The best .NET agent architecture does not make the model responsible for everything.
A better architecture looks like this:
User / UI / API
|
v
Agent instructions + session
|
v
Model reasoning
|
v
C# tools with narrow permissions
|
v
Application services / APIs / data stores
|
v
Audited response back to user
This keeps your existing engineering discipline intact.
The agent becomes an intelligent orchestration layer, not a replacement for your application architecture.
Where Semantic Kernel fits now
Many .NET developers learned AI orchestration through Semantic Kernel, and those concepts remain useful: prompts, functions, plugins, memory, connectors, filters, and telemetry.
Microsoft Agent Framework provides a direct agent and workflow abstraction. For a simple function-tool agent, registration can be concise:
AIAgent agent = chatClient.AsAIAgent(
instructions: "You are a helpful assistant.",
tools: [AIFunctionFactory.Create(OpsAgentTools.GetIncident)]);
That does not mean every Semantic Kernel application needs an immediate rewrite. Migration should be driven by capability, maintenance, and product needs. Existing business services and tool contracts can often remain in place while the orchestration layer changes.
Final thought
The most important part of building agents is not the model call.
The important part is the boundary.
What can the agent see?
What can it call?
What can it change?
What must it explain?
What must it refuse?
For .NET teams, Microsoft Agent Framework is valuable because it lets us answer those questions using familiar engineering patterns: C#, strong typing, async/await, dependency injection, middleware, telemetry, and controlled hosting.
Start small.
Create one read-only agent.
Expose two or three safe tools.
Add session context.
Observe behavior.
Then decide whether the next step is retrieval, API hosting, workflow orchestration, or human approval.
That path is much safer than starting with a fully autonomous system and trying to add engineering discipline later.
Complete command summary
# Create the console project
dotnet new console -n OpsAgentDemo -f net10.0
cd OpsAgentDemo
# Add the reviewed package versions
dotnet add package Azure.AI.OpenAI --version 2.9.0-beta.1
dotnet add package Azure.Identity --version 1.21.0
dotnet add package Microsoft.Agents.AI.OpenAI --version 1.11.0
dotnet add package Microsoft.Extensions.AI --version 10.6.0
dotnet add package OpenAI --version 2.10.0
dotnet add package System.ClientModel --version 1.13.0
dotnet add package Microsoft.Extensions.Configuration --version 10.0.9
dotnet add package Microsoft.Extensions.Configuration.UserSecrets --version 10.0.9
dotnet add package Microsoft.Extensions.Configuration.EnvironmentVariables --version 10.0.9
# Configure the console project's secrets
dotnet user-secrets init
dotnet user-secrets set AZURE_OPENAI_ENDPOINT "https://<your-resource-name>.openai.azure.com/"
dotnet user-secrets set AZURE_OPENAI_DEPLOYMENT_NAME "<your-deployment-name>"
# Optional local API-key authentication
dotnet user-secrets set AZURE_OPENAI_API_KEY "<your-azure-openai-key>"
# Or use Microsoft Entra authentication
az login
az account set --subscription "<subscription-id-or-name>"
# Restore, build, and run
dotnet restore
dotnet build --no-restore
dotnet run --no-build
References
- Microsoft Agent Framework overview
- Microsoft Agent Framework GitHub repository
- Azure OpenAI agents with Microsoft Agent Framework
- Function tools with Microsoft Agent Framework
- Human approval for function tools
- Running agents: streaming and non-streaming
- Agent sessions
- Session storage and restoration
- Hosting agents
- Semantic Kernel migration guide
- AzureOpenAIClient constructors
- Azure OpenAI built-in roles
- Microsoft.Agents.AI.OpenAI 1.11.0 on NuGet
- Azure.AI.OpenAI 2.9.0-beta.1 on NuGet
- OpenAI 2.10.0 on NuGet
- System.ClientModel 1.13.0 on NuGet
- .NET 10 download page