Tuesday, May 19, 2026

Microsoft Agent Framework: Agents on Azure Functions with .NET

In this post, let's have a look at hosting an Agent on Azure Functions using the Microsoft Agent Framework. In a previous post, I have have written about getting started with Microsoft Agent Framework in .NET. If you haven't read the previous post, I would recommend doing so.

In the previous post, we have been running our agent as a console application. That's great for learning, but it isn't how we would actually run agents in production. Agents typically need an HTTP endpoint to be invoked from a client, and they need to persist conversation state across requests so a user can have a multi-turn conversation.

The Microsoft Agent Framework ships with a hosting package for Azure Functions (Microsoft.Agents.AI.Hosting.AzureFunctions) that gives us both of these out of the box. It builds on top of Durable Functions, so each conversation thread is durably persisted, can survive process restarts, and can be resumed later. Let's see how it works.

Let's start creating an Azure Functions worker project targeting .NET 10 (isolated worker model). On top of the usual Functions packages, we need a couple of extras (versions are the latest as of today, they will change):
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <AzureFunctionsVersion>v4</AzureFunctionsVersion>
    <_FunctionsSkipCleanOutput>true</_FunctionsSkipCleanOutput>
    <RootNamespace>MafSamples.DurableFunctions</RootNamespace>
    <ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally>
  </PropertyGroup>

  <ItemGroup>
    <FrameworkReference Include="Microsoft.AspNetCore.App" />
    <PackageReference Include="Azure.AI.Projects" Version="2.1.0-beta.2" />
    <PackageReference Include="Azure.Identity" Version="1.21.0" />
    <PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.6.1-preview.260514.1" />
    <PackageReference Include="Microsoft.Agents.AI.Hosting.AzureFunctions" Version="1.6.1-preview.260514.1" />
    <PackageReference Include="Microsoft.Azure.Functions.Worker" Version="2.52.0" />
    <PackageReference Include="Microsoft.Azure.Functions.Worker.Sdk" Version="2.0.7" />
    <PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore" Version="2.1.0" />
  </ItemGroup>

  <ItemGroup>
    <None Update="host.json">
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
    </None>
    <None Update="local.settings.json">
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
      <CopyToPublishDirectory>Never</CopyToPublishDirectory>
    </None>
  </ItemGroup>

</Project> 
Microsoft.Agents.AI.Hosting.AzureFunctions does the heavy lifting and plugs the agent into the Functions host, exposes an HTTP endpoint per agent, and wires up Durable Functions backed state for each conversation thread.

local.settings.json

Durable Functions needs a storage backend, and for local development we will be using Azurite storage emulator for simplicity. We could also go with the Durable Task Scheduler (DTS) and get its monitoring features out of the box, but let's leave that for another post.
{
  "IsEncrypted": false,
  "Values": {
    "AzureWebJobsStorage": "UseDevelopmentStorage=true",
    "FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated",
    "AZURE_AI_ENDPOINT": "https://<your-foundry-resource>.services.ai.azure.com/",
    "AZURE_AI_MODEL": "<DEPLOYMENT_NAME>"
  }
}

Program.cs

Here is the entire program. The agent definition is the same one we have used in the previous post, a small weekend-planner with three tools. The new changes are FunctionsApplication.CreateBuilder and ConfigureDurableAgents:
using Azure.AI.Projects;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Hosting.AzureFunctions;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Builder;
using Microsoft.DurableTask;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Hosting;
using System.ComponentModel;

var endpoint = Environment.GetEnvironmentVariable("AZURE_AI_ENDPOINT")!;
var deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL")!;

// For local development, using AzureCliCredential
var credential = new AzureCliCredential();

AIAgent agent = new AIProjectClient(new Uri(endpoint), credential)
    .AsAIAgent(
        model: deploymentName,
        name: "weekend-planner",
        instructions: """
            You help users plan their weekends and choose the best activities for the given weather.
            If an activity would be unpleasant in weather, don't suggest it.
            Include date of the weekend in response.
            """,
        tools: [
            AIFunctionFactory.Create(GetWeather),
            AIFunctionFactory.Create(GetActivities),
            AIFunctionFactory.Create(GetCurrentDate)
        ]);

// Automatically creates HTTP endpoints and manages state persistence
using IHost app = FunctionsApplication
    .CreateBuilder(args)
    .ConfigureFunctionsWebApplication()
    .ConfigureDurableAgents(options =>
        options.AddAIAgent(agent)
    )
    .Build();

app.Run();

