Azure Functions or Foundry Agent Service Is the Wrong Question

Enterprise teams keep asking me the same architecture question. Should agent work run in Azure Functions or in Foundry Agent Service? The question sounds like a choice between two products. It is not. It is a question about where you draw the Foundry Agent Service architecture boundary, and the honest answer changed in the last twelve months while most of the diagrams did not.

The Foundry Agent Service architecture boundary, as commonly drawn

Here is the version of the pattern I see repeated in decks, posts, and internal architecture docs.

Azure Functions sits below the line as the action layer. Tools, action handlers, data retrieval, API adapters. Stateless, discrete, testable, independently scalable. Foundry Agent Service sits above the line as the orchestration and reasoning layer. Thread state, tool coordination, evaluation, governance. The banner at the bottom says to keep orchestration in Foundry and actions in Functions.

Most of that diagram is still correct. One box in it is now wrong, and it happens to be the box the whole argument rests on.

The claim that stopped being true

The reasoning behind the pattern went like this. Azure Functions is a stateless runtime. If you build agent orchestration inside it, you end up hand building a state machine: persisting conversation turns yourself, tracking which tool calls already completed, writing your own resume logic after a crash, and owning every retry path end to end. That is real work, it is easy to get wrong, and Foundry Agent Service does it for you. So orchestration belongs above the line.

In 2025 that argument held. Then the Durable Task extension for Microsoft Agent Framework shipped, and Azure Functions acquired the thing it was said to lack.

Durable agents give you persistent sessions that survive process crashes, restarts, and scaling events. They checkpoint automatically, so completed steps do not re-execute on recovery. Sequential, fan out and fan in, and conditional routing across multiple agents all work, with context.GetAgent() pulling a registered agent into an orchestration. And the whole thing runs on the Flex Consumption plan and scales to zero.

That is a managed state machine, in Azure Functions, supported by Microsoft. The strongest paragraph in the received pattern now argues against a product that exists.

Preview status, read from the feed rather than the docs

Note the status carefully, because this is where enthusiasm outruns the facts. The core Microsoft Agent Framework packages are stable. The durable hosting packages are not. Microsoft.Agents.AI.Hosting.AzureFunctions and Microsoft.Agents.AI.DurableTask have never had a stable release. Neither Learn page carries a preview banner, but every documented install command passes --prerelease, and the feed agrees.

Building the sample turned up something sharper than the package labels. Microsoft.Agents.AI.Foundry 1.19.0 binds Azure.AI.Projects 2.1.0.0, and version 2.1.0 exists only as a beta. Pin the stable 2.0.1 underneath it and the compiler refuses with a version conflict. So the maturity question does not stop at the hosting packages. Today, the current Agent Framework Foundry integration cannot be assembled on top of a stable data plane SDK at all.

Say that out loud in an architecture review, and cite the package feed rather than the docs.

Three substrates above the Foundry Agent Service architecture boundary

Once you accept that Functions can orchestrate, the Foundry Agent Service architecture boundary stops being a layer cake and becomes a decision.

Foundry prompt agents. Configuration rather than runtime code. Foundry owns the thread state, the lifecycle, the tracing, the evaluation, and the retry behavior. This is the fastest route to a governed production agent, and it costs you the least code and the least control. When a team wants an agent in front of existing APIs and does not want to run anything, this is the answer.

Durable agents on Azure Functions. Your orchestration, in C#, with checkpointing supplied by the durable runtime. You own the deployment pipeline, the infrastructure as code, the tests, the alerting, and the pager. You get deterministic, inspectable control flow and the same operational tooling as the rest of your estate. When the orchestration has real branching, compensation, or long running steps that must survive a restart, this earns its keep.

Foundry hosted agents. Your Agent Framework or LangGraph code, shipped as a container image or a zip into Foundry’s runtime. Foundry supplies the endpoint, the scaling, a dedicated Microsoft Entra identity per agent, and session level state. You supply the code. This is the case that blurs the boundary the original diagram insists on keeping clean, because neither side owns the whole thing.

One action layer below the line

Below the line, nothing changes. Azure Functions remains the action and tool layer for all three. You surface tools over MCP at the Functions webhook endpoint, register them once in a Foundry toolbox (I covered how that moves tool authentication off the agent in my Foundry Toolboxes post), and let any substrate above discover them at runtime. The tool code does not know or care which orchestrator called it, and hardening it is ordinary API work of the kind I described in Azure Functions behind API Management.

The companion repo, and what it proves

I built a companion repo to check that this holds in practice rather than only in a diagram. It contains one action layer and three orchestrators over it, running the same order fulfillment task. You can find it at function-foundry-boundry.

All three substrates ran against the deployed action layer. The durable agent is deployed and measured, with the smoke suite passing twelve of twelve: the action layer, the idempotency guarantee, and a full orchestration ending in exactly one customer notification. Its prompt agent sibling was created against the live MCP endpoint and driven from the Foundry playground. And the hosted agent is registered and active in Foundry, with its container exercised end to end over the responses protocol self hosted; the one path I have not driven is invoking that container through Foundry’s own runtime. Getting all of this working took forty one documented findings, every one listed in the repo’s verification log, and several of them taught me more than the sample did. The repo states its coverage in the same words, because a sample that overstates what it proved is worse than no sample.

What actually differs, in code

Building all three surfaced a difference I did not expect to be so concrete.

The action layer exposes five operations: look up an order, check inventory, reserve stock, arrange delivery, and notify the customer. Two of those change state a customer can see. Reserving stock commits inventory. Notifying the customer sends a message that cannot be unsent.

In the durable agent, those two operations do not appear in the model’s tool set at all. The orchestrator calls them as activities, where the durable runtime commits them exactly once even if the surrounding agent turns replay. What the model gets is three read oriented tools and the job of producing a plan, while the runtime executes the parts that spend money.

