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.


