[Description("Returns weather data for a given city.")]
static WeatherResult GetWeather(
    [Description("The city to get the weather for.")] string city)
{
    Console.WriteLine($"[Tool] Getting weather for '{city}'.");

    return new WeatherResult(18, "Rainy");
}

[Description("Returns a list of leisure activities for a given city and date, each with a name and location.")]
static List<LeisureActivity> GetActivities(
    [Description("The city to get activities for.")] string city,
    [Description("The date to get activities for in format YYYY-MM-DD.")] string date)
{
    Console.WriteLine($"[Tool] Getting activities for '{city}' on '{date}'.");

    return
    [
        new("Hiking", city),
        new("Beach", city),
        new("Museum", city)
    ];
}

[Description("Gets the current date from the system and returns as a string in format YYYY-MM-DD.")]
static string GetCurrentDate()
{
    Console.WriteLine("[Tool] Getting current date");

    return DateTime.Now.ToString("yyyy-MM-dd");
}

record WeatherResult(int Temperature, string Description);

record LeisureActivity(string Name, string Location);

// Dummy orchestrator to satisfy Durable Functions requirement of having at least one orchestration in the assembly.
public static class DummyOrchestrator
{
    [Function(nameof(DummyOrchestrator))]
    public static Task RunOrchestrator(
        [OrchestrationTrigger] TaskOrchestrationContext context)
    {
        return Task.CompletedTask;
    }
}
Notice we don't define any HTTP-triggered functions ourselves. ConfigureDurableAgents registers an HTTP endpoint for every agent we add via AddAIAgent, using the agent's name. For our weekend-planner agent, the endpoint will be:
POST /api/agents/weekend-planner/run  
The one quirky bit is the DummyOrchestrator. The Functions runtime needs at least one [OrchestrationTrigger] to be discovered in the assembly for the Durable extension to bind correctly. (see issue: microsoft/agent-framework/issues/5927)

Now let's run the Azure Function App and we can see something like below:
Function App
Then we can call the agent over HTTP. Here is a request to start a new conversation:
POST http://localhost:7098/api/agents/weekend-planner/run
Content-Type: application/json

{
  "message": "What should I do this weekend in Auckland?"
}  
The response comes back with the agent's reply.
Start a chat
Note the x-ms-thread-id under response Headers. That value is the durable identifier for the conversation. To continue the same conversation, we pass it back as a query parameter:
POST http://localhost:7098/api/agents/weekend-planner/run?thread_id=<x-ms-thread-id>
Content-Type: application/json

{
  "message": "What is the weather like there?"
}
  
Chat Continuation
The agent remembers we were talking about a specific city and answers in context without us writing a single line of state management code. Without a thread, if we ask the same above question as a fresh conversation, it will return something like What is the city etc. The thread, messages, and tool calls are all persisted in the storage account via Durable Functions. If the Functions host restarts, the conversation continues from where it left off.


Hope this helps.

Happy Coding.

Regards,
Jaliya

Wednesday, May 13, 2026

EF Core 11.0: Temporal Period Properties Backed by CLR Properties

In this post, let's have a look at a small but very welcome improvement to SQL Server Temporal Tables support in EF Core shipping with .NET 11 Preview 4, the ability to map temporal period columns (PeriodStart and PeriodEnd) to regular CLR properties on the entity.

Long time ago I blogged about EF Core 6.0: Introducing Support for SQL Server Temporal Tables, where we saw how to opt an entity into temporal tracking using IsTemporal(). Back then, the period columns were always managed as shadow properties, which meant we had to read them through EF.Property<DateTime>(...) whenever we wanted to project, filter, or order by them. That works, but it isn't great when you actually want to surface those values on the entity itself.

Starting with EF Core 11 Preview 4, we now have new strongly-typed overloads of HasPeriodStart and HasPeriodEnd that accept a lambda expression pointing to a CLR property on the entity. EF Core still wires them up with ValueGenerated.OnAddOrUpdate and BeforeSaveBehavior.Ignore under the hood, so SQL Server's SYSTEM_TIME machinery keeps owning the values, we just get to read them like any other column.

Let's have a look at it in action.

First, the project file. Make sure you are using >= EF Core 11 Preview 4.
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="11.0.0-preview.4.26230.115">
  <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
  <PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="11.0.0-preview.4.26230.115" />
Now the entity. Notice how PeriodStart and PeriodEnd are just plain DateTime properties, no shadow property tricks required.
public class Category
{
    public int Id { get; set; }