In the prompt agent and the hosted agent, there is no activity boundary to put them behind. The model itself has to be trusted to call each one exactly once, and your protection is instructions plus an idempotency key enforced by the tool.

That asymmetry is the trade, stated in about twelve lines. It is not a bug in either approach. It is what “who owns retry and failure handling” means once you stop discussing it abstractly.

Three substrates, three failures, one shape

I did not have to wait long for the trade to show itself in data. All three substrates ran against the same action layer on the same day, with instructions that said, in each case, to notify the customer exactly once and to use the delivery slot they were given. The prompt agent fulfilled its order correctly and notified the customer twice, in two differently worded messages, because two generations each decided a message was warranted; the reservation, protected by the idempotency key rather than by instructions, did not duplicate. The hosted agent notified exactly once, and booked the delivery for the literal phrase “next available”, because with no orchestrator in the stack nobody owns the clock, so the model passed prose where a date belonged and the action layer recorded it faithfully. And the durable agent, in an early revision that let the model judge whether stock sufficed, refused a fulfillable order with twelve units on the shelf, which is why the shipped version keeps the model out of that decision entirely. Three substrates, three different failures, one shape: whatever the runtime owned, held. Whatever the model was merely instructed about, eventually did not.

One practical note for anyone following the same path. The durable types are split across three namespaces that do not follow from one another: AIAgent, AgentSession and AgentResponse<T> come from Microsoft.Agents.AI, DurableAIAgent and context.GetAgent from Microsoft.Agents.AI.DurableTask, and ConfigureDurableAgents from Microsoft.Agents.AI.Hosting.AzureFunctions. The DurableTask package is documented as the bring your own compute option, which makes it easy to leave out of a Functions project. You need it on both paths, because the hosting package does not surface the orchestration types.

The one control that survives the decision

Which brings up the idempotency key. Every substrate can retry a tool call, and every substrate can replay. So the action layer refuses to care which one is calling and enforces safety itself. reserve_stock takes a reservation key and returns the existing reservation rather than holding stock twice. The key derives from the order, never from the run. A run derived key is indistinguishable from no key at all, because every retry invents a fresh one. I measured that exact failure in an earlier sample, and it produced two authorizations against a balance that only had room for one.

Put the idempotency key in the action layer regardless of which substrate you pick. It is the one control that survives the architecture decision.

What the durable orchestration looks like

The reason this is worth showing rather than describing is that the code contains almost no plumbing. Here is the middle of the fulfillment orchestration, trimmed for length.

[Function(nameof(FulfilOrder))]
public static async Task<FulfilmentOutcome> FulfilOrder(
[OrchestrationTrigger] TaskOrchestrationContext context)
{
var request = context.GetInput<FulfilOrderRequest>();
DurableAIAgent agent = context.GetAgent(AgentName);
AgentSession session = await agent.CreateSessionAsync();
// Facts come from activities, never from the model.
var order = await context.CallActivityAsync<Order?>(
nameof(ToolActivities.LookupOrder), request.OrderId);
if (order is null) { /* unknown order, return */ }
// The key derives from the order, never from the run. And there is no stock
// pre-check: reserve_stock IS the stock check, enforced atomically by the
// action layer, which is the only answer that cannot go stale between
// checking and reserving.
var reservationKey = $"resv-{request.OrderId}";
var reservation = await context.CallActivityAsync<ReservationResult>(
nameof(ToolActivities.ReserveStock),
new ReserveArgs(order.Sku, order.Quantity, reservationKey));
if (reservation.Reservation is null) { /* notify once with the action layer's reason, return */ }
// Only now does the model get a turn: book the slot the orchestrator computed,
// and write the customer message. Checkpointed: on replay this returns from
// history without calling the model.
AgentResponse<FulfilmentPlan> plan = await agent.RunAsync<FulfilmentPlan>(
message: $"Stock is held for {request.OrderId}. Call arrange_delivery with " +
$"the slot exactly as '{slot}'. Then write one short message " +
"telling the customer what happens next.",
session: session);
// ...
}

Count what is absent. There is no thread table, no bookkeeping to work out which steps already ran, no resume logic, and no retry loop. context.GetAgent pulls in an agent registered at startup, each RunAsync is a checkpoint, and CallActivityAsync marks the operations that must commit exactly once.

When the model judged a fact

An earlier revision opened this orchestration with a triage turn: the agent was told to look the order up, check inventory, and decide whether there was enough free stock. It worked all day, and then, with twelve units free and two wanted, it returned canFulfil false with the reason “Free stock is lower than the order quantity”. The tools had been called, and the inventory tool had answered twelve. Yet the model fumbled a comparison a CPU does perfectly, the orchestrator took its word for it, and a customer was told an order could not be fulfilled while eleven spare units sat on the shelf. The division of labour above is the correction: activities fetch facts, the action layer enforces constraints, and the model plans and writes, which is the work only a model can do. It never decides whether.

Registration is equally short. In Program.cs you build an ordinary Agent Framework agent against the Foundry project, then hand it to the durable host.

AIAgent agent = new AIProjectClient(new Uri(foundryEndpoint), new DefaultAzureCredential())
.AsAIAgent(model: modelDeployment, instructions: Instructions, name: AgentName,
tools: [ /* the read oriented tools only */ ]);
builder.ConfigureDurableAgents(options => options.AddAIAgent(agent));

Compare that against what the same behavior cost in 2025, and you can see why the received pattern needs updating rather than repeating.

What deploying it actually cost

I would not have found any of the following by reading documentation, and three of them would sink a production build.

