Building RAG Pipelines with Azure Functions: Event-Driven Data Retrieval at Scale

This is the fifth and final post in the series on AI and Azure Functions. The first post mapped all four AI-enabled patterns. Posts two through four went deep on MCP server hosting, the serverless agents runtime, and Durable Functions for directed agentic workflows. This post covers the fourth pattern: retrieval-augmented generation.

RAG is where Azure Functions earns its place through a capability that is easy to underestimate: the ability to handle multiple events from multiple data sources simultaneously. A RAG pipeline is only as good as its retrieval layer, and retrieval latency in production comes down to two things: how fast you can query your data sources, and how fast you can scale when demand spikes. Azure Functions on Flex Consumption handles both.

What RAG on Azure Functions actually looks like

The Microsoft Learn documentation is deliberately brief on this pattern: “RAG systems require fast data retrieval and processing. Functions can interact with multiple data sources simultaneously and provide the rapid scale required by RAG scenarios.”

That one-liner contains three architectural decisions worth unpacking.

Multiple data sources simultaneously. A retrieval layer that queries one source at a time is a bottleneck. A function app can fan out — trigger parallel calls to Azure AI Search, a Cosmos DB vector store, a blob-indexed knowledge base, and a case history API — collect all results, and pass the aggregated context to the language model in a single call. This is the fan-out/fan-in pattern from the Durable Functions post, applied to retrieval rather than to a directed workflow.

Event-driven. The most common RAG pattern in tutorials is synchronous: the user sends a query, retrieval runs, and the LLM responds. But production RAG often has an asynchronous dimension — new documents arrive, get processed, get indexed. Azure Functions handles both sides: HTTP-triggered retrieval for the synchronous path, and blob/Event Hubs/Cosmos DB change feed triggers for the ingestion pipeline that keeps the index current. The same function app can serve both.

Rapid event-driven scaling. The Azure OpenAI binding extension adds stateful chat session support — the function maintains conversation history across turns without you writing session management code. Combined with Flex Consumption’s scale-to-zero billing, that means a RAG-enabled chat endpoint costs nothing when idle and scales in seconds when traffic arrives.

The Azure OpenAI binding extension

The binding extension is the most concrete piece of infrastructure the docs call out for RAG. It provides three bindings relevant to RAG scenarios:

Text completion input binding — calls an Azure OpenAI deployment and returns the response. Used for one-shot prompt/response patterns.

Chat completion input binding — maintains a stateful chat session across function invocations. The session history is stored externally (Azure Table Storage by default) and automatically injected into each prompt. This is what the “Custom chat bot” sample in the docs demonstrates.

Embeddings input binding — generates vector embeddings from text. Used in the ingestion pipeline to embed documents before writing them to a vector store.

A simple RAG function using the chat completion binding looks like this in C#:

[Function(nameof(RagChat))]
public static async Task<IActionResult> RagChat(
[HttpTrigger(AuthorizationLevel.Function, "post")] HttpRequestData req,
[TextCompletion(
"{query}",
Model = "%OPENAI_DEPLOYMENT%",
SystemPrompt = "You are a helpful assistant. Answer using only the context provided."
)] TextCompletionResponse completion)
{
return new OkObjectResult(completion.Content);
}

The binding handles the Azure OpenAI call, authentication via managed identity, and response parsing. You write the retrieval logic — what context to inject into {query} — and the binding handles the LLM call.

The production version adds a retrieval step before the binding executes. In practice, this means the HTTP trigger extracts the user query, a call to Azure AI Search (or your vector store of choice) retrieves relevant passages, those passages are injected into the prompt template, and then the binding submits the augmented prompt to the LLM.

APIM as the LLM gateway layer

The binding extension simplifies the function code, but it routes directly to Azure OpenAI — there is no governance layer between the function and the model by default. For production RAG pipelines, that matters: you need rate limiting per client, token quota management, cost attribution across tenants or use cases, and a single point for monitoring and logging LLM calls.

APIM fills that role. Place it between the function app and Azure OpenAI, configure an inbound policy to validate the request and apply per-subscription token quotas, and configure an outbound policy to log usage. The function app calls the APIM endpoint rather than the Azure OpenAI endpoint directly.

One practical constraint from real deployment experience: if you use the Azure AI Foundry Agent Service SDK alongside Azure Functions, the SDK routes LLM calls directly to Azure OpenAI and bypasses APIM entirely. The standard OpenAI SDK doesn’t behave this way—calls flow through APIM as expected. The Citadel Platform series covers this in detail, including how to structure APIM policies for the token quota patterns that matter in production.

For RAG pipelines specifically, two APIM policies are worth implementing:

Token quota by subscription key. Set a per-day or per-hour token budget for each API consumer. RAG queries are often significantly larger than simple completions because the retrieved context inflates the prompt — a query with 2,000 tokens of retrieved passages consumes far more quota than a direct LLM call. Factor this into your quota design.

Semantic caching. APIM’s semantic caching policy stores LLM responses and returns cached results for semantically similar queries. For a RAG pipeline serving a knowledge base that changes infrequently, this can dramatically reduce token consumption and latency.

The ingestion pipeline

The retrieval side of RAG gets most of the attention, but the ingestion pipeline- how documents get into the index is where Azure Functions adds less-discussed value.

When a new document arrives in blob storage, the function extracts text, generates a vector embedding, and writes the result to Azure AI Search. An Event Hubs trigger handles high-throughput ingestion where many documents arrive simultaneously. A Cosmos DB change feed trigger keeps a vector index in sync with a transactional database.

The key advantage over a dedicated ingestion service is that the same managed identity, Application Insights workspace, and azd deployment pipeline cover both retrieval and ingestion. Operational complexity stays low even as the pipeline grows.

One constraint to know before building: blob triggers on Flex Consumption require EventGrid as the source. Standard polling blob triggers are not supported on the Flex Consumption plan. This means you must configure an EventGrid system topic and event subscription after provisioning — a two-step post-provision operation that cannot be included in the same Bicep deployment as the function app.

A minimal ingestion function using the Azure OpenAI SDK for embeddings — the recommended approach until the embeddings binding extension reaches GA:

[Function(nameof(IngestDocument))]
public async Task Run(
[EventGridTrigger] EventGridEvent eventGridEvent,
CancellationToken cancellationToken)
{
// Read blob from storage, generate embedding, write to Azure AI Search
var blobContent = await ReadBlobAsync(eventGridEvent.Subject, cancellationToken);
var embeddingClient = openAiClient.GetEmbeddingClient(embeddingsDeployment);
var embedding = await embeddingClient.GenerateEmbeddingAsync(
blobContent, cancellationToken: cancellationToken);
var document = new SearchDocument { ContentVector = embedding.Value.ToFloats().ToArray() };
await searchClient.IndexDocumentsAsync(
IndexDocumentsBatch.Upload([document]), cancellationToken: cancellationToken);
}

The EventGrid trigger fires on MicrosoftStorage.BlobCreated events; the function reads the blob, generates the vector, and writes to the index. EventGrid delivery includes retry logic —failed events are retried with exponential backoff for up to 24 hours.

Connecting the four patterns

This is the final post in the series, so it is worth mapping how the four AI patterns relate to each other in a production architecture. They are not mutually exclusive.

A realistic enterprise RAG system might use all four:

  • Azure Functions RAG pipeline handles retrieval — HTTP-triggered queries fan out to multiple sources, ingestion functions process new documents via blob and Event Hubs triggers.
  • MCP binding extension exposes the retrieval function as an MCP tool, so AI agents and Copilot can call it directly
  • Durable Functions orchestrates multi-step retrieval workflows, including approve-before-index, parallel ingestion with fan-out, andandlong-running document processing with human-in-the-loop review.
  • Serverless agents runtime wraps the whole thing in an agent that decides which data sources to query based on the user’s intent, using the retrieval function as one of its tools

APIM sits in front of the Azure OpenAI calls regardless of which pattern makes the LLM request. The function app’s managed identity authenticates to both APIM and the downstream Azure services. Application Insights provides a unified view across all four patterns.

The spectrum from the Durable Functions post applies here too: RAG retrieval is mostly directed (the function knows which sources to query), but the agent layer that decides what to retrieve and how to use the results is autonomous. Azure Functions provides the event-driven, scalable retrieval infrastructure for both ends of that spectrum.

What to know before building

Choose your vector store before choosing your binding. The Azure OpenAI binding extension integrates cleanly with Azure AI Search. If you are using Cosmos DB for MongoDB vCore, a Postgres pgvector extension, or an external vector database, you write the retrieval logic directly using the SDK — the binding handles the LLM call, not the retrieval.

The embeddings binding and the chat completion binding have different cost profiles. Embeddings are cheap per token; completions are expensive. The ingestion pipeline (embeddings) can run at high volume with low cost. The retrieval + completion path (chat) is where token costs accumulate. Design your APIM quota policies accordingly.

Stateful chat sessions need external storage. The chat completion binding stores session history in Azure Table Storage by default. For multi-tenant RAG, partition session keys by user or tenant — the default key scheme does not enforce isolation automatically.

Retrieval quality determines output quality. Azure Functions handles the retrieval infrastructure; what you retrieve is your responsibility. This distinction matters more than it might seem.

With keyword search alone, a query like “List all Metallica albums” retrieves documents based on term frequency across the index. An index containing Radiohead (48 releases) and Porcupine Tree alongside Metallica will surface Radiohead and Porcupine Tree documents ahead of Metallica ones, because those bands have more indexed content matching common terms. The LLM then filters the irrelevant context and may miss albums that simply did not appear in the retrieved passages.

Hybrid search combining keyword retrieval with vector retrieval addresses this directly. Generate an embedding for the user query, add a VectorizedQuery targeting the content_vector field, and Azure AI Search fuses both signals using reciprocal rank fusion. Semantically relevant documents surface regardless of term frequency:

var vectorQuery = new VectorizedQuery(queryVector)
{
KNearestNeighborsCount = 20,
Fields = { "content_vector" }
};
var searchOptions = new SearchOptions
{
Size = 20,
VectorSearch = new VectorSearchOptions { Queries = { vectorQuery } }
};
// Hybrid: keyword query string + vector query sent together
var results = await searchClient.SearchAsync<MusicDocument>(
userMessage, searchOptions, cancellationToken);

The companion repo implements hybrid search in MusicChatAgent.cs. Chunk size, embedding model choice, and whether you use keyword, semantic, or hybrid search have more impact on output quality than any infrastructure decision.

Testing the RAG pipeline

The companion repo supports several query patterns that exercise different aspects of the pipeline. These are worth running after ingesting a few bands to verify both the retrieval and the grounding behaviour.

Artist-specific ranking — tests hybrid search surfacing the right band:

{"message": "List all Metallica albums with their ratings, ordered highest to lowest"}

Date filtering via LLM reasoning — the index has no date filter; the model reasons over retrieved content:

{"message": "What albums did Tool release in the 1990s?"}

Cross-band comparison — tests whether retrieval surfaces relevant documents from multiple bands:

{"message": "Compare Opeth and Porcupine Tree — which has more critically acclaimed albums?"}

Rating threshold query:

{"message": "Which albums across all bands have a rating above 4.5?"}

Not-in-index grounding test — the agent should say it does not have information rather than hallucinate:

{"message": "Tell me about a band called Coldplay"}

Validation — should return HTTP 400:

{"message": ""}