    public string Name { get; set; }

    public DateTime PeriodStart { get; set; }

    public DateTime PeriodEnd { get; set; }
}
And here is the DbContext. The interesting bit is inside IsTemporal, where we now pass a property selector to HasPeriodStart and HasPeriodEnd.
public class MyDbContext : DbContext
{
    public DbSet<Category> Categories { get; set; }

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        optionsBuilder
            .UseSqlServer(@"<ConnectionString>");
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Category>()
            .ToTable(c => c.IsTemporal(t =>
            {
                t.HasPeriodStart(x => x.PeriodStart);
                t.HasPeriodEnd(x => x.PeriodEnd);
            }));
    }
}
Now let's add a row, update it, and read the history back. Note that the OrderBy and the projection just use x.PeriodStart and x.PeriodEnd directly, no more EF.Property<DateTime>(x, "PeriodStart").
using var context = new MyDbContext();

context.Database.EnsureDeleted();
context.Database.EnsureCreated();

Category category = new() { Name = "Category A" };
await context.Categories.AddAsync(category);
await context.SaveChangesAsync();

category.Name = "Category A Updated";
context.Categories.Update(category);
await context.SaveChangesAsync();

foreach (var item in await context.Categories
    .TemporalAll()
    .Where(x => x.Id == category.Id)
    .OrderBy(x => x.PeriodStart)
    .Select(x => new
    {
        Category = x,
        x.PeriodStart,
        x.PeriodEnd
    })
    .ToListAsync())
{
    Console.WriteLine($"Name: '{item.Category.Name}', " + $"Start: '{item.PeriodStart}' - End: '{item.PeriodEnd}'.");
}
And the output:
Name: 'Category A', Start: '13/05/2026 9:53:01 am' - End: '13/05/2026 9:53:01 am'.
Name: 'Category A Updated', Start: '13/05/2026 9:53:01 am' - End: '31/12/9999 11:59:59 pm'.
A couple of things worth calling out:
  • EF Core still owns the values. The CLR properties are configured to be ignored on save, so even if you set them in code, SQL Server's SYSTEM_TIME will overwrite them on insert/update.
  • The current row will always have PeriodEnd set to 31/12/9999 11:59:59 pm, that is SQL Server's sentinel for "still active".
  • All the existing temporal query operators: TemporalAll, TemporalAsOf, TemporalFromTo, TemporalBetween, TemporalContainedIn, keep working exactly as before.
Small change, but a really nice quality-of-life improvement for anyone working with temporal tables.

Hope this helps.

Happy Coding.

Regards,
Jaliya

Wednesday, April 29, 2026

EF Core 11.0: Querying JSON Columns with JsonPathExists and JsonContains

In this post, let's have a look at two new functions introduced in EF Core 11.0EF.Functions.JsonPathExists() and EF.Functions.JsonContains(). Both are aimed at making it easier to query JSON data stored in your database, without falling back to raw SQL.

For SQL Server, JsonPathExists() translates to the JSON_PATH_EXISTS function (available since SQL Server 2022), and JsonContains() translates to the JSON_CONTAINS function (which is new in SQL Server 2025).

At the time of writing, .NET 11 Preview 3 is the latest and we need to be using EF Core 11 Preview 3 NuGet packages for these functions to work.
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="11.0.0-preview.3.26207.106">
  <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
  <PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="11.0.0-preview.3.26207.106" />
Let's set up a simple Customer with a Contact as a complex type stored as a JSON column. The Contact  wraps an Address. Note that JSON_CONTAINS requires SQL Server 2025, so we are also setting the compatibility level to 170 via UseCompatibilityLevel(170). Without that, EF Core won't translate JsonContains() for you.
public class Customer
{
    public int Id { get; set; }

    public required string Name { get; set; }

    public required Contact Contact { get; set; }
}

public class Contact
{
    public required Address Address { get; set; }
}

public class Address
{
    public required string Street { get; set; }

    public required string City { get; set; }

    public required string State { get; set; }

    public required string PostalCode { get; set; }
}

public class MyDbContext : DbContext
{
    public DbSet<Customer> Customers { get; set; }

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        optionsBuilder
            .UseSqlServer(@"<ConnectionString>", o => o.UseCompatibilityLevel(170));
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Customer>()
            .ComplexProperty(c => c.Contact, b => b.ToJson());
    }
}

EF.Functions.JsonPathExists()