The preview and stable halves of one product are on different trains. Microsoft.Agents.AI is stable at 1.19. Microsoft.Agents.AI.DurableTask and Microsoft.Agents.AI.Hosting.AzureFunctions are preview at 1.16. The durable packages hook into internals of the core package, nothing declares an upper bound, and NuGet pairs them without a word. It restores, compiles, deploys and starts. The agent answers in eighteen seconds. Then the orchestrator fails a determinism check, gets retried forever, and reports Running until you go looking. No compiler error, no restore warning, no startup error.

The fix is to pin the whole family to the older train, which means pinning the stable package down to match the previews. That is worth saying out loud in a review, because it is backwards from how anyone manages versions.

Failures that look like working systems

Two Azure Functions apps must not share a deployment container. Flex Consumption keeps the deployed package in the container you name. Point two apps at the same one and the second deployment overwrites the first, so an app runs the other app’s code. It does not fail. The host starts cleanly, registers whatever it found, and every route you wrote returns 404. The only reliable tell is comparing the registered function list against what you wrote.

A client that turns failures into nulls defeats the whole point of a durable activity. My first tool client returned null on any non-success status. When a function key went missing, the agent received “no such order” and reasoned confidently about a fabricated premise, while the notification activity returned success having done nothing. Exactly-once is only a guarantee if a failed call is allowed to look like one. An activity that swallows a transport error is worse than one that crashes, because neither the runtime’s retry nor the orchestration’s error handling ever engages.

Two smaller ones worth carrying. In-process state in the action layer is simply wrong on a platform that scales out, and moving reservations to Table Storage made the idempotency key stronger than the lock it replaced, because a duplicate row key returns 409 atomically across every instance. And the delivery date belongs in the orchestrator, not the prompt: an orchestrator must be deterministic, so time comes from the replay-safe clock. Ask a model for “the earliest weekday morning slot” and you will get a booking reference containing the literal phrase, which is funny once and then a determinism bug forever.

Where the platform enforces the Foundry Agent Service architecture boundary

One thing I expected to argue for turned out not to need arguing. Flex Consumption allows exactly one site per plan, so the action layer and the orchestrator cannot share one. The received diagram calls the action layer “independently scalable” as a design aspiration you could honour or skip. On this platform you have no choice: the two sides of the boundary scale on separate plans by construction and cannot starve each other of instances.

That is a better argument for drawing the line than any of mine, because it is a constraint rather than an opinion.

The four questions that settle it

When teams argue about this, they argue about product features. The argument ends faster if you ask four operational questions instead.

Who owns retry and failure handling when a tool call dies? With a prompt agent, Foundry does. With a durable agent, you do, and you decide the policy. And finally, with a hosted agent, it splits, and the split is the thing to write down.

Where does session state live, and in whose subscription? All three substrates support bring your own resources for conversation state, using Azure Storage, Cosmos DB, or Azure AI Search inside your own subscription and under your own retention policy. This matters enormously for data residency, and it is also why residency alone cannot pick a substrate for you. It used to be the deciding argument. It is now available on every path.

Which control plane does the on call engineer open at three in the morning? This question ends more debates than the other three combined. If the answer is “it depends which agent”, you have not drawn a boundary, you have accumulated one.

Is the topology documented, or improvised per team? Teams that struggle operationally are rarely the ones that picked the wrong substrate. They are the ones that picked different substrates for different agents without recording why, and inherited the failure modes of both with the runbook of neither.

Where the Foundry Agent Service architecture boundary is the wrong frame

I have made this sound tidier than it is. Several situations break the framing above.

When the orchestration is genuinely trivial. A single agent, three tools, no branching, no long running steps. Reaching for a durable orchestrator here buys you preview packages, a Durable Task backend, and a deployment pipeline in exchange for checkpointing you will never exercise. Use a prompt agent and move on.

When your team has no .NET or Python depth. The durable path is code. It rewards a team that already runs Functions, already has infrastructure as code, and already reviews pull requests. It punishes a team that wanted an agent and got a distributed systems project.

Constraints that make the decision for you

When the preview status is disqualifying. Some regulated programs cannot take a dependency on packages with no stable release, whatever the docs imply. That constraint is legitimate and it makes the decision for you. Prompt agents and hosted agents are generally available. Say so in the review and let the constraint do its work.

Do not soften this one. The train skew above is exactly the class of failure that preview status is warning you about, and it cost most of a day to find in a sample with four files of orchestration.

When you actually need multi agent collaboration across organizational boundaries. The Agent2Agent protocol appears in a lot of diagrams, including the one at the top of this post, positioned as a settled capability alongside thread state and tool coordination. It is still preview. Design for it if you like, but do not commit a program to it this quarter.

When you are migrating rather than starting. If you are on Agents classic, the deprecation deadline is doing your planning for you. Classic retires on 31 March 2027, and the migration guide makes clear it is not a rename. That is not far away for an enterprise program with a change freeze and a release train.

Draw the Foundry Agent Service architecture boundary before anyone writes code

The original pattern got the important thing right. There is a boundary, it matters, and architectures fail when nobody draws it. What it got wrong was assuming the Foundry Agent Service architecture boundary always falls in the same place.

It does not. You now have three legitimate substrates above the line and one action layer below it. The action layer stays where it is, and the tools you write for it survive every one of these decisions, which is a good reason to build that layer first and pick the substrate second.

Pick one orchestration substrate per workload. Write down which one and why. Then let the four questions above, rather than a diagram, decide the next one.

Don’t Build Around Today’s Model. Build for the AI Control Plane

Microsoft isn’t just shipping AI models. It is building toward a model-agnostic AI control plane. The bigger story behind the recent Microsoft Foundry updates is not another model launch. It is the architecture direction. Once you see it, the individual announcements fall into place as layers of one stack:

AI Control Plane → Model Router → Agents → RAG/Knowledge → MCP/Tools → Enterprise Systems

The AI control plane takes shape

Two August updates make the direction concrete. First, the model router update expanded the router to 28 regions for global standard and 21 data zone regions. The supported pool now includes Anthropic Claude Opus 4.8 and the GPT-5.6 family, while deprecated models such as DeepSeek-V3.1 and the gpt-5-chat line were pruned. Second, Foundry added DeepSeek-V4-Flash-0731 and NVIDIA Nemotron 3.5 Lightning to the catalog, each available through multiple deployment paths: Direct from Azure, Fireworks on Foundry, or the Hugging Face collection on managed compute.

I covered the router update in more detail on InfoQ, including the caveats around behavioral stability. Here, I want to focus on what the updates mean for your architecture.

Neither update is spectacular on its own. Together, however, they show a platform where models arrive, improve, and retire underneath a stable endpoint. Your application keeps calling the same integration while the pool refreshes. That is control plane behavior, not model shipping.

Agent ≠ Model

Here is the key idea for enterprise architects: an agent is not a model. An agent is an orchestration unit with instructions, knowledge, and tools. The model is a swappable dependency underneath it.

The model router makes that separation operational. It selects a model per request, optimizing for quality, cost, and latency within the regions your governance allows. You can run it in balanced, quality, or cost mode, and you can restrict routing to an approved subset of models. Moreover, every response includes a model field that shows which model handled the request, so the routing decisions leave an auditable trail.

Microsoft frames this as a hill climb: model selection as a continuous, measured loop rather than a one-time decision. In an ecosystem where the frontier moves monthly, a hardcoded model choice goes stale fast. An agent bound to the control plane instead of a specific model can evolve without a rebuild every time the leaderboard changes.

What this means for your architecture

For enterprise architects, the practical guidance follows directly. Treat model selection as configuration, not code. Put governance, observability, and policy at the control plane layer, because that is where they survive model churn. Ground agents in your own knowledge through RAG, and connect them to enterprise systems through MCP and tools. Consequently, each layer can evolve at its own pace. The stack outlives any single model.

Where this is the wrong answer

Model-agnostic routing is not free, and it is not always right. If your workload requires reproducible behavior, for example in regulated decision flows, a pinned model version beats a router that refreshes its pool automatically. Your evaluations were run against a specific model; a silent pool refresh invalidates them until you re-run. There are operational caveats too: Anthropic models still need to be deployed separately before the router can reach them, and routing modes take time to propagate. Finally, a single well-tuned small model per agent remains the simpler option for narrow, high-volume tasks. Routing adds a layer you must monitor. Only accept that cost when model diversity actually pays for itself.

Closing thoughts

The direction is clear even where the details will shift. Models are becoming interchangeable parts. The durable investment is the control plane: routing, governance, evaluation, and the connective tissue to your enterprise systems. So don’t build around today’s model. Build for the control plane, and let the models come and go.

What’s your approach: one model per agent, or model-agnostic agents?

Azure Functions Hosted Skills: When to Use It

Editor’s note (September 2026): Microsoft has renamed this feature from “Azure Functions serverless agents runtime” to “Azure Functions hosted skills.” The content below has been updated to reflect the new name. The URL remains unchanged.

Most conversations about agent runtimes on Azure land on Azure AI Foundry Agent Service. That is the right starting point for teams that want a fully managed, enterprise-grade agent host. But it is not the only option and for event-driven scenarios, it is often not the best one.

The Azure Functions hosted skills is a preview programming model that lets you define agents as function apps. Agents are triggered by events, schedules, messages, or HTTP requests. They run on Flex Consumption with scale-to-zero, managed identity, and Application Insights. And they are deployed with azd like any other function app.

This post explains what the runtime actually is, how its three configuration files work together, and where it fits relative to Foundry Agent Service and Durable Functions. If you are new to Azure Functions as an AI platform, start with Azure Functions AI Integration: The Quiet Powerhouse which maps all four AI-enabled patterns. If you are looking specifically at MCP server hosting, Hosting MCP Servers on Azure Functions covers the three hosting options in detail.

How Azure Functions hosted skills work

The runtime is a programming model built on top of Azure Functions. When an event fires — a timer, an HTTP request, a queue message — the runtime starts the agent, runs it through Microsoft Agent Framework, and handles the trigger registration and endpoint wiring automatically.

You do not write trigger code, and do not implement an agent loop, and define three files, deploy a function app, and the runtime does the rest.

The core project files are:

  • .agent.md — defines the hosted skill: its instructions, its trigger, and the tools it can use
  • agents.config.yaml — app-wide runtime defaults, including the model deployment and any shared infrastructure (such as an Azure Container Apps dynamic session pool for sandboxed code execution)
  • mcp.json — lists the remote MCP servers available to hosted skills in the app
  • tools/ — optional folder containing custom Python tool functions decorated with @tool for app-specific logic
  • skills/ — optional folder containing reusable SKILL.md prompt assets that hosted skills can load on demand

The runtime discovers these files at startup, registers the required triggers and endpoints, and wires the agent to Microsoft Agent Framework. You can have multiple hosted skills in a single function app each defined in its own .agent.md file, all sharing the app-wide configuration.

What Azure Functions hosted skills deploy

The Microsoft Learn quickstart deploys two agents from a single function app:

Chat agent (main.agent.md) — an HTTP-triggered agent that exposes a debug chat UI in the browser. It can execute sandboxed Python code via an Azure Container Apps dynamic session pool and browse the web. No email tooling.

Blog summary agent (daily_microsoft_blog_summary.agent.md) — a timer-triggered agent. The YAML front matter in the file declares the schedule; the markdown body contains the agent instructions. On each timer fire, the agent gathers recent Microsoft blog posts, summarises them, and emails the digest via a managed MCP server connected to Microsoft 365 Outlook.