The hybrid search implementation means artist-specific queries return only that artist’s documents. With keyword-only search, a query for Metallica in an index containing Radiohead (48 releases) would return mostly Radiohead documents — the vector component ensures semantic relevance wins over term frequency.

The companion repo — Music RAG Agent

The music-rag-agent companion repo implements the full two-pipeline RAG system described in this post. It scrapes band and album data from SputnikMusic, generates embeddings with the Azure OpenAI SDK, indexes into Azure AI Search, and exposes an HTTP chat endpoint that retrieves relevant passages and returns grounded answers.

After ingesting Tool, Opeth, Porcupine Tree, and Radiohead you can ask questions like:

“Which band has the highest rated albums — Tool, Opeth, Porcupine Tree or Radiohead?”

The retrieval correctly surfaces Sputnik ratings and vote counts for each album. The ingestion pipeline handles special characters in album titles (Ænima, Opiate²) via URL-safe Base64 document keys, and the EventGrid trigger ensures reliable delivery with 24-hour retry backoff.

Known Sputnik artist IDs: Tool = 83, Opeth = 932, Porcupine Tree = 328, Radiohead = 86.


Series wrap-up

This post completes the series on AI and Azure Functions. The five posts covered:

  1. The four AI-enabled patterns and how to choose between them
  2. Hosting MCP servers — binding extension (GA), self-hosted SDK (preview), and queue-based tools
  3. The serverless agents runtime — markdown agents, the three configuration files, and dynamic workflows
  4. Durable Functions for directed agentic workflows — five patterns with working C# samples verified on Azure
  5. RAG pipelines — event-driven retrieval, the Azure OpenAI binding extension, and APIM governance

The companion code repositories are at github.com/steefjan1.

Durable Functions as the Orchestration Layer for Directed Agentic Workflows

Not all AI orchestration should be autonomous. Some scenarios need predictable, directed steps, and that is where Durable Functions fit in the Azure Functions AI stack.

The previous post in this series covered the serverless agents runtime, where you give the agent instructions and tools and Microsoft Agent Framework determines the execution path. This post covers the opposite end of the spectrum: Durable Functions agentic workflows, where you define the steps and the model executes them in a known sequence. The workflow is deterministic. The AI is a participant, not the orchestrator.

If your AI-driven process has fixed, ordered steps and you need auditability, fault tolerance, and long-running execution, Durable Functions is the right tool. If you want a model to determine the steps dynamically, go back to the serverless agents runtime.

A horizontal spectrum from directed (left) to autonomous (right). Durable Functions sits on the left with the label "predefined steps, deterministic." The serverless agents runtime sits on the right with "emergent path, AI decides steps." A dashed vertical line marks the decision boundary in the centre.
Durable Functions and the serverless agents runtime occupy opposite ends of the orchestration spectrum. The decision boundary is simple: if the steps are known in advance, use Durable Functions.

What Durable Functions brings to agentic scenarios

Durable Functions is an extension of Azure Functions that lets you build stateful workflows in a serverless environment. The runtime manages state, checkpoints, retries, and recovery so workflows can run reliably for long periods minutes, hours, or days.

The doc positions it clearly for agentic use: “Some scenarios require a higher level of predictability or well-defined steps. These directed agentic workflows orchestrate separate tasks or interactions that agents must follow.”

Three capabilities make Durable Functions particularly well-suited for AI orchestration:

  • State persistence across steps. A workflow that calls an LLM, waits for a human decision, calls another service, and then writes a result can span hours or days without holding a connection open or burning compute. Durable Functions checkpoints state after every activity function completes.
  • Built-in retry and fault tolerance. LLM calls fail. External services time out. Durable Functions handles retries at the activity level with configurable backoff. You define the retry policy once, and every step in the workflow inherits it.
  • Human-in-the-loop support. The external events pattern lets a workflow pause and wait for human input — an approval, a correction, a classification decision. The workflow resumes when the event arrives, with full state intact.

The four patterns that map to agentic AI

Durable Functions has several application patterns documented in the Microsoft Learn docs. Four map directly to common agentic AI scenarios.

The patterns below are illustrated in Python, which maps clearly to the Durable Functions programming model. The companion repository implements all five patterns in C# (.NET 8, isolated worker). The orchestrator and activity structure are identical, but the syntax differs.

A two-by-two grid. Top left: function chaining — sequential AI pipeline with extract, classify, LLM call, write steps shown in sequence. Top right: fan-out/fan-in — a query fans out to three parallel retrievals (KB, policy docs, cases) then aggregates into a single LLM call. Bottom left: human interaction — AI result flows to a paused wait state then to human decision. Bottom right: monitor — a check-done-wait loop with a return result exit.
The four patterns map to distinct agentic AI scenarios. Dashed borders on the wait states indicate the workflow is paused with no compute consumed.

Function chaining — sequential AI pipeline

The simplest pattern: step A completes, then step B runs, then step C. Each step takes the previous step’s output as input.

In agentic terms, this is the document processing pipeline: extract text → classify intent → call LLM with classification context → write structured result. Each activity function is independently retryable. If the LLM call fails, Durable Functions retries it without re-running extraction.

@app.orchestration_trigger(context_name="context")
def document_pipeline_orchestrator(context: df.DurableOrchestrationContext):
text = yield context.call_activity("extract_text", context.get_input())
classification = yield context.call_activity("classify_intent", text)
result = yield context.call_activity("call_llm", {
"text": text,
"classification": classification
})
yield context.call_activity("write_result", result)
return result

The orchestrator function contains no business logic; it coordinates. The activity functions contain the work. This separation makes each step independently testable and observable.

Fan-out/fan-in — parallel retrieval and aggregation

The orchestrator starts multiple activity functions simultaneously and waits for all to complete before continuing. This is the RAG retrieval pattern: query multiple data sources in parallel, collect all results, and pass the aggregated context to the LLM.

@app.orchestration_trigger(context_name="context")
def parallel_retrieval_orchestrator(context: df.DurableOrchestrationContext):
query = context.get_input()
# Fan out — all three retrieval calls run in parallel
tasks = [
context.call_activity("search_knowledge_base", query),
context.call_activity("search_policy_documents", query),
context.call_activity("search_case_history", query),
]
results = yield context.task_all(tasks)
# Fan in — aggregate and call LLM once with full context
response = yield context.call_activity("call_llm_with_context", {
"query": query,
"context": results
})
return response

The Durable Functions tutorial in the Learn docs demonstrates this pattern with parallel text file analysis: multiple files are processed simultaneously, results are aggregated, and a single output is returned.

Human interaction — approval and correction loops

The external events pattern lets a workflow pause indefinitely waiting for human input. This is the compliance review pattern: the AI produces a draft or classification, a human reviews it, and the workflow continues with the human’s decision.

@app.orchestration_trigger(context_name="context")
def approval_orchestrator(context: df.DurableOrchestrationContext):
input_data = context.get_input()
# AI produces initial classification
ai_result = yield context.call_activity("classify_with_ai", input_data)
# Notify reviewer and wait — the workflow pauses here
yield context.call_activity("notify_reviewer", {
"result": ai_result,
"instance_id": context.instance_id
})
# Wait for human decision — could be minutes or days
human_decision = yield context.wait_for_external_event("ReviewDecision")
# Continue with human-approved or corrected result
final_result = yield context.call_activity("process_decision", {
"ai_result": ai_result,
"human_decision": human_decision
})
return final_result

The workflow pauses at wait_for_external_event without consuming resources. When the reviewer submits their decision, the Durable Functions client sends the event and the workflow resumes immediately.

Monitor — polling until a condition is met

The monitor pattern runs a check on a schedule until a condition is satisfied, then exits or escalates. In agentic terms: poll an external system until a document is processed, a model inference job completes, or a status changes.

@app.orchestration_trigger(context_name="context")
def monitor_orchestrator(context: df.DurableOrchestrationContext):
input_data = context.get_input()
expiry = context.current_utc_datetime + timedelta(hours=24)
while context.current_utc_datetime < expiry:
status = yield context.call_activity("check_processing_status", input_data)
if status == "completed":
return yield context.call_activity("retrieve_result", input_data)
elif status == "failed":
return yield context.call_activity("handle_failure", input_data)
# Wait before next poll — no compute consumed during wait
next_check = context.current_utc_datetime + timedelta(minutes=5)
yield context.create_timer(next_check)
return yield context.call_activity("handle_timeout", input_data)

A real pattern: risk-class-driven routing

A pattern I work with in integration architecture is risk-class-driven routing — an AI classification step followed by different downstream workflows depending on the risk class assigned.

The AI classifies an incoming request as low, medium, or high risk. The orchestrator branches based on the classification:

  • Low risk — automated processing, result written directly
  • Medium risk — automated processing with human notification and override window
  • High risk — human review required before any processing continues
@app.orchestration_trigger(context_name="context")
def risk_routing_orchestrator(context: df.DurableOrchestrationContext):
request = context.get_input()
# AI classification step
risk_class = yield context.call_activity("classify_risk", request)
if risk_class == "LOW":
return yield context.call_activity("process_automated", request)
elif risk_class == "MEDIUM":
result = yield context.call_activity("process_automated", request)
yield context.call_activity("notify_supervisor", {
"result": result,
"override_window_minutes": 30
})
try:
override = yield context.wait_for_external_event(
"SupervisorOverride",
timeout=timedelta(minutes=30)
)
return override
except TimeoutError:
return result
else: # HIGH
yield context.call_activity("notify_reviewer", request)
human_decision = yield context.wait_for_external_event("ReviewDecision")
return yield context.call_activity("process_with_decision", {
"request": request,
"decision": human_decision
})
Flowchart starting with an incoming request flowing to an AI risk classification step. Three branches fan out: LOW routes to automated processing and a result is written with no human involvement. MEDIUM routes to automated processing followed by supervisor notification with a 30-minute override window — a diamond asks whether an override arrived, branching to override applied (yes) or automated result stands (no). HIGH routes to mandatory reviewer notification, a paused wait state of up to 8 hours, then a decision diamond branching to process with decision (yes) or timeout escalation (timeout). Dashed box borders indicate workflow pause states.
The three branches reflect proportional human oversight: the higher the AI-assigned risk class, the more human involvement is required before processing continues. Dashed borders mark states where the workflow pauses and consumes no compute.

This pattern appears in healthcare authorization, financial transaction review, and any domain where the cost of an incorrect automated decision varies by risk level. Durable Functions is the right runtime because it handles the human-in-the-loop wait without consuming compute, and it checkpoints state so a mid-workflow restartdoesn’t lose the AI classification result.

The companion repository implements all five patterns in C# with full azd deployment. The TESTING.md file contains the exact curl commands to exercise each pattern, including submitting external events for the approval and risk-routing workflows, all verified on Azure.

When Durable Functions is not the right answer

The directed vs autonomous distinction is the primary decision. But two other constraints matter.

  • Avoid it for very short workflows. If your workflow completes in under a second and has no human-in-the-loop steps, the Durable Functions overhead storage writes and checkpoint reads adds latency you don’t need. A simple function chain without orchestration is faster and cheaper.
  • Avoid it when the steps are not known in advance. If the AI needs to decide dynamically which tools to call and in what order, Durable Functions cannot model that. That is the serverless agents runtime; the AI is the orchestrator, not a participant.
  • Consider Logic Apps Agent Loop for low-code scenarios. If the workflow involves mostly connector-based integrations rather than custom code, Logic Apps with its Agent Loop pattern may be the right choice. Durable Functions earns its place when you need custom code logic at each step, tight control over retry behavior, or the ability to unit test each activity function independently.