This checks whether a given JSON path exists inside a JSON document. The nice thing is, since our Contact is a complex type mapped to JSON, we can pass the property directly and use a typed-style path against the underlying JSON shape. Say we want to defensively pull customers whose Contact JSON actually has an Address.PostalCode:
// EF.Functions.JsonPathExists(): customers whose Contact JSON has an Address.PostalCode
List<Customer> customersWithPostalCode = await context.Customers
    .Where(c => EF.Functions.JsonPathExists(c.Contact, "$.Address.PostalCode"))
    .ToListAsync();
And the SQL generated:
SELECT [c].[Id], [c].[Name], [c].[Contact]
FROM [Customers] AS [c]
WHERE JSON_PATH_EXISTS([c].[Contact], N'$.Address.PostalCode') = 1

EF.Functions.JsonContains()


While EF Core 11 already automatically translates LINQ Contains queries over primitive collections to JSON_CONTAINS (when targeting SQL Server 2025), there are cases where you want to invoke it directly, for example, to search for a value at a specific JSON path. That's where this function comes in.

One small heads-up: at the time of writing, JsonContains() is flagged as experimental (diagnostic EF9106), so you'll need to suppress it at the call site for the project to build.

Let's pull all the customers in Redmond:
// EF.Functions.JsonContains(): customers in Redmond
#pragma warning disable EF9106 // JsonContains is for evaluation purposes only
List<Customer> redmondCustomers = await context.Customers
    .Where(c => EF.Functions.JsonContains(c.Contact, "Redmond", "$.Address.City") == 1)
    .ToListAsync();
#pragma warning restore EF9106
And the generated SQL:
SELECT [c].[Id], [c].[Name], [c].[Contact]
FROM [Customers] AS [c]
WHERE JSON_CONTAINS([c].[Contact], N'Redmond', N'$.Address.City') = 1
Both functions can be used with scalar string properties, complex types, and owned entity types mapped to JSON columns, so as you can see, you don't need a raw JSON string column to take advantage of these. And as a bonus, JSON_CONTAINS can take advantage of a JSON index, if one is defined on the column.

More Read:

Hope this helps.

Happy Coding.

Regards,
Jaliya

Tuesday, April 14, 2026

Microsoft Agent Frameworks' Agent Middleware with .NET

In this post, let's have a look at Microsoft Agent Framework's Agent Middleware with .NET. In my previous post, we looked at getting started with Microsoft Agent Framework in .NET by building an agent with tools. In this post, let's see how we can use middleware to intercept and inspect agent runs, function calls, and chat client calls.
 
Microsoft Agent Framework supports three types of middleware.
  • Agent Run Middleware: Allows interception of all agent runs, so that input and output can be inspected and/or modified as needed.
  • Function Calling Middleware: Allows interception of all function calls executed by the agent, so that input and output can be inspected and modified as needed.
  • Chat Client Middleware: Allows interception of calls to an IChatClient implementation, where an agent is using IChatClient  for inference calls, for example, when using ChatClientAgent.
Let's see how we can set up each of these middleware types.

The middleware is registered using the builder pattern. Agent Run Middleware and Function Calling Middleware are applied to the AIAgent via AsBuilder()Chat Client Middleware is applied to the IChatClient  instance via the clientFactory parameter.
AIAgent agent = new AIProjectClient(new Uri(endpoint), credential)
    .AsAIAgent(
        model: deploymentName,
        name: "weekend-planner",
        instructions: """
            You help users plan their weekends and choose the best activities for the given weather.
            If an activity would be unpleasant in weather, don't suggest it.
            Include date of the weekend in response.
            """,
        tools: [
            AIFunctionFactory.Create(GetWeather),
            AIFunctionFactory.Create(GetActivities),
            AIFunctionFactory.Create(GetCurrentDate)
        ],
        clientFactory: (chatClient) => chatClient
            .AsBuilder()
            // Chat Client Middleware
            .Use(getResponseFunc: CustomChatClientMiddleware, getStreamingResponseFunc: CustomChatClientStreamlingMiddleware)
            .Build())
    .AsBuilder()
    // Agent Run Middleware
    .Use(runFunc: CustomAgentRunMiddleware, runStreamingFunc: CustomAgentRunStreamingMiddleware)
    // Function Calling Middleware
    .Use(CustomFunctionCallingMiddleware)
    .Build();


Agent Run Middleware


