Learn how to build production-grade Power Automate flows that integrate Azure OpenAI and AI Builder for real business automation — from secure authentication and structured prompt engineering to error handling, ALM promotion, and cost management. This is the complete, no-shortcuts guide to deploying AI-powered flows that actually survive production.

Your company's customer support team is drowning. They receive 800+ emails a day, and the triage process — reading each one, figuring out urgency, routing to the right department, and drafting an initial response — eats up nearly two hours per agent every morning before any real work gets done. You've been handed the mandate to automate it. The natural instinct is to write a bunch of conditional logic: if the email contains the word "refund," route it to billing; if it contains "broken," route it to support. You know, even before you finish the sentence, that this will be a brittle disaster. What you actually need is a system that understands language the way a human does — one that can read an email, infer intent and sentiment, generate a contextually appropriate draft response, and log a structured record for your CRM. That's exactly what you can build by combining Power Automate with Azure OpenAI and AI Builder.
By the end of this lesson, you won't just know how to drop a few connector actions into a flow. You'll understand the architectural choices that determine whether an AI-powered automation survives production or falls apart under load, how to authenticate securely without hardcoding credentials, how to parse and transform LLM outputs reliably, and how to handle the failure modes that will absolutely occur in a live environment. We'll build a complete, production-grade email triage and response flow from scratch, and then layer in an AI Builder prediction model to prioritize tickets based on historical resolution data.
What you'll learn:
Before you work through this lesson, you should have:
gpt-4o or gpt-4 deployment created (the resource endpoint and API key accessible)If you haven't yet created an Azure OpenAI deployment, do that first. Navigate to the Azure portal, create an Azure OpenAI resource, then go to Azure OpenAI Studio and deploy a model. Note your endpoint URL (it looks like https://your-resource-name.openai.azure.com/) and your API key from the resource's "Keys and Endpoint" blade.
The biggest mistake people make when building AI-powered flows is treating the AI call as "just another action" and building linearly without thinking about the flow's data shape at each stage. Before touching the designer, you need a clear mental model of what data enters the flow, what the AI returns, how that return value is transformed, and what gets written downstream.
Here's the architecture we're building:
Email arrives (Outlook trigger)
→ Extract metadata (subject, sender, body, timestamp)
→ Call Azure OpenAI (classify intent, sentiment, urgency, draft reply)
→ Parse LLM JSON response
→ Call AI Builder Sentiment model (validate/augment)
→ Conditional routing based on classification
→ Write record to Dataverse
→ Send draft reply to agent's queue (Teams adaptive card)
→ Log to Application Insights (optional but recommended)
The critical insight here is that Azure OpenAI and AI Builder serve different purposes in this stack, and you shouldn't try to make one do the other's job. Azure OpenAI is your generalist: it's reading unstructured text and producing complex, nuanced outputs — classification, reasoning, draft generation. AI Builder's prebuilt Sentiment model is your specialist: it's a purpose-built, deterministic-feeling model that gives you a consistent, structured sentiment score you can trust for routing logic. Using Azure OpenAI for sentiment directly is fine, but having AI Builder's dedicated model as a secondary signal gives you redundancy and auditability — regulators and managers love being able to point to a recognized Microsoft model as the sentiment arbiter.
At the time of writing, the native Azure OpenAI connector in Power Automate is still maturing, and production teams regularly run into rate limit handling issues and limited control over request headers. The premium HTTP action gives you full control over the request body, headers, timeout settings, and retry policies. Once you understand how to call Azure OpenAI via HTTP, the principles transfer directly to any REST API — which is a career-spanning skill worth developing deliberately.
Never — and I mean never — hardcode an API key in a flow action. If your flow is exported, shared, or accidentally shown in a screen recording, you've exposed a credential that can rack up thousands of dollars in Azure charges or exfiltrate your data.
The right pattern has two layers. For environments where your organization has Azure Key Vault integrated, you store the secret there and reference it via a Power Automate connection to Key Vault, fetching it at runtime. For environments without Key Vault access, you use Power Platform Environment Variables of type Secret (available in managed environments), which encrypt the value at rest and expose it only within flows.
In the Power Apps maker portal (make.powerapps.com), navigate to your target environment, then go to Solutions. Open or create a solution — and yes, all production flows should live inside a solution for ALM (Application Lifecycle Management) purposes. Inside the solution, select New → Environment Variable. Give it the name AzureOpenAI_APIKey and set the type to Secret. Enter your API key as the current value.
For your endpoint URL, create a second environment variable with type String and value https://your-resource-name.openai.azure.com/openai/deployments/gpt-4o/chat/completions?api-version=2024-02-01. This URL structure is the Azure OpenAI REST API format — the API version matters and should match your deployment.
Architecture note: Separating the endpoint into its own environment variable means promoting from dev to prod only requires updating the variable value, not opening and editing the flow. This is the difference between a maintainable system and a support nightmare.
Inside a flow, when you configure a field that accepts dynamic content, you can reference environment variables using the expression parameters('AzureOpenAI_APIKey'). This only works for environment variables defined within the same solution as your flow — another reason to always work inside solutions.
With authentication handled, let's build the core AI call. In your flow, after your trigger and any initial data extraction steps, add a new action. Search for "HTTP" and select the HTTP action (the one labeled simply "HTTP" — it's a premium connector).
Configure the action as follows:
Method: POST
URI: @{parameters('AzureOpenAI_APIEndpoint')}
Headers:
Content-Type: application/jsonapi-key: @{parameters('AzureOpenAI_APIKey')}The body is where the real work happens.
For automation contexts, you should always instruct the model to return JSON. Unstructured prose is beautiful and human, but it's poison in an automation pipeline because you can't reliably parse it. Use the system message to establish the output contract, and use the user message to inject the actual email content.
Here's the request body:
{
"messages": [
{
"role": "system",
"content": "You are an expert customer support triage assistant. Analyze the customer email provided and return ONLY a valid JSON object — no markdown, no explanation, no code fences — with exactly these fields:\n- intent: one of [billing_inquiry, technical_support, refund_request, general_feedback, escalation_request, account_access]\n- urgency: one of [critical, high, medium, low]\n- sentiment: one of [positive, neutral, negative, hostile]\n- sentiment_score: a float between -1.0 (most negative) and 1.0 (most positive)\n- key_entities: an array of strings representing product names, order numbers, or account identifiers mentioned\n- suggested_routing_team: one of [billing, tier1_support, tier2_support, account_management, executive_escalation]\n- draft_reply: a professionally worded, empathetic reply of 3-5 sentences that acknowledges the issue and sets expectations for next steps. Do not resolve the issue — just acknowledge and set expectations.\n- confidence: a float between 0.0 and 1.0 representing your confidence in the intent classification"
},
{
"role": "user",
"content": "Sender: @{triggerOutputs()?['body/from']}\nSubject: @{triggerOutputs()?['body/subject']}\nReceived: @{triggerOutputs()?['body/receivedDateTime']}\n\nEmail Body:\n@{triggerOutputs()?['body/body/content']}"
}
],
"temperature": 0.2,
"max_tokens": 800,
"response_format": {"type": "json_object"}
}
Several things in this prompt deserve explanation.
First, notice "response_format": {"type": "json_object"} — this is the JSON mode feature available in GPT-4o and GPT-4 Turbo. It forces the model to return valid JSON, but it does not enforce a specific schema. The system prompt does that work. The combination of JSON mode plus explicit schema instructions in the system message is the most reliable way to get parseable output.
Second, temperature: 0.2 is intentional. Lower temperature means more deterministic, consistent output — you're not asking for creative writing, you're asking for reliable classification. For draft replies where a touch of natural variation is acceptable, you might bump this to 0.4, but stay below 0.5 for anything that feeds into routing logic.
Third, the system prompt uses \n within the JSON string to represent literal newlines. In the Power Automate expression editor, the body field accepts this as a text expression — wrap the entire JSON in an expression using json(...) or enter it directly as the raw body. If you're pasting multi-line JSON into the body field, use the "Switch to input entire array" option if available, or use a Compose action beforehand.
A practice that will save you enormous debugging time is staging complex request bodies in a Compose action before the HTTP call:
Action: Compose
Name: Compose_OpenAI_RequestBody
Inputs:
{
"messages": [
{
"role": "system",
"content": "..."
},
{
"role": "user",
"content": concat('Sender: ', triggerOutputs()?['body/from'], '\nSubject: ', triggerOutputs()?['body/subject'], '\n\nEmail Body:\n', triggerOutputs()?['body/body/content'])
}
],
"temperature": 0.2,
"max_tokens": 800,
"response_format": {"type": "json_object"}
}
Then in the HTTP action's body field, reference outputs('Compose_OpenAI_RequestBody'). Now when something goes wrong, you can inspect the run history, click the Compose action, and see exactly what was sent to the API — an invaluable debugging shortcut.
The HTTP action returns the full API response as a JSON object. The model's actual text output lives at outputs('HTTP_CallAzureOpenAI')?['body']?['choices'][0]?['message']?['content']. This is a string containing JSON — nested JSON inside JSON. You need to parse it before you can access individual fields.
Add a Compose action to extract the content string:
Action: Compose
Name: Compose_LLMResponseString
Inputs: @{outputs('HTTP_CallAzureOpenAI')?['body']?['choices'][0]?['message']?['content']}
Then add a Parse JSON action:
Action: Parse JSON
Name: ParseJSON_LLMOutput
Content: @{outputs('Compose_LLMResponseString')}
Schema: (paste the schema below)
Use this schema, which matches the output contract you defined in the system prompt:
{
"type": "object",
"properties": {
"intent": {"type": "string"},
"urgency": {"type": "string"},
"sentiment": {"type": "string"},
"sentiment_score": {"type": "number"},
"key_entities": {
"type": "array",
"items": {"type": "string"}
},
"suggested_routing_team": {"type": "string"},
"draft_reply": {"type": "string"},
"confidence": {"type": "number"}
}
}
After this action succeeds, you can reference body('ParseJSON_LLMOutput')?['intent'], body('ParseJSON_LLMOutput')?['urgency'], and so on as clean, typed values throughout the rest of the flow.
Warning: Parse JSON will fail if the content string doesn't match the schema — for example, if the model hallucinates an extra field or returns a slightly malformed structure. This is a real failure mode. We'll address it in the error handling section.
Now let's add AI Builder to the picture. AI Builder actions appear as first-class citizens in the Power Automate designer — no HTTP calls, no manual parsing. They're fully managed, connected to your Power Platform environment, and draw from your AI Builder credit allocation.
After your Parse JSON action, add a new step and search for "Sentiment Analysis." Select the AI Builder action Analyze positive or negative sentiment in text. For the Language field, select the appropriate option or use "Auto detect." For the Text field, reference your original email body: triggerOutputs()?['body/body/content'].
The action returns:
Overall text sentiment: one of Positive, Neutral, or NegativePositive score, Neutral score, Negative score: floats between 0 and 1Now you have two sentiment signals: the one from Azure OpenAI (nuanced, contextual, part of a larger classification) and the one from AI Builder (purpose-built, auditable, consistent). You can use them in concert. For example, your routing logic might say: if Azure OpenAI classifies urgency as "high" AND AI Builder sentiment is "Negative" with a negative score above 0.7, escalate to executive support regardless of other factors.
The prebuilt Extract information from text (entity extraction) action can identify names, organizations, dates, phone numbers, and product codes from the email body without any training. Add it after the sentiment analysis step, feeding in the email body. The outputs are structured arrays of detected entities, each with a type and a value.
This is useful as a validation layer — if your Azure OpenAI response includes key entities but the AI Builder extraction finds none of them in the text, that's a signal the LLM may have hallucinated entity values. You can build a confidence-adjustment step based on this cross-check.
The prebuilt models are powerful, but the real differentiation comes from custom models trained on your organization's own data. Let's walk through the pattern for calling a custom prediction model — specifically, one trained to predict ticket resolution time based on historical support data.
Assume you've already built and published a custom Prediction model in AI Builder called "SupportTicketPriorityPredictor." This model was trained on a Dataverse table of historical support tickets, with features including intent_category, sentiment_score, customer_tier, and product_line, and the prediction target is resolution_time_category (one of: under_1_hour, under_4_hours, under_24_hours, over_24_hours).
In your flow, after you've extracted these values from the Azure OpenAI output, add the action Make a prediction (under AI Builder). Select your model from the dropdown. Map the input columns:
intent_category → body('ParseJSON_LLMOutput')?['intent']sentiment_score → body('ParseJSON_LLMOutput')?['sentiment_score']customer_tier → (however you retrieve this — from a prior Dataverse lookup by sender email)product_line → (from the key_entities array or a product lookup)The action returns Prediction result (the predicted class label) and a Confidence score (a probability-like float between 0 and 1). Pair this with the urgency from Azure OpenAI for a composite priority signal that's both AI-generated and grounded in your historical data.
Important: Custom AI Builder models must be in a published state to be called from flows. If you retrain the model, you need to republish before flows pick up the new version. There is no concept of model versioning aliases in AI Builder — publication is atomic, and all flows immediately use the new model. Test thoroughly before publishing a retrained model.
With your AI-derived signals in hand — intent, urgency, sentiment from two sources, predicted resolution time, and routing team — you need to translate these into concrete actions. This is where many flows become a tangled mess of nested conditions. Use the Switch control instead of nested Condition blocks wherever you're branching on a single categorical variable.
Add a Switch control. The "On" expression references body('ParseJSON_LLMOutput')?['suggested_routing_team']. Create cases for each routing team value: billing, tier1_support, tier2_support, account_management, executive_escalation.
Within each case, the actions will differ. For tier1_support, you might:
For executive_escalation, you'd skip Teams and instead send an email to a specific escalation distribution list and trigger a separate notification flow.
Teams adaptive cards are JSON templates that render as rich interactive messages. Power Automate's Teams connector has a "Post adaptive card and wait for a response" action that lets you collect agent feedback (approve/edit the draft reply, change the routing) directly from the card. This closes the human-in-the-loop loop without requiring agents to leave Teams.
The adaptive card body for an agent review card would look like this (condensed for clarity):
{
"type": "AdaptiveCard",
"version": "1.4",
"body": [
{
"type": "TextBlock",
"text": "New Support Ticket: @{body('ParseJSON_LLMOutput')?['intent']}",
"weight": "bolder",
"size": "medium"
},
{
"type": "FactSet",
"facts": [
{"title": "Urgency", "value": "@{body('ParseJSON_LLMOutput')?['urgency']}"},
{"title": "Sentiment", "value": "@{body('ParseJSON_LLMOutput')?['sentiment']}"},
{"title": "Predicted Resolution", "value": "@{outputs('MakeAPrediction')?['body/responsev2/predictionOutput/result']?['resolution_time_category']}"},
{"title": "Sender", "value": "@{triggerOutputs()?['body/from']}"}
]
},
{
"type": "TextBlock",
"text": "Draft Reply:",
"weight": "bolder"
},
{
"type": "TextBlock",
"text": "@{body('ParseJSON_LLMOutput')?['draft_reply']}",
"wrap": true
}
],
"actions": [
{"type": "Action.Submit", "title": "Approve & Send", "data": {"action": "approve"}},
{"type": "Action.Submit", "title": "Edit Before Sending", "data": {"action": "edit"}}
]
}
Tip: The "Post adaptive card and wait for a response" action has a configurable timeout. Set it to something reasonable — 4 hours for standard tickets, 30 minutes for critical urgency. After timeout, configure the action to treat the response as auto-approved, or escalate further. The default is to just fail the flow run, which is not what you want in production.
Every AI decision in your production flow should produce a written record. This isn't just good practice — it's how you build the dataset that lets you retrain your AI Builder model in six months and evaluate whether the Azure OpenAI classifications were accurate.
Create a Dataverse table called AI_EmailTriageLog with these columns:
| Column Name | Type | Notes |
|---|---|---|
| TriageLogId | Autonumber | Primary key |
| EmailMessageId | Text | From the trigger |
| SenderEmail | Text | |
| ReceivedDateTime | DateTime | |
| LLMIntent | Text | From Azure OpenAI |
| LLMUrgency | Text | |
| LLMSentiment | Text | |
| LLMSentimentScore | Decimal | |
| LLMConfidence | Decimal | |
| AIBuilderSentiment | Text | |
| AIBuilderNegativeScore | Decimal | |
| PredictedResolutionCategory | Text | |
| PredictionConfidence | Decimal | |
| RoutingTeam | Text | |
| DraftReply | Multiline Text | |
| AgentAction | Text | approve/edit/escalate |
| RawLLMResponse | Multiline Text | Full JSON string |
| FlowRunId | Text | workflow()?['run']?['name'] |
| ProcessedAt | DateTime | utcNow() |
The RawLLMResponse column is particularly important. Store the full, unparsed JSON string from the LLM every time. This is your forensic record — if you ever need to investigate why a ticket was routed incorrectly, you have the exact output the model returned, not just the parsed fields you chose to store.
Add a Create a new record (Dataverse) action after your routing switch completes, mapping all these fields.
This is where production flows diverge from demos. A demo flow has a happy path. A production flow has a happy path and a dozen error paths that keep the business running when AI services misbehave.
The HTTP action has a configurable retry policy. Click the three dots on the HTTP action, select "Settings," and find the Retry Policy section. For Azure OpenAI calls:
Exponential backoff is essential here because the most common transient failure mode is rate limiting (HTTP 429). Azure OpenAI has tokens-per-minute and requests-per-minute limits per deployment. When you hit them, the API returns a 429 with a Retry-After header. Power Automate's exponential retry policy respects this without any additional configuration — the exponential backoff naturally spaces requests further apart.
Warning: The default retry policy in Power Automate is "None." Every HTTP action you add to a production flow should have an explicit retry policy configured. The default means a transient network error kills your flow immediately.
For critical failure handling, use the "Configure run after" setting on a subsequent action. When you add an action after your HTTP call, click the three dots and choose "Configure run after." You can specify that this action should run when the previous action has failed, timed out, or been skipped — not just on success.
Create a Scope action called "Scope_AzureOpenAI_Call" that contains your HTTP call and Parse JSON. Then create a parallel action outside the scope that runs "is failed" on that scope. Inside this failure branch:
This pattern ensures that even when Azure OpenAI is down or rate-limited beyond retry tolerance, the business process continues — just without the AI assistance.
Parse JSON failure is particularly insidious because it means the LLM returned something, but it wasn't the format you expected. This is more dangerous than a total HTTP failure because your flow might partially succeed.
Add a Condition action after Parse JSON that checks whether it succeeded:
Expression: equals(outputs('ParseJSON_LLMOutput')?['statusCode'], 400)
Actually, a more robust check: use the result() function in a Scope, or check the action's status using if(equals(actions('ParseJSON_LLMOutput')?['status'], 'Succeeded'), true, false).
When Parse JSON fails, fall back to regex extraction. Add a Compose action that uses Power Automate's uriComponentToString and regular expression–style operations with split and indexOf to extract at minimum the intent and urgency from the raw string. These are lossy but better than nothing:
Expression for emergency intent extraction:
if(
contains(toLower(outputs('Compose_LLMResponseString')), 'billing'),
'billing_inquiry',
if(
contains(toLower(outputs('Compose_LLMResponseString')), 'refund'),
'refund_request',
'general_feedback'
)
)
It's not pretty, but it's a safety net that keeps your downstream Dataverse write from failing on null fields.
Building the flow is 40% of the job. Making it maintainable in production is the other 60%.
Your flow, all its connections, and all its environment variables must live in a single unmanaged solution during development. When you're ready to promote to test or production, you export the solution as a Managed solution and import it. Managed solutions prevent direct editing in the target environment — which is exactly what you want, because it means production changes must go through your development process.
The key ALM consideration for AI-powered flows is connection references. Every connector your flow uses — the HTTP connector, Dataverse, Teams, AI Builder — appears in the solution as a Connection Reference. When you import into a new environment, you map each connection reference to an actual connection in that environment. Document these mappings before you have to do an emergency deployment at 11 PM.
The endpoint URL environment variable you created earlier is the mechanism for pointing at different Azure OpenAI deployments in different environments. Your dev environment might point at a smaller, cheaper GPT-3.5-Turbo deployment (for testing without burning credits). Your production environment points at GPT-4o. The flow itself is identical — only the environment variable values change.
For AI Builder models, the model is tied to the environment. If you have a custom prediction model, you'll need to re-create or migrate it in each environment. AI Builder does support exporting and importing models as part of solutions — make sure your model is added to the solution before export.
If your organization has an Application Insights workspace connected to your Power Platform environment (available in Dataverse-backed environments with the Application Insights integration enabled), your flow runs automatically emit telemetry. But you can add custom telemetry by calling the Application Insights REST API from your flows using an HTTP action after key milestones.
Send a custom event after every successful AI classification:
{
"name": "EmailTriageClassified",
"properties": {
"intent": "@{body('ParseJSON_LLMOutput')?['intent']}",
"urgency": "@{body('ParseJSON_LLMOutput')?['urgency']}",
"confidence": "@{body('ParseJSON_LLMOutput')?['confidence']}",
"routingTeam": "@{body('ParseJSON_LLMOutput')?['suggested_routing_team']}",
"flowRunId": "@{workflow()?['run']?['name']}"
}
}
This gives you a searchable, queryable log in Azure that lets you build dashboards showing classification distribution over time, confidence score trends, and routing team volume — exactly the data you need to evaluate whether the system is working and justify its continued operation to leadership.
Running hundreds of LLM calls per day has real cost and performance implications that you need to design around.
Every Azure OpenAI call costs tokens — both input (prompt) tokens and output (completion) tokens. Your prompt template is fixed-length plus the email body. Long emails can push your input token count high. Set max_tokens: 800 for the completion (as we did) to cap the output cost, but consider also truncating the email body input to a maximum of 2000 characters for classification purposes — the first 2000 characters of most emails contain the key information, and beyond that you're paying for signal you likely don't need for classification purposes.
Build a Compose action that truncates the email body:
Expression:
if(
greater(length(triggerOutputs()?['body/body/content']), 2000),
concat(substring(triggerOutputs()?['body/body/content'], 0, 2000), '... [truncated]'),
triggerOutputs()?['body/body/content']
)
At current pricing (approximate), GPT-4o costs around $0.005 per 1,000 input tokens and $0.015 per 1,000 output tokens. An average call with our prompt might use ~600 input tokens and ~300 output tokens, costing roughly $0.0075 per email. At 800 emails/day, that's about $6/day or ~$180/month. Not enormous, but worth tracking — and a model that consumes more tokens than expected could spike this significantly.
By default, Power Automate flows triggered by Outlook process one email at a time. For 800 emails in a two-hour morning window, that's roughly 6.7 emails per minute, or one every 9 seconds. If your Azure OpenAI call takes an average of 3-5 seconds (typical for GPT-4o), you should be fine — the flow will queue naturally.
However, if you hit rate limits during peak load, the exponential retry policy will start adding latency. Monitor your Azure OpenAI deployment's TPM (tokens per minute) and RPM (requests per minute) limits and provision appropriately. For high-volume production scenarios, consider provisioned throughput (PTU) deployments in Azure OpenAI, which give you dedicated capacity without rate limiting.
If your flow trigger is something other than per-email (e.g., a scheduled flow processing a batch), you can use the Apply to Each action with its concurrency control set to a value like 5 — processing 5 emails simultaneously. This dramatically improves throughput but requires your Azure OpenAI deployment to handle the concurrent load.
Warning: AI Builder actions also consume credits. AI Builder Sentiment Analysis costs 1 credit per 1,000 characters. A 500-character email costs 1 credit. At 800 emails/day, that's 800 credits/day or ~24,000 credits/month just for sentiment. Check your credit allocation and budget accordingly. AI Builder credits are purchased in packs or come with Power Apps Premium licenses.
Build a complete working version of the email triage flow described in this lesson. Here's your scaffolded challenge:
Phase 1 – Foundation (30 minutes) Create a new cloud flow in a solution. Use the "When a new email arrives" trigger with the folder set to a specific test folder (create a folder called "AI_Test_Inbox" in your Outlook). Add a Compose action that constructs the Azure OpenAI request body as described, using hardcoded test values (not environment variables yet). Run the flow manually by sending a test email to yourself and moving it to the test folder.
Phase 2 – AI Integration (45 minutes) Replace the hardcoded request body with environment variable references. Add the HTTP action and call your Azure OpenAI deployment. Add the Compose action to extract the response content, then the Parse JSON action to structure it. Add the AI Builder Sentiment Analysis action. Run the flow end-to-end and inspect the run history to confirm all outputs are populated correctly.
Phase 3 – Routing and Storage (30 minutes)
Add the Switch control for routing team, with at least three cases: billing, tier1_support, and an "otherwise" default. Inside the tier1_support case, add a Dataverse "Create a new record" action writing the key AI outputs to the AI_EmailTriageLog table you create. In the default case, add a Teams notification using the Post a message action (not the adaptive card — keep it simple for now).
Phase 4 – Error Handling (30 minutes)
Wrap your HTTP call and Parse JSON in a Scope. Add a parallel branch action after the scope, configured to run on failure. In the failure branch, write a record to AI_EmailTriageLog with status "AI_FAILURE" and all AI fields set to null. Send a Teams notification to yourself with the error details using result('Scope_AzureOpenAI_Call') to capture the failure information.
Stretch goal: Implement the adaptive card Teams notification with approve/edit response handling, and use the agent's response to update the Dataverse record with the AgentAction field.
"Parse JSON fails intermittently, even with JSON mode enabled"
JSON mode reduces but does not eliminate malformed outputs. The most common cause is the email body containing characters that break JSON encoding — particularly unescaped double quotes, backslashes, or control characters. Pre-process the email body through a replace() expression to escape known problematic characters before injecting it into the prompt. Also check that your prompt body isn't being truncated by Power Automate's expression length limits — very long prompts can be silently cut off.
"HTTP action returns 401 Unauthorized"
Almost always an API key issue. Verify that the environment variable is in the same solution as the flow, that the api-key header name is exact (it's not Authorization: Bearer, it's api-key for Azure OpenAI), and that the key hasn't been rotated in Azure without updating the environment variable.
"HTTP action returns 404 Not Found"
Check your endpoint URL carefully. The URL format must be: https://{resource-name}.openai.azure.com/openai/deployments/{deployment-name}/chat/completions?api-version=2024-02-01. The deployment-name in the URL must match the deployment name in Azure OpenAI Studio exactly — not the model name (gpt-4o) but the deployment name you assigned when you deployed it (often the same, but not always).
"AI Builder action returns no output / empty results" First confirm the model is published. For the prebuilt Sentiment action, check that AI Builder is enabled in your environment admin center. Go to the Power Platform admin center, select your environment, and verify that AI Builder is listed as enabled. Also check that the user account or service principal running the flow has AI Builder permissions in that environment.
"Flow runs fine in dev but fails in production after solution import" Almost always a connection reference mapping issue. After importing the managed solution, go to the solution's connection references and verify each one is mapped to a valid connection owned by an account that has appropriate permissions in production. Also check that environment variables have their current value set in the production environment — environment variable current values are not exported with solutions (by design), so you must set them manually after import.
"The LLM returns valid JSON but with additional fields I didn't ask for"
This is normal behavior. JSON mode ensures valid JSON but not exact schema conformance. Your Parse JSON schema only extracts the fields you defined — additional fields are silently ignored. This is safe. The risk direction is the opposite: the LLM occasionally omits a field. Make all schema properties optional (remove them from a required array) and use null-safe operators (?) when accessing parsed values.
"Confidence score from Azure OpenAI seems unreliable" It is. LLM-generated confidence scores are not calibrated probabilities — they're the model's subjective self-assessment. Don't use them as hard thresholds. Use them as directional signals in combination with other factors. If the confidence is below 0.5, consider routing to human triage rather than making any automated decision. The AI Builder prediction confidence, by contrast, is a properly calibrated posterior probability from a trained classifier — you can use those values more assertively.
You've built something substantial here. Let's take stock of what you now understand and can implement.
You know how to call Azure OpenAI via the HTTP action with proper authentication via environment variables, constructing prompts that produce reliable, parseable JSON outputs through the combination of JSON mode and explicit schema instructions in the system message. You know how to parse nested JSON responses, stage intermediate values in Compose actions for debuggability, and use AI Builder's prebuilt and custom models as complementary specialist components alongside the generalist LLM. You've designed routing logic using Switch controls, built an audit trail in Dataverse that supports future model evaluation and retraining, and implemented failure paths that keep the business running when AI services are unavailable. You understand the cost and performance math that turns an impressive demo into a sustainable production system.
The architecture pattern you've built — LLM for nuanced understanding, specialist models for structured signals, conditional routing, human-in-the-loop via adaptive cards, and a comprehensive audit log — is not specific to email triage. It applies directly to document processing, invoice classification, contract review routing, support ticket deflection, and any other business process that involves making decisions based on unstructured text at scale.
Where to go next:
The skills you've developed in this lesson sit at the intersection of enterprise automation and applied AI — and that intersection is where most of the valuable, non-trivial work in modern data and automation engineering happens.