The storage backend — Durable Task Scheduler

One operational detail worth knowing before you deploy: Durable Functions needs a storage backend to persist workflow state. The recommended option is Durable Task Scheduler, a managed service that handles task hub storage without requiring you to manage Azure Storage queues and tables manually.

For agentic workflows, which may run for hours and involve many checkpoints, the Durable Task Scheduler is worth the setup once it is fully available. At the time of writing, the azureManaged storage provider requires a preview extension bundle that isn’t included in the current release. The default Azure Storage backend works correctly for all patterns and is what the companion repo uses. Check the Durable Task Scheduler quickstart for the current availability status before planning a production deployment.

Dynamic workflows — the bridge between the two runtimes

The spectrum diagram at the top of this post places Durable Functions on the directed end and the serverless agents runtime on the autonomous end. Dynamic workflows, an experimental feature in the serverless agents runtime, sits between the two, and it is worth knowing about before you commit to one pattern.

The concept: flip workflows.enabled: true in a .agent.md file’s front matter, and the agent gains five built-in tools, including start_workflow. When the agent decides the work is workflow-shaped, a multi-step plan, a fan-out across data sources, a wait; it calls start_workflow with a DAG of tasks. The runtime validates the DAG and launches it as a Durable Functions orchestration. The agent gets back a workflow_id immediately and ends its turn. The Durable orchestration runs the plan in the background.

The AI authors the plan. Durable Functions guarantees the execution

This is a meaningful architectural shift. With the patterns in this post, you write the orchestrator code; you define the steps. With dynamic workflows, the LLM authors the DAG at runtime based on the task it is given. The execution is still deterministic and fault-tolerant because Durable Functions is running it, but the plan itself is emergent. Three concrete advantages the docs cite over chaining tool calls in conversation:

  • Lower token cost. Intermediate task results stay inside the orchestration. The agent sees only the final completion envelope, not every fan-out result. The docs reference roughly a 10× reduction on multi-tool workflows.
  • Lower latency. Each direct tool call is a model round-trip. A 20-step plan is one model turn to author the workflow, not 20.
  • Context-window discipline. Hundreds of kilobytes of intermediate data log lines, search hits, and line items never reach the model’s context. The agent reasons over the summary.

How it works in practice. Workflow tools are Python functions decorated with @workflow_tool rather than the standard @tool. The agent authors a DAG of tool tasks and wait tasks with depends_on edges for sequencing. ${node_id.result} templates let upstream outputs flow into downstream task arguments; the resolution happens inside the orchestrator, not in the agent’s context.

{
"tasks": [
{ "id": "fetch_a", "type": "tool", "tool": "fetch_logs", "args": {"service": "auth"} },
{ "id": "fetch_b", "type": "tool", "tool": "fetch_logs", "args": {"service": "api"} },
{ "id": "summarize", "type": "tool", "tool": "summarize",
"args": {"sources": ["${fetch_a.result}", "${fetch_b.result}"]},
"depends_on": ["fetch_a", "fetch_b"] }
]
}

The incident triage sample in the azure-functions-agents-runtime repo shows this working end to end: an agent that fans out log fetches across multiple services, waits, then summarises the evidence.

What to know before using it. Dynamic workflows are experimental v1 with real constraints:

  • workflows.enabled: true is currently only honored on main.agent.md — dedicated agents can’t use it yet. That is flagged as a v2 constraint to lift.
  • v1 handlers must be synchronous. No per-task retry or timeout policies yet — those are v2.
  • The plan cap is 50 nodes, 10 parallel tasks, and a 24-hour maximum wait duration.
  • Completion is poll-based. The chat UI polls GET /agents/{slug}/workflows and injects a synthetic user message when a workflow reaches a terminal state, which triggers the agent to call get_workflow_status and summarise.

What comes next

The next post covers the fourth AI pattern: building RAG pipelines with Azure Functions, event-driven data retrieval at scale, the Azure OpenAI binding extension, and where APIM fits as the LLM gateway layer.

Up next: Building RAG Pipelines with Azure Functions: Event-Driven Data Retrieval at Scale

Agentic AI Design Patterns on Azure

Like a lot of people on LinkedIn right now, I have seen no shortage of infographics laying out agentic AI design patterns: tidy boxes and arrows for Tool Use, ReAct, Reflection, Planning, Orchestrator, Sequential Chain, Parallel Fan-out/Fan-in, Hierarchical, and P2P Mesh. These diagrams are a fine way to get the shape of an idea across. However, a diagram cannot tell you whether the thing actually works, or what breaks when you try to stand it up for real. So I spent the last stretch actually building agentic AI on Azure: all nine patterns, for real, on Azure’s PaaS offerings. That meant writing Bicep IaC, running azd up, making actual HTTP calls against the deployed endpoints, and running actual Application Insights queries when things went wrong. In the end I had nine independently deployable reference samples, not nine slides.

Writing the Bicep was the easy part. Getting all nine actually to stand up and respond correctly was where the real lessons were. This is a retrospective on what broke, why, and what I’d tell someone else to do to avoid it.

Overview diagram for building agentic AI on Azure, showing all 9 agentic AI design patterns mapped to an Azure PaaS reference implementation" title="Building Agentic AI on Azure: 9 Design Patterns Overview
All nine patterns, at a glance: the shape everyone’s LinkedIn infographic gets right. This post covers getting each one running on Azure.

Lesson 1: model availability is regional, and the error message won’t always tell you clearly

The first failure mode hit me repeatedly, across multiple patterns. azd up would warn that gpt-4.1 “was not found” in the target region, then fail validation outright with InvalidResourceProperties: The specified SKU 'Standard' for model 'gpt-4.1' is not supported in this region.

This is not a bug in the Bicep. It is genuinely true that not every Azure OpenAI model and SKU combination is available in every region, and availability also varies by subscription. centralus and westeurope both rejected gpt-4.1 for me at different points, while swedencentral consistently worked across every pattern in the repo. So if you are scripting or templating Azure OpenAI deployments, do not hardcode a region and assume it will work everywhere. Instead, treat azd env set AZURE_LOCATION swedencentral as a reflexive first move whenever a fresh pattern’s deploy attempt fails.

Lesson 2: Logic Apps Standard is powerful, and standing it up correctly is its own skill

The Sequential Chain pattern (input → extract → draft → validate → output, a fixed pipeline) is a textbook fit for a low-code workflow engine. So I built it on Logic Apps Standard first, and it ended up costing me, by a wide margin, the most debugging time of the whole project. Here is what went wrong, roughly in order:

  • ServiceProvider-type triggers cannot have a recurrence property. If you copy a recurrence block over from an ApiConnection-style trigger example, you get a WorkflowProcessingFailed error, and the message does not obviously point at the fix.
  • Connector parameter names are not always what you would guess. Azure literally names the built-in OpenAI connector’s connection parameter openAIEndpoint, not endpoint. I only discovered this from Azure’s own runtime error message, not by guessing at the schema.
  • Windows versus Linux hosting changes what app settings you need, and not symmetrically. Windows-hosted Premium plans require WEBSITE_CONTENTAZUREFILECONNECTIONSTRING and WEBSITE_CONTENTSHARE, but carrying those same settings over to a Linux plan actively breaks content sync.
  • WorkflowStandard (WS1) is a hard requirement, not a suggestion. Azure explicitly rejects an ElasticPremium (EP1) substitution with “Logic Apps can be deployed only to ‘WorkflowStandard’ Sku or App Service Environments,” even though the two SKUs share the same underlying compute family.
  • Regional SKU quota is real, and it will not show up until you deploy. SubscriptionIsOverQuotaForSku for “WS1 VMs” hit me in two different regions. I eventually fixed it by decoupling the Logic App’s region from the rest of the pattern’s resources through a separate Bicep parameter, which let it land somewhere with quota headroom.
  • Deterministic role-assignment names collide across resource recreation. If you name a role assignment with guid(scope, resourceId, roleName) and then delete and recreate the resource whose managed identity receives that role, Azure refuses to update the existing assignment to point at the new principal ID. So you first have to find and delete the stale assignment by its exact resource ID.
  • Finally, after fixing all of the above, I ran into a Logic App instance that had simply wedged itself: Sequence contains no elements on startup, Kudu unreachable or crawling, and no combination of RBAC or config fixes helping. The only real fix was deleting and recreating the whole site resource.

Durable Functions

After all that, I rebuilt the same pattern on Durable Functions instead. An orchestrator calls three sequential activities, each a plain Azure OpenAI chat completion call, and the built-in RetryOptions does the retry work that Logic Apps’ declarative retry policies would otherwise have done. It deployed in under two minutes and worked on the first real test. The conceptual case for a low-code workflow engine still holds here, since a fixed sequence of stages is exactly what that kind of tool is for. However, if you want to move fast while building agentic AI on Azure, and you already have a working Durable Functions convention elsewhere in your stack, do not underestimate how much extra surface area Logic Apps Standard adds compared with staying in code you already know how to debug.

Lesson 3: RBAC is not one system, and Cosmos DB has its own

The P2P Mesh pattern (three Azure Functions reacting to each other’s events through Event Grid, with Cosmos DB tracking completion state) threw a 403 Forbidden: "Request blocked by Auth... does not have required RBAC permissions to perform action Microsoft.DocumentDB/databaseAccounts/readMetadata". This happened even though the function app’s managed identity already had every role I expected under Microsoft.Authorization/roleAssignments.

Here is the catch: Cosmos DB runs its own, separate, native RBAC system. A normal Azure role assignment against a Cosmos account grants control-plane access, but it does nothing for data-plane operations like reading or writing items. Instead, you need a Microsoft.DocumentDB/databaseAccounts/sqlRoleAssignments resource, scoped specifically to one of Cosmos’s own built-in role definitions. (The “Cosmos DB Built-in Data Contributor” role has a fixed, well-known GUID: 00000000-0000-0000-0000-000000000002.) So if your Bicep only includes the generic role-assignment resource type pointed at a Cosmos account, it silently grants nothing useful.

(Once I fixed that, the very next error was a 404 reading “Owner resource does not exist” on the database and container the app code expected. Provisioning the Cosmos account is not the same as provisioning the database and container inside it. Remember that if your Bicep stops at the account level.)

Lesson 4: pick the “native” endpoint type when Azure offers one

