Code samples for the blog post Durable Functions as the Orchestration Layer for Directed Agentic Workflows.
Five patterns, each implemented as a self-contained Durable Functions orchestrator in C# (.NET 8, isolated worker). All patterns share a single function app and deploy via azd.
| Orchestrator | Pattern | Agentic scenario | Verified |
|---|---|---|---|
DocumentPipelineOrchestrator |
Function chaining | Sequential AI pipeline — extract, classify, call LLM, write result | ✓ |
ParallelRetrievalOrchestrator |
Fan-out / fan-in | Parallel retrieval from 3 sources, aggregated LLM call | ✓ |
ApprovalOrchestrator |
Human interaction | AI classification + human review with 24h timeout | ✓ |
MonitorOrchestrator |
Monitor | Poll every 5 minutes until processing completes or 24h timeout | ✓ |
RiskRoutingOrchestrator |
Risk-class routing | AI classification → LOW/MEDIUM/HIGH branch with human-in-the-loop | ✓ |
- Azure Developer CLI (azd)
- Azure Functions Core Tools v4
- .NET 8 SDK
- Azurite for local storage emulation
- An Azure subscription
# Start Azurite in a separate terminal
npx azurite --silent
cd src
cp local.settings.json.example local.settings.json
dotnet build
func startazd upSelect Central US when prompted — the Durable Task Scheduler preview resource is available there. After provisioning completes, assign the additional storage roles manually (see Known Issues below).
See TESTING.md for the full curl command sequence for each pattern including how to submit external events for the approval and risk-routing patterns.
Quick summary of verified results:
Pattern 1 — Sequential pipeline:
curl -X POST "<base-url>/api/pipeline/start?code=<key>" \
-H "Content-Type: application/json" \
-d '{"documentId": "doc-001", "content": "Insurance claim for water damage."}'
# Result: {"Pattern":"chaining","Succeeded":true,"Result":"[Stub] Answer to 'doc-001'..."}Pattern 5 — Risk routing (LOW, instant):
curl -X POST "<base-url>/api/risk/start?code=<key>" \
-H "Content-Type: application/json" \
-d '{"documentId": "doc-001", "content": "Routine claim."}'
# Result: {"Pattern":"risk-routing-low","Succeeded":true,"Result":"[Automated result] Processed doc-001"}The Bicep template assigns Storage Blob Data Owner to the managed identity but Durable Functions also needs table and queue access. After azd up completes, run:
principalId=$(az functionapp identity show --name <func-name> --resource-group <rg> --query principalId -o tsv)
storageId=$(az storage account show --name <storage-name> --resource-group <rg> --query id -o tsv)
az role assignment create --assignee $principalId --role "Storage Table Data Contributor" --scope $storageId
az role assignment create --assignee $principalId --role "Storage Queue Data Contributor" --scope $storageIdThen restart the function app:
az functionapp restart --name <func-name> --resource-group <rg>The azureManaged storage provider requires Microsoft.Azure.Functions.ExtensionBundle.Preview but the preview bundle does not include the provider in the current release. This repo uses the standard Azure Storage backend (Microsoft.Azure.Functions.ExtensionBundle [4.*, 5.0.0)) which works correctly and demonstrates all five patterns without any functional difference for local or deployed testing.
Microsoft.Azure.Functions.Worker.Sdk (all versions to 2.0.7) generates a WorkerExtensions.csproj hardcoded to net6.0. Extension packages that require net8.0 cause build failures. The workaround already applied in this repo: mark the DurableTask.AzureManaged extension with PrivateAssets="all" and use the extension bundle for host-side loading. Do not add it back without also removing it from the extension bundle.
The role ID for Durable Task Data Contributor is not a well-known global GUID — it varies by subscription. The Bicep uses a var for the role ID. If deployment fails with RoleDefinitionDoesNotExist, find the correct ID for your subscription:
az role definition list --name "Durable Task Data Contributor" --query "[].name" -o tsvUpdate infra/main.bicep with the returned GUID.
Activity functions contain the work, orchestrators contain the coordination. No business logic lives in orchestrators. Each activity is independently testable.
Azure OpenAI calls are stubbed. Set AZURE_OPENAI_ENDPOINT and AZURE_OPENAI_DEPLOYMENT in local.settings.json to enable real LLM calls. The stubs are deterministic — doc-001 always routes LOW risk, doc-003 always routes HIGH.
Records not tuples for activity inputs. C# tuples do not serialize correctly through Durable Functions activity calls. All multi-field activity inputs use named records (ProcessWithDecisionInput, NotifySupervisorInput).
This repo accompanies an ongoing series on AI and Azure Functions at sjwiggers.com.