Expose Your Agent via API Done
Take Jordan Miller's incident triage assistant out of the terminal and host it as a scalable service.
Overview
Jordan Miller’s assistant is now highly capable: it has a defined Persona, a reasoning Brain, external Tools, and persistent Memory. However, it currently lives only in a local console window. To be truly useful, it needs to be accessible by other systems—Slack bots, monitoring dashboards, or mobile apps.
In this final module of the Agent Essentials path, we will host the assistant as a Service using Azure Functions. This transforms Jordan’s local logic into a scalable API endpoint that maintains conversation state automatically through the Durable Task Framework.
Agent Anatomy
We have reached the fifth and final pillar: Hosting. This is the bridge that turns a script into a professional, distributed application.
Jordan’s on-call identity.
Reasoning about alerts.
External capabilities.
State and persistence.
Exposing as a service.
Why Durable Agents?
In the previous modules, we used a local console app. If that app crashed or the network blipped during an LLM call, the state was lost. By hosting on Azure Functions with Durable Task, your agent becomes “durable.” It can pause execution while waiting for an LLM response and resume exactly where it left off, ensuring reliable, multi-turn conversations even across server restarts.
Setup Your Infrastructure
Hosting an agent requires a Durable Task Scheduler (DTS) to manage the state of the conversation. This ensures that Jordan’s assistant remembers the conversation history regardless of the underlying server’s lifecycle.
graph LR
Client(["HTTP Client"]) -- "POST /run" --> Func["Azure Function"]
Func -- "1. Load" --> DTS[("Durable Task")]
Func -- "2. Execute" --> Brain["🧠 LLM Brain"]
Brain -- "3. Response" --> Func
Func -- "4. Save" --> DTS
Func -- "JSON" --> Client
style Func fill:#f9f9ff,stroke:#6366f1,stroke-width:2px
style DTS fill:#f9f9ff,stroke:#6366f1,stroke-width:2px
style Brain fill:#e0e7ff,stroke:#4338ca,stroke-width:2pxThe request flow through a stateful, hosted agent service.
PrerequisiteInfrastructure: Durable Task Scheduler (DTS)Essential setup for stateful agent hosting
The Agent Framework uses the Durable Task Scheduler (DTS) to manage the state and coordination of your agents.
Configuration: host.json
Add the following to your project’s host.json to enable the Durable Task extension:
{
"extensions": {
"durableTask": {
"storageProvider": {
"type": "azureManaged",
"connectionStringName": "DurableTaskSchedulerConnectionString"
}
}
}
}1. Start the Emulator
docker run -p 8080:8080 -p 8082:8082 mcr.microsoft.com/durabletask/scheduler-emulator:latest
2. Connection String
Add this to your local.settings.json:
“DurableTaskSchedulerConnectionString”: “Endpoint=http://localhost:8080;Authentication=None” 1. Provision Resource
Deploy a Durable Task Scheduler instance via the Azure Portal or Bicep templates.
2. Assign Permissions
Enable Managed Identity on your Function App and assign it the Durable Task Data Contributor role on the DTS resource.
3. Connection String
Set this in your App Configuration or local.settings.json:
“DurableTaskSchedulerConnectionString”: “Endpoint=https://your-scheduler.durabletask.io;Authentication=ManagedIdentity” 🛠️ Tooling: Azure Functions Core Tools
To scaffold and run your agent host, you need the Azure Functions Core Tools (v4.x). If you don’t have them yet, follow the official installation guide for your platform.
🌐 Choosing Your Hosting Model
The Agent Framework leverages the Durable Task engine, giving you two primary hosting paths depending on your infrastructure needs:
- Azure Functions (Durable Functions): What we use here. It offers deep Azure integration, built-in HTTP management, and flexible storage options.
- Standalone SDKs: Ideal for self-hosting on any platform (Kubernetes, IoT Edge, etc.) where you want to manage the host yourself.
1 Initialize the Project
First, create a new Azure Functions project using the Azure Functions Core Tools. We will use the Isolated Worker Model, which is the modern standard for .NET functions.
func init IncidentTriage.Host --worker-runtime dotnet-isolated --target-framework net10.0
cd IncidentTriage.Host
2 Install required packages
We focus on the hosting bridge and the worker extensions required for stateful execution.
dotnet add package Microsoft.Agents.AI.Hosting.AzureFunctions
dotnet add package Microsoft.Agents.AI.OpenAI
dotnet add package Microsoft.Azure.Functions.Worker.Extensions.DurableTask.AzureManaged
dotnet add package Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore
dotnet add package OpenAI
dotnet add package Microsoft.Extensions.AI
dotnet restoreMicrosoft.Agents.AI.Hosting.AzureFunctionsThe hosting bridge. It provides the ConfigureDurableOptions extension to register agents as HTTP services.
Microsoft.Azure.Functions.Worker.Extensions.DurableTask.AzureManagedThe worker extension that enables the Function app to talk to the Durable Task Scheduler backend.
Microsoft.Extensions.AIProvides the unified .NET abstractions for AI. Required for structured memory and tool metadata in hosted environments.
dotnet add package Microsoft.Agents.AI.Hosting.AzureFunctions
dotnet add package Microsoft.Agents.AI.OpenAI
dotnet add package Azure.AI.OpenAI
dotnet add package Microsoft.Azure.Functions.Worker.Extensions.DurableTask.AzureManaged
dotnet add package Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore
dotnet add package Azure.Identity
dotnet add package Microsoft.Extensions.AI
dotnet restoreMicrosoft.Agents.AI.Hosting.AzureFunctionsThe hosting bridge. It provides the ConfigureDurableOptions extension to register agents as HTTP services.
Microsoft.Azure.Functions.Worker.Extensions.DurableTask.AzureManagedThe worker extension that enables the Function app to talk to the Durable Task Scheduler backend.
Microsoft.Extensions.AIProvides the unified .NET abstractions for AI. Required for structured memory and tool metadata in hosted environments.
Build the Host
1 Register your Agent
In the Azure Functions host, we register the agent logic. This automatically creates a set of HTTP endpoints to interact with the agent.
Replace the contents of Program.cs with the following:
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Hosting.AzureFunctions;
using Microsoft.Azure.Functions.Worker.Builder;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Hosting;
using OpenAI;
using OpenAI.Chat;
using System.ClientModel;
using System.ComponentModel;
using System.Text;
// 1. Configure the Provider
var endpoint = Environment.GetEnvironmentVariable("OPENAI_ENDPOINT") ?? "http://localhost:1234/v1";
var modelName = Environment.GetEnvironmentVariable("OPENAI_MODEL_NAME") ?? "google/gemma-4-e4b";
var chatClient = new OpenAIClient(new ApiKeyCredential("dummy"), new OpenAIClientOptions { Endpoint = new Uri(endpoint) })
.GetChatClient(modelName);
// 2. Initialize Agent with Memory Provider and Tools
var operatorMemory = new OperatorMemory(chatClient.AsIChatClient());
AIAgent triageAgent = chatClient.AsAIAgent(new ChatClientAgentOptions
{
Name = "TriageAgent",
ChatOptions = new()
{
Instructions = """
You are an enterprise incident triage assistant.
Summarize the incident, identify likely severity,
and suggest the next investigation step.
Always address the operator by their name and use their role to tailor your response.
Keep answers concise and operational.
""",
Tools = [
AIFunctionFactory.Create(GetServiceStatus, "GetServiceStatus"),
AIFunctionFactory.Create(GetOnCallEngineer, "GetOnCallEngineer")
]
},
AIContextProviders = [operatorMemory]
});
// 3. Register with the Function Host
var builder = FunctionsApplication.CreateBuilder(args);
builder.ConfigureFunctionsWebApplication();
builder.ConfigureDurableOptions(options =>
{
options.Agents.AddAIAgent(triageAgent, true, false);
});
using IHost app = builder.Build();
app.Run();
// --- Tool Definitions (from Module 2) ---
[Description("Gets the current health status for an enterprise service.")]
static string GetServiceStatus(
[Description("The service name to check, such as checkout, payments, or inventory.")] string serviceName)
{
return serviceName.ToLowerInvariant() switch
{
"checkout" => "Checkout is DEGRADED in West Europe. P95 latency is 4.8s. Payment retries are elevated.",
"payments" => "Payments is HEALTHY. No active regional alerts.",
"inventory" => "Inventory is HEALTHY. Last sync 2 minutes ago.",
_ => $"{serviceName} has no active status record in the demo store."
};
}
[Description("Gets the name of the engineer currently on-call.")]
static string GetOnCallEngineer(
[Description("The service name to check.")] string serviceName) => "Taylor Vance (@tvance)";
// --- Memory Provider Implementation ---
internal sealed class OperatorMemory(IChatClient chatClient) : AIContextProvider
{
private readonly ProviderSessionState<OperatorContext> _sessionState = new(_ => new OperatorContext(), nameof(OperatorMemory));
public override IReadOnlyList<string> StateKeys => [_sessionState.StateKey];
public OperatorContext GetContext(AgentSession session) => _sessionState.GetOrInitializeState(session);
protected override async ValueTask StoreAIContextAsync(InvokedContext context, CancellationToken ct)
{
var op = _sessionState.GetOrInitializeState(context.Session);
if ((op.Name is null || op.Role is null || op.IncidentId is null) && context.RequestMessages.Any(m => m.Role == ChatRole.User))
{
var result = await chatClient.GetResponseAsync<OperatorContext>(context.RequestMessages,
new ChatOptions { Instructions = "Extract operator name, role, and incident ID if present." }, cancellationToken: ct);
op.Name ??= result.Result.Name;
op.Role ??= result.Result.Role;
op.IncidentId ??= result.Result.IncidentId;
}
_sessionState.SaveState(context.Session, op);
}
protected override ValueTask<AIContext> ProvideAIContextAsync(InvokingContext context, CancellationToken ct)
{
var op = _sessionState.GetOrInitializeState(context.Session);
var instructions = new StringBuilder();
if (op.Name != null) instructions.Append($"The operator is {op.Name}, a {op.Role}. ");
if (op.IncidentId != null)
{
// PRIORITY: Injected facts should override conflicting conversation history
instructions.Append($"The current incident context is {op.IncidentId}. (Priority: Use this ID even if history mentions a different one). ");
}
return new ValueTask<AIContext>(new AIContext
{
Instructions = instructions.Length > 0 ? instructions.ToString() : "Ask for the operator's name, role, and the incident ID they are triaging."
});
}
}
internal sealed class OperatorContext
{
public string? Name { get; set; }
public string? Role { get; set; }
public string? IncidentId { get; set; }
} using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Hosting.AzureFunctions;
using Microsoft.Azure.Functions.Worker.Builder;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Hosting;
using Azure.AI.OpenAI;
using Azure.Identity;
using OpenAI.Chat;
using System.ComponentModel;
using System.Text;
// 1. Configure the Provider
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT")!;
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME")!;
var chatClient = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()).GetChatClient(deploymentName);
// 2. Initialize Agent with Memory Provider and Tools
var operatorMemory = new OperatorMemory(chatClient.AsIChatClient());
AIAgent triageAgent = chatClient.AsAIAgent(new ChatClientAgentOptions
{
Name = "TriageAgent",
ChatOptions = new()
{
Instructions = """
You are an enterprise incident triage assistant.
Summarize the incident, identify likely severity,
and suggest the next investigation step.
Always address the operator by their name and use their role to tailor your response.
Keep answers concise and operational.
""",
Tools = [
AIFunctionFactory.Create(GetServiceStatus, "GetServiceStatus"),
AIFunctionFactory.Create(GetOnCallEngineer, "GetOnCallEngineer")
]
},
AIContextProviders = [operatorMemory]
});
// 3. Register with the Function Host
var builder = FunctionsApplication.CreateBuilder(args);
builder.ConfigureFunctionsWebApplication();
builder.ConfigureDurableOptions(options =>
{
options.Agents.AddAIAgent(triageAgent, true, false);
});
using IHost app = builder.Build();
app.Run();
// --- Tool Definitions (from Module 2) ---
[Description("Gets the current health status for an enterprise service.")]
static string GetServiceStatus(
[Description("The service name to check, such as checkout, payments, or inventory.")] string serviceName)
{
return serviceName.ToLowerInvariant() switch
{
"checkout" => "Checkout is DEGRADED in West Europe. P95 latency is 4.8s. Payment retries are elevated.",
"payments" => "Payments is HEALTHY. No active regional alerts.",
"inventory" => "Inventory is HEALTHY. Last sync 2 minutes ago.",
_ => $"{serviceName} has no active status record in the demo store."
};
}
[Description("Gets the name of the engineer currently on-call.")]
static string GetOnCallEngineer(
[Description("The service name to check.")] string serviceName) => "Taylor Vance (@tvance)";
// --- Memory Provider Implementation ---
internal sealed class OperatorMemory(IChatClient chatClient) : AIContextProvider
{
private readonly ProviderSessionState<OperatorContext> _sessionState = new(_ => new OperatorContext(), nameof(OperatorMemory));
public override IReadOnlyList<string> StateKeys => [_sessionState.StateKey];
public OperatorContext GetContext(AgentSession session) => _sessionState.GetOrInitializeState(session);
protected override async ValueTask StoreAIContextAsync(InvokedContext context, CancellationToken ct)
{
var op = _sessionState.GetOrInitializeState(context.Session);
if ((op.Name is null || op.Role is null || op.IncidentId is null) && context.RequestMessages.Any(m => m.Role == ChatRole.User))
{
var result = await chatClient.GetResponseAsync<OperatorContext>(context.RequestMessages,
new ChatOptions { Instructions = "Extract operator name, role, and incident ID if present." }, cancellationToken: ct);
op.Name ??= result.Result.Name;
op.Role ??= result.Result.Role;
op.IncidentId ??= result.Result.IncidentId;
}
_sessionState.SaveState(context.Session, op);
}
protected override ValueTask<AIContext> ProvideAIContextAsync(InvokingContext context, CancellationToken ct)
{
var op = _sessionState.GetOrInitializeState(context.Session);
var instructions = new StringBuilder();
if (op.Name != null) instructions.Append($"The operator is {op.Name}, a {op.Role}. ");
if (op.IncidentId != null)
{
// PRIORITY: Injected facts should override conflicting conversation history
instructions.Append($"The current incident context is {op.IncidentId}. (Priority: Use this ID even if history mentions a different one). ");
}
return new ValueTask<AIContext>(new AIContext
{
Instructions = instructions.Length > 0 ? instructions.ToString() : "Ask for the operator's name, role, and the incident ID they are triaging."
});
}
}
internal sealed class OperatorContext
{
public string? Name { get; set; }
public string? Role { get; set; }
public string? IncidentId { get; set; }
} ⚠️ Configuration Reminder
Ensure your local.settings.json contains the AZURE_OPENAI_ENDPOINT and AZURE_OPENAI_DEPLOYMENT_NAME variables, as well as the DurableTaskSchedulerConnectionString required for the host to start.
Try it
Experiment with how the Agent behaves when exposed via a stateful API.
First Interaction: Start a Thread
Once your host is running, you can talk to Jordan’s assistant via any HTTP client. This first call initializes a new conversation thread.
curl -X POST http://localhost:7071/api/agents/TriageAgent/run \
-H "Content-Type: text/plain" \
-d "My name is Jordan. There is a latency spike in the checkout service!"The Response: The agent returns the triage summary and a unique thread ID in the headers.
HTTP/1.1 200 OK
Content-Type: text/plain
x-ms-thread-id: 7886412a-6d34-4c71-9ea6-c87a80e5fcd9
Hello Jordan. I've noted the latency spike in Checkout.
Checking the service status now... Continue the Conversation
Because we are using Durable Task, the agent remembers the context of the thread. To continue the same conversation, include the thread_id in the query string.
# Use the ID returned in the x-ms-thread-id header
curl -X POST "http://localhost:7071/api/agents/TriageAgent/run?thread_id=7886412a-6d34-4c71-9ea6-c87a80e5fcd9" \
-H "Content-Type: text/plain" \
-d "Who is the on-call engineer for this service?"The Goal: Even if you restart the server, the agent will remember that you are Jordan and you were discussing the checkout service, because the state is persisted in the DTS backend.
Survives System Failures
Because the state is managed by the Durable Task Scheduler, your agent is “crash proof.” The framework checkpoints the conversation state at every turn.
- Start a conversation and get a
thread_id. - Crash the app (press
Ctrl+Cin the terminal). - Restart the app and immediately send a follow-up request using the same
thread_id.
The Goal: The agent resumes exactly where it left off. Even if the server dies mid-execution, the Durable Task backend ensures the “brain” state and conversation history are safely recovered, making Jordan’s assistant resilient enough for production.
Summary & Next Steps
Congratulations! You’ve completed the Agent Essentials path. Jordan Miller now has a production-ready assistant that can reason, act, and remember—all hosted as a scalable service.
What’s next? While a single agent is powerful, real-world complexity often requires multiple components working together. In the Advanced Orchestration journey, you’ll learn how to move beyond single-turn reasoning and build robust Compound AI Systems using Workflows and Orchestrators.