Still on P2P Mesh: wiring Event Grid subscriptions to Azure Functions endpoints through a raw webhook URL (https://<app>.azurewebsites.net/runtime/webhooks/eventgrid?functionName=X&code=<key>) produced a persistent 401 Unauthorized, Webhook endpoint validation failed. It survived two fixes I tried first: swapping the master key for the eventgrid_extension system key, and then directly inspecting the deployed function, which confirmed its eventGridTrigger binding matched what Event Grid expected. Neither fix mattered, because a plain authenticated GET against that same URL came back 400 Bad Request. That proved the key itself was fine, so the actual problem had to be something specific to Event Grid’s own validation handshake against a webhook-typed endpoint.

The actual fix was switching az eventgrid event-subscription create from a raw webhook URL to --endpoint-type azurefunction --endpoint <function-resource-id>. That is the native destination type Event Grid offers for Azure Functions, and it authenticates through the ARM resource ID instead of a function key embedded in a URL. It worked immediately. So the broader lesson: if a platform offers a first-class integration type for a specific target, prefer it over hand-rolling the equivalent with a generic webhook. That way, you inherit the platform’s own handling of edge cases you would not think of yourself.

Lesson 5: “Azure AI Foundry” isn’t one resource type

The Orchestrator pattern, a central agent hosted on Azure AI Foundry Agent Service that delegates to specialist tools, failed with a raw DNS error: Name or service not known, against a hostname the app was trying to reach. This was not an auth failure or a permissions failure. The hostname genuinely did not exist.

The root cause: the Bicep provisioned the older Azure AI Foundry resource model, a Microsoft.MachineLearningServices/workspaces hub plus a project workspace, which is the original Azure AI Studio approach. Meanwhile, the app code used Azure.AI.Projects.AIProjectClient, which only understands the newer, unified Foundry resource model: a Microsoft.CognitiveServices/accounts resource with kind: 'AIServices' and allowProjectManagement: true, plus a projects child resource. That newer model is reachable at a https://<account>.services.ai.azure.com/api/projects/<project> URL that only exists for that resource type. So two things share the “Azure AI Foundry” name, yet neither is a drop-in substitute for the other from an SDK’s point of view. As a result, it is worth explicitly checking which resource model your SDK version actually expects before wiring up IaC for it, because the error you get when you guess wrong will not obviously point at “you deployed the wrong kind of resource.”

The smaller stuff that added up

A few recurring, less dramatic gotchas worth a line each:

  • Double-check the resource group you think you are querying. More than once I pulled exceptions from the wrong pattern’s Application Insights instance, either because I was still in the wrong working directory, or because I grabbed the first App Insights component Azure CLI happened to return rather than the one for the resource group I actually cared about. So always sanity-check the resource group name before trusting a KQL result.
  • A previous session’s PowerShell variable can look like a fresh result. If a command throws partway through an assignment, the variable keeps its old value. Then, if that old value happens to look plausible (in my case, a valid-looking Durable Functions status payload from an entirely different pattern), it becomes easy to mistake it for real output from the command you just ran.
  • “No results” and “the backing store does not exist yet” are different failure modes. Code that gracefully handles an empty index or table does not automatically handle a missing one. For example, one specialist service in the Orchestrator pattern caught SqlException broadly and degraded gracefully when its sample table lacked seed data. By contrast, the AI Search-backed specialist had no equivalent guard for a 404 on a not-yet-created index, so it took the whole request down with it. Therefore, if you document “unseeded stores degrade gracefully,” actually test the unseeded case for every specialist, not just the one you happened to build first.

The takeaway: what building agentic AI on Azure actually requires

None of these were exotic failures. In fact, every one of them turned out to be a documented, well-understood Azure behavior once I found the right doc page. What made them expensive was that the error messages, on their own, rarely pointed straight at the fix: a DNS failure that was actually a resource-model mismatch, a 401 that was actually about endpoint type rather than credentials, and a 403 that was actually a second, unrelated RBAC system. So the practical lesson is not “Azure is fragile.” Instead, building agentic AI on Azure means dealing with more independently moving parts than the getting-started docs suggest, and the fastest way through is treating every unexplained error as worth one layer deeper of investigation before you assume the obvious fix is the real one.

I have now deployed and tested all nine patterns end to end. If you want the full breakdown of each one, including what it is for, the Azure architecture, and when to reach for it, start with the design patterns overview and go from there. The repo itself is public, so you can see exactly what each pattern provisions.

The Azure Functions Serverless Agents Runtime Explained

Let’s discuss the Azure Functions serverless agents runtime. 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 serverless agents runtime is a programming model that lets you define agents as function apps. Events, schedules, messages, or HTTP requests trigger agents. 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 the Azure Functions serverless agents runtime works

The runtime is a programming model built on top of Azure Functions. When an event fires a timer, an HTTP request, or 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 or implement an agent loop. You define three files, deploy a function app, and the runtime does the rest.

Those three files are:

  • .agent.md — defines the agent: 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 the agents in the app

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 agents in a single function app, each defined in its own .agent.md file, all sharing the app-wide configuration.

What the Azure Functions serverless agents runtime deploys

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 * * *"
tools:
- mcp_server: outlook
---

The markdown body below contains the agent’s instruction set; it tells the agent what to gather, how to summarise it, and 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 lives here, so every agent uses the same Azure AI Foundry model unless you override it. This setting also defines the session pool endpoint for sandboxed code execution.

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

How Azure Functions Serverless Agents Runtime differs 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.

The serverless agents runtime 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
Need a fully managed agent host with enterprise SLAsFoundry Agent Service
Agents triggered by events, schedules, or queue messagesServerless agents runtime
Need VNet integration or custom deployment pipelinesServerless agents runtime
Want scale-to-zero billing for bursty agent workloadsServerless agents runtime
Need agents embedded in an existing function appServerless agents runtime
Prefer not to manage the agent host infrastructureFoundry Agent Service

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

How Azure Functions Serverless Agents Runtime differs 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.

The serverless agents runtime 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 the serverless agents runtime.

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 organizations have restrictions on which model deployments are permitted.

The azd template provisions real Azure resources with real costs. The Flex Consumption plan keeps costs very low for low-traffic agents, but the Foundry model deployment, Container Apps session pool, and Connector Namespace resources still incur 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. The runtime 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.

Agent Chat UI showing a response to "What is the weather in Amsterdam?" — current temperature 19°C (66°F), relative humidity 54%, wind speed 8.3 km/h, and a 3-day forecast showing today's high of 22.4°C with 2% precipitation chance, tomorrow's high of 19.7°C with 67% precipitation chance, and the day after at 19.7°C with 2% precipitation chance.
The weather agent runs on the Azure Functions serverless agents runtime, pulling live conditions and a 3-day forecast for Amsterdam from Open-Meteo via sandboxed Python code execution in an Azure Container Apps dynamic session. The agent is defined in a single main.agent.md file.

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

The Four Things Naive RAG Diagrams Leave Out

You might have seen the diagrams like four boxes, left to right, with indexing, retrieval, augmentation, and generation. Parse the PDF, chunk the text, embed the chunks, store the vectors. Then embed the question, search, stuff the results into a prompt, and generate. I have seen it circulating every few weeks with a fresh coat of branding and a caption promising an end to hallucination.

The diagram is not wrong. It is a decent first explanation of naive RAG. The problem, however, starts when someone treats it as a design. TThe version I saw recently ended its augmentation box with three words: zero hallucination guaranteed.

That claim is where I want to start, because it is the tell. Retrieval-augmented generation reduces fabrication. It does not eliminate it. Anyone promising zero has not yet run an evaluation against their own system.

So here are the four things the four-box picture leaves out, in the order they will hurt you. I have put runnable samples for each one in a companion repository.

Gap 1: Retrieval is not the same thing as vector search

Naive RAG diagrams draw a single arrow from question to embedding to vector database. That works beautifully in demos, because demo questions are written in the same register as the source documents.

Production questions are not. They contain product codes, policy numbers, abbreviations, proper nouns, and negations. Embeddings capture meaning, and a product code has no meaning to capture.

I built a small corpus to measure this rather than assert it: eight synthetic Dutch policy documents, two consecutive years of the same policy, a collective variant with a structurally identical pricing table under different codes, and a separate reglement for medical aids. Thirty-three chunks. Then eleven questions with a known correct chunk for each.

What vector-only missed

Vector-only retrieval got seven of the eleven right. The failures were not random:

  • Which discount applies to code BAS-VR-400? returned the document’s changes section, which names the code but never prices it. Right document, wrong section, and the retrieved chunk looks relevant enough to answer from.
  • Does medical acceptance apply to package AANV-CO-03 in 2026? returned the 2025 document, which says the opposite: right topic, wrong year, inverted answer.
  • How many physiotherapy treatments are in the Extra package in 2025? returned a chunk from the basic policy entirely.

That second one is the one that should worry you. It is not a near miss. The prose in the two years is nearly identical, the answer is reversed, and nothing downstream can tell. An assessor reading a fluent, cited, confidently wrong answer about acceptance criteria has no signal that anything went sideways.

Keyword search, meanwhile, nails the code lookups. Okapi BM25 has been solving this problem since before any of us had an opinion about transformers.

Worth being precise about what this does and does not prove. My first version of this corpus had three documents and eight chunks, and vector-only scored four out of five, because with eight chunks there is nothing to confuse. The gap only appears once the corpus contains things that genuinely resemble each other. If your own evaluation shows dense retrieval doing fine, check whether your test set is hard before concluding your pipeline is.

Fusion widens the pool, reranking picks the answer

Therefore, the answer is not to pick a side. Azure AI Search will run both and fuse the result lists with reciprocal rank fusion. Then a semantic reranker reorders the fused list using a cross-encoder that actually reads the query against each candidate.

Here is where my expectations were wrong, and where the measurement earned its keep. Across the eleven questions:

StrategyTop-1 correctMRR@5
Vector-only7 / 110.77
Hybrid (BM25 + vector, RRF)6 / 110.72
Hybrid + semantic reranker11 / 111.00

Adding keyword search made it worse. Hybrid lost a case that vector-only got right, and fixed none.

Why fusion alone went backwards

The reason is visible in the failures. BM25 matches the literal string BAS-VR-350, and that code appears in the document’s changes section, which names codes without pricing them. Lexical matching therefore promoted chunks that contain the code and cannot answer the question. Reciprocal rank fusion then faithfully merged two ranked lists, because RRF has no notion of whether a chunk answers anything. It fuses positions, not relevance.

The cross-encoder is what fixed it. It reads the question against each candidate and understands that a question about a discount needs the row with a price in it, not the sentence announcing that the code exists. That took the same candidate set from six correct to eleven.

So the lesson is sharper than “use hybrid search”. Hybrid retrieval widens the candidate pool; reranking is what converts a wider pool into better answers. Ship the first without the second, and you may go backward quietly, because nothing in the pipeline reports that it happened.

Diagram comparing vector-only retrieval, which returns the wrong table row, with hybrid BM25 and vector search fused by RRF and reordered by a semantic reranker, which returns the correct row.
Fusion widens the candidate pool; the cross-encoder is what turns it into a correct answer.

One clean question per turn is an assumption

Query handling is the other half of this gap. The diagram assumes one clean question per turn. Real questions arrive compound: we switched to the Compleet package in March, does my son’s dental work fall under that or under the basic policy, and does the deductible apply? That is three questions. Embed the whole sentence, and you retrieve the average of three intents, which is nothing in particular.

Agentic retrieval in Azure AI Search handles that by decomposing the query into subqueries, running them in parallel, reranking each, and merging. Extractive retrieval went generally available in API version 2026-04-01. Query planning and answer synthesis remain preview. Worth knowing which half you are depending on before you promise it to a steering committee.

Gap 2: Chunking is most of the work

“Chunk text for sharp recall.” One bullet. In practice, this single decision determines more of your answer quality than your choice of model.

Fixed-size splitting is what every quickstart does and what almost nothing should do. Run a 400-character window with 50 characters of overlap over a document containing a pricing table, and the splitter lands mid-row. This is the actual output from the sample, not an illustration:

                        | EUR 3,00          | BAS-VR-100  |
| EUR 200 | EUR 6,50 | BAS-VR-200 |
| EUR 300 | EUR 10,00 | BAS-VR-300 |
| EUR 400 | EUR 14,00 | BAS-VR-400 |
| EUR 500 | EUR 19,00 | BAS-VR-500 |

## 2. Fysiotherapie

Fysiotherapie wordt vanaf de 21e

Look at what survived. No header row, so nothing says which column is the deductible, which is the monthly discount, and which is the product code. The first row is cut mid-cell: its deductible tier is gone, leaving a discount attached to nothing. No document title, so nothing says this is the 2026 basic policy rather than the 2025 one or the collective variant, all three of which carry a table of exactly this shape with different numbers. And the chunk runs on into an unrelated section about physiotherapy, ending mid-sentence.

Retrieve that, and the model has to guess. It will guess. It will sound certain. And the citation attached to it will make the wrong answer more credible, not less.

Diagram showing a fixed-size splitter cutting a pricing table in half so one chunk holds rows without a header, next to structure-aware chunking that splits on headings and keeps the table intact.
The headerless-table count is a defect count. Each one can produce a confident wrong answer about a product code.

What structure-aware chunking does differently

The sample runs three chunkers over the same document and counts how many chunks ended up holding table rows with no header. Fixed-size produces one out of three. Recursive paragraph splitting produces none, but leaves every chunk without a section heading. Structure-aware produces four chunks, none headerless, none context-free.

The difference is three rules, and none of them is clever: split on headings rather than character counts, never split a table, and prepend the document title and section heading to every chunk so an isolated chunk still says what it is.

That last rule is what makes the three near-identical pricing tables in this corpus distinguishable at all. Without it, retrieval has to tell them apart on the numbers alone.

Gap 3: Retrieval without authorization is a breach with a chat interface

This is the gap that should worry you most, and it is absent from every version of the diagram I have seen.

Put every document in one index. Wire up a chat interface. Now every user can reach every document, because semantic search does not know about your authorization model. The retrieval layer will happily surface an internal work instruction, an HR file, or a legal memo to whoever asks a question shaped roughly like its contents.

The filter is a query construct, not a prompt instruction

Two things follow. First, the filter is a server-side query construct, not a prompt instruction. Telling the model “only use documents the user may see” is not a control; it is a suggestion to a system that has already been handed the text. Second, the filter must derive from validated token claims, never from anything the user typed.

Diagram showing a single unfiltered index returning a restricted work instruction to a customer service agent, and a corrected pipeline where an OData filter built from validated token claims trims results before the model sees them.
The filter belongs in the query. An instruction in the system prompt is a suggestion to a model that already has the text.

Two mechanisms, and the one that fails open

Azure AI Search gives you two mechanisms. The durable one is an explicit filterable collection of group identifiers on each document plus an OData filter built from the caller’s claims, which works today on the stable API and which you own end to end. The managed one ingests RBAC scopes, ACLs, or Purview sensitivity labels alongside the content and enforces them at query time when you pass the user’s token in the x-ms-query-source-authorization header.

The managed route has a sharp edge worth memorizing. If the knowledge source was created without ingestionPermissionOptions, the index holds no permission metadata, and results come back unfiltered regardless of the header. It fails open quietly, and the only way to fix it is to recreate the knowledge source. As of the current GA release, document-level permissions on indexed sources remain in preview.

Whichever you choose, write the leak test. The sample repository includes one: a query, an unauthorized caller, and an assertion that fails the build if the restricted document comes back. Twelve lines. Run it in CI.

Gap 4: “Zero hallucination” is a claim, and claims get measured

Grounding the prompt does not guarantee a grounded answer. Three failure modes survive the diagram intact.

The model can prefer what it already knows over what you retrieved. Ask about a monthly premium that appears nowhere in your corpus, and a model trained on the open internet has plausible Dutch premiums available. It will produce one.

The model can blend two chunks into a claim neither of them makes. This is the subtle one, because every individual fact traces back to a source.

And the model can answer confidently when retrieval returned nothing relevant at all, because nothing in the naive RAG pipeline tells it that “I do not know” is an available output.

Diagram of three hallucination modes that survive naive RAG — parametric leakage, blended claims, and no refusal path — alongside a corrected pipeline with citation-enforced prompting and a judge producing four scores.
Refusal rate is the number that separates grounded from fluent.

Three rules that make it measurable

Consequently, the fix is threefold and unexciting: an explicit refusal string in the prompt so refusal is detectable rather than inferred, mandatory citation of a reference identifier after every claim so each statement is checkable, and an evaluation set that contains questions your corpus cannot answer.

That last point is the one most teams skip. Everyone builds a golden set of questions the documents answer well. Almost nobody includes the withdrawn product code, the topic that was never documented, or the answer that lives in a file the user may not see. Those are exactly the cases that generate the incident report.

Score retrieval and generation separately, too. A wrong answer with good retrieval is a generation problem. A wrong answer with bad retrieval is a retrieval problem. Without both numbers, you will spend a week tuning the wrong half of the system.

What the numbers actually said

Then the result, which surprised me: fourteen out of fourteen on groundedness and valid citations, and five out of five refusals on the unanswerable questions. The model corrected false premises rather than accepting them; asked whether medical acceptance applied in 2026, it answered yes and cited; asked about a product code withdrawn before the corpus begins, it refused outright rather than interpolating a plausible price from the neighboring rows.

Terminal output of an evaluation run over fourteen questions: nine answered with citations, five refused, scoring 14/14 on groundedness, citation validity and retrieval hit, and 5/5 on refusals.
The point of gap 4 is that this output exists at all. An ungrounded system produces no such table, only fluent answers and no way to tell.

I want to be careful about what that number means, because it is easy to oversell in the other direction. Retrieval hit fourteen out of fourteen in the same run, so generation was working from good material throughout. This is not evidence that hallucination is solved. It is evidence that the three unexciting mechanisms above are sufficient when retrieval is doing its job, which is the argument for building all four gaps rather than any one of them.

One caveat I would want stated if someone showed me this number: the judge was the same model as the generator. A model scoring its own output shares its own blind spots, and the groundedness figure is inflated to an unknown degree by that. Use a different model for judging if the number needs to carry weight.

Where this is the wrong answer

If your corpus is fifty pages of prose, with no product codes, no tables, one audience, and low stakes, then the four-box diagram is enough. Hybrid retrieval, structure-aware chunking, security trimming, and a groundedness harness are all overhead you do not need yet. Build the simple thing, ship it, and see what breaks.

The four gaps become urgent at specific, recognizable moments: the first exact-match question that returns the wrong table row, the first document that should not be visible to everyone, and the first stakeholder who asks how you know the answers are right. If none of those have happened, you are fine.

The part the diagram gets right

Context beats prompt engineering. That much is true, and it is the reason the picture keeps circulating. But “give the model the right information at the right time” restates the problem. It is not a solution. The right information depends on hybrid retrieval and reranking. The right time depends on query decomposition. Whether the user was entitled to that information depends on trimming. And whether the model actually used it depends on evaluation.

Do all four and the numbers hold up — mine did. Skip any one of them, and you will not find out which one you skipped until someone asks a question about last year’s policy and gets this year’s answer.

Four boxes, four gaps. The gaps are where the engineering lives.

The samples are at steefjan1/naive-rag-gap: hybrid retrieval and reranking, chunking, security trimming, and groundedness evaluation, provisioned with a single azd up. Four of the five run end to end against a live service; the agentic retrieval sample needs a knowledge base that none of the scripts create, so treat that one as a sketch rather than a worked example.

They target Azure AI Search API versions current as of August 2026. Agentic retrieval and document-level permissions are both moving quickly, so check the docs before assuming a preview flag is still a preview flag.

Citadel Grew Up: What the citadel-v1 Release Means If You Built on the AI Hub Gateway.

The Citadel Governance Hub accelerator that sits underneath my entire five-part Citadel Platform series just had a significant release. In addition, the citadel-v1 branch of the AI Hub Gateway Solution Accelerator repositions the project from “a solid APIM gateway pattern” to the official reference implementation of Layer 1 in Microsoft’s AI Citadel Blueprint.

I cloned the branch and went through it with one question in mind: what does this change for anyone who, like me, deployed and built on the earlier iteration? The answer starts with one finding. As a practitioner, the whole point of this blog is honesty: the API surface I used throughout the series is now explicitly labeled legacy.

Note that citadel-v1 has not yet been merged to main; if you deployed from the main branch without specifying --branch citadel-v1, you are on the earlier architecture.

Let’s start with the bigger picture, then get to that.

The 4-layer AI Citadel Blueprint

The README now frames the accelerator as one layer of a larger architecture. The AI Citadel Blueprint describes four interlocking layers, each with its own responsibility and implementation:

  • Layer 1, the Governance Hub, is this accelerator: runtime enforcement through a unified AI gateway, policy-as-code, identity validation, token rate limiting, content filtering, and cost attribution—everything my series built and tested lives in this layer.
  • Next, layer 2, AI Control Plane, covers the agent runtime, observability, and compliance: agent traces, AI evaluations, and fleet operations, implemented through the Microsoft Foundry control plane.
  • Subsequently, layer 3, Agent Identity, handles agent identity and lifecycle governance through Agent 365: unique agent identities, blueprints, shadow agent detection, and a sponsorship model.
  • And finally, layer 4, the Security Fabric, provides unified protection through Microsoft Defender for AI threat intelligence, Purview for data governance, and Entra for authentication and authorization.
Stack diagram of the four AI Citadel Blueprint layers: Security Fabric, Agent Identity, AI Control Plane,, and Governance Hub at the base. A bracket marks Layer 1 and part of Layer 2 as covered by the Citadel blog series, with Layers 3 and 4 marked not yet explored.
The four layers of the AI Citadel Blueprint, with the series’ coverage marked: Layer 1 fully, Layer 2 partially through the registry work.

Looking back at the series through this lens, my five posts covered Layer 1 thoroughly, and the registry work with Azure API Center reached into Layer 2 territory before the layer had that name. The kill switch from Part 4 sits squarely in Layer 1 as runtime enforcement. What the series never touched, and what I now have vocabulary for, is Layers 3 and 4. That’s useful: it turns “what’s missing from my platform” from a vague feeling into a named checklist.

What citadel-v1 Changes in the AI Hub Gateway: The API Surface

Here’s the finding that matters most if you followed the series. The new LLM Access Guide defines three API surfaces on the gateway, and it’s blunt about which one you should use.

The Azure OpenAI API surface, at /openai/deployments/{deployment-id}/*, preserves the exact URL shape the Azure OpenAI SDK expects. This is what Part 2 of my series wired the weather agent against, and it’s what every code sample in the series uses. The guide now labels it “legacy integration only,” for existing code that pins that URL shape. Not the target state for new work.

The Universal LLM API, at /models/*, exposes a clean OpenAI v1-compatible surface across many models and providers through a single stable path.

The Unified AI API, at /unified-ai/*, is the recommended surface: a single wildcard endpoint that serves OpenAI-compatible calls and every provider-native pattern with dynamic routing behind it.

LLM ACCESS guide

The citadel-v1 branch documents these three surfaces in its LLM access guide. Check the guides folder in the branch for the current filename, as the documentation is actively evolving.

Comparison of three API surfaces in citadel-v1: the Azure OpenAI API at /openai/deployments marked legacy, the Universal LLM API at /models as a valid alternative, and the Unified AI API at /unified-ai marked recommended. A dashed arrow shows the migration path from the legacy surface to the recommended one.
Three API surfaces on the citadel-v1 gateway. The path my series used is now labeled legacy; nothing broke, but the arrow points one way.

I want to be precise about what this does and doesn’t mean. Nothing broke. Code targeting /openai/deployments/... keeps working, and the surface exists precisely because migrations take time. But the arrow points one way: new integrations should target /unified-ai/v1/*, and my series should be read with that footnote attached. If I started the series today, Part 2 would look different.

This doesn’t invalidate the architectural argument, and I’d argue it strengthens it. The reason the series routed the standard OpenAI SDK through APIM was to keep every call on a governed path. The Unified AI API is that same principle with a better front door: one endpoint, every provider, every pattern, all governed. The lesson survived the release; only the URL changed. Citadel is evolving fast, and this is what evolving looks like from the inside.

Contract-driven everything

The second big theme in citadel-v1 is contracts, and if you read my registry post about the AI Publish Contract, this will feel familiar in the best way.

The accelerator now ships a Citadel Access Contract package: declarative, version-controlled .bicepparam files that onboard an AI use case end-to-end. One contract deployment creates the APIM product (with naming like LLM-Healthcare-PatientAssistant-DEV), the subscription with its key, optional Key Vault secret storage, and optionally an APIM connection for Microsoft Foundry agents. I described this as a pattern worth building in the registry post, and the access contract is now live while the publish contract remains upcoming in the current release.

Alongside it sits a backend onboarding contract (llmBackendConfig) for declaratively registering LLM backends, and the whole thing is versioned through a release.json manifest at the repository root. That manifest is worth a moment of appreciation: instead of one monolithic version number, it tracks independent, component-scoped versions for the routing logic, the backend contract shape, the access contract shape, and the usage ingestion pipeline. A change to routing doesn’t force a re-version of contracts that didn’t change. That’s a small design decision that signals the project expects to be operated, not just deployed once.

The parallel to the AI Publish Contract from my registry post is direct. Both encode the same conviction: onboarding an AI workload should be a reviewed, versioned artifact in a repository, not a sequence of portal clicks someone half-remembers. The access contract governs how a workload reaches the gateway. The publish contract governs registration and description. A mature platform wants both.

Multi-provider routing, briefly

The gateway is no longer an Azure OpenAI front door with ambitions. AWS Bedrock, Google Gemini, and Anthropic Claude are first-class citizens, each available through OpenAI-compatible access, provider-native access, or both.

The design that makes this work without chaos is a fragment-based routing architecture, and one detail from the onboarding guide shows how much operational scar tissue is encoded in it. Every API type declares its own compatible pool types, and the Universal LLM API restricts pool selection to OpenAI-compatible pools before backend selection runs. Why? Because if the same model ID is registered against both a native Bedrock pool and an OpenAI-compatible one, a naive router could send an unrewritten OpenAI-shaped path to the native provider, which answers with something as friendly as com.amazon.coral.service#UnknownOperationException. The guide documents the failure mode by name. Someone hit that error so you don’t have to, which is exactly what a good accelerator encodes.

For a platform team, the practical consequence is real: model choice becomes a routing decision instead of an architecture decision. Adding Claude or Gemini to an estate governed by the hub doesn’t create a second governance perimeter. It adds a backend behind the one you already operate.

What I’d do differently starting today

Distilling this into advice for anyone deploying now:

Target the Unified AI API from day one. Start at /unified-ai/v1/* with the standard OpenAI SDK. You get the same governed path my series argued for, plus provider reach and a native-access upgrade path you’ll eventually want.

  • Adopt the access contract instead of hand-rolling onboarding. The .bicepparam contract per use case gives you reviewable, repeatable onboarding with product, subscription, and secrets in one deployment. I built a weaker version of this by hand during the series; you don’t have to.
  • Pin your contract versions consciously. release.json gives you independent version tracks. Treat contract shape changes as reviewable events in your own repo, the same way you’d treat an API schema change.
  • Look at the PII blocking mode. The PII framework now supports managed identity authentication to the Language Services, regex pre-processing before NLP detection, and a strict mode that rejects requests containing PII with a 400 instead of masking. For regulated industries, that hard-fail option changes the compliance conversation: some data should never reach the model, masked or not.

What’s next

The obvious follow-up experiment: migrating the weather agent from the legacy /openai/deployments/... path to the Unified AI API, documenting whatever breaks along the way. If the routing architecture delivers on its promise, that migration should be a base-URL change. If it isn’t, that’s a post worth writing too.

The accelerator that started this series as a useful pattern is now the reference implementation of a named layer in a published blueprint, with contracts, multi-provider routing, and a defined seam toward agent-runtime governance. Preview or not, the direction is clear, and it’s the direction the series has been arguing for all along: one governed front door, everything registered, nothing invisible.

If you’ve deployed citadel-v1 or migrated from the earlier iteration, I’d like to hear what surprised you.

Azure Governance and Identity for Integration Architects

In the Azure PaaS map post, governance and identity got a paragraph and a firm line: managed identity everywhere, secrets in Key Vault, posture visible. That paragraph is the one most PaaS write-ups skip; they cover compute, messaging, and data, then wave at security on the way out.

For an integration platform in a regulated industry, though, this layer isn’t the afterthought. It’s the part that decides whether the thing can run at all. So this post gives it the space the map couldn’t. Azure governance and identity are where a proof of concept becomes something an auditor will sign off on, and where many otherwise good architectures quietly fail their first compliance review.

The two jobs this layer does

Governance and identity sounds like one concern. In practice it’s two, and keeping them apart makes the design clearer.

Diagram splitting the layer into two columns. Identity answers "who" and contains Microsoft Entra ID, managed identities, and RBAC role assignments. Governance answers "whether and how" and contains Azure Policy, Defender for Cloud, and the audit trail. A note reads that both are always required.
Identity answers “who”: Entra ID, managed identities, RBAC. Governance answers “whether and how”: policy, D\defender, the audit trail. Identity without governance is secure but unprovable; governance without identity is documented but wide open. A platform needs both.

Identity answers who. Who is this caller? What are they allowed to reach? This is Entra ID, managed identities, and role assignments the machinery that authenticates and authorizes every hop in the platform.

Governance answers whether and how. Is this configuration allowed? Can we prove it stayed allowed? This is Azure Policy, Defender for Cloud, and the audit trail the machinery that constrains what the platform can be and evidences it after the fact.

An integration platform needs both. Identity without governance gives you a secure platform you can’t prove is secure. Governance without identity gives you a well-documented platform anyone can walk into. So let’s take each in turn, then the compliance frame that ties them together.

Identity: managed identity as the default

Here’s the single most important rule in this layer. Every service authenticates as itself, with no secret in configuration. That’s what managed identity gives you, and it’s the foundation everything else sits on.

Without it, you’re back to connection strings and API keys scattered across app settings and config files. Each one is a secret that can leak, expire, or get committed to a repo by accident. With managed identity, the platform issues each service an identity in Entra ID, and that identity authenticates directly to Key Vault, SQL, Service Bus, and Storage. No secret changes hands. Nothing to rotate, nothing to leak.

A few design points that matter for integration specifically:

  • System-assigned or user-assigned? A system-assigned identity is tied to one resource and dies with it. A user-assigned identity is a standalone resource you attach to many. For an integration platform with several services that need the same access, a user-assigned identity is usually cleaner: you grant permissions once and attach it everywhere, rather than managing a dozen separate grants.
  • Least privilege, per service. Managed identity makes authentication clean, but authorisation is still on you. Each service should hold exactly the roles it needs and nothing more. The integration service that reads from Service Bus doesn’t need write access to the whole storage account. So scope the role assignments tightly, because a broad grant is a standing risk long after anyone remembers making it.
  • RBAC over access policies. Where a service supports Azure RBAC for its data plane, Key Vault now prefers it over the older per-resource access policy model. RBAC gives you one consistent permission model across the platform, which is far easier to audit than a patchwork of resource-specific policies.

Identity: Entra ID and the human side

Managed identity handles service-to-service. Entra ID also governs the human and external edges of the platform.

For inbound calls, Entra ID handles authentication and authorisation validating tokens, enforcing scopes, and applying conditional access where the risk warrants it. App Service and API Management both integrate with it directly, so the platform can offload the whole OIDC flow rather than hand-rolling token validation. That’s less code and, more to the point, less code to get wrong.

The honest note: Entra ID’s built-in flows are excellent for standard cases and awkward for unusual ones. If your authorization logic is genuinely complex, with per-tenant rules, dynamic scopes, and fine-grained resource permissions, understand where the platform’s built-in handling stops and your own logic has to begin. Leaning on Easy Auth for something it wasn’t designed for is a common way to end up with authorization gaps you don’t discover until an audit.

Secrets: Key Vault for what can’t be an identity

Managed identity removes most secrets. It doesn’t remove all of them. Third-party API keys, certificates, signing keys these can’t be a managed identity, so they need somewhere safe to live. That’s Key Vault.

Flow diagram. An integration service authenticates as its user-assigned managed identity in Entra ID. That identity reaches SQL, Service Bus, and Storage directly with no secret changing hands. For third-party API keys and certificates that can't be a managed identity, the service reads them from Key Vault at runtime. A note explains that rotating a secret becomes a Key Vault operation rather than a redeploy, and every read is logged.
A service authenticates as its Entra ID-managed identity, reaching SQL, Service Bus, and Storage with no secrets changing hands. For the few secrets that can’t be an identity-based third-party key or certificate, it reads them from Key Vault at runtime using the same identity.

The pattern is straightforward. Secrets live in Key Vault, and services read them at runtime using their managed identity. No secret sits in configuration; the app holds a reference, and the platform resolves it. As a result, rotating a secret is a Key Vault operation, not a redeploy.

For a regulated integration platform, Key Vault also stores the certificates that underpin private connectivity and signing, and it provides an access log of every secret read, which matters more than it sounds, because “who accessed this key and when” is a question auditors actually ask.

Governance: policy, posture, and the audit trail

Identity secures the platform. Governance proves it and constrains it so it can’t drift out of compliance.

Azure Policy enforces the guardrails. Policy lets you assert rules across the platform and block or flag anything that violates them. No public endpoints. Encryption required. Approved regions only, which matters directly under data-residency rules. Policy is what stops a well-meaning change from quietly breaking a compliance requirement, because it refuses the change rather than trusting everyone to remember the rule.

Defender for Cloud gives you posture. It surfaces misconfigurations, missing controls, and active threats across the platform, and scores you against benchmarks. For an integration platform touching regulated data, that continuous posture view is the difference between finding a gap yourself and having an auditor find it for you.

The audit trail evidences everything. Azure Monitor and the activity log record what changed, who changed it, and when. Under most compliance regimes, you don’t just have to be secure; you have to prove you were secure, continuously, over time. The audit trail is that proof. So wire it in from the start, because you can’t reconstruct an audit trail you didn’t capture.

The compliance frame: why this layer is non-negotiable

Everything above applies to any serious platform. In a regulated Dutch healthcare context, though, the frame is sharper, and it’s worth naming the constraints that turn best practices into requirements.

Under the AVG, the Dutch implementation of the GDPR imposes strict obligations on the handling, minimization, and residency of personal data. Approved-region policy, tight role scoping, and the access trail stop being good hygiene and become the evidence you’re meeting those obligations. NEN 7510, the Dutch standard for information security in healthcare, adds specific controls around access management and traceability that map almost directly onto Entra ID role assignments and the audit trail. DORA brings operational-resilience and third-party-risk requirements that lean on the same posture and logging foundations. And the EU AI Act, where the platform touches AI workloads, layers on transparency and oversight duties that again rest on identity and audit.

The through-line: these regimes don’t ask for exotic new machinery. They ask you to apply identity, policy, and audit rigorously, and to prove it. So the governance layer isn’t compliance overhead bolted onto the architecture. Done right, it is the compliance posture, expressed as configuration.

Where this layer gets over-applied

Consistent with the series the honesty section. Rigour in this layer is right; ceremony for its own sake is not.

  • Not every secret needs a Key Vault reference if it isn’t a secret. A non-sensitive configuration value doesn’t belong in Key Vault just because everything else does. Reserve it for things that actually need protecting, or you bury the real secrets in noise.
  • Not every workload needs the strictest tier of every control. A platform handling public reference data doesn’t need the same isolation and scrutiny as one touching patient records. Match the rigour to the data classification, rather than applying maximum controls uniformly and paying for it in friction everywhere.
  • Policy that only flags is policy that gets ignored. A pile of advisory policies nobody acts on is worse than a few enforced ones, because it creates the appearance of governance without the substance. Enforce what matters; don’t drown the signal.

The shape of it

For an integration architect, Azure governance and identity are the layer that decides whether the platform is allowed to exist, not just whether it works. Managed identity removes the secrets. Entra ID governs who gets in. Key Vault holds what’s left. Policy constrains the platform, Defender watches it, and the audit trail proves it continuously, the way every regime from AVG to the EU AI Act actually demands. Get this layer right, and compliance stops being a gate you dread. It becomes a property the architecture already has.

Want the layer this sits inside? The Azure PaaS map puts governance in context against compute, integration, and data, and walks the five-question framework across all of them.

Managed Identity in Logic Apps Standard: A Zero Trust Read

For years, Managed Identity has been the easy part of a Zero Trust story on Azure right up until a developer opened VS Code. Deployed Logic Apps could already authenticate to Entra-protected resources with no secrets in sight. The local dev loop, however, couldn’t. You’d wire up a connection string or a local key to run and debug, then swap it out for Managed Identity before deployment. Two auth models, one workflow, and a seam that every “we don’t store secrets” policy quietly depended on someone remembering to close.

Wagner Silveira’s TechCommunity walkthrough, Use connectors with Managed Identity in the Logic Apps Standard extension, covers an update that closes that seam. You can now build connectors — both Azure managed connectors and service provider connectors — that use Managed Identity as the authentication parameter, and run them locally while you develop and debug. Locally, the extension authenticates as your signed-in developer identity through the Azure default credential pattern. After deployment, that same connection authenticates as the app’s managed identity instead. In other words: same connector definition, same auth model, just a different identity behind it depending on where it runs.

Why this is a Zero Trust story, not just a DX improvement

It’s tempting to file this under developer experience and move on. I’d argue it belongs in the identity and access conversation instead.

Zero Trust asks for consistent, identity-based enforcement everywhere, not only in production, where the compliance team is watching, but in every environment a workload touches. The dev-to-prod credential swap was a structural exception to that principle: a place where the enforced pattern was “do it properly,” and the practiced pattern was “do it however runs today.” Local .env files and connection strings scoped to a developer’s convenience tend to outlive the sprint that created them. As a result, it’s usually the dev environment, not prod, where stray credentials pile up quietly, invisible to your access reviews.

Collapsing local and deployed auth onto the same Managed Identity model doesn’t just remove secrets from one more service. More importantly, it removes the exception itself. There’s no longer a version of “getting this connector running” that skips Entra-issued, RBAC-governed identity. That’s the real Zero Trust gain here: not that a secret disappeared, but that a place secrets used to hide no longer exists.

Where this doesn’t do the work for you

Managed Identity is authentication, not authorization, and this update doesn’t change that. It gets you a verified identity at the door; it says nothing about what that identity is allowed to touch once it’s inside. Silveira is direct about this in his post; if a connection throws an authorization error, check the RBAC role assignment on the target resource first, because Managed Identity will happily authenticate an identity that’s been granted far more access than the workflow actually needs.

In practice, that means the credential-less win is only as good as the RBAC discipline behind it. An identity with Contributor on a resource group, because nobody wanted to think about scoping, isn’t meaningfully more “Zero Trust” than a connection string sitting in a config file; it’s just a differently shaped version of too much trust. If you’re rolling this out, the RBAC assignment deserves the design review; the connector configuration doesn’t.

There’s also a smaller, practical catch worth knowing before you build on this: the Managed Identity path for managed connectors doesn’t populate dynamic values in the designer. You lose the friendly dropdowns and supply values manually instead. It’s a minor friction, but it’ll surprise the first person on your team who hits it mid-demo.

Try it yourself

I’ve put together a small sample project, a Logic Apps Standard workflow that lists blobs on a schedule, authenticated with Managed Identity both locally and once deployed, plus Bicep that provisions the whole thing end to end (including a role assignment scoped to exactly one storage account, not the resource group). azd up gets you a running workflow; no manual portal clicking required.

Grab it from the sample repo (link once published) and run:

azd auth login
azd up

One setting made the difference between “deploys clean” and “actually authenticates”: WORKFLOWS_AUTHENTICATION_METHOD, set to managedServiceIdentity on the deployed app. It’s easy to miss; I did, in an earlier version of this sample, because the connection, the access policy, and the RBAC role assignment can all be independently correct, and the workflow will still fail at runtime with a bare Key 'token' not found in connection profile until that setting is in place. If you deploy this yourself and hit that exact error, that’s almost certainly why.

The governance question this actually raises

Platform and integration teams have mostly answered “can we authenticate without secrets here?” The harder question left standing is: do we, consistently, everywhere, including local dev? This release removes the last technical excuse for Logic Apps Standard. What’s left is a policy and habit question. Does your team’s definition of “done” for a new connector include verifying it never touched a stored credential, in any environment? Or does that check still only happen at the production gate?

Worth asking before the next connector goes into a workflow, not after.

Further reading: Wagner Silveira’s original walkthrough, Use connectors with Managed Identity in the Logic Apps Standard extension, on the Azure Integration Services Blog.

Azure Data Patterns for Integration Architects

In the Azure PaaS map post, the data layer got one paragraph and a rule: pick by access pattern, not by which service feels modern. That rule holds. But it’s also where most write-ups stop: SQL for relational integrity, Cosmos DB for scale, and Redis in front; for an integration architect, that’s the least interesting part of the story.

The interesting part is what the data layer has to do that’s specific to integration. Messages arrive twice. Workflows run for hours and need somewhere to keep their state. A write to your database and a publish to a queue have to succeed or fail together. So this post skips the service comparison and covers the patterns instead. Azure data patterns for integration are less about which store you pick and more about how you use it.

Why integration data is different

A typical application owns its data. It writes, it reads, it controls the whole path. Integration doesn’t work that way. Instead, integration sits between systems it doesn’t own, reacting to events it didn’t originate, and it has to stay correct when those systems misbehave.

That changes what the data layer is for. It’s no longer just persistence. It becomes the place where you enforce correctness that the messaging layer can’t guarantee on its own. Three patterns come up again and again. Let’s take them in turn.

Pattern 1: Idempotency stores

Here’s the problem. At-least-once delivery is the norm for most messaging systems, including Service Bus. So the same message can arrive twice after a retry, a redelivery, or a consumer crash-and-restart. Process it twice, and you’ve charged the card twice or created two orders. That’s not a rare edge case. In a busy integration platform, it’s a Tuesday.

Flow diagram of an idempotency store. A message that may arrive twice reaches a decision: has this ID been seen before? The check queries an idempotency store in Cosmos DB or Redis, keyed by ID with a TTL. If yes, the handler skips the duplicate. If no, it records the ID and processes the message once.
At-least-once delivery means the same message can arrive twice. Check the ID against a store first; skip if seen; record and process once if not.

The fix is an idempotency store. Before you process a message, you check whether you’ve seen its ID before. If you have, you skip it. If you haven’t, you record the ID and proceed. As a result, duplicate deliveries become harmless.

The design questions that matter:

  • Where does the key come from? Ideally, the source system supplies a stable business key, an order ID, and a transaction reference. Failing that, a hash of the message content works, though it’s more fragile.
  • Where do you store it? This is a high-frequency, low-latency lookup on a single key. Therefore Cosmos DB or Redis fit well, and a relational table works too if the volume is modest. The access pattern points at the store, exactly as the map post argued.
  • How long do you keep it? Retention has to outlast the longest possible redelivery window. Too short, and a late duplicate slips through. So set a TTL that comfortably exceeds your retry and dead-letter timelines, then expire old keys automatically.

The honest note: idempotency at the store isn’t the same as an idempotent operation. If the downstream side effect isn’t itself safe to repeat, the store only narrows the window; it doesn’t close it. Design the operation to tolerate retries wherever you can.

Pattern 2: The outbox pattern

This one solves the dual-write problem, and the dual-write problem is subtle enough that plenty of teams ship it broken.

Picture a handler that does two things. It writes a record to the database, and it publishes an event to a queue. Both must happen, or neither. But they’re two separate systems, so there’s no shared transaction. Write succeeds, publish fails; now the database and the downstream world disagree. Publish succeeds, write fails; now you’ve announced something that didn’t happen.

Diagram of the outbox pattern. A handler writes a business record and an outbox row inside one dashed database transaction boundary, so they commit together. A publisher tails the outbox, marks each row done, and sends to a broker and consumer with at-least-once delivery. A callout notes the consumer therefore needs an idempotency store.
The business record and the outbox event commit in a single database transaction, so they succeed together. A separate publisher then tails the outbox and publishes each event, giving at-least-once delivery downstream.

The outbox pattern closes the gap. Instead of publishing directly, you write the event into an “outbox” table in the same database transaction as your business record. Because they share one transaction, they commit together or not at all. Then a separate process reads the outbox and publishes the events, marking each one done as it goes.

A few things fall out of this design:

  • The database becomes the source of truth for what should be published. If the publisher crashes mid-run, it restarts and picks up where it left off. Nothing is lost, because nothing left the database until it was safely committed.
  • Publishing becomes at-least-once. The publisher might send an event, crash before marking it done, and send it again on restart. So the consumer on the other end needs, you guessed it, an idempotency store. The two patterns work together.
  • A change-feed makes it cleaner. Cosmos DB’s change feed, or a similar mechanism, lets the publisher tail committed changes rather than poll a table. That reduces latency and load, though a simple polling publisher is perfectly fine to start.

The trade-off is honest latency. The outbox adds a hop between commit and publish. For most integration workloads that’s a few seconds at most, and well worth it for the correctness guarantee. But if you need genuinely instant propagation, the outbox isn’t your pattern.

Pattern 3: State for long-running workflows

Synchronous request-response keeps its state in memory for the length of a call. Integration workflows don’t have that luxury. A process can span minutes, hours, or days while waiting for approval, a batch window, or an external callback. That state has to live somewhere durable, because the compute running it will scale, restart, and move underneath it.

So where does workflow state go? It depends on who’s orchestrating.

Diagram of state handling for long-running workflows. A workflow instance branches on who orchestrates. Engine-managed state covers Logic Apps, which uses its own runtime store, and Durable Functions, which uses a storage backend. Hand-rolled orchestration keeps state in an explicit Cosmos DB store, one document per instance. A band across the bottom shows the correlation ID, stored with the instance and carried on every outbound call, which matches an external callback back to the right in-flight instance.
The compute running a workflow scales, restarts, and moves, so the state has to live somewhere durable. A workflow engine manages it for you; hand-rolled orchestration needs an explicit store. Either way, a correlation ID reconnects a callback to the right in-flight instance.
  • Logic Apps and Durable Functions manage their own state: Both persist workflow state for you; that’s a large part of why they exist. Durable Functions keeps it in a storage backend; Standard Logic Apps keeps it in its own runtime store. In these cases, you rarely touch the state directly, but you should know it’s there and know that it’s what makes the workflow survive a restart.
  • Hand-rolled orchestration needs an explicit store: When you’re coordinating steps in your own code rather than a workflow engine, you own the state. A document store like Cosmos DB fits well here: one document per workflow instance, updated as the process advances through its steps. The flexible schema helps, because a workflow’s state shape often evolves as you add steps.
  • Correlation is the piece people forget: Long-running workflows wait for things to come back, and when a callback arrives, you have to match it to the right in-flight instance. That means a correlation ID, stored with the instance and carried on every outbound call. Without it, you have durable state you can’t reconnect to the event that needs it.

Where these patterns are the wrong answer

Consistent with the rest of the series, the honesty section. Patterns solve problems, and applying them where the problem doesn’t exist adds cost.

  • Skip the idempotency store when the operation is naturally idempotent: Setting a status to “shipped” twice changes nothing. If every side effect is already safe to repeat, a dedup store is machinery you don’t need.
  • Skip the outbox when you don’t dual-write: If a handler only writes to the database, or only publishes, there’s no gap to close. The outbox earns its keep specifically when one commit must produce one publish.
  • Skip explicit state stores when a workflow engine already owns the state: Standing up your own Cosmos-backed state store next to Durable Functions duplicates what the runtime already gives you. Reach for the explicit store only when you’re orchestrating by hand.

The shape of it

For an integration architect, the data layer isn’t mainly a choice between SQL and Cosmos. That choice matters, but the access pattern usually makes it for you. The real work is the patterns that keep an integration platform correct when systems it doesn’t control misbehave. So an idempotency store absorbs duplicate deliveries. An outbox makes both a write and a publish succeed. A durable state store lets a workflow outlive the compute running it. Get those right, and the underlying store SQL, Cosmos, and Redis become implementation details rather than the headline.

Want the layer this sits inside? The Azure PaaS map puts data in context against compute, integration, and governance, and walks the five-question framework across all of them. And the messaging and orchestration post covers the delivery guarantees these patterns lean on.

Token Economics in Practice: What the Citadel Cost Attribution Policy Actually Meters

The FinOps Foundation published a piece called Token Economics: The Atomic Unit of AI Value, and it’s one of the better attempts I’ve seen at giving AI cost management a real vocabulary. Tokens as the atomic unit of cost, goodput instead of raw throughput, and a warning that the token meter is increasingly hidden inside SaaS subscriptions you don’t control.

Most writing on this topic stays theoretical. I have something to test it against. The Citadel Platform series on this blog built cost attribution and semantic caching into a real APIM gateway, running in Sweden Central, metering a real agent. So instead of summarizing the FinOps article, this post uses it as a checklist. Where does the Citadel implementation already deliver on token economics, and where does it fall short?

The honest answer: it holds up well on attribution and caching, and it has clear gaps on goodput, yield, and routing. Let’s go through it.

Scorecard table mapping six token economics concepts to Citadel implementation status. Cost attribution, semantic caching, and gateway meter visibility are implemented. Goodput tracking and model routing are gaps. Token yield rate has the data in Cosmos DB but no outcome tagging yet.
The scorecard up front: where the Citadel Hub delivers on token economics today, and where the honest gaps are.

Three token economics ideas worth carrying forward

I’ll paraphrase the three concepts I’m testing against, and you should read the original for the full argument.

Goodput, not throughput. Raw token volume tells you what you spent, not what you got. Goodput asks how many of those tokens produced useful output within acceptable latency. A retry storm and a productive session can burn the same token count.

The cost stack extends beyond the token. Tokens are the atomic unit, but the bill includes orchestration overhead, retries, tool-calling scaffolding, and increasingly, SaaS subscriptions that embed token consumption behind a flat price. When the meter sits inside someone else’s product, you lose the visibility FinOps depends on.

Engineering levers matter more than procurement levers. Model routing, semantic caching, and compressing tool-calling overhead move the cost curve more than negotiating a discount does. The FinOps article cites Cloudflare’s Code Mode work, which cut MCP tool-schema token overhead dramatically by changing how tools present themselves to the model.

Now let’s hold the Citadel Hub against those three ideas.

What the Citadel Hub already meters

Every call the weather agent makes flows through apim-wpvlimv4ngkns, and two of the five governance policies from earlier in the series do the token economics work.

The cost attribution policy emits token metrics per call, dimensioned by subscription and agent:

xml

<azure-openai-emit-token-metric namespace="citadel">
<dimension name="Subscription ID" />
<dimension name="Agent ID" value="@(context.Request.Headers.GetValueOrDefault("X-Agent-Id", "unknown"))" />
<dimension name="API ID" />
</azure-openai-emit-token-metric>

That gives us prompt tokens, completion tokens, and total tokens per agent, per subscription, queryable in Application Insights. When someone asks what the weather agent cost last week, the answer is a query, not an estimate.

The semantic caching policy sits in front of the model and short-circuits repeat questions:

xml

<azure-openai-semantic-cache-lookup
score-threshold="0.85"
embeddings-backend-id="embeddings-backend"
embeddings-backend-auth="system-assigned">
<vary-by>@(context.Request.Headers.GetValueOrDefault("X-Agent-Id", "unknown"))</vary-by>
</azure-openai-semantic-cache-lookup>

A cache hit costs an embedding call instead of a full completion. For an agent that answers weather questions, where “what’s the weather in Amsterdam” arrives in twenty phrasings, that’s not a rounding error.

In FinOps for AI terms, the first policy lives in the Understand Usage and Cost domain, and the second in Optimize Usage and Cost. So far, the framework and the implementation agree.

Where the mapping holds up

Two places, and one of them matters more than I expected before reading the article.

Semantic caching is a named lever. The FinOps article lists it explicitly as an engineering-side optimization, and the Citadel implementation has it running in production policy XML, not on a roadmap slide. Score threshold tuning is real work (0.85 took iterations, and I documented the false-positive risk in the original policy deep dive), but the lever exists, and it’s been pulled.

The gateway is the anti-aggregator, and the FinOps article’s sharpest warning is that token consumption is being hidden in SaaS subscriptions, where a flat monthly price hides a metered reality beneath the surface. The hub-and-spoke model is the architectural inverse of that problem. Nothing reaches a model without crossing the gateway, so nothing consumes tokens invisibly. The whole point of Part 2 in the Citadel series was to refuse the path that bypasses the meter when the Agent Service SDK tries to call the model directly.

I’d go one step further than the FinOps article does. Centralized metering isn’t just a FinOps convenience. It’s the same choke point that enforces content safety and the kill switch. Cost visibility and governance aren’t two systems in this architecture, they’re one policy pipeline.

Where Citadel’s token economics fall short

This is the useful part, because the gaps are specific.

  • No goodput tracking: The Hub knows how many tokens the agent consumed. It does not know how many of them were worth consuming. Time-to-first-token and tokens-per-second aren’t captured as dimensions, and nothing distinguishes a completion the user acted on from one that got regenerated three times. By the article’s standard, Citadel measures throughput and calls it a day.
  • No token yield rate: Closely related, but distinct. Yield asks: cost per successful outcome, not per call. The weather agent writes every conversation to Cosmos DB (Part 3 of the series), so the raw material for outcome tagging exists. Nothing joins it to the token metrics yet. That’s a gap in instrumentation, not in data.
  • No model routing: Every query hits the same deployment, whether it’s “weather in Ede” or a multi-step tool-calling chain. The article’s Pareto framing (bulk tokens, mid-tier tokens, premium low-latency tokens, reasoning tokens, drawn from SemiAnalysis’s InferenceX benchmarking) implies a cascade: cheap model first, escalate on need. APIM can express this with backend pools and routing policy. Citadel doesn’t, yet.
  • Tool-schema overhead is unmeasured: Every tool-calling request carries the Open-Meteo tool definition in the payload, on every single call. One tool, so the overhead is small. But the Cloudflare finding the article cites is a warning about what happens at ten or twenty tools, and I have no metric today that would even show me the problem growing.

Why this bites harder on agentic workloads

There’s a compounding effect the article touches on that I can back with a documented example. Orchestration overhead isn’t a fixed tax, it multiplies through agent chains.

In the Logic Apps Agent Loop series, I found that sequential agents don’t pass plain strings between each other. Each agent action returns a structured JSON messages array, and you need a Compose action to bridge it into the next agent. Every one of those bridged payloads is tokens. Single-agent token math is linear. Multi-agent token math is not, and that’s where token economics stops being a dashboard exercise. If your metering only captures totals per call, the orchestration overhead hides inside numbers that look individually reasonable.

Diagram comparing expected linear token cost of a three-agent chain against actual cost. The top row shows three agents each assumed to cost one unit. The bottom row shows each agent's payload growing as it carries the previous agents' messages arrays across Compose bridges, so the chain costs well over three units.
Per-call totals look reasonable in isolation. Each chained agent drags the accumulated context of every agent before it.

What I’d add to the Citadel Hub next

In order of effort against payoff:

  • Outcome tagging first: The conversations container already holds every run. Adding a resolution field (answered, retried, abandoned) and joining it against the token metrics in Application Insights gets me a real token yield rate with no new infrastructure. This is the cheapest gap to close and the one that changes the conversation from “what did we spend” to “what did we get.”
  • Latency dimensions second: Emitting time-to-first-token and total duration alongside the existing token dimensions turns the same App Insights workspace into a goodput dashboard. APIM sees the timing already, it just doesn’t emit it.
  • A routing experiment third: The weather agent is a good candidate for a two-tier cascade precisely because it’s boring. Simple lookups go to a small model, tool-calling chains escalate. If the cascade breaks the agent, it breaks it cheaply, and I’ll write up whatever goes wrong.

Tool-schema compression stays on the watch list rather than the to-do list. With one tool, measuring it first beats optimizing it blind.

Pitfalls

Adopting the vocabulary without the substance is the most common trap. It’s easy to say ‘we do token economics’ because a dashboard shows token counts. Raw volume without yield or goodput is accounting, not economics. The article’s framework is only useful if the uncomfortable metrics come with it.

Treating flat-price AI tools as flat costs is the second trap. When teams around you adopt AI SaaS tooling, those subscriptions consume tokens on someone’s meter. Budgeting them as fixed line items repeats the exact mistake the article warns about, one procurement layer up.

Optimizing the cache before understanding the traffic is the last one. A semantic cache with an aggressive threshold saves tokens and quietly serves wrong answers. Tune against logged real queries, never against the token savings number alone. I learned this at 0.85, and the number that’s right for a weather agent is wrong for an agent where two similar-sounding questions need different answers.

Closing

The FinOps article gives this space the vocabulary it needs, and the Citadel Platform gives me somewhere to test that vocabulary against running policy XML. The scorecard: attribution and caching, solid. Goodput, yield, and routing: real gaps with concrete next steps.

The bigger takeaway is architectural. Every improvement on that list lands in the same place, the gateway. APIM started this series as a governance layer. It’s ending it as the FinOps instrumentation layer too, and I don’t think that’s a coincidence. The choke point that can say no to a request is the same choke point that can tell you what the request cost.

If you’re metering your own agent platform, I’d like to hear which of these gaps you closed first, and whether the yield numbers surprised you.