The Agent Run Middleware intercepts all agent runs. Here we are logging the message count before and after the agent run. The middleware calls agent.RunAsync() to invoke the next middleware in the chain.
async Task<AgentResponse> CustomAgentRunMiddleware(IEnumerable<ChatMessage> messages,
    AgentSession? session,
    AgentRunOptions? options,
    AIAgent agent,
    CancellationToken cancellationToken)
{
    Console.WriteLine($"[AgentRun] Message Count: '{messages.Count()}'.");

    AgentResponse response = await agent.RunAsync(messages, session, options, cancellationToken)
        .ConfigureAwait(false);

    Console.WriteLine($"[AgentRun] Response Message Count: '{response.Messages.Count}'.");

    return response;
}
It also supports a streaming variant using IAsyncEnumerable<AgentResponseUpdate>.
async IAsyncEnumerable<AgentResponseUpdate> CustomAgentRunStreamingMiddleware(IEnumerable<ChatMessage> messages,
    AgentSession? session,
    AgentRunOptions? options,
    AIAgent agent,
    [EnumeratorCancellation] CancellationToken cancellationToken)
{
    Console.WriteLine($"[AgentRunStreaming] Message Count: '{messages.Count()}'.");

    List<AgentResponseUpdate> updates = [];
    await foreach (AgentResponseUpdate update in agent.RunStreamingAsync(messages, session, options, cancellationToken))
    {
        updates.Add(update);
        yield return update;
    }

    Console.WriteLine($"[AgentRunStreaming] Response Message Count: '{updates.ToAgentResponse().Messages.Count}'.");
}

Function Calling Middleware


The Function Calling Middleware intercepts all function calls executed by the agent. Here we are logging the function name being called and the result returned. The middleware uses the next delegate to invoke the next middleware in the chain.
async ValueTask<object?> CustomFunctionCallingMiddleware(
    AIAgent agent,
    FunctionInvocationContext context,
    Func<FunctionInvocationContext, CancellationToken, ValueTask<object?>> next,
    CancellationToken cancellationToken)
{
    Console.WriteLine($"      [FunctionCall] Calling: '{context!.Function.Name}'.");

    object? result = await next(context, cancellationToken);

    Console.WriteLine($"      [FunctionCall] Result: '{context!.Function.Name}' = '<OMITTED>'.");

    return result;
}

Chat Client Middleware


The Chat Client Middleware intercepts calls to IChatClient . This is useful when you want to inspect the raw messages being sent to and received from the underlying LLM. Here we are logging all messages with their content types, distinguishing between TextContent, FunctionCallContent, and FunctionResultContent.
async Task<ChatResponse> CustomChatClientMiddleware(IEnumerable<ChatMessage> messages,
    ChatOptions? options,
    IChatClient client,
    CancellationToken token)
{
    LogChatClientMessages("Messages", messages);

    ChatResponse response = await client.GetResponseAsync(messages, options, token)
        .ConfigureAwait(false);

    LogChatClientMessages("Response", response.Messages);

    return response;
}
And the streaming variant.
async IAsyncEnumerable<ChatResponseUpdate> CustomChatClientStreamlingMiddleware(IEnumerable<ChatMessage> messages,
    ChatOptions? options,
    IChatClient client,
    [EnumeratorCancellation] CancellationToken token)
{
    LogChatClientMessages("Messages", messages);

    List<ChatResponseUpdate> updates = [];
    await foreach (ChatResponseUpdate update in client.GetStreamingResponseAsync(messages, options, token))
    {
        updates.Add(update);
        yield return update;
    }

    LogChatClientMessages("Response", updates.ToChatResponse().Messages);
}
The helper method LogChatClientMessages logs the messages with their content types.
void LogChatClientMessages(string label, IEnumerable<ChatMessage> messages)
{
    Console.WriteLine();
    Console.WriteLine($"   [ChatClient] {label}:");
    foreach (ChatMessage message in messages)
    {
        foreach (AIContent content in message.Contents)
        {
            string detail = content switch
            {
                TextContent text => text.Text,
                FunctionCallContent fc => $"Call: {fc.Name}({string.Join(", ", fc.Arguments?.Select(a => $"{a.Key}={a.Value}") ?? [])})",
                FunctionResultContent fr => $"Result: {fr.CallId} = '<OMITTED>'",
                _ => content.GetType().Name
            };
            Console.WriteLine($"      [{message.Role}] {detail}");
        }
    }
}
When I run this, I could see something like below:
Agent Middleware Output
Complete source code:
   https://github.com/jaliyaudagedara/maf-samples/blob/main/dotnet/samples/02-agent-middleware/Program.cs

Hope this helps.

More read:

Happy Coding.

Regards,
Jaliya