What gets provisioned by azd up for this template:

ResourcePurpose
Flex Consumption function appHosts the agents
Azure AI Foundry project + model deploymentLLM for agent reasoning
Azure Container Apps dynamic session poolSandboxed Python code execution
Storage accountFunction app state
Application InsightsMonitoring
Connector Namespace + M365 Outlook connectionEmail delivery (optional)
Managed MCP serverExposes the Outlook connector to agents

The provisioning is handled entirely by Bicep via azd you do not configure any of this manually.

How the agent definition files work

.agent.md is a markdown file with YAML front matter. The front matter declares the trigger and any agent-level configuration. The markdown body is the system prompt — the instructions the agent follows when it runs.

The timer-triggered blog summary agent front matter looks roughly like:

---
trigger:
type: timer
schedule: "0 0 8 * * *" # timer_trigger uses underscore in type field
tools:
- mcp_server: outlook
---

The markdown body below that is the agent’s instruction set what to gather, how to summarise it, how to format the email. You write it in plain English.

agents.config.yaml sets defaults that apply across all agents in the app. The model deployment is set here, so every agent uses the same Azure AI Foundry model unless overridden. The session pool endpoint for sandboxed code execution is also set here.

mcp.json lists the remote MCP servers the agents can call. In the quickstart template, this includes the managed MCP server for the Microsoft 365 Outlook connector when email delivery is enabled. The runtime reads this file at startup and makes those servers available to all hosted skills in the app.

How Azure Functions hosted skills differ from Foundry Agent Service

The distinction matters for architecture decisions.

Foundry Agent Service is a fully managed service. Microsoft operates the agent host. You configure agents through the Foundry portal or SDK, connect tools, and the service handles orchestration, state, and scaling. It has enterprise SLAs, built-in tooling, and a managed lifecycle.

Azure Functions hosted skills is a programming model you deploy yourself. You own the function app. You manage the deployment, the model connection, and the infrastructure. In return, you get the full Azure Functions hosting model — event-driven triggers, Flex Consumption billing, VNet integration, managed identity, and azd-based deployment pipelines.

The decision table:

SituationUse
Event-driven triggers, AI reasoning, scale-to-zero, VNet integrationAzure Functions hosted skills
Expose deterministic functions as tools for another AI clientAzure Functions MCP extension
Long-running, multi-step orchestration with human-in-the-loop approvalsDurable Functions
Create and manage AI agents without custom hostingMicrosoft Foundry or Copilot Studio

These are not mutually exclusive. Azure Functions hosted skills can call tools hosted in Foundry via MCP servers, and Foundry agents can call tools hosted in Azure Functions. The two can coexist in the same architecture.

How Azure Functions hosted skills differ from Durable Functions

Post 4 in this series covers Durable Functions for directed agentic workflows in detail, but the short version is:

Durable Functions is for directed, deterministic workflows — you define the steps, the model executes them in order, and Durable Functions handles state, retry, and fault tolerance. The workflow is predictable.

Azure Functions hosted skills is for autonomous agents — you give the agent instructions and tools, and Microsoft Agent Framework determines how to use them to accomplish the goal. The execution path is not predetermined.

If your AI-driven process has fixed, ordered steps and you need auditability, use Durable Functions. If you want the agent to figure out the steps, use Azure Functions hosted skills.

What to know before you build

It is preview. The programming model, file format, and configuration details are subject to change. Do not build production-critical workloads on this today without a plan for the preview-to-GA migration.

It requires a Foundry project and model deployment. The azd template provisions both automatically, but you need an Azure subscription with permissions to create Foundry resources and model deployments. Some organisations have restrictions on which model deployments are permitted.

The azd template provisions real Azure resources with real costs. The Flex Consumption plan itself is very low cost for low-traffic agents, but the Foundry model deployment, Container Apps session pool, and Connector Namespace resources all have associated costs. Review the Bicep templates in infra/ before running azd up in a production subscription.

Custom Python tools are how you add app-specific logic. Azure Functions hosted skills provides the agent loop and the MCP connections. For anything that requires your own code — calling internal APIs, reading proprietary data sources, applying business rules — you write Python tool functions and register them in the agent definition.

Getting started

The quickstart template is the right starting point:

azd init --template Azure-Samples/functions-quickstart-serverless-agents-azd -e serverless-agents
azd env set TO_EMAIL <your-email>
azd up

Review the three configuration files in src/ before deploying. They are short and readable, and understanding them before the first deployment saves debugging time later.

The email delivery step (setting TO_EMAIL and authorizing the Microsoft 365 Outlook connection) is optional. If you skip it, the timer agent still runs and returns its digest in the final response, which you can verify in Application Insights logs.

Try it with a weather sample.

If you want to see the runtime in action with a minimal, self-contained example before committing to the full quickstart, I built a companion sample: a weather chat agent that fetches live conditions and 3-day forecasts for any location using Open-Meteo, no API key, no M365 connector, no email setup required.

The agent is defined in a single main.agent.md file. It uses Python code execution via the Container Apps session pool to call the Open-Meteo API and returns structured weather data in the chat UI. Deploy it in three commands:

git clone https://github.com/steefjan1/weather-agents
cd weather-agents
azd up

Select Central US when prompted for location — the runtime is in preview and region availability is limited. The chat UI is at https://<function-app-name>.azurewebsites.net/api/agents/main/ once deployment completes.

The README documents two known issues you will hit if you try to build from scratch rather than the official quickstart: a broken transitive dependency in azurefunctions-agents-runtime that pins a yanked version of github-copilot-sdk, and the region constraint. Both are worth knowing before you invest time in a custom deployment.

