One pass usually looks like this:
- The application receives a task and decides whether it goes to a single agent or into a workflow.
- The framework creates a session and the related conversation or run state.
- The agent calls the model provider and, if necessary, invokes tools or MCP.
- If the task is represented as a workflow, the data continues through the graph via executors and edges.
- Middleware can intercept the processing flow, while observability records telemetry and tracing.
- If the scenario is long, checkpointing, persistence, or human-in-the-loop are involved.
- The system returns not just text, but a result together with execution context, state, and observability trace.
Agent Framework manages the full lifecycle of an agent or workflow scenario.
Example of a simple agentLet’s look at what the simplest agent looks like in Agent Framework.
First, add the required packages:
dotnet add package Azure.AI.Projects --prerelease dotnet add package Azure.Identity dotnet add package Microsoft.Agents.AI.Foundry --prerelease Now let’s create a minimal agent. In this example, the agent gets the role of a DevOps assistant, connects to a model through Azure AI Project, and responds to the user’s task.
using System;using Azure.AI.Projects;using Azure.Identity;using Microsoft.Agents.AI;var endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");var deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "<model name>";// Create a client for Azure AI Project.// AzureCliCredential is convenient for local development if you are already logged in via Azure CLI.var projectClient = new AIProjectClient( new Uri(endpoint), new AzureCliCredential());// Convert the Azure AI Project client into an AI agent.// The agent gets a model, a name, and instructions that define its role.AIAgent agent = projectClient.AsAIAgent( model: deploymentName, name: "DevOpsAssistant", instructions: """ You are a DevOps assistant. You analyze deployment and release issues. Keep answers concrete. Do not invent facts if the context is missing. """);// Run the agent with a user task.// RunAsync is the simplest way to send a task and get the final response.var result = await agent.RunAsync( "Find out why the release pipeline started failing after the container image was changed.");Console.WriteLine(result);What matters here:
- Agent is an application object with a defined role.
- Provider / client is the layer for connecting to a specific AI backend. In the example, AIProjectClient is used.
- Credential layer is the authentication mechanism. Here, AzureCliCredential is used, which is convenient for local development.
- Instructions are the role contract. They explain how the agent should behave, what style it should use, and what it should not do.
- RunAsync(...) is the shortest path from task to answer. You pass a task to the agent, and it returns a result.
Such an agent is suitable for simple scenarios: ask a question, analyze a problem, get a draft analysis, or ask the model to use connected tools.
For example, in a DevOps scenario, such an agent can be used for initial analysis:
var answer = await agent.RunAsync("""The deployment failed after we changed the base image from ubuntu:22.04 to alpine.What should we check first?""");Console.WriteLine(answer);Here, the agent is not yet managing a complex process. It simply acts as one role that helps the engineer understand the situation.
If you need to receive the answer gradually instead of waiting for the final result all at once, you can use streaming mode through
RunStreamingAsync(...). This is convenient for UIs, chats, and long answers.
Minimal workflow exampleNow let’s look at a workflow.
If an agent is one role, then a workflow is already an execution graph. In it, we explicitly describe the steps, execution order, and data transfer between them.
The example below is intentionally simple. Its goal is to show the mechanics: there is a first step, there is a second step, and there is a connection between them.
using System;using System.Threading;using Microsoft.Agents.AI.Workflows;// This function represents the first workflow step.// It normalizes the input before passing it to the next step.Func<string, string> normalize = text => text.Trim();// Bind the function as a workflow executor.// Executor is a workflow node that can receive input and produce output.var normalizeExecutor = normalize.BindAsExecutor("NormalizeExecutor");// This executor represents the second workflow step.// It checks whether the deployment plan contains a rollback strategy.class RiskTagExecutor() : Executor<string, string>("RiskTagExecutor"){ public override ValueTask<string> HandleAsync( string message, IWorkflowContext context, CancellationToken cancellationToken = default) { // This is intentionally simple demo logic. // In a real system, this could call an agent, a policy engine, or an external API. var output = message.Contains("rollback", StringComparison.OrdinalIgnoreCase) ? $"READY: {message}" : $"CHECK_MANUALLY: {message}"; return ValueTask.FromResult(output); }}RiskTagExecutor riskTag = new();// Create a workflow that starts with NormalizeExecutor.WorkflowBuilder builder = new(normalizeExecutor);// Add an edge from the first executor to the second executor.// This means the output of NormalizeExecutor becomes input for RiskTagExecutor.builder.AddEdge(normalizeExecutor, riskTag).WithOutputFrom(riskTag);// Build the workflow graph.var workflow = builder.Build();// Run the workflow in the current process.// The input goes to the first executor and then flows through the graph.await using Run run = await InProcessExecution.RunAsync( workflow, "Deploy plan includes rollback and smoke tests.");// Read workflow events.// ExecutorCompletedEvent tells us that a workflow node has finished execution.foreach (WorkflowEvent evt in run.NewEvents){ if (evt is ExecutorCompletedEvent completed) { Console.WriteLine($"{completed.ExecutorId}: {completed.Data}"); }}This workflow has two steps.
The first step is
NormalizeExecutor. It normalizes the input text.
The second step is
RiskTagExecutor. It checks whether the plan contains the word
rollback. If
rollback exists, the result is marked as
READY. If
rollback is not found, the result is marked as
CHECK_MANUALLY.
So the process looks like this:
Input -> NormalizeExecutor -> RiskTagExecutor -> Output
This simple example clearly shows the main difference between a workflow and an agent.
To an agent, we would say:
“Look at the deployment plan and decide whether everything is okay.”
But in a workflow, we explicitly define the process:
- first normalize the input data;
- then check for the presence of a rollback strategy;
- then return the result;
- if necessary, later add review, approval, or the next step.
Now let’s make the scheme more complex and look at additional framework components.
1. Tools: the agent starts not only answering, but actingWithout tools, an agent can only reason based on the text it has been given. With tools, it can perform actions: call functions, access APIs, retrieve pipeline status, check a deployment, or read external data.
For example, let’s add a simple DevOps tool to the agent that returns release status.
using System;using System.ComponentModel;using Azure.AI.Projects;using Azure.Identity;using Microsoft.Agents.AI;using Microsoft.Extensions.AI;var endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");var deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini";[Description("Get the release status by release id.")]static string GetReleaseStatus( [Description("Release id, for example release-1042.")] string releaseId){ // Demo implementation. In production, call Azure DevOps, GitHub Actions, or another release system. return releaseId switch { "release-1042" => "Failed: container image pull error. Registry returned unauthorized.", "release-1043" => "Succeeded.", _ => "Release was not found." };}AIAgent agent = new AIProjectClient( new Uri(endpoint), new AzureCliCredential()) .AsAIAgent( model: deploymentName, name: "DevOpsAssistant", instructions: """ You are a DevOps assistant. Use available tools when you need factual release information. Keep answers concrete and do not invent facts. """, tools: [AIFunctionFactory.Create(GetReleaseStatus)]);Console.WriteLine(await agent.RunAsync("""Analyze release-1042.Find out why it failed and suggest the first thing to check."""));Here, the important architectural change is that the agent is no longer limited to the user’s text. It can call GetReleaseStatus, get the factual result, and then produce an answer based on it.
Previously, it looked like this:
User prompt -> Agent -> Model -> Text answer Now it looks like this:
User prompt -> Agent -> Model decides to call a tool -> GetReleaseStatus(...) -> Tool result -> Model -> Grounded answer For DevOps scenarios, this is a fundamental difference. The agent can now work not only as an “advisor”, but also as a participant in the diagnostic process.
2. Sessions and state: the agent stops starting every request from scratchThe next problem of a simple agent is the lack of stable context between calls.
If you call
RunAsync(...) without a session every time, each run is a separate operation. But troubleshooting almost always happens in multiple steps: first the user describes the problem, then brings a log, then clarifies the error, then asks to check a hypothesis.
For this, Agent Framework has
AgentSession.
AgentSession session = await agent.CreateSessionAsync();Console.WriteLine(await agent.RunAsync("""Release release-1042 failed after we changed the container image.""", session));Console.WriteLine(await agent.RunAsync("""The error says: unauthorized when pulling image from the registry.What should I check next?""", session));The second call receives the same session. That means the agent continues the same conversation and can take the previous context into account. According to the documentation,
AgentSession is a container for conversation state; it can contain history, memory, or references to external storage, and
RunAsync(...) updates the session with input and output messages.
In practice, this turns the agent from a one-off model call into a multi-turn assistant:
Run 1:User: Release failed after image change.
Agent: Check registry access, image tag, service connection.
Run 2 with same session:
User: Error is unauthorized when pulling image.
Agent: Since we are already investigating image pull failure, check registry credentials...
Without a session, the second answer would be less precise because the agent would have to reconstruct the context again.
3. Memory and context providers: the agent receives external contextSession solves the problem of conversation history. But often the agent needs not only the dialogue, but additional facts: a runbook, release rules, ownership, known issues, architecture notes.
In Agent Framework, this is handled by context providers. They are connected to the agent through options and can add additional instructions, messages, or tools to the request before the model call. In the documentation, this is described through
AIContextProvider; it can store session-specific state inside
AgentSession, rather than in the provider instance itself.
A simplified context provider connection looks like this:
using Microsoft.Agents.AI;AIAgent agentWithContext = new AIProjectClient( new Uri(endpoint), new AzureCliCredential()) .AsAIAgent( model: deploymentName, options: new ChatClientAgentOptions() { ChatOptions = new() { Instructions = """ You are a DevOps assistant. Use provided operational context when analyzing deployment issues. """ }, AIContextProviders = [ new DeploymentRunbookContextProvider() ] });The provider itself can be a separate component that adds the required operational context before the agent run.
using System.Threading;using Microsoft.Agents.AI;using Microsoft.Extensions.AI;internal sealed class DeploymentRunbookContextProvider : AIContextProvider{ public DeploymentRunbookContextProvider() : base(null, null) { } protected override ValueTask<AIContext> ProvideAIContextAsync( InvokingContext context, CancellationToken cancellationToken = default) { // In production, load this from Git, wiki, vector search, or an internal API. var runbook = """ Deployment policy: - Production releases require a rollback plan. - Smoke tests must run after deployment. - Registry access errors should be checked against service connection permissions. - Database migrations require manual approval. """; return new ValueTask<AIContext>(new AIContext { Messages = [ new ChatMessage( ChatRole.User, $"Operational context for this investigation:\n{runbook}") ] }); }}Now the agent receives not only the user’s question, but also additional engineering context. This greatly reduces the risk of generic advice and makes the answers closer to the rules of a specific system.
4. Middleware: control appears around the agentWhen an agent has tools and state, the next question arises: how do we control execution?
For example, we may need to:
- log all agent runs;
- count the number of input and output messages;
- block dangerous actions;
- intercept tool calls;
- measure latency;
- handle errors centrally.
In Agent Framework, middleware is added through a builder:
var middlewareEnabledAgent = agent .AsBuilder() .Use(runFunc: LoggingMiddleware, runStreamingFunc: null) .Build();The middleware for an agent run itself looks like this:
using System.Diagnostics;using System.Linq;using System.Threading;using System.Threading.Tasks;using Microsoft.Agents.AI;using Microsoft.Extensions.AI;async Task<AgentResponse> LoggingMiddleware( IEnumerable<ChatMessage> messages, AgentSession? session, AgentRunOptions? options, AIAgent innerAgent, CancellationToken cancellationToken){ // Log input message count before the agent runs. Console.WriteLine($"Input messages: {messages.Count()}"); var stopwatch = Stopwatch.StartNew(); try { // Continue agent execution. var response = await innerAgent .RunAsync(messages, session, options, cancellationToken) .ConfigureAwait(false); stopwatch.Stop(); // Log output message count after the agent has completed. Console.WriteLine($"Output messages: {response.Messages.Count}"); Console.WriteLine($"Agent duration: {stopwatch.ElapsedMilliseconds} ms"); return response; } catch (Exception ex) { stopwatch.Stop(); // Log errors in one central place. Console.WriteLine($"Agent failed after {stopwatch.ElapsedMilliseconds} ms: {ex.Message}"); throw; }}After that, we run not the original agent, but the agent with middleware:
Console.WriteLine(await middlewareEnabledAgent.RunAsync("""Analyze release-1042 and explain why the deployment failed."""));This is an important architectural transition. Logging, control, policies, and error handling are no longer spread across prompts and business logic. They become a separate layer around the agent.
5. Observability: agent work can be analyzed after executionThere is another useful level: middleware not for the whole agent run, but for specific function calls.
For example, you can log every tool the agent calls:
using System;using System.Threading;using System.Threading.Tasks;using Microsoft.Agents.AI;async ValueTask<object?> FunctionCallingMiddleware( AIAgent agent, FunctionInvocationContext context, Func<FunctionInvocationContext, CancellationToken, ValueTask<object?>> next, CancellationToken cancellationToken){ // Log the function name before execution. Console.WriteLine($"Function call: {context.Function.Name}"); var result = await next(context, cancellationToken); // Log the function result after execution. Console.WriteLine($"Function result: {result}"); return result;}It is connected like this:
var agentWithFunctionMiddleware = agent .AsBuilder() .Use(FunctionCallingMiddleware) .Build();Console.WriteLine(await agentWithFunctionMiddleware.RunAsync("""Check release-1042 and explain the failure."""));This is especially useful when tools call external systems: Azure DevOps, GitHub, Kubernetes, ServiceNow, internal APIs. You can see which tool was called, what result it returned, and you can add a security policy or approval before dangerous actions. Microsoft separately describes function calling middleware as a mechanism for intercepting function calls; to continue execution, the middleware must call the provided next.
How the architecture changesA minimal agent:
User -> Agent -> Model -> Answer An agent with tools, session, and middleware:
User -> Agent run middleware -> AgentSession -> Context providers -> Agent -> Model -> Function calling middleware -> Tools -> Tool result -> Model -> Answer And this is where Agent Framework becomes interesting not as “yet another wrapper over an LLM,” but as an engineering framework.
Tools give the agent the ability to act.
Session allows work to continue over multiple steps.
Context providers add the required facts from external sources.
Middleware provides control, logging, security, and error handling.
Observability then allows you to analyze all this work like a normal production system: which calls were made, where the error occurred, which tool ran, and how long each stage took.
As a result, the agent stops being just a prompt with a name. It becomes a managed application component around which real DevOps, support, release management, and automation scenarios can be built.
How to gradually build a multi-agent system on Microsoft Agent Framework