Up next: Durable Functions as the Orchestration Layer for Directed Agentic Workflows

Citadel APIM Kill Switch: Stop a Governed Agent Cold

In the previous post we added conversation persistence to the Microsoft Foundry Citadel Platform on Azure. As a result, every agent run now produces a structured document in the spoke’s Cosmos DB conversations container. The agent is fully operational: it routes through the APIM governance hub, executes tool calls, stores its history, and returns grounded responses. However, the question that every enterprise AI architect eventually faces remains: what happens when it needs to stop?

Not a graceful shutdown. Not a redeployment. An immediate, operator-triggered containment the kind you need when an agent is behaving unexpectedly, consuming runaway tokens, or has been flagged by your security team. In a Microsoft Foundry Citadel Platform on Azure deployment, the answer is the Kill Switch: a layered containment system built into the APIM hub that stops agent traffic cold without touching the agent code, the spoke, or the Azure OpenAI deployment.

This post implements three of the five Citadel kill switch layers against the hub we deployed in Sweden Central:

  • Layer 1 — Named Value flip: instant global block via a single boolean
  • Layer 2 — JWT claim block: identity-based containment via header validation
  • Layer 3 — Agent ID blocklist: surgical per-agent blocking

The Scenario

The weather agent (agent_with_memory.py) is running in production. Specifically, it is routing through apim-wpvlimv4ngkns.azure-api.net, storing conversations in the spoke Cosmos DB, and generating token usage events in the hub Cosmos DB. Everything is working. Then your security team flags it. The agent needs to stop immediately while the incident is investigated. You have seconds, not minutes. For example, redeploying the spoke takes too long. Rotating the APIM subscription key is irreversible and affects all consumers. Therefore, the Kill Switch is the right tool.

The Kill Switch is the right tool. The APIM hub has built-in pre-wiring, requires no code changes, and can trigger actions in under 30 seconds.

To ensure reliability, always pre-wire the kill switch as Layer 1 before you need it. Remember, you can’t flip a Named Value that doesn’t exist. In addition, the inbound policy must already be in place, checking the Named Value on every request, before any incident occurs.

Prerequisites

From the previous posts you should have:

  • Hub deployed in rg-ai-hub-gateway-dev with APIM instance apim-wpvlimv4ngkns
  • agent_with_memory.py running and saving to Cosmos DB
  • Azure CLI authenticated

Citadel Kill Switch Layer 1 — Named Value Flip

How It Works

A Named Value called kill-switch-enabled is created in APIM and set to false. An inbound policy on the OpenAI API checks this value on every request. When the value is flipped to true, all requests through the gateway immediately return HTTP 403 — no code changes, no redeployment, no spoke involvement.

Step 1.1 — Create the Named Value

az apim nv create `
--resource-group rg-ai-hub-gateway-dev `
--service-name apim-wpvlimv4ngkns `
--named-value-id kill-switch-enabled `
--display-name "kill-switch-enabled" `
--value "false" `
--secret false

Verify it was created:

az apim nv show `
--resource-group rg-ai-hub-gateway-dev `
--service-name apim-wpvlimv4ngkns `
--named-value-id kill-switch-enabled `
--query "value" -o tsv

Should return false.

Step 1.2 — Add the Inbound Policy

In the Azure Portal:

  1. Navigate to apim-wpvlimv4ngkns → APIs → Azure OpenAI Service API → All operations
  2. Click Policies → Inbound processing → Edit
  3. Add this policy inside the <inbound> section, before any other policies:
<!-- Kill Switch Layer 1: Named Value flip -->
<set-variable name="killSwitchActive" value="@("{{kill-switch-enabled}}" == "true")" />
<choose>
<when condition="@((bool)context.Variables["killSwitchActive"])">
<return-response>
<set-status code="403" reason="Agent Suspended" />
<set-header name="Content-Type" exists-action="override">
<value>application/json</value>
</set-header>
<set-header name="x-kill-switch-layer" exists-action="override">
<value>1-named-value</value>
</set-header>
<set-body>{"error": {"code": "KillSwitchActive", "message": "Agent access has been suspended by the governance hub.", "layer": 1}}</set-body>
</return-response>
</when>
</choose>

The dedicated Named Value check policy provides a cleaner approach.

<!-- Kill Switch Layer 1: Named Value flip -->
<!-- Pre-wire this BEFORE any incident. Set kill-switch-enabled=true to activate. -->
<set-variable name="killSwitchActive" value="@("{{kill-switch-enabled}}" == "true")" />
<choose>
<when condition="@((bool)context.Variables["killSwitchActive"])">
<return-response>
<set-status code="403" reason="Agent Suspended" />
<set-header name="Content-Type" exists-action="override">
<value>application/json</value>
</set-header>
<set-header name="x-kill-switch-layer" exists-action="override">
<value>1-named-value</value>
</set-header>
<set-body>{"error": {"code": "KillSwitchActive", "message": "Agent access has been suspended by the governance hub. Contact your administrator.", "layer": 1}}</set-body>
</return-response>
</when>
</choose>

Click Save.

Step 1.3 — Confirm Agent Runs Normally

With kill-switch-enabled set to false, the agent should still work:

python agent_with_memory.py

Expected output: normal run, conversation saved, answer returned.

Step 1.4 — Trigger the Kill Switch

az apim nv update `
--resource-group rg-ai-hub-gateway-dev `
--service-name apim-wpvlimv4ngkns `
--named-value-id kill-switch-enabled `
--value "true"

Now run the agent:

python agent_with_memory.py

Expected output:

The agent stops. No spoke changes occur. No code changes happen. One CLI command executes.

Step 1.5 — Reset

az apim nv update `
--resource-group rg-ai-hub-gateway-dev `
--service-name apim-wpvlimv4ngkns `
--named-value-id kill-switch-enabled `
--value "false"

Citadel Kill Switch Layer 2 — Agent Approval Header

How It Works

The agent is required to pass a custom header x-agent-token containing a signed JWT with a specific claim (agt-approved: true). The APIM inbound policy validates this claim. If the claim is absent or the token is invalid, the system blocks the request with a 401 status. This action simulates identity-based containment, revoking the agent’s token or invalidating its claim at the identity provider level.

Step 2.1 — Update the Agent to Send a Header

Add the x-agent-token header to agent_with_memory.py. In this demo, we simulate the token by using a simple header value. IIn a production environment, Entra ID issues a JWT.

Modify the AzureOpenAI client creation in agent_with_memory.py:

client = AzureOpenAI(
azure_endpoint=apim_base,
api_key=cfg["APIM_SUBSCRIPTION_KEY"],
api_version="2024-02-01",
default_headers={
"x-agent-id": "citadel-weather-agent-v1"
}
)

Step 2.2 — Add the JWT Claim Check Policy

In the portal, add this policy after the Layer 1 block in the inbound section:

<!-- Kill Switch Layer 2: Agent approval header check -->
<choose>
<when condition="@(context.Request.Headers.GetValueOrDefault("x-agent-approved", "false") != "true")">
<return-response>
<set-status code="401" reason="Agent Not Approved" />
<set-header name="Content-Type" exists-action="override">
<value>application/json</value>
</set-header>
<set-header name="x-kill-switch-layer" exists-action="override">
<value>2-agent-approval</value>
</set-header>
<set-body>{"error": {"code": "AgentNotApproved", "message": "Agent identity could not be verified. Approval header missing or invalid.", "layer": 2}}</set-body>
</return-response>
</when>
</choose>

Step 2.3 — Trigger Layer 2

Remove the x-agent-approved header from the agent (or set it to false) and run:

python agent_with_memory.py

Expected output:

Note the response header x-kill-switch-layer: 2-agent-approval this indicates which containment layer fired and is critical for incident triage.

Pitfall: Policy Order Matters

Layer 1 must appear before Layer 2 in the policy document. APIM evaluates inbound policies top to bottom and stops at the first <return-response>. If Layer 2 appears before Layer 1, a globally suspended agent would return a 401 (identity error) instead of a 403 (suspended), obscuring the true containment reason in your incident log.

Citadel Kill Switch Layer 3 — Agent ID Blocklist in APIM

How It Works

A Named Value called blocked-agent-ids holds a comma-separated list of agent IDs. The inbound policy checks the x-agent-id header against this list. When agents match, the system blocks them with a 403 status code. Non-matching agents continue operating normally. This approach allows for surgical containment, stopping one specific agent while allowing all others to function.

Step 3.1 — Create the Blocklist Named Value

az apim nv create `
--resource-group rg-ai-hub-gateway-dev `
--service-name apim-wpvlimv4ngkns `
--named-value-id blocked-agent-ids `
--display-name "blocked-agent-ids" `
--value "none" `
--secret false

Start with an empty value — no agents blocked.

Step 3.2 — Add the Blocklist Policy

Add this policy after Layer 2 in the inbound section:

<!-- Kill Switch Layer 3: Agent ID blocklist -->
<set-variable name="agentId" value="@(context.Request.Headers.GetValueOrDefault("x-agent-id", ""))" />
<set-variable name="blockedIds" value="@("{{blocked-agent-ids}}")" />
<choose>
<when condition="@{
var agentId = (string)context.Variables["agentId"];
var blockedIds = (string)context.Variables["blockedIds"];
if (string.IsNullOrEmpty(agentId) || string.IsNullOrEmpty(blockedIds)) { return false; }
return blockedIds.Split(',').Any(id => id.Trim() == agentId.Trim());
}">
<return-response>
<set-status code="403" reason="Agent Blocked" />
<set-header name="Content-Type" exists-action="override">
<value>application/json</value>
</set-header>
<set-header name="x-kill-switch-layer" exists-action="override">
<value>3-agent-blocklist</value>
</set-header>
<set-body>{"error": {"code": "AgentBlocked", "message": "Agent has been added to the governance blocklist.", "layer": 3}}</set-body>
</return-response>
</when>
</choose>

Step 3.3 — Add the Agent to the Blocklist

az apim nv update `
--resource-group rg-ai-hub-gateway-dev `
--service-name apim-wpvlimv4ngkns `
--named-value-id blocked-agent-ids `
--value "citadel-weather-agent-v1"

Run the agent:

python agent_with_memory.py

Expected output:

Step 3.4 — Surgical Validation

The power of Layer 3 is specificity. If you had a second agent with a different x-agent-id say citadel-docs-agent-v1 it would pass through Layer 3 unaffected while citadel-weather-agent-v1 remains blocked. One agent stopped, all others running. This is the enterprise AI governance pattern: granular control without broad disruption.

Remove the agent from the blocklist:

az apim nv update --resource-group rg-ai-hub-gateway-dev
--service-name apim-wpvlimv4ngkns --named-value-id blocked-agent-ids
--value "none"

Validating the Citadel Kill Switch in Application Insights

Rather than using the CLI — which has a 5–10 minute Log Analytics ingestion lag — go directly to Application Insights in the portal for immediate results:

  1. Portal → appi-apim-wpvlimv4ngkns in rg-ai-hub-gateway-dev
  2. Left sidebar → Logs
  3. Paste and run this query:
requests
| where timestamp > ago(2h)
| where resultCode in ("200", "401", "403")
| project timestamp, resultCode, duration, name
| order by timestamp desc

The results table tells the complete kill switch story in two columns — resultCode and duration:

The duration contrast is the definitive proof that the kill switch works as designed. The 401s and 403s resolve in under 50ms, stopped cold at the APIM inbound policy before a single token is sent to Azure OpenAI. The 200s take 683ms–2010ms because they made the full round trip through the governance hub to Azure OpenAI and back.

Zero tokens consumed on blocked requests, zero cost, and zero Cosmos DB writes in the spoke. The agent is stopped at the perimeter.

For a sharper view that highlights exactly which kill switch layer fired on each blocked request, add the response header to the query. Unfortunately APIM response headers are not automatically projected into the requests table in Application Insights — but you can distinguish the layers by combining result code and timing:

requests
| where timestamp > ago(2h)
| where resultCode in ("200", "401", "403")
| extend killSwitchLayer = case(
resultCode == "401", "Layer 2 — agent approval",
resultCode == "403" and duration < 10, "Layer 1 or 3 — gateway block",
resultCode == "200", "Normal — LLM call completed",
"Unknown"
)
| project timestamp, resultCode, duration, killSwitchLayer
| order by timestamp desc

This gives you a readable incident log showing which containment layer was active at each point in time, directly useful for DORA incident post-mortem documentation and EU AI Act Article 17 risk management records.

The Complete Three-Layer Kill Switch Policy

Here is the complete inbound policy block containing all three layers, ready to paste into APIM:

 <!-- Kill Switch Layer 1: Named Value flip -->
        <set-variable name="killSwitchActive" value="@("{{kill-switch-enabled}}" == "true")" />
        <choose>
            <when condition="@((bool)context.Variables["killSwitchActive"])">
                <return-response>
                    <set-status code="403" reason="Agent Suspended" />
                    <set-header name="Content-Type" exists-action="override">
                        <value>application/json</value>
                    </set-header>
                    <set-header name="x-kill-switch-layer" exists-action="override">
                        <value>1-named-value</value>
                    </set-header>
                    <set-body>{"error": {"code": "KillSwitchActive", "message": "Agent access has been suspended by the governance hub.", "layer": 1}}</set-body>
                </return-response>
            </when>
        </choose>
        <!-- Kill Switch Layer 2: Agent approval header -->
        <choose>
            <when condition="@(context.Request.Headers.GetValueOrDefault("x-agent-approved", "false") != "true")">
                <return-response>
                    <set-status code="401" reason="Agent Not Approved" />
                    <set-header name="Content-Type" exists-action="override">
                        <value>application/json</value>
                    </set-header>
                    <set-header name="x-kill-switch-layer" exists-action="override">
                        <value>2-agent-approval</value>
                    </set-header>
                    <set-body>{"error": {"code": "AgentNotApproved", "message": "Agent identity could not be verified. Approval header missing or invalid.", "layer": 2}}</set-body>
                </return-response>
            </when>
        </choose>
        <!-- Kill Switch Layer 3: Agent ID blocklist -->
        <set-variable name="agentId" value="@(context.Request.Headers.GetValueOrDefault("x-agent-id", ""))" />
        <set-variable name="blockedIds" value="@("{{blocked-agent-ids}}")" />
        <choose>
            <when condition="@{
        var agentId = (string)context.Variables["agentId"];
        var blockedIds = (string)context.Variables["blockedIds"];
        if (string.IsNullOrEmpty(agentId) || string.IsNullOrEmpty(blockedIds)) { return false; }
        return blockedIds.Split(',').Any(id => id.Trim() == agentId.Trim());
    }">
                <return-response>
                    <set-status code="403" reason="Agent Blocked" />
                    <set-header name="Content-Type" exists-action="override">
                        <value>application/json</value>
                    </set-header>
                    <set-header name="x-kill-switch-layer" exists-action="override">
                        <value>3-agent-blocklist</value>
                    </set-header>
                    <set-body>{"error": {"code": "AgentBlocked", "message": "Agent has been added to the governance blocklist.", "layer": 3}}</set-body>
                </return-response>
            </when>
        </choose>

Pitfalls Summary

PitfallFix
Named Value doesn’t exist at incident timePre-wire Layer 1 during normal operations — never during an incident
Policy evaluation error on {{kill-switch-enabled}}Named Value must exist before the policy referencing it is saved
Layer 2 fires before Layer 1 in policyPolicy order matters — Layer 1 must be first in the inbound block
Agent ID header not sentAdd x-agent-id to default_headers in AzureOpenAI client
Blocklist with trailing spaces blocks nothingUse .Trim() in the policy C# expression when splitting
Kill switch left active after testAlways reset Named Values after testing — kill-switch-enabled=false, blocked-agent-ids=""

What the Kill Switch Demonstrates About Citadel

The three layers reveal something important about the Citadel architecture: governance lives in the hub, not the agent. The agent code has no knowledge of the kill switch. The spoke has no kill switch configuration. The Azure OpenAI deployment is untouched. All containment logic is in the APIM hub’s inbound policy — one place, centrally managed, instantly effective.

This is the enterprise AI control plane pattern in practice. When an incident occurs:

  • Layer 1 stops everything immediately while you triage
  • Layer 2 enforces identity verification once normal operations resume
  • Layer 3 surgically targets the offending agent while other agents continue

The x-kill-switch-layer response header ensures your incident log captures exactly which containment mechanism fired, giving you a clean audit trail for post-mortem analysis — directly relevant for DORA incident reporting and EU AI Act Article 17 risk management documentation.

What’s Next

The next post in this series takes the dev setup and hardens it for non-prod: networkIsolation=true, APIM Premium SKU, per-spoke subscription keys with independent quotas, and Azure Policy at the management group level. The kill switch policies we built here carry forward unchanged governance in the hub environment, which is environment-